//! The gate's surface: dry-run preview and the real promotion.
use axum::Json;
use axum::extract::{Path, Query, State};
use jac_decision::DecisionLedger;
use jac_repo::RefName;
use jac_telemetry::Envelope;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::dto::VerdictDto;
use crate::error::ApiError;
use crate::routes::snapshots::{all_paths, descends_from};
use crate::routes::{with_repo, with_repo_mut};
use crate::state::SharedState;
/// Emits `jac.promote.verdict` — used from both the dry-run preview and the
/// real promotion, so the console sees every gate evaluation, not only the
/// ones that moved a ref.
fn emit_verdict(
state: &SharedState,
slug: &str,
verdict: &VerdictDto,
changed: usize,
preview: bool,
at_ms: i64,
) {
let (label, decisions_checked, ungoverned, reason) = verdict.telemetry_fields();
let mut envelope = Envelope::event("jac.promote.verdict", at_ms)
.attr("repo", slug.to_owned())
.attr("verdict", label)
.attr_bool("ungoverned", ungoverned)
.attr_bool("preview", preview)
.measurement(
"decisions_checked",
f64::from(u32::try_from(decisions_checked).unwrap_or(u32::MAX)),
)
.measurement(
"changed_paths",
f64::from(u32::try_from(changed).unwrap_or(u32::MAX)),
);
if let Some(reason) = reason {
envelope = envelope.attr("reason", reason);
}
state.telemetry.emit(envelope);
}
#[derive(Debug, Deserialize)]
pub(crate) struct PreviewQuery {
pub from: String,
pub into: String,
}
/// `GET /api/repos/{slug}/promote/preview?from=&into=` — evaluates the gate
/// over the blast radius without moving anything. The frontend's live gate
/// panel drives this on every ref selection.
pub(crate) async fn preview(
State(state): State<SharedState>,
Path(slug): Path<String>,
Query(query): Query<PreviewQuery>,
) -> Result<Json<Value>, ApiError> {
let (body, verdict_dto, changed_count, at) = with_repo(&state, &slug, |entry| {
let from = RefName::parse(&query.from)?;
let into = RefName::parse(&query.into)?;
let from_head = entry
.repo
.head(&from)
.ok_or_else(|| ApiError::not_found(format!("ref `{}` is unbound", from.as_str())))?;
let (changed, fast_forward) = match entry.repo.head(&into) {
Some(into_head) => {
let ff = descends_from(entry, from_head, into_head)?;
(entry.repo.changed_between(into_head, from_head)?, ff)
}
// A new target ref: everything in `from` is the blast radius.
None => (all_paths(entry, from_head)?, true),
};
let verdict = jac_gate::evaluate(entry.repo.ledger() as &dyn DecisionLedger, &changed);
let verdict_dto = VerdictDto::of(&verdict, false, None);
let changed: Vec<String> = changed.iter().map(|p| p.as_str().to_owned()).collect();
let changed_count = changed.len();
let body = json!({
"verdict": &verdict_dto,
"changed": changed,
"fast_forward": fast_forward,
});
Ok((
body,
verdict_dto,
changed_count,
entry.repo.now().as_millis(),
))
})?;
emit_verdict(&state, &slug, &verdict_dto, changed_count, true, at);
Ok(Json(body))
}
#[derive(Debug, Deserialize)]
pub(crate) struct PromoteRequest {
pub from: String,
pub into: String,
}
/// `POST /api/repos/{slug}/promote` — the real thing. `200` for admitted
/// AND blocked: a blocked verdict is a result the caller routes a human to,
/// not an error. Non-fast-forward is the honest `409`.
pub(crate) async fn run(
State(state): State<SharedState>,
Path(slug): Path<String>,
Json(req): Json<PromoteRequest>,
) -> Result<Json<VerdictDto>, ApiError> {
let (dto, changed_count, at) = with_repo_mut(&state, &slug, |entry| {
let from = RefName::parse(&req.from)?;
let into = RefName::parse(&req.into)?;
// Captured before `promote` moves anything: the blast radius that
// was actually gated, for the telemetry event.
let changed_count = match (entry.repo.head(&from), entry.repo.head(&into)) {
(Some(from_head), Some(into_head)) => entry
.repo
.changed_between(into_head, from_head)
.map(|p| p.len())
.unwrap_or(0),
(Some(from_head), None) => all_paths(entry, from_head).map(|p| p.len()).unwrap_or(0),
(None, _) => 0,
};
let verdict = entry.repo.promote(&from, &into)?;
let ref_moved = verdict.is_admitted();
let into_head = entry
.repo
.head(&into)
.map(|id| id.digest().to_hex().as_str().to_owned());
let dto = VerdictDto::of(
&verdict,
ref_moved,
ref_moved.then_some(into_head).flatten(),
);
Ok((dto, changed_count, entry.repo.now().as_millis()))
})?;
emit_verdict(&state, &slug, &dto, changed_count, false, at);
Ok(Json(dto))
}