1use std::collections::BTreeMap;
16
17use serde::Serialize;
18
19use crate::config::{MergeMode, MergeStyle};
20use crate::run::{
21 CommandOutcome, ContinuationOutcome, E2eStatus, GateStatus, JobStatus, Liveness,
22 OperatorFixOutcome, ReviewRound, RunState, tail,
23};
24use crate::verdict::{Finding, ReviewVote, Severity};
25
26pub const SCHEMA: u32 = 1;
29
30const OUTPUT_TAIL: usize = 2_000;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Tone {
38 Ok,
40 Warn,
42 Fail,
44 Neutral,
46}
47
48#[derive(Debug, Serialize)]
50pub struct RunReportView {
51 pub schema: u32,
53 pub header: Header,
55 pub sections: Vec<Section>,
57 pub active_seats: String,
60}
61
62#[derive(Debug, Serialize)]
64pub struct Header {
65 pub id: String,
67 pub status: String,
69 pub tone: Tone,
71 pub unmerged_by_design: bool,
73 pub needs_attention: bool,
75 pub repo: String,
77 pub base_branch: String,
79 pub base_commit: String,
81 pub created: String,
83 pub task: String,
85 pub origin: String,
87 pub state_dir: String,
89}
90
91#[derive(Debug, Serialize)]
93pub struct Head {
94 pub title: String,
96 pub tone: Tone,
98 pub default_open: bool,
100}
101
102fn head(title: &str, tone: Tone, default_open: bool) -> Head {
103 Head {
104 title: title.to_owned(),
105 tone,
106 default_open,
107 }
108}
109
110#[derive(Debug, Serialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum Section {
114 Candidates {
116 #[serde(flatten)]
118 head: Head,
119 items: Vec<CandidateRow>,
121 },
122 Judging {
124 #[serde(flatten)]
126 head: Head,
127 judges: Vec<JudgeRow>,
129 deliberation: Vec<String>,
131 votes: Vec<VoteRow>,
133 },
134 Tally {
136 #[serde(flatten)]
138 head: Head,
139 tally: TallyView,
141 },
142 Notes {
144 #[serde(flatten)]
146 head: Head,
147 key: &'static str,
149 lines: Vec<String>,
151 },
152 Review {
154 #[serde(flatten)]
156 head: Head,
157 rounds: Vec<RoundView>,
159 handed_off: Option<String>,
161 },
162 OperatorFixes {
164 #[serde(flatten)]
166 head: Head,
167 requests: Vec<OperatorFixView>,
169 },
170 BaseSync {
172 #[serde(flatten)]
174 head: Head,
175 base_sync: BaseSyncView,
177 },
178 PreGate {
180 #[serde(flatten)]
182 head: Head,
183 commands: Vec<CommandView>,
185 committed: Option<String>,
187 },
188 Gate {
190 #[serde(flatten)]
192 head: Head,
193 status: &'static str,
195 commands: Vec<CommandView>,
197 },
198 Merge {
200 #[serde(flatten)]
202 head: Head,
203 merge: MergeView,
205 },
206 Followups {
208 #[serde(flatten)]
210 head: Head,
211 items: Vec<FollowupRow>,
213 },
214 ReleaseBump {
216 #[serde(flatten)]
218 head: Head,
219 bump: ReleaseBumpView,
221 },
222 WinnerWorktree {
224 #[serde(flatten)]
226 head: Head,
227 worktree: String,
229 branch: String,
231 provisional: bool,
233 },
234 Jobs {
236 #[serde(flatten)]
238 head: Head,
239 groups: Vec<JobGroup>,
241 coverage: &'static str,
243 empty_note: Option<&'static str>,
245 },
246}
247
248#[derive(Debug, Serialize)]
250pub struct CandidateRow {
251 pub label: String,
253 pub agent: String,
255 pub state: &'static str,
257 pub detail: Option<String>,
259 pub files: usize,
261 pub commits: usize,
263 pub duration_secs: u64,
265 pub winner: bool,
267 pub provisional_winner: bool,
269}
270
271#[derive(Debug, Serialize)]
273pub struct JudgeRow {
274 pub judge: usize,
276 pub agent: String,
278 pub ranking: String,
280 pub confidence: Option<u8>,
282 pub failed: Option<String>,
284}
285
286#[derive(Debug, Serialize)]
288pub struct VoteRow {
289 pub judge: usize,
291 pub agent: String,
293 pub vote: String,
295 pub changed: bool,
297}
298
299#[derive(Debug, Serialize)]
301pub struct TallyView {
302 pub met_quorum: bool,
304 pub decided: bool,
306 pub provisional: bool,
308 pub winner: String,
310 pub judges: usize,
312 pub present: usize,
314 pub quorum: usize,
316 pub uncontested: Option<String>,
318 pub first_choice: BTreeMap<String, usize>,
320 pub initial: &'static str,
322 pub unanimous_final: bool,
324 pub changed_votes: usize,
326 pub tie_break: Option<String>,
328 pub rate_limited: Vec<String>,
330}
331
332#[derive(Debug, Serialize)]
334pub struct RoundView {
335 pub round: usize,
337 pub status: &'static str,
339 pub tone: Tone,
341 pub head: String,
343 pub verified_head: Option<String>,
345 pub raised: usize,
347 pub blocking: usize,
349 pub answered: usize,
351 pub expected: usize,
353 pub missing: Vec<String>,
355 pub e2e: E2eView,
357 pub verdict: Option<VerdictView>,
359 pub fix: Option<FixView>,
361 pub commands: Vec<CommandView>,
363 pub reviewers: Vec<ReviewerRow>,
365 pub findings: Vec<FindingView>,
367 pub reconsideration: Vec<RevoteRow>,
369 pub default_open: bool,
371}
372
373#[derive(Debug, Serialize)]
375pub struct E2eView {
376 pub state: &'static str,
378 pub build_failed: bool,
380 pub defer_reason: Option<String>,
382 pub retried: bool,
384}
385
386#[derive(Debug, Serialize)]
388pub struct VerdictView {
389 pub vote: &'static str,
391 pub label: &'static str,
393 pub split: bool,
395}
396
397#[derive(Debug, Serialize)]
399pub struct FixView {
400 pub agent: String,
402 pub addressed: usize,
404 pub rejected: usize,
406 pub committed: bool,
408 pub tree_changed: bool,
410 pub report_lost: Option<String>,
412 pub continuation: Option<String>,
414}
415
416#[derive(Debug, Serialize)]
418pub struct ReviewerRow {
419 pub reviewer: usize,
421 pub agent: String,
423 pub vote: Option<&'static str>,
425 pub failed: Option<String>,
427}
428
429#[derive(Debug, Serialize)]
431pub struct RevoteRow {
432 pub reviewer: usize,
434 pub vote: Option<&'static str>,
436 pub reason: String,
438}
439
440#[derive(Debug, Serialize)]
442pub struct FindingView {
443 pub id: String,
445 pub reviewer: Option<usize>,
447 pub severity: Option<&'static str>,
449 pub title: String,
451 pub file: Option<String>,
453 pub line: Option<u32>,
455 pub blocking: bool,
457 pub state: &'static str,
459 pub declined_why: Option<String>,
461}
462
463#[derive(Debug, Serialize)]
465pub struct CommandView {
466 pub command: String,
468 pub state: &'static str,
470 pub code: Option<i32>,
472 pub duration_ms: u64,
474 pub output_tail: Option<String>,
476}
477
478#[derive(Debug, Serialize)]
480pub struct OperatorFixView {
481 pub requested_at: String,
483 pub stale: bool,
485 pub reason: String,
487 pub findings: Vec<OperatorFindingRow>,
489 pub follow_up_review_run: Option<String>,
491 pub unverified_commit: bool,
493}
494
495#[derive(Debug, Serialize)]
497pub struct OperatorFindingRow {
498 pub id: String,
500 pub severity: &'static str,
502 pub title: String,
504 pub outcome: &'static str,
506 pub why: Option<String>,
508}
509
510#[derive(Debug, Serialize)]
512pub struct BaseSyncView {
513 pub base_branch: String,
515 pub tip: String,
517 pub state: &'static str,
519 pub behind: usize,
521 pub attempts: usize,
523 pub conflict: Option<String>,
525 pub already_in: Option<String>,
527}
528
529#[derive(Debug, Serialize)]
531pub struct MergeView {
532 pub mode: String,
534 pub state: &'static str,
536 pub detail: String,
538 pub branch: Option<String>,
540 pub squash_caveat: bool,
542 pub red_at_merge: Vec<String>,
544}
545
546#[derive(Debug, Serialize)]
548pub struct FollowupRow {
549 pub task: String,
551 pub findings: Vec<String>,
553}
554
555#[derive(Debug, Serialize)]
557pub struct ReleaseBumpView {
558 pub version: Option<String>,
560 pub pr_url: Option<String>,
562 pub automerge: &'static str,
564 pub problem: Option<String>,
566 pub action_required: Option<String>,
568}
569
570#[derive(Debug, Serialize)]
572pub struct JobGroup {
573 pub node: String,
575 pub seat: String,
577 pub jobs: Vec<JobRow>,
579}
580
581#[derive(Debug, Serialize)]
583pub struct JobRow {
584 pub id: String,
586 pub status: &'static str,
588 pub exit_code: Option<i32>,
590 pub round: Option<usize>,
592 pub checked_at: String,
594 pub description: String,
596 pub result_summary: String,
598}
599
600fn short(commit: &str) -> String {
601 commit.chars().take(7).collect()
602}
603
604fn first_line(text: &str) -> String {
605 text.lines()
606 .map(str::trim)
607 .find(|l| !l.is_empty())
608 .unwrap_or("")
609 .to_owned()
610}
611
612fn severity_name(s: Severity) -> &'static str {
613 match s {
614 Severity::Nit => "nit",
615 Severity::Minor => "minor",
616 Severity::Major => "major",
617 Severity::Blocker => "blocker",
618 }
619}
620
621fn vote_name(v: ReviewVote) -> &'static str {
622 match v {
623 ReviewVote::Approve => "approve",
624 ReviewVote::ApproveWithFindings => "approve_with_findings",
625 ReviewVote::Reject => "reject",
626 }
627}
628
629fn command(o: &CommandOutcome, warn_instead_of_fail: bool) -> CommandView {
630 let state = if o.resource_blocked {
631 "blocked"
632 } else if o.ok() {
633 "pass"
634 } else if warn_instead_of_fail {
635 "warn"
636 } else {
637 "fail"
638 };
639 CommandView {
640 command: o.command.clone(),
641 state,
642 code: o.code,
643 duration_ms: o.duration_ms,
644 output_tail: (!o.ok()).then(|| tail(&o.output_tail, OUTPUT_TAIL)),
645 }
646}
647
648fn status_tone(state: &RunState) -> Tone {
649 use crate::run::RunStatus as S;
650 if state.needs_attention() {
651 return Tone::Warn;
652 }
653 match state.status {
654 S::Merged | S::Ready => Tone::Ok,
655 S::Failed => Tone::Fail,
656 S::Stalled | S::Blocked => Tone::Warn,
657 _ => Tone::Neutral,
658 }
659}
660
661fn round_view(r: &ReviewRound, last: bool) -> RoundView {
662 let raised = r.reviews.iter().map(|x| x.findings.len()).sum();
663 let status = if r.incomplete() {
664 "incomplete"
665 } else if r.clean {
666 "clean"
667 } else {
668 "open"
669 };
670 let e2e_state = match r.e2e_status() {
671 E2eStatus::NotConfigured => "not_configured",
672 E2eStatus::Deferred => "deferred",
673 E2eStatus::Passed => "pass",
674 E2eStatus::Failed => "fail",
675 E2eStatus::ResourceBlocked => "blocked",
676 };
677 let e2e = E2eView {
678 state: e2e_state,
679 build_failed: r.e2e_status() == E2eStatus::Failed
680 && r.e2e.iter().any(CommandOutcome::build_failed),
681 defer_reason: r
682 .e2e_defer_reason
683 .clone()
684 .filter(|_| e2e_state == "deferred"),
685 retried: r.verify_retried,
686 };
687 let tone = if e2e_state == "fail" && !e2e.build_failed {
688 Tone::Fail
689 } else if status == "clean" {
690 Tone::Ok
691 } else {
692 Tone::Warn
693 };
694 let missing = if r.incomplete() {
695 r.reviews
696 .iter()
697 .filter_map(|x| {
698 x.failed
699 .as_ref()
700 .map(|why| format!("review-{}: {why}", x.reviewer))
701 })
702 .collect()
703 } else {
704 Vec::new()
705 };
706 let fix = r.fix.as_ref().map(|f| FixView {
707 agent: f.agent.clone(),
708 addressed: f.addressed.len(),
709 rejected: f.rejected.len(),
710 committed: f.committed,
711 tree_changed: r.progressed,
712 report_lost: f.failed.clone(),
713 continuation: f.continuation.as_ref().and_then(|c| match c.outcome {
714 ContinuationOutcome::NotNeeded => None,
715 ContinuationOutcome::Resumed => Some(format!("resumed x{}", c.attempts)),
716 ContinuationOutcome::Exhausted => Some(format!("exhausted x{}", c.attempts)),
717 ContinuationOutcome::QuotaLost => Some("quota".to_owned()),
718 ContinuationOutcome::NoSession => Some("no session to resume".to_owned()),
719 }),
720 });
721
722 let mut findings: Vec<FindingView> = Vec::new();
723 for rec in &r.reviews {
724 for f in &rec.findings {
725 findings.push(finding_view(r, rec.reviewer, f));
726 }
727 }
728 if let Some(fix) = &r.fix {
732 for rej in &fix.rejected {
733 if !findings.iter().any(|f| f.id == rej.id) {
734 findings.push(FindingView {
735 id: rej.id.clone(),
736 reviewer: None,
737 severity: None,
738 title: String::new(),
739 file: None,
740 line: None,
741 blocking: false,
742 state: "declined",
743 declined_why: Some(rej.why.clone()),
744 });
745 }
746 }
747 }
748
749 RoundView {
750 round: r.round,
751 status,
752 tone,
753 head: short(&r.head),
754 verified_head: r.verified_head.as_deref().map(short),
755 raised,
756 blocking: r.blocking,
757 answered: r.answered,
758 expected: r.expected,
759 missing,
760 e2e,
761 verdict: r.verdict.map(|v| VerdictView {
762 vote: vote_name(v),
763 label: v.label(),
764 split: r.vote_split,
765 }),
766 fix,
767 commands: r.e2e.iter().map(|o| command(o, false)).collect(),
768 reviewers: r
769 .reviews
770 .iter()
771 .map(|x| ReviewerRow {
772 reviewer: x.reviewer,
773 agent: x.agent.clone(),
774 vote: x.vote.map(vote_name),
775 failed: x.failed.clone(),
776 })
777 .collect(),
778 findings,
779 reconsideration: r
780 .reconsideration
781 .iter()
782 .map(|rv| RevoteRow {
783 reviewer: rv.reviewer,
784 vote: rv.vote.map(vote_name),
785 reason: match rv.vote {
786 Some(_) => rv.reason.clone(),
787 None => format!("no revote ({})", rv.failed.as_deref().unwrap_or("unknown")),
788 },
789 })
790 .collect(),
791 default_open: last || status != "clean",
792 }
793}
794
795fn finding_view(r: &ReviewRound, reviewer: usize, f: &Finding) -> FindingView {
796 let fixed = r
797 .fix
798 .as_ref()
799 .is_some_and(|fix| fix.addressed.contains(&f.id));
800 let declined = r
801 .fix
802 .as_ref()
803 .and_then(|fix| fix.rejected.iter().find(|x| x.id == f.id));
804 let (state, declined_why) = match (fixed, declined) {
805 (true, _) => ("fixed", None),
806 (false, Some(rej)) => ("declined", Some(rej.why.clone())),
807 (false, None) => ("open", None),
808 };
809 FindingView {
810 id: f.id.clone(),
811 reviewer: Some(reviewer),
812 severity: Some(severity_name(f.severity)),
813 title: f.title.clone(),
814 file: f.file.clone(),
815 line: f.line,
816 blocking: f.severity.blocks(),
817 state,
818 declined_why,
819 }
820}
821
822fn jobs_section(state: &RunState) -> Option<Section> {
823 let codex = state
824 .config
825 .agents
826 .iter()
827 .any(|a| a.kind == crate::config::AgentKind::Codex);
828 if state.jobs.is_empty() && !codex {
829 return None;
830 }
831 let mut by_seat: BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> = BTreeMap::new();
832 for j in &state.jobs {
833 by_seat
834 .entry((j.node.as_str(), j.seat.as_str()))
835 .or_default()
836 .push(j);
837 }
838 let groups = by_seat
839 .into_iter()
840 .map(|((node, seat), records)| JobGroup {
841 node: node.to_owned(),
842 seat: seat.to_owned(),
843 jobs: records
844 .into_iter()
845 .map(|j| JobRow {
846 id: j.id.clone(),
847 status: match j.status {
848 JobStatus::Completed => "completed",
849 JobStatus::Failed => "failed",
850 JobStatus::Unknown => "unknown",
851 },
852 exit_code: j.exit_code,
853 round: j.round,
854 checked_at: j.checked_at.to_string(),
855 description: first_line(&j.description),
856 result_summary: tail(&j.result_summary, OUTPUT_TAIL),
857 })
858 .collect(),
859 })
860 .collect();
861 Some(Section::Jobs {
862 head: head("background jobs", Tone::Neutral, false),
863 groups,
864 coverage: "adapter coverage: codex only today; other backends, and a command a CLI \
865 never reported finishing, leave no entry here - that is unknown, never \
866 \"nothing ran\"",
867 empty_note: state.jobs.is_empty().then_some(
868 "no completed command evidence yet for this run (see active seats for what is \
869 still mid-turn)",
870 ),
871 })
872}
873
874pub fn build(state: &RunState, live: Liveness) -> RunReportView {
877 let mut sections: Vec<Section> = Vec::new();
878 let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
879
880 sections.push(Section::Candidates {
881 head: head(
882 "candidates",
883 if state.candidates.iter().any(|c| c.failed.is_some()) {
884 Tone::Warn
885 } else {
886 Tone::Neutral
887 },
888 true,
889 ),
890 items: state
891 .candidates
892 .iter()
893 .map(|c| {
894 let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
895 let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
896 (Some(e), _, _) => ("failed", Some(e.clone())),
897 (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
898 (None, true, None) => ("no_change", None),
899 _ => (
900 "changed",
901 Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
902 ),
903 };
904 CandidateRow {
905 label: c.label.to_string(),
906 agent: c.agent.clone(),
907 state: st,
908 detail,
909 files: c.files,
910 commits: c.commits,
911 duration_secs: c.duration_ms / 1000,
912 winner: is_winner && met_quorum,
913 provisional_winner: is_winner && !met_quorum,
914 }
915 })
916 .collect(),
917 });
918
919 if !state.judgements.is_empty() {
920 sections.push(Section::Judging {
921 head: head("blind judging", Tone::Neutral, false),
922 judges: state
923 .judgements
924 .iter()
925 .map(|j| JudgeRow {
926 judge: j.judge,
927 agent: j.agent.clone(),
928 ranking: j.ranking.iter().collect(),
929 confidence: j.confidence,
930 failed: j.failed.clone(),
931 })
932 .collect(),
933 deliberation: state
934 .tally
935 .as_ref()
936 .filter(|t| t.deliberated)
937 .map(|_| {
938 state
939 .deliberation
940 .iter()
941 .flat_map(|round| {
942 round.turns.iter().map(move |turn| {
943 format!(
944 "r{} judge {} -> {}",
945 round.round,
946 turn.judge,
947 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
948 )
949 })
950 })
951 .collect()
952 })
953 .unwrap_or_default(),
954 votes: state
955 .votes
956 .iter()
957 .map(|v| VoteRow {
958 judge: v.judge,
959 agent: v.agent.clone(),
960 vote: v.vote.unwrap_or('?').to_string(),
961 changed: v.changed,
962 })
963 .collect(),
964 });
965 }
966
967 if let Some(t) = &state.tally {
968 let initial = match (t.rankings, t.unanimous_initial) {
969 (0, _) => "none",
970 (1, _) => "one",
971 (_, true) => "unanimous",
972 (_, false) => "split",
973 };
974 sections.push(Section::Tally {
975 head: head(
976 "tally",
977 if t.met_quorum { Tone::Ok } else { Tone::Fail },
978 true,
979 ),
980 tally: TallyView {
981 met_quorum: t.met_quorum,
982 decided: t.met_quorum,
983 provisional: !t.met_quorum,
984 winner: t.winner.to_string(),
985 judges: t.judges,
986 present: t.present,
987 quorum: t.quorum,
988 uncontested: t.uncontested.clone(),
989 first_choice: t
990 .first_choice
991 .iter()
992 .map(|(k, v)| (k.to_string(), *v))
993 .collect(),
994 initial,
995 unanimous_final: t.unanimous_final,
996 changed_votes: t.changed_votes,
997 tie_break: t.tie_break.clone(),
998 rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
999 },
1000 });
1001 }
1002
1003 let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1004 (!lines.is_empty()).then(|| Section::Notes {
1005 head: head(title, tone, true),
1006 key,
1007 lines,
1008 })
1009 };
1010 sections.extend(notes(
1011 "handovers",
1012 "seats handed over",
1013 Tone::Warn,
1014 state
1015 .handovers
1016 .iter()
1017 .map(|h| {
1018 format!(
1019 "{} {} -> {} ({}: {})",
1020 h.seat, h.from, h.to, h.node, h.reason
1021 )
1022 })
1023 .collect(),
1024 ));
1025 sections.extend(notes(
1026 "withheld",
1027 "withheld from commit",
1028 Tone::Warn,
1029 state
1030 .withheld
1031 .iter()
1032 .map(|w| {
1033 format!(
1034 "{} ({} lockfile; the directory uses {})",
1035 w.path, w.manager, w.kept_by
1036 )
1037 })
1038 .collect(),
1039 ));
1040
1041 if !state.reviews.is_empty() {
1042 let n = state.reviews.len();
1043 let rounds: Vec<RoundView> = state
1044 .reviews
1045 .iter()
1046 .enumerate()
1047 .map(|(i, r)| round_view(r, i + 1 == n))
1048 .collect();
1049 let handed_off = state.handed_off_with_open_findings().then(|| {
1050 format!(
1051 "handed off with {} finding(s) still open - gate and e2e were green; see above \
1052 for what a person should still look at",
1053 state.open_findings().len()
1054 )
1055 });
1056 let tone = if handed_off.is_some() {
1057 Tone::Warn
1058 } else {
1059 rounds.last().map_or(Tone::Neutral, |r| r.tone)
1060 };
1061 sections.push(Section::Review {
1062 head: head("review + verification", tone, true),
1063 rounds,
1064 handed_off,
1065 });
1066 }
1067
1068 if !state.operator_fixes.is_empty() {
1069 sections.push(Section::OperatorFixes {
1070 head: head("operator fix(es)", Tone::Neutral, true),
1071 requests: state
1072 .operator_fixes
1073 .iter()
1074 .map(|req| OperatorFixView {
1075 requested_at: req.requested_at.to_string(),
1076 stale: req.stale,
1077 reason: req.reason.clone(),
1078 findings: req
1079 .findings
1080 .iter()
1081 .map(|f| {
1082 let (outcome, why) = match &f.outcome {
1083 OperatorFixOutcome::Pending => ("pending", None),
1084 OperatorFixOutcome::Addressed => ("addressed", None),
1085 OperatorFixOutcome::Rejected { why } => {
1086 ("rejected", Some(why.clone()))
1087 }
1088 OperatorFixOutcome::Unreported => ("unreported", None),
1089 };
1090 OperatorFindingRow {
1091 id: f.id.clone(),
1092 severity: severity_name(f.severity),
1093 title: f.title.clone(),
1094 outcome,
1095 why,
1096 }
1097 })
1098 .collect(),
1099 follow_up_review_run: req.follow_up_review_run.clone(),
1100 unverified_commit: req.follow_up_review_run.is_none()
1101 && req.fix.as_ref().is_some_and(|fx| fx.committed),
1102 })
1103 .collect(),
1104 });
1105 }
1106
1107 if let Some(bs) = &state.base_sync {
1108 let (st, tone) = if bs.already_in.is_some() {
1109 ("already_in", Tone::Neutral)
1110 } else if bs.conflict.is_some() {
1111 ("conflict", Tone::Fail)
1112 } else if bs.behind == 0 {
1113 ("in_sync", Tone::Ok)
1114 } else {
1115 ("behind", Tone::Warn)
1116 };
1117 sections.push(Section::BaseSync {
1118 head: head("base sync", tone, true),
1119 base_sync: BaseSyncView {
1120 base_branch: state.base_branch.clone(),
1121 tip: short(&bs.tip),
1122 state: st,
1123 behind: bs.behind,
1124 attempts: bs.attempts,
1125 conflict: bs.conflict.as_deref().map(first_line),
1126 already_in: bs
1127 .already_in
1128 .as_ref()
1129 .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1130 },
1131 });
1132 }
1133
1134 if !state.pre_gate.is_empty() {
1135 let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1136 sections.push(Section::PreGate {
1137 head: head(
1138 "pre_gate",
1139 if all_ok { Tone::Ok } else { Tone::Warn },
1140 !all_ok,
1141 ),
1142 commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1143 committed: state.pre_gate_commit.as_deref().map(short),
1144 });
1145 }
1146
1147 match state.gate_status() {
1148 GateStatus::NotRun => {}
1149 gs => {
1150 let st = match gs {
1151 GateStatus::PassedWithNoCommands => "passed_no_commands",
1152 GateStatus::Passed => "passed",
1153 _ => "failed",
1154 };
1155 sections.push(Section::Gate {
1156 head: head("gate", if gs.ok() { Tone::Ok } else { Tone::Fail }, true),
1157 status: st,
1158 commands: state.gate.iter().map(|o| command(o, false)).collect(),
1159 });
1160 }
1161 }
1162
1163 if let Some(m) = &state.merge {
1164 let by_design = m.mode == MergeMode::None;
1165 let (st, tone) = if by_design {
1166 ("not_landed_by_design", Tone::Neutral)
1167 } else if m.ok {
1168 ("ok", Tone::Ok)
1169 } else if m.empty {
1170 ("empty", Tone::Warn)
1171 } else {
1172 ("not_merged", Tone::Warn)
1173 };
1174 sections.push(Section::Merge {
1175 head: head("merge", tone, true),
1176 merge: MergeView {
1177 mode: format!("{:?}", m.mode).to_lowercase(),
1178 state: st,
1179 detail: m.detail.lines().next().unwrap_or("").to_owned(),
1180 branch: by_design
1181 .then(|| state.winner().map(|w| w.branch.clone()))
1182 .flatten(),
1183 squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1184 red_at_merge: state
1185 .pr
1186 .as_ref()
1187 .map(|pr| pr.red_at_merge.clone())
1188 .unwrap_or_default(),
1189 },
1190 });
1191 }
1192
1193 if !state.followups.is_empty() {
1194 sections.push(Section::Followups {
1195 head: head("follow-ups", Tone::Neutral, true),
1196 items: state
1197 .followups
1198 .iter()
1199 .map(|f| FollowupRow {
1200 task: crate::queue::short(&f.task).to_owned(),
1201 findings: f.findings.clone(),
1202 })
1203 .collect(),
1204 });
1205 }
1206
1207 if let Some(b) = &state.release_bump {
1208 let automerge = if b.automerge_enabled {
1209 "enabled"
1210 } else if b.merged_directly {
1211 "merged_directly"
1212 } else if b.local {
1213 "local"
1214 } else if b.problem.is_some() {
1215 "failed"
1216 } else {
1217 "not_enabled"
1218 };
1219 sections.push(Section::ReleaseBump {
1220 head: head(
1221 "release bump",
1222 if automerge == "failed" || b.action_required.is_some() {
1223 Tone::Warn
1224 } else {
1225 Tone::Neutral
1226 },
1227 true,
1228 ),
1229 bump: ReleaseBumpView {
1230 version: b.version.clone(),
1231 pr_url: b.pr_url.clone(),
1232 automerge,
1233 problem: b.problem.as_deref().map(first_line),
1234 action_required: b.action_required.clone(),
1235 },
1236 });
1237 }
1238
1239 sections.extend(notes(
1240 "leaks",
1241 "blindness warnings",
1242 Tone::Warn,
1243 state
1244 .leaks
1245 .iter()
1246 .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1247 .collect(),
1248 ));
1249
1250 if let Some(w) = state.winner()
1251 && !w.folded
1252 {
1253 sections.push(Section::WinnerWorktree {
1254 head: head("winner worktree", Tone::Neutral, true),
1255 worktree: w.worktree.display().to_string(),
1256 branch: w.branch.clone(),
1257 provisional: !met_quorum,
1258 });
1259 }
1260
1261 sections.extend(jobs_section(state));
1262
1263 RunReportView {
1264 schema: SCHEMA,
1265 header: Header {
1266 id: state.id.clone(),
1267 status: state.status.display_label().to_owned(),
1268 tone: status_tone(state),
1269 unmerged_by_design: state.unmerged_by_design(),
1270 needs_attention: state.needs_attention(),
1271 repo: state.repo.display().to_string(),
1272 base_branch: state.base_branch.clone(),
1273 base_commit: short(&state.base_commit),
1274 created: state.created_local(),
1275 task: first_line(&state.instruction),
1276 origin: crate::run::origin_label(state.origin.as_ref()),
1277 state_dir: state.dir().display().to_string(),
1278 },
1279 sections,
1280 active_seats: crate::report::active_seats(state, live),
1281 }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use crate::config::Config;
1288 use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1289 use crate::verdict::Rejection;
1290 use std::path::PathBuf;
1291
1292 fn state() -> RunState {
1293 crate::run::pin_test_home();
1295 RunState::new(
1296 PathBuf::from("/repo/magi"),
1297 "main".to_owned(),
1298 "0123456789abcdef".to_owned(),
1299 "Add a thing\n\nlong".to_owned(),
1300 Config::default(),
1301 )
1302 }
1303
1304 fn finding(id: &str, severity: Severity) -> Finding {
1305 Finding {
1306 id: id.to_owned(),
1307 severity,
1308 file: Some("src/a.rs".to_owned()),
1309 line: Some(7),
1310 title: format!("title {id}"),
1311 detail: String::new(),
1312 }
1313 }
1314
1315 fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1316 CommandOutcome {
1317 command: "cargo test".to_owned(),
1318 code,
1319 output_tail: "boom\n".repeat(1_000),
1320 duration_ms: 5,
1321 resource_blocked: blocked,
1322 }
1323 }
1324
1325 fn round(n: usize) -> ReviewRound {
1326 let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1327 "round": n, "head": "abcdef0123", "reviews": []
1328 }))
1329 .expect("round");
1330 r.answered = 1;
1331 r.expected = 1;
1332 r.reviews.push(
1333 serde_json::from_value::<ReviewRecord>(serde_json::json!({
1334 "reviewer": 1, "agent": "a"
1335 }))
1336 .expect("record"),
1337 );
1338 r
1339 }
1340
1341 fn tally(met: bool) -> Tally {
1342 serde_json::from_value(serde_json::json!({
1343 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1344 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1345 "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1346 "quorum": 2, "met_quorum": met, "rankings": 1
1347 }))
1348 .expect("tally")
1349 }
1350
1351 fn kinds(v: &RunReportView) -> Vec<String> {
1352 serde_json::to_value(&v.sections)
1353 .expect("json")
1354 .as_array()
1355 .expect("array")
1356 .iter()
1357 .map(|s| s["kind"].as_str().expect("kind").to_owned())
1358 .collect()
1359 }
1360
1361 #[test]
1362 fn sections_follow_the_text_report_order() {
1363 let mut s = state();
1364 s.tally = Some(tally(true));
1365 s.reviews.push(round(1));
1366 s.base_sync = Some(
1367 serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1368 .expect("sync"),
1369 );
1370 s.pre_gate.push(cmd(Some(0), false));
1371 s.jobs.push(
1372 serde_json::from_value(serde_json::json!({
1373 "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1374 "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1375 "exit_code": 0, "source": "codex"
1376 }))
1377 .expect("job"),
1378 );
1379 let v = build(&s, Liveness::Unknown);
1380 assert_eq!(
1381 kinds(&v),
1382 [
1383 "candidates",
1384 "tally",
1385 "review",
1386 "base_sync",
1387 "pre_gate",
1388 "jobs"
1389 ]
1390 );
1391 let json = serde_json::to_value(&v).expect("json");
1392 assert_eq!(json["schema"], SCHEMA);
1393 let jobs = json["sections"].as_array().expect("a").last().expect("l");
1394 assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1395 assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1396 }
1397
1398 #[test]
1399 fn a_stalled_tally_is_never_decided() {
1400 let mut s = state();
1401 s.tally = Some(tally(false));
1402 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1403 let t = json["sections"]
1404 .as_array()
1405 .expect("a")
1406 .iter()
1407 .find(|x| x["kind"] == "tally")
1408 .expect("tally");
1409 assert_eq!(t["tally"]["met_quorum"], false);
1410 assert_eq!(t["tally"]["decided"], false);
1411 assert_eq!(t["tally"]["provisional"], true);
1412 assert_eq!(t["tone"], "fail");
1413 }
1414
1415 #[test]
1416 fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1417 let mut s = state();
1418 let mut r = round(1);
1419 r.reviews[0].findings = vec![
1420 finding("R1-1-1", Severity::Blocker),
1421 finding("R1-1-2", Severity::Major),
1422 finding("R1-1-3", Severity::Minor),
1423 finding("R1-1-4", Severity::Nit),
1424 ];
1425 r.fix = Some(
1426 serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1427 );
1428 let fix = r.fix.as_mut().expect("fix");
1429 fix.addressed = vec!["R1-1-1".to_owned()];
1430 fix.rejected = vec![
1431 Rejection {
1432 id: "R1-1-2".to_owned(),
1433 why: "not a bug".to_owned(),
1434 },
1435 Rejection {
1436 id: "R9-9-9".to_owned(),
1437 why: "unknown id".to_owned(),
1438 },
1439 ];
1440 s.reviews.push(r);
1441 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1442 let review = json["sections"]
1443 .as_array()
1444 .expect("a")
1445 .iter()
1446 .find(|x| x["kind"] == "review")
1447 .expect("review");
1448 let f = &review["rounds"][0]["findings"];
1449 let at = |i: usize| {
1450 (
1451 f[i]["severity"].clone(),
1452 f[i]["blocking"].clone(),
1453 f[i]["state"].clone(),
1454 )
1455 };
1456 assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1457 assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1458 assert_eq!(f[1]["declined_why"], "not a bug");
1459 assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1460 assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1461 assert_eq!(
1462 f[4]["id"], "R9-9-9",
1463 "an unmatched declined id is not dropped"
1464 );
1465 assert_eq!(f[4]["state"], "declined");
1466 }
1467
1468 #[test]
1469 fn a_deferred_e2e_is_not_a_failed_one() {
1470 let mut s = state();
1471 let mut deferred = round(1);
1472 deferred.e2e_deferred = true;
1473 deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1474 let mut failed = round(2);
1475 failed.e2e = vec![cmd(Some(1), false)];
1476 let mut blocked = round(3);
1477 blocked.e2e = vec![cmd(None, true)];
1478 let none = round(4);
1479 s.reviews = vec![deferred, failed, blocked, none];
1480 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1481 let rounds = &json["sections"]
1482 .as_array()
1483 .expect("a")
1484 .iter()
1485 .find(|x| x["kind"] == "review")
1486 .expect("review")["rounds"];
1487 let st = |i: usize| {
1488 rounds[i]["e2e"]["state"]
1489 .as_str()
1490 .expect("state")
1491 .to_owned()
1492 };
1493 assert_eq!(
1494 [st(0), st(1), st(2), st(3)],
1495 ["deferred", "fail", "blocked", "not_configured"]
1496 );
1497 assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1498 let tail = rounds[1]["commands"][0]["output_tail"]
1499 .as_str()
1500 .expect("tail");
1501 assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1502 }
1503}