use anyhow::Result;
use super::pipeline::{ReleaseReport, gate_succeeded, query_release_history};
use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Frame {
pub release_id: String,
pub repo: String,
pub git_sha: String,
pub gate_status: String,
pub good: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Suspect {
pub repo: String,
pub dep_distance: usize,
pub last_good_sha: Option<String>,
pub first_bad_sha: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Trace {
pub repo: String,
pub last_good: Option<Frame>,
pub first_bad: Option<Frame>,
pub frames: Vec<Frame>,
pub suspect_shas: Vec<String>,
pub suspects: Vec<Suspect>,
}
impl Trace {
pub fn is_green(&self) -> bool {
self.first_bad.is_none()
}
}
pub fn trace_gate(reports: &[ReleaseReport], repo: &str) -> Trace {
let frames: Vec<Frame> = reports
.iter()
.filter_map(|r| {
r.repos.iter().find(|x| x.repo == repo).map(|rec| Frame {
release_id: r.release_id.to_string(),
repo: repo.to_string(),
git_sha: rec.git.sha.clone(),
gate_status: rec.gate_status.clone(),
good: gate_succeeded(&rec.gate_status),
})
})
.collect();
let last_good_idx = frames.iter().rposition(|f| f.good);
let (last_good, first_bad, suspect_shas) = match last_good_idx {
Some(i) => {
let after = &frames[i + 1..];
let first_bad = after.iter().find(|f| !f.good).cloned();
let mut shas: Vec<String> = Vec::new();
for f in after {
if !shas.contains(&f.git_sha) {
shas.push(f.git_sha.clone());
}
if !f.good {
break;
}
}
(Some(frames[i].clone()), first_bad, shas)
}
None => {
let first_bad = frames.iter().find(|f| !f.good).cloned();
let mut shas: Vec<String> = Vec::new();
for f in &frames {
if !shas.contains(&f.git_sha) {
shas.push(f.git_sha.clone());
}
}
(None, first_bad, shas)
}
};
Trace { repo: repo.to_string(), last_good, first_bad, frames, suspect_shas, suspects: Vec::new() }
}
fn candidates_from_graph(graph: &WorkspaceGraph, target: &str) -> Vec<(String, usize)> {
let mut out = vec![(target.to_string(), 0usize)];
for dep in graph.deps_transitive(target) {
let dist = graph
.dep_path(target, &dep)
.map(|p| p.len().saturating_sub(1))
.unwrap_or(usize::MAX);
out.push((dep, dist));
}
out
}
pub fn rank_suspects(
reports: &[ReleaseReport],
trace: &Trace,
candidates: &[(String, usize)],
) -> Vec<Suspect> {
let (Some(lg), Some(fb)) = (&trace.last_good, &trace.first_bad) else {
return Vec::new();
};
let find = |id: &str| reports.iter().find(|r| r.release_id.to_string() == id);
let (Some(lg_r), Some(fb_r)) = (find(&lg.release_id), find(&fb.release_id)) else {
return Vec::new();
};
let sha = |rep: &ReleaseReport, repo: &str| {
rep.repos.iter().find(|x| x.repo == repo).map(|x| x.git.sha.clone())
};
let mut out: Vec<Suspect> = candidates
.iter()
.filter_map(|(repo, dist)| {
let last_good_sha = sha(lg_r, repo);
let first_bad_sha = sha(fb_r, repo);
let changed = match (&last_good_sha, &first_bad_sha) {
(Some(a), Some(b)) => a != b,
(None, Some(_)) => true, _ => false,
};
changed.then(|| Suspect {
repo: repo.clone(),
dep_distance: *dist,
last_good_sha,
first_bad_sha,
})
})
.collect();
out.sort_by(|a, b| a.dep_distance.cmp(&b.dep_distance).then(a.repo.cmp(&b.repo)));
out
}
pub async fn trace_gate_async(
wh: &IcebergWarehouse,
workspace: &str,
repo: &str,
graph: Option<&WorkspaceGraph>,
) -> Result<Trace> {
let reports = query_release_history(wh, workspace, None).await?;
let mut trace = trace_gate(&reports, repo);
if let Some(g) = graph {
let candidates = candidates_from_graph(g, repo);
trace.suspects = rank_suspects(&reports, &trace, &candidates);
}
Ok(trace)
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::pipeline::{RepoGitState, RepoReleaseRecord};
use uuid::Uuid;
fn report(repo: &str, sha: &str, status: &str) -> ReleaseReport {
ReleaseReport {
release_id: Uuid::new_v4(),
workspace_name: "ws".into(),
dep_graph_snapshot_id: Uuid::nil(),
repos: vec![RepoReleaseRecord {
repo: repo.into(),
build_order_idx: 0,
git: RepoGitState { sha: sha.into(), branch: "main".into(), dirty: false },
gate_status: status.into(),
tests_passed: 0,
tests_failed: 0,
published_versions: vec![],
}],
}
}
#[test]
fn finds_green_to_red_boundary() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded_dry_run"),
report("znippy", "c3", "failed_test"),
];
let t = trace_gate(&reports, "znippy");
assert!(!t.is_green());
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "c3");
assert_eq!(t.first_bad.as_ref().unwrap().gate_status, "failed_test");
assert_eq!(t.suspect_shas, vec!["c3".to_string()]);
assert_eq!(t.frames.len(), 3);
}
#[test]
fn boundary_endpoints_define_the_bisect_range() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded"),
report("znippy", "c3", "failed_regression"),
];
let t = trace_gate(&reports, "znippy");
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "c3");
assert_eq!(t.suspect_shas, vec!["c3".to_string()]);
}
#[test]
fn still_green_has_no_regression() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded"),
];
let t = trace_gate(&reports, "znippy");
assert!(t.is_green());
assert!(t.first_bad.is_none());
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert!(t.suspect_shas.is_empty());
}
#[test]
fn never_green_marks_all_suspect() {
let reports = vec![
report("znippy", "a1", "failed_test"),
report("znippy", "b2", "failed_bench"),
];
let t = trace_gate(&reports, "znippy");
assert!(t.last_good.is_none());
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "a1");
assert_eq!(t.suspect_shas, vec!["a1".to_string(), "b2".to_string()]);
}
fn multi(repos: &[(&str, &str, &str)]) -> ReleaseReport {
ReleaseReport {
release_id: Uuid::new_v4(),
workspace_name: "ws".into(),
dep_graph_snapshot_id: Uuid::nil(),
repos: repos
.iter()
.map(|(r, sha, status)| RepoReleaseRecord {
repo: (*r).into(),
build_order_idx: 0,
git: RepoGitState { sha: (*sha).into(), branch: "main".into(), dirty: false },
gate_status: (*status).into(),
tests_passed: 0,
tests_failed: 0,
published_versions: vec![],
})
.collect(),
}
}
#[test]
fn ranks_changed_repos_nearest_first() {
let reports = vec![
multi(&[("app", "a1", "succeeded"), ("liba", "L1", "succeeded"), ("util", "U1", "succeeded")]),
multi(&[("app", "a2", "failed_test"), ("liba", "L1", "succeeded"), ("util", "U2", "succeeded")]),
];
let t = trace_gate(&reports, "app");
let candidates = vec![("app".into(), 0usize), ("liba".into(), 1usize), ("util".into(), 2usize)];
let suspects = rank_suspects(&reports, &t, &candidates);
let names: Vec<&str> = suspects.iter().map(|s| s.repo.as_str()).collect();
assert_eq!(names, vec!["app", "util"]);
assert_eq!(suspects[0].last_good_sha.as_deref(), Some("a1"));
assert_eq!(suspects[0].first_bad_sha.as_deref(), Some("a2"));
assert_eq!(suspects[1].repo, "util");
}
}