← 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))
}