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