jacquardThe gate

← snapshot

5174 bytes
//! 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))
}