jacquardSnapshot

← snapshot

9141 bytes
//! Review remarks: raise, list, settle.
//!
//! What a reviewer says while reading — "use randomness to avoid a thundering
//! herd" — anchored to the exact claim that prompted it. See
//! [`crate::state::Remark`] for why these are not engine objects.

use core::str::FromStr as _;

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use jac_core::HumanId;
use jac_telemetry::Envelope;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::error::ApiError;
use crate::routes::{with_repo, with_repo_mut};
use crate::state::{Remark, RemarkAnchor, RemarkKind, RemarkState, RepoEntry, SharedState};

/// Same bounds as a statement: long enough for a real thought, short enough
/// that it is a remark and not a document.
const MAX_BODY: usize = 2000;
/// A quote is context, not content; anything longer is a copy of the file.
const MAX_QUOTE: usize = 600;

const ANCHOR_KINDS: [&str; 4] = ["decision", "file", "snapshot", "gate"];

#[derive(Debug, Deserialize)]
pub(crate) struct AnchorBody {
    pub kind: String,
    pub id: String,
    #[serde(default)]
    pub quote: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct RaiseRequest {
    pub anchor: AnchorBody,
    /// The reviewer's own words.
    pub body: String,
    /// `suggestion`, `question`, or `note`.
    pub kind: String,
    /// Who is speaking. Declared, never authenticated.
    pub by: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct ListQuery {
    /// `open`, `settled`, or absent for all.
    #[serde(default)]
    pub state: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct SettleRequest {
    /// `drafted` or `declined`.
    pub outcome: String,
    /// The decision this became, when the outcome is `drafted`.
    #[serde(default)]
    pub decision: Option<String>,
    /// Why it was closed, when the outcome is `declined`.
    #[serde(default)]
    pub because: Option<String>,
}

fn remark_json(remark: &Remark, entry: &RepoEntry) -> Value {
    let (state, detail) = match &remark.state {
        RemarkState::Open => ("open", Value::Null),
        RemarkState::Drafted { decision } => ("drafted", json!({ "decision": decision })),
        RemarkState::Declined { because } => ("declined", json!({ "because": because })),
    };
    json!({
        "id": remark.id.to_string(),
        "anchor": {
            "kind": remark.anchor.kind,
            "id": remark.anchor.id,
            "quote": remark.anchor.quote,
        },
        "body": remark.body,
        "kind": remark.kind.label(),
        "by": {
            "id": remark.by.to_string(),
            "display_name": entry.humans.get(&remark.by).map(|h| h.display_name.clone()),
        },
        "at": remark.at,
        "state": state,
        "outcome": detail,
    })
}

/// `GET /api/repos/{slug}/remarks?state=`.
pub(crate) async fn list(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Query(query): Query<ListQuery>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let want = query.state.as_deref();
        if let Some(other) = want
            && other != "open"
            && other != "settled"
        {
            return Err(ApiError::invalid(format!(
                "unknown remark state `{other}` — expected `open` or `settled`"
            )));
        }
        let remarks: Vec<Value> = entry
            .remarks
            .values()
            .filter(|r| match want {
                Some("open") => matches!(r.state, RemarkState::Open),
                Some("settled") => !matches!(r.state, RemarkState::Open),
                _ => true,
            })
            .map(|r| remark_json(r, entry))
            .collect();
        let open = entry
            .remarks
            .values()
            .filter(|r| matches!(r.state, RemarkState::Open))
            .count();
        Ok(Json(json!({ "remarks": remarks, "open": open })))
    })
}

/// `POST /api/repos/{slug}/remarks`.
pub(crate) async fn raise(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<RaiseRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
    let kind = RemarkKind::parse(req.kind.trim()).ok_or_else(|| {
        ApiError::invalid(format!(
            "unknown remark kind `{}` — expected `suggestion`, `question`, or `note`",
            req.kind.trim()
        ))
    })?;

    let body = req.body.trim().to_owned();
    if body.is_empty() {
        return Err(ApiError::invalid(
            "a remark needs words — say what should change",
        ));
    }
    if body.chars().count() > MAX_BODY {
        return Err(ApiError::invalid(format!(
            "a remark is at most {MAX_BODY} characters"
        )));
    }

    let anchor_kind = req.anchor.kind.trim().to_owned();
    if !ANCHOR_KINDS.contains(&anchor_kind.as_str()) {
        return Err(ApiError::invalid(format!(
            "unknown anchor kind `{anchor_kind}` — expected one of {}",
            ANCHOR_KINDS.join(", ")
        )));
    }
    if req.anchor.id.trim().is_empty() {
        return Err(ApiError::invalid("an anchor needs something to point at"));
    }
    let quote = req
        .anchor
        .quote
        .map(|q| q.trim().to_owned())
        .filter(|q| !q.is_empty())
        .map(|q| {
            if q.chars().count() > MAX_QUOTE {
                q.chars().take(MAX_QUOTE).collect()
            } else {
                q
            }
        });

    let by = HumanId::from_str(req.by.trim())
        .map_err(|_| ApiError::invalid(format!("`{}` is not a human id", req.by.trim())))?;

    let id = state.mint_id().get();
    let (payload, kind_label) = with_repo_mut(&state, &slug, |entry| {
        if !entry.humans.contains_key(&by) {
            return Err(ApiError::invalid(format!(
                "`{by}` is not registered against this repo"
            )));
        }
        let remark = Remark {
            id,
            anchor: RemarkAnchor {
                kind: anchor_kind,
                id: req.anchor.id.trim().to_owned(),
                quote,
            },
            body,
            kind,
            by,
            at: entry.repo.now().as_millis(),
            state: RemarkState::Open,
        };
        let payload = remark_json(&remark, entry);
        entry.remarks.insert(id, remark);
        Ok((payload, kind.label()))
    })?;

    // Counts and labels only — never the reviewer's words.
    state.telemetry.emit(
        Envelope::event(
            "jac.remark.raised",
            payload["at"].as_i64().unwrap_or_default(),
        )
        .attr("repo", slug.clone())
        .attr("kind", kind_label)
        .attr("anchor", payload["anchor"]["kind"].as_str().unwrap_or("?"))
        .attr_bool("quoted", !payload["anchor"]["quote"].is_null()),
    );

    Ok((StatusCode::CREATED, Json(payload)))
}

/// `POST /api/repos/{slug}/remarks/{id}/settle`.
pub(crate) async fn settle(
    State(state): State<SharedState>,
    Path((slug, id)): Path<(String, String)>,
    Json(req): Json<SettleRequest>,
) -> Result<Json<Value>, ApiError> {
    let id: u64 = id
        .parse()
        .map_err(|_| ApiError::not_found(format!("no remark `{id}`")))?;

    let outcome = match req.outcome.trim() {
        "drafted" => {
            let decision = req
                .decision
                .as_deref()
                .map(str::trim)
                .filter(|d| !d.is_empty())
                .ok_or_else(|| {
                    ApiError::invalid("a drafted remark has to name the decision it became")
                })?;
            RemarkState::Drafted {
                decision: decision.to_owned(),
            }
        }
        "declined" => RemarkState::Declined {
            because: req
                .because
                .as_deref()
                .map(str::trim)
                .filter(|b| !b.is_empty())
                .unwrap_or("no reason given")
                .to_owned(),
        },
        other => {
            return Err(ApiError::invalid(format!(
                "unknown outcome `{other}` — expected `drafted` or `declined`"
            )));
        }
    };

    let label = match outcome {
        RemarkState::Drafted { .. } => "drafted",
        RemarkState::Declined { .. } => "declined",
        RemarkState::Open => "open",
    };

    let payload = with_repo_mut(&state, &slug, |entry| {
        let remark = entry
            .remarks
            .get_mut(&id)
            .ok_or_else(|| ApiError::not_found(format!("no remark `{id}`")))?;
        if !matches!(remark.state, RemarkState::Open) {
            return Err(ApiError::Conflict(format!(
                "remark `{id}` is already settled"
            )));
        }
        remark.state = outcome;
        let remark = remark.clone();
        Ok(remark_json(&remark, entry))
    })?;

    state.telemetry.emit(
        Envelope::event(
            "jac.remark.settled",
            payload["at"].as_i64().unwrap_or_default(),
        )
        .attr("repo", slug)
        .attr("outcome", label),
    );

    Ok(Json(payload))
}