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 Graft {
156 #[serde(flatten)]
158 head: Head,
159 graft: GraftView,
161 },
162 Review {
164 #[serde(flatten)]
166 head: Head,
167 rounds: Vec<RoundView>,
169 handed_off: Option<String>,
171 },
172 OperatorFixes {
174 #[serde(flatten)]
176 head: Head,
177 requests: Vec<OperatorFixView>,
179 },
180 BaseSync {
182 #[serde(flatten)]
184 head: Head,
185 base_sync: BaseSyncView,
187 },
188 PreGate {
190 #[serde(flatten)]
192 head: Head,
193 commands: Vec<CommandView>,
195 committed: Option<String>,
197 },
198 Gate {
200 #[serde(flatten)]
202 head: Head,
203 status: &'static str,
205 commands: Vec<CommandView>,
207 },
208 Merge {
210 #[serde(flatten)]
212 head: Head,
213 merge: MergeView,
215 },
216 Followups {
218 #[serde(flatten)]
220 head: Head,
221 items: Vec<FollowupRow>,
223 },
224 ReleaseBump {
226 #[serde(flatten)]
228 head: Head,
229 bump: ReleaseBumpView,
231 },
232 WinnerWorktree {
234 #[serde(flatten)]
236 head: Head,
237 worktree: String,
239 branch: String,
241 provisional: bool,
243 },
244 Jobs {
246 #[serde(flatten)]
248 head: Head,
249 groups: Vec<JobGroup>,
251 coverage: &'static str,
253 empty_note: Option<&'static str>,
255 },
256}
257
258#[derive(Debug, Serialize)]
260pub struct CandidateRow {
261 pub label: String,
263 pub agent: String,
265 pub state: &'static str,
267 pub detail: Option<String>,
269 pub files: usize,
271 pub commits: usize,
273 pub duration_secs: u64,
275 pub winner: bool,
277 pub provisional_winner: bool,
279}
280
281#[derive(Debug, Serialize)]
283pub struct JudgeRow {
284 pub judge: usize,
286 pub agent: String,
288 pub ranking: String,
290 pub confidence: Option<u8>,
292 pub failed: Option<String>,
294}
295
296#[derive(Debug, Serialize)]
298pub struct VoteRow {
299 pub judge: usize,
301 pub agent: String,
303 pub vote: String,
305 pub changed: bool,
307}
308
309#[derive(Debug, Serialize)]
311pub struct DeliberationTurnView {
312 pub judge: usize,
314 pub agent: String,
316 pub tentative: Option<String>,
318 pub body: String,
320 pub body_md: Vec<crate::md::Node>,
322}
323
324#[derive(Debug, Serialize)]
326pub struct DeliberationRoundView {
327 pub round: usize,
329 pub turns: Vec<DeliberationTurnView>,
331}
332
333#[derive(Debug, Serialize)]
335pub struct TallyView {
336 pub met_quorum: bool,
338 pub decided: bool,
340 pub provisional: bool,
342 pub winner: String,
344 pub judges: usize,
346 pub present: usize,
348 pub quorum: usize,
350 pub uncontested: Option<String>,
352 pub first_choice: BTreeMap<String, usize>,
354 pub initial: &'static str,
356 pub unanimous_final: bool,
358 pub changed_votes: usize,
360 pub tie_break: Option<String>,
362 pub rate_limited: Vec<String>,
364}
365
366#[derive(Debug, Serialize)]
368pub struct RoundView {
369 pub round: usize,
371 pub status: &'static str,
373 pub tone: Tone,
375 pub head: String,
377 pub verified_head: Option<String>,
379 pub raised: usize,
381 pub blocking: usize,
383 pub answered: usize,
385 pub expected: usize,
387 pub missing: Vec<String>,
389 pub e2e: E2eView,
391 pub verdict: Option<VerdictView>,
393 pub fix: Option<FixView>,
395 pub commands: Vec<CommandView>,
397 pub reviewers: Vec<ReviewerRow>,
399 pub findings: Vec<FindingView>,
401 pub reconsideration: Vec<RevoteRow>,
403 pub default_open: bool,
405}
406
407#[derive(Debug, Serialize)]
409pub struct E2eView {
410 pub state: &'static str,
412 pub build_failed: bool,
414 pub defer_reason: Option<String>,
416 pub retried: bool,
418}
419
420#[derive(Debug, Serialize)]
422pub struct VerdictView {
423 pub vote: &'static str,
425 pub label: &'static str,
427 pub split: bool,
429}
430
431#[derive(Debug, Serialize)]
433pub struct FixView {
434 pub agent: String,
436 pub addressed: usize,
438 pub rejected: usize,
440 pub committed: bool,
442 pub tree_changed: bool,
444 pub report_lost: Option<String>,
446 pub continuation: Option<String>,
448}
449
450#[derive(Debug, Serialize)]
452pub struct ReviewerRow {
453 pub reviewer: usize,
455 pub agent: String,
457 pub vote: Option<&'static str>,
459 pub failed: Option<String>,
461}
462
463#[derive(Debug, Serialize)]
465pub struct RevoteRow {
466 pub reviewer: usize,
468 pub vote: Option<&'static str>,
470 pub reason: String,
472}
473
474#[derive(Debug, Serialize)]
476pub struct FindingView {
477 pub id: String,
479 pub reviewer: Option<usize>,
481 pub severity: Option<&'static str>,
483 pub title: String,
485 pub file: Option<String>,
487 pub line: Option<u32>,
489 pub blocking: bool,
491 pub state: &'static str,
493 pub declined_why: Option<String>,
495}
496
497#[derive(Debug, Serialize)]
499pub struct CommandView {
500 pub command: String,
502 pub state: &'static str,
504 pub code: Option<i32>,
506 pub duration_ms: u64,
508 pub output_tail: Option<String>,
510}
511
512#[derive(Debug, Serialize)]
514pub struct OperatorFixView {
515 pub requested_at: String,
517 pub stale: bool,
519 pub reason: String,
521 pub findings: Vec<OperatorFindingRow>,
523 pub follow_up_review_run: Option<String>,
525 pub unverified_commit: bool,
527}
528
529#[derive(Debug, Serialize)]
531pub struct OperatorFindingRow {
532 pub id: String,
534 pub severity: &'static str,
536 pub title: String,
538 pub outcome: &'static str,
540 pub why: Option<String>,
542}
543
544#[derive(Debug, Serialize)]
546pub struct BaseSyncView {
547 pub base_branch: String,
549 pub tip: String,
551 pub state: &'static str,
553 pub behind: usize,
555 pub attempts: usize,
557 pub conflict: Option<String>,
559 pub already_in: Option<String>,
561}
562
563#[derive(Debug, Serialize)]
565pub struct MergeView {
566 pub mode: String,
568 pub state: &'static str,
570 pub detail: String,
572 pub branch: Option<String>,
574 pub squash_caveat: bool,
576 pub red_at_merge: Vec<String>,
578}
579
580#[derive(Debug, Serialize)]
582pub struct FollowupRow {
583 pub task: String,
585 pub findings: Vec<String>,
587}
588
589#[derive(Debug, Serialize)]
591pub struct ReleaseBumpView {
592 pub version: Option<String>,
594 pub pr_url: Option<String>,
596 pub automerge: &'static str,
598 pub problem: Option<String>,
600 pub action_required: Option<String>,
602}
603
604#[derive(Debug, Serialize)]
606pub struct JobGroup {
607 pub node: String,
609 pub seat: String,
611 pub jobs: Vec<JobRow>,
613}
614
615#[derive(Debug, Serialize)]
617pub struct JobRow {
618 pub id: String,
620 pub status: &'static str,
622 pub exit_code: Option<i32>,
624 pub round: Option<usize>,
626 pub checked_at: String,
628 pub description: String,
630 pub result_summary: String,
632}
633
634fn short(commit: &str) -> String {
635 commit.chars().take(7).collect()
636}
637
638fn first_line(text: &str) -> String {
639 text.lines()
640 .map(str::trim)
641 .find(|l| !l.is_empty())
642 .unwrap_or("")
643 .to_owned()
644}
645
646fn severity_name(s: Severity) -> &'static str {
647 match s {
648 Severity::Nit => "nit",
649 Severity::Minor => "minor",
650 Severity::Major => "major",
651 Severity::Blocker => "blocker",
652 }
653}
654
655fn vote_name(v: ReviewVote) -> &'static str {
656 match v {
657 ReviewVote::Approve => "approve",
658 ReviewVote::ApproveWithFindings => "approve_with_findings",
659 ReviewVote::Reject => "reject",
660 }
661}
662
663fn command(o: &CommandOutcome, warn_instead_of_fail: bool) -> CommandView {
664 let state = if o.resource_blocked {
665 "blocked"
666 } else if o.ok() {
667 "pass"
668 } else if warn_instead_of_fail {
669 "warn"
670 } else {
671 "fail"
672 };
673 CommandView {
674 command: o.command.clone(),
675 state,
676 code: o.code,
677 duration_ms: o.duration_ms,
678 output_tail: (!o.ok()).then(|| tail(&o.output_tail, OUTPUT_TAIL)),
679 }
680}
681
682fn status_tone(state: &RunState) -> Tone {
683 use crate::run::RunStatus as S;
684 if state.needs_attention() {
685 return Tone::Warn;
686 }
687 match state.status {
688 S::Merged | S::Ready => Tone::Ok,
689 S::Failed => Tone::Fail,
690 S::Stalled | S::Blocked => Tone::Warn,
691 _ => Tone::Neutral,
692 }
693}
694
695fn round_view(r: &ReviewRound, last: bool) -> RoundView {
696 let raised = r.reviews.iter().map(|x| x.findings.len()).sum();
697 let status = if r.incomplete() {
698 "incomplete"
699 } else if r.clean {
700 "clean"
701 } else {
702 "open"
703 };
704 let e2e_state = match r.e2e_status() {
705 E2eStatus::NotConfigured => "not_configured",
706 E2eStatus::Deferred => "deferred",
707 E2eStatus::Passed => "pass",
708 E2eStatus::Failed => "fail",
709 E2eStatus::ResourceBlocked => "blocked",
710 };
711 let e2e = E2eView {
712 state: e2e_state,
713 build_failed: r.e2e_status() == E2eStatus::Failed
714 && r.e2e.iter().any(CommandOutcome::build_failed),
715 defer_reason: r
716 .e2e_defer_reason
717 .clone()
718 .filter(|_| e2e_state == "deferred"),
719 retried: r.verify_retried,
720 };
721 let tone = if e2e_state == "fail" && !e2e.build_failed {
722 Tone::Fail
723 } else if status == "clean" {
724 Tone::Ok
725 } else {
726 Tone::Warn
727 };
728 let missing = if r.incomplete() {
729 r.reviews
730 .iter()
731 .filter_map(|x| {
732 x.failed
733 .as_ref()
734 .map(|why| format!("review-{}: {why}", x.reviewer))
735 })
736 .collect()
737 } else {
738 Vec::new()
739 };
740 let fix = r.fix.as_ref().map(|f| FixView {
741 agent: f.agent.clone(),
742 addressed: f.addressed.len(),
743 rejected: f.rejected.len(),
744 committed: f.committed,
745 tree_changed: r.progressed,
746 report_lost: f.failed.clone(),
747 continuation: f.continuation.as_ref().and_then(|c| match c.outcome {
748 ContinuationOutcome::NotNeeded => None,
749 ContinuationOutcome::Resumed => Some(format!("resumed x{}", c.attempts)),
750 ContinuationOutcome::Exhausted => Some(format!("exhausted x{}", c.attempts)),
751 ContinuationOutcome::QuotaLost => Some("quota".to_owned()),
752 ContinuationOutcome::NoSession => Some("no session to resume".to_owned()),
753 }),
754 });
755
756 let mut findings: Vec<FindingView> = Vec::new();
757 for rec in &r.reviews {
758 for f in &rec.findings {
759 findings.push(finding_view(r, rec.reviewer, f));
760 }
761 }
762 if let Some(fix) = &r.fix {
766 for rej in &fix.rejected {
767 if !findings.iter().any(|f| f.id == rej.id) {
768 findings.push(FindingView {
769 id: rej.id.clone(),
770 reviewer: None,
771 severity: None,
772 title: String::new(),
773 file: None,
774 line: None,
775 blocking: false,
776 state: "declined",
777 declined_why: Some(rej.why.clone()),
778 });
779 }
780 }
781 }
782
783 RoundView {
784 round: r.round,
785 status,
786 tone,
787 head: short(&r.head),
788 verified_head: r.verified_head.as_deref().map(short),
789 raised,
790 blocking: r.blocking,
791 answered: r.answered,
792 expected: r.expected,
793 missing,
794 e2e,
795 verdict: r.verdict.map(|v| VerdictView {
796 vote: vote_name(v),
797 label: v.label(),
798 split: r.vote_split,
799 }),
800 fix,
801 commands: r.e2e.iter().map(|o| command(o, false)).collect(),
802 reviewers: r
803 .reviews
804 .iter()
805 .map(|x| ReviewerRow {
806 reviewer: x.reviewer,
807 agent: x.agent.clone(),
808 vote: x.vote.map(vote_name),
809 failed: x.failed.clone(),
810 })
811 .collect(),
812 findings,
813 reconsideration: r
814 .reconsideration
815 .iter()
816 .map(|rv| RevoteRow {
817 reviewer: rv.reviewer,
818 vote: rv.vote.map(vote_name),
819 reason: match rv.vote {
820 Some(_) => rv.reason.clone(),
821 None => format!("no revote ({})", rv.failed.as_deref().unwrap_or("unknown")),
822 },
823 })
824 .collect(),
825 default_open: last || status != "clean",
826 }
827}
828
829fn finding_view(r: &ReviewRound, reviewer: usize, f: &Finding) -> FindingView {
830 let fixed = r
831 .fix
832 .as_ref()
833 .is_some_and(|fix| fix.addressed.contains(&f.id));
834 let declined = r
835 .fix
836 .as_ref()
837 .and_then(|fix| fix.rejected.iter().find(|x| x.id == f.id));
838 let (state, declined_why) = match (fixed, declined) {
839 (true, _) => ("fixed", None),
840 (false, Some(rej)) => ("declined", Some(rej.why.clone())),
841 (false, None) => ("open", None),
842 };
843 FindingView {
844 id: f.id.clone(),
845 reviewer: Some(reviewer),
846 severity: Some(severity_name(f.severity)),
847 title: f.title.clone(),
848 file: f.file.clone(),
849 line: f.line,
850 blocking: f.severity.blocks(),
851 state,
852 declined_why,
853 }
854}
855
856fn jobs_section(state: &RunState) -> Option<Section> {
857 let codex = state
858 .config
859 .agents
860 .iter()
861 .any(|a| a.kind == crate::config::AgentKind::Codex);
862 if state.jobs.is_empty() && !codex {
863 return None;
864 }
865 let mut by_seat: BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> = BTreeMap::new();
866 for j in &state.jobs {
867 by_seat
868 .entry((j.node.as_str(), j.seat.as_str()))
869 .or_default()
870 .push(j);
871 }
872 let groups = by_seat
873 .into_iter()
874 .map(|((node, seat), records)| JobGroup {
875 node: node.to_owned(),
876 seat: seat.to_owned(),
877 jobs: records
878 .into_iter()
879 .map(|j| JobRow {
880 id: j.id.clone(),
881 status: match j.status {
882 JobStatus::Completed => "completed",
883 JobStatus::Failed => "failed",
884 JobStatus::Unknown => "unknown",
885 },
886 exit_code: j.exit_code,
887 round: j.round,
888 checked_at: j.checked_at.to_string(),
889 description: first_line(&j.description),
890 result_summary: tail(&j.result_summary, OUTPUT_TAIL),
891 })
892 .collect(),
893 })
894 .collect();
895 Some(Section::Jobs {
896 head: head("background jobs", Tone::Neutral, false),
897 groups,
898 coverage: "adapter coverage: codex only today; other backends, and a command a CLI \
899 never reported finishing, leave no entry here - that is unknown, never \
900 \"nothing ran\"",
901 empty_note: state.jobs.is_empty().then_some(
902 "no completed command evidence yet for this run (see active seats for what is \
903 still mid-turn)",
904 ),
905 })
906}
907
908#[derive(Debug, Serialize)]
910pub struct GraftStrengthView {
911 pub id: String,
913 pub source: String,
915 pub text: String,
917 pub state: &'static str,
920 pub why: Option<String>,
922}
923
924#[derive(Debug, Serialize)]
926pub struct GraftHandoverView {
927 pub from: String,
929 pub to: String,
931 pub reason: String,
933}
934
935#[derive(Debug, Serialize)]
937pub struct GraftView {
938 pub outcome: &'static str,
940 pub reason: Option<String>,
942 pub agent: Option<String>,
944 pub handovers: Vec<GraftHandoverView>,
946 pub winner: Option<String>,
948 pub sources: Vec<String>,
950 pub lines: Option<usize>,
952 pub limit: Option<usize>,
954 pub requested: Vec<GraftStrengthView>,
956 pub adopted_count: usize,
958 pub rejected_count: usize,
960 pub unconfirmed_count: usize,
962 pub notes_md: Vec<crate::md::Node>,
964 pub reasoning_md: Vec<crate::md::Node>,
966 pub recovered: bool,
968 pub unavailable: Vec<crate::graft_report::Unavailable>,
970 pub diff_available: bool,
972 pub pre: String,
974 pub post: Option<String>,
976}
977
978fn graft_view(
979 state: &RunState,
980 g: &crate::run::GraftRecord,
981 rec: Option<&crate::graft_report::Recovered>,
982) -> GraftView {
983 use crate::graft_report::{Standing, Unavailable, standing};
984 use crate::run::GraftOutcome;
985 let mut unavailable: Vec<Unavailable> = Vec::new();
986 let mut recovered = false;
987 let requested_src: Vec<_> = if g.requested.is_empty() {
988 match rec {
989 Some(r) => {
990 recovered |= !r.requested.is_empty();
991 r.requested.clone()
992 }
993 None => Vec::new(),
994 }
995 } else {
996 g.requested.clone()
997 };
998 let adoption = match (&g.adoption, rec) {
999 (Some(a), _) => Some(a.clone()),
1000 (None, Some(r)) if g.adoption_error.is_none() => {
1001 recovered |= r.adoption.is_some();
1002 r.adoption.clone()
1003 }
1004 _ => None,
1005 };
1006 if let Some(r) = rec {
1007 unavailable.extend(r.unavailable.iter().cloned());
1008 }
1009 if let Some(e) = &g.adoption_error {
1010 unavailable.push(Unavailable {
1011 what: "adoption",
1012 reason: e.clone(),
1013 });
1014 }
1015 if g.requested.is_empty() && requested_src.is_empty() && rec.is_none() {
1016 unavailable.push(Unavailable {
1017 what: "requested",
1018 reason: "legacy_record".to_owned(),
1019 });
1020 }
1021 if adoption.is_none() && g.adoption_error.is_none() && rec.is_none() {
1022 unavailable.push(Unavailable {
1023 what: "adoption",
1024 reason: "legacy_record".to_owned(),
1025 });
1026 }
1027 let requested: Vec<GraftStrengthView> = requested_src
1028 .iter()
1029 .map(|s| {
1030 let (st, why) = match adoption.as_ref().map(|a| standing(s, a)) {
1031 None => ("unknown", None),
1032 Some(Standing::Adopted) => ("adopted", None),
1033 Some(Standing::Rejected(w)) => ("rejected", Some(w).filter(|w| !w.is_empty())),
1034 Some(Standing::Unreported) => ("unreported", None),
1035 };
1036 GraftStrengthView {
1037 id: s.id.clone(),
1038 source: s.source.to_string(),
1039 text: s.text.clone(),
1040 state: st,
1041 why,
1042 }
1043 })
1044 .collect();
1045 let count = |st: &str| requested.iter().filter(|r| r.state == st).count();
1046 let (adopted_count, rejected_count) = (count("adopted"), count("rejected"));
1047 let mut sources: Vec<String> = requested.iter().map(|r| r.source.clone()).collect();
1048 sources.dedup();
1049 let (outcome, reason) = match &g.outcome {
1050 GraftOutcome::Applied => ("applied", None),
1051 GraftOutcome::Running => ("unfinished", None),
1052 GraftOutcome::RolledBack(w) => ("rolled_back", Some(w.clone())),
1053 };
1054 let md = |t: &str| {
1055 if t.trim().is_empty() {
1056 Vec::new()
1057 } else {
1058 crate::md::to_nodes(t, &crate::md::ImageBase::None)
1059 }
1060 };
1061 let short = |c: &str| c.chars().take(12).collect::<String>();
1062 if outcome == "applied" && g.post.is_none() {
1063 unavailable.push(Unavailable {
1064 what: "diff",
1065 reason: "no_post_commit".to_owned(),
1066 });
1067 }
1068 GraftView {
1069 outcome,
1070 reason,
1071 agent: g.agent.clone(),
1072 handovers: state
1073 .handovers
1074 .iter()
1075 .filter(|h| h.node == "graft")
1076 .map(|h| GraftHandoverView {
1077 from: h.from.clone(),
1078 to: h.to.clone(),
1079 reason: h.reason.clone(),
1080 })
1081 .collect(),
1082 winner: state.tally.as_ref().map(|t| t.winner.to_string()),
1083 sources,
1084 lines: g.lines,
1085 limit: g.limit,
1086 unconfirmed_count: requested.len() - adopted_count - rejected_count,
1087 adopted_count,
1088 rejected_count,
1089 requested,
1090 notes_md: adoption.as_ref().map(|a| md(&a.notes)).unwrap_or_default(),
1091 reasoning_md: adoption
1092 .as_ref()
1093 .map(|a| md(&a.reasoning))
1094 .unwrap_or_default(),
1095 recovered,
1096 unavailable,
1097 diff_available: g.post.is_some(),
1098 pre: short(&g.pre),
1099 post: g.post.as_deref().map(short),
1100 }
1101}
1102
1103pub fn build(state: &RunState, live: Liveness) -> RunReportView {
1106 build_with(state, live, None)
1107}
1108
1109pub fn build_with(
1112 state: &RunState,
1113 live: Liveness,
1114 graft: Option<&crate::graft_report::Recovered>,
1115) -> RunReportView {
1116 let mut sections: Vec<Section> = Vec::new();
1117 let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
1118
1119 sections.push(Section::Candidates {
1120 head: head(
1121 "candidates",
1122 if state.candidates.iter().any(|c| c.failed.is_some()) {
1123 Tone::Warn
1124 } else {
1125 Tone::Neutral
1126 },
1127 true,
1128 ),
1129 items: state
1130 .candidates
1131 .iter()
1132 .map(|c| {
1133 let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
1134 let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
1135 (Some(e), _, _) => ("failed", Some(e.clone())),
1136 (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
1137 (None, true, None) => ("no_change", None),
1138 _ => (
1139 "changed",
1140 Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
1141 ),
1142 };
1143 CandidateRow {
1144 label: c.label.to_string(),
1145 agent: c.agent.clone(),
1146 state: st,
1147 detail,
1148 files: c.files,
1149 commits: c.commits,
1150 duration_secs: c.duration_ms / 1000,
1151 winner: is_winner && met_quorum,
1152 provisional_winner: is_winner && !met_quorum,
1153 }
1154 })
1155 .collect(),
1156 });
1157
1158 if !state.judgements.is_empty() {
1159 sections.push(Section::Judging {
1160 head: head("blind judging", Tone::Neutral, false),
1161 judges: state
1162 .judgements
1163 .iter()
1164 .map(|j| JudgeRow {
1165 judge: j.judge,
1166 agent: j.agent.clone(),
1167 ranking: j.ranking.iter().collect(),
1168 confidence: j.confidence,
1169 failed: j.failed.clone(),
1170 })
1171 .collect(),
1172 deliberation: state
1173 .tally
1174 .as_ref()
1175 .filter(|t| t.deliberated)
1176 .map(|_| {
1177 state
1178 .deliberation
1179 .iter()
1180 .flat_map(|round| {
1181 round.turns.iter().map(move |turn| {
1182 format!(
1183 "r{} judge {} -> {}",
1184 round.round,
1185 turn.judge,
1186 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
1187 )
1188 })
1189 })
1190 .collect()
1191 })
1192 .unwrap_or_default(),
1193 rounds: state
1194 .deliberation
1195 .iter()
1196 .map(|r| DeliberationRoundView {
1197 round: r.round,
1198 turns: r
1199 .turns
1200 .iter()
1201 .map(|t| DeliberationTurnView {
1202 judge: t.judge,
1203 agent: t.agent.clone(),
1204 tentative: t.tentative.map(|c| c.to_string()),
1205 body: t.body.clone(),
1206 body_md: crate::md::to_nodes(&t.body, &crate::md::ImageBase::None),
1207 })
1208 .collect(),
1209 })
1210 .collect(),
1211 votes: state
1212 .votes
1213 .iter()
1214 .map(|v| VoteRow {
1215 judge: v.judge,
1216 agent: v.agent.clone(),
1217 vote: v.vote.unwrap_or('?').to_string(),
1218 changed: v.changed,
1219 })
1220 .collect(),
1221 });
1222 }
1223
1224 if let Some(t) = &state.tally {
1225 let initial = match (t.rankings, t.unanimous_initial) {
1226 (0, _) => "none",
1227 (1, _) => "one",
1228 (_, true) => "unanimous",
1229 (_, false) => "split",
1230 };
1231 sections.push(Section::Tally {
1232 head: head(
1233 "tally",
1234 if t.met_quorum { Tone::Ok } else { Tone::Fail },
1235 true,
1236 ),
1237 tally: TallyView {
1238 met_quorum: t.met_quorum,
1239 decided: t.met_quorum,
1240 provisional: !t.met_quorum,
1241 winner: t.winner.to_string(),
1242 judges: t.judges,
1243 present: t.present,
1244 quorum: t.quorum,
1245 uncontested: t.uncontested.clone(),
1246 first_choice: t
1247 .first_choice
1248 .iter()
1249 .map(|(k, v)| (k.to_string(), *v))
1250 .collect(),
1251 initial,
1252 unanimous_final: t.unanimous_final,
1253 changed_votes: t.changed_votes,
1254 tie_break: t.tie_break.clone(),
1255 rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
1256 },
1257 });
1258 }
1259
1260 let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1261 (!lines.is_empty()).then(|| Section::Notes {
1262 head: head(title, tone, true),
1263 key,
1264 lines,
1265 })
1266 };
1267 sections.extend(notes(
1268 "handovers",
1269 "seats handed over",
1270 Tone::Warn,
1271 state
1272 .handovers
1273 .iter()
1274 .map(|h| {
1275 format!(
1276 "{} {} -> {} ({}: {})",
1277 h.seat, h.from, h.to, h.node, h.reason
1278 )
1279 })
1280 .collect(),
1281 ));
1282 sections.extend(notes(
1283 "withheld",
1284 "withheld from commit",
1285 Tone::Warn,
1286 state
1287 .withheld
1288 .iter()
1289 .map(|w| {
1290 format!(
1291 "{} ({} lockfile; the directory uses {})",
1292 w.path, w.manager, w.kept_by
1293 )
1294 })
1295 .collect(),
1296 ));
1297
1298 if let Some(g) = &state.graft {
1299 let view = graft_view(state, g, graft);
1300 let tone = match view.outcome {
1301 "applied" if view.unconfirmed_count == 0 && !view.requested.is_empty() => Tone::Ok,
1302 "applied" => Tone::Neutral,
1303 _ => Tone::Warn,
1304 };
1305 sections.push(Section::Graft {
1306 head: head("graft", tone, true),
1307 graft: view,
1308 });
1309 }
1310
1311 if !state.reviews.is_empty() {
1312 let n = state.reviews.len();
1313 let rounds: Vec<RoundView> = state
1314 .reviews
1315 .iter()
1316 .enumerate()
1317 .map(|(i, r)| round_view(r, i + 1 == n))
1318 .collect();
1319 let handed_off = state.handed_off_with_open_findings().then(|| {
1320 format!(
1321 "handed off with {} finding(s) still open - gate and e2e were green; see above \
1322 for what a person should still look at",
1323 state.open_findings().len()
1324 )
1325 });
1326 let tone = if handed_off.is_some() {
1327 Tone::Warn
1328 } else {
1329 rounds.last().map_or(Tone::Neutral, |r| r.tone)
1330 };
1331 sections.push(Section::Review {
1332 head: head("review + verification", tone, true),
1333 rounds,
1334 handed_off,
1335 });
1336 }
1337
1338 if !state.operator_fixes.is_empty() {
1339 sections.push(Section::OperatorFixes {
1340 head: head("operator fix(es)", Tone::Neutral, true),
1341 requests: state
1342 .operator_fixes
1343 .iter()
1344 .map(|req| OperatorFixView {
1345 requested_at: req.requested_at.to_string(),
1346 stale: req.stale,
1347 reason: req.reason.clone(),
1348 findings: req
1349 .findings
1350 .iter()
1351 .map(|f| {
1352 let (outcome, why) = match &f.outcome {
1353 OperatorFixOutcome::Pending => ("pending", None),
1354 OperatorFixOutcome::Addressed => ("addressed", None),
1355 OperatorFixOutcome::Rejected { why } => {
1356 ("rejected", Some(why.clone()))
1357 }
1358 OperatorFixOutcome::Unreported => ("unreported", None),
1359 };
1360 OperatorFindingRow {
1361 id: f.id.clone(),
1362 severity: severity_name(f.severity),
1363 title: f.title.clone(),
1364 outcome,
1365 why,
1366 }
1367 })
1368 .collect(),
1369 follow_up_review_run: req.follow_up_review_run.clone(),
1370 unverified_commit: req.follow_up_review_run.is_none()
1371 && req.fix.as_ref().is_some_and(|fx| fx.committed),
1372 })
1373 .collect(),
1374 });
1375 }
1376
1377 if let Some(bs) = &state.base_sync {
1378 let (st, tone) = if bs.already_in.is_some() {
1379 ("already_in", Tone::Neutral)
1380 } else if bs.conflict.is_some() {
1381 ("conflict", Tone::Fail)
1382 } else if bs.behind == 0 {
1383 ("in_sync", Tone::Ok)
1384 } else {
1385 ("behind", Tone::Warn)
1386 };
1387 sections.push(Section::BaseSync {
1388 head: head("base sync", tone, true),
1389 base_sync: BaseSyncView {
1390 base_branch: state.base_branch.clone(),
1391 tip: short(&bs.tip),
1392 state: st,
1393 behind: bs.behind,
1394 attempts: bs.attempts,
1395 conflict: bs.conflict.as_deref().map(first_line),
1396 already_in: bs
1397 .already_in
1398 .as_ref()
1399 .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1400 },
1401 });
1402 }
1403
1404 if !state.pre_gate.is_empty() {
1405 let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1406 sections.push(Section::PreGate {
1407 head: head(
1408 "pre_gate",
1409 if all_ok { Tone::Ok } else { Tone::Warn },
1410 !all_ok,
1411 ),
1412 commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1413 committed: state.pre_gate_commit.as_deref().map(short),
1414 });
1415 }
1416
1417 match state.gate_status() {
1418 GateStatus::NotRun => {}
1419 gs => {
1420 let st = match gs {
1421 GateStatus::PassedWithNoCommands => "passed_no_commands",
1422 GateStatus::Passed => "passed",
1423 _ => "failed",
1424 };
1425 sections.push(Section::Gate {
1426 head: head("gate", if gs.ok() { Tone::Ok } else { Tone::Fail }, true),
1427 status: st,
1428 commands: state.gate.iter().map(|o| command(o, false)).collect(),
1429 });
1430 }
1431 }
1432
1433 if let Some(m) = &state.merge {
1434 let by_design = m.mode == MergeMode::None;
1435 let (st, tone) = if by_design {
1436 ("not_landed_by_design", Tone::Neutral)
1437 } else if m.ok {
1438 ("ok", Tone::Ok)
1439 } else if m.empty {
1440 ("empty", Tone::Warn)
1441 } else {
1442 ("not_merged", Tone::Warn)
1443 };
1444 sections.push(Section::Merge {
1445 head: head("merge", tone, true),
1446 merge: MergeView {
1447 mode: format!("{:?}", m.mode).to_lowercase(),
1448 state: st,
1449 detail: m.detail.lines().next().unwrap_or("").to_owned(),
1450 branch: by_design
1451 .then(|| state.winner().map(|w| w.branch.clone()))
1452 .flatten(),
1453 squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1454 red_at_merge: state
1455 .pr
1456 .as_ref()
1457 .map(|pr| pr.red_at_merge.clone())
1458 .unwrap_or_default(),
1459 },
1460 });
1461 }
1462
1463 if !state.followups.is_empty() {
1464 sections.push(Section::Followups {
1465 head: head("follow-ups", Tone::Neutral, true),
1466 items: state
1467 .followups
1468 .iter()
1469 .map(|f| FollowupRow {
1470 task: crate::queue::short(&f.task).to_owned(),
1471 findings: f.findings.clone(),
1472 })
1473 .collect(),
1474 });
1475 }
1476
1477 if let Some(b) = &state.release_bump {
1478 let automerge = if b.automerge_enabled {
1479 "enabled"
1480 } else if b.merged_directly {
1481 "merged_directly"
1482 } else if b.local {
1483 "local"
1484 } else if b.problem.is_some() {
1485 "failed"
1486 } else {
1487 "not_enabled"
1488 };
1489 sections.push(Section::ReleaseBump {
1490 head: head(
1491 "release bump",
1492 if automerge == "failed" || b.action_required.is_some() {
1493 Tone::Warn
1494 } else {
1495 Tone::Neutral
1496 },
1497 true,
1498 ),
1499 bump: ReleaseBumpView {
1500 version: b.version.clone(),
1501 pr_url: b.pr_url.clone(),
1502 automerge,
1503 problem: b.problem.as_deref().map(first_line),
1504 action_required: b.action_required.clone(),
1505 },
1506 });
1507 }
1508
1509 sections.extend(notes(
1510 "leaks",
1511 "blindness warnings",
1512 Tone::Warn,
1513 state
1514 .leaks
1515 .iter()
1516 .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1517 .collect(),
1518 ));
1519
1520 if let Some(w) = state.winner()
1521 && !w.folded
1522 {
1523 sections.push(Section::WinnerWorktree {
1524 head: head("winner worktree", Tone::Neutral, true),
1525 worktree: w.worktree.display().to_string(),
1526 branch: w.branch.clone(),
1527 provisional: !met_quorum,
1528 });
1529 }
1530
1531 sections.extend(jobs_section(state));
1532
1533 RunReportView {
1534 schema: SCHEMA,
1535 header: Header {
1536 id: state.id.clone(),
1537 status: state.status.display_label().to_owned(),
1538 tone: status_tone(state),
1539 unmerged_by_design: state.unmerged_by_design(),
1540 needs_attention: state.needs_attention(),
1541 repo: state.repo.display().to_string(),
1542 base_branch: state.base_branch.clone(),
1543 base_commit: short(&state.base_commit),
1544 created: state.created_local(),
1545 task: first_line(&state.instruction),
1546 origin: crate::run::origin_label(state.origin.as_ref()),
1547 state_dir: state.dir().display().to_string(),
1548 },
1549 sections,
1550 active_seats: crate::report::active_seats(state, live),
1551 }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556 use super::*;
1557 use crate::config::Config;
1558 use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1559 use crate::verdict::Rejection;
1560 use std::path::PathBuf;
1561
1562 fn state() -> RunState {
1563 crate::run::pin_test_home();
1565 RunState::new(
1566 PathBuf::from("/repo/magi"),
1567 "main".to_owned(),
1568 "0123456789abcdef".to_owned(),
1569 "Add a thing\n\nlong".to_owned(),
1570 Config::default(),
1571 )
1572 }
1573
1574 fn finding(id: &str, severity: Severity) -> Finding {
1575 Finding {
1576 id: id.to_owned(),
1577 severity,
1578 file: Some("src/a.rs".to_owned()),
1579 line: Some(7),
1580 title: format!("title {id}"),
1581 detail: String::new(),
1582 }
1583 }
1584
1585 fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1586 CommandOutcome {
1587 command: "cargo test".to_owned(),
1588 code,
1589 output_tail: "boom\n".repeat(1_000),
1590 duration_ms: 5,
1591 resource_blocked: blocked,
1592 }
1593 }
1594
1595 fn round(n: usize) -> ReviewRound {
1596 let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1597 "round": n, "head": "abcdef0123", "reviews": []
1598 }))
1599 .expect("round");
1600 r.answered = 1;
1601 r.expected = 1;
1602 r.reviews.push(
1603 serde_json::from_value::<ReviewRecord>(serde_json::json!({
1604 "reviewer": 1, "agent": "a"
1605 }))
1606 .expect("record"),
1607 );
1608 r
1609 }
1610
1611 fn tally(met: bool) -> Tally {
1612 serde_json::from_value(serde_json::json!({
1613 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1614 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1615 "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1616 "quorum": 2, "met_quorum": met, "rankings": 1
1617 }))
1618 .expect("tally")
1619 }
1620
1621 fn kinds(v: &RunReportView) -> Vec<String> {
1622 serde_json::to_value(&v.sections)
1623 .expect("json")
1624 .as_array()
1625 .expect("array")
1626 .iter()
1627 .map(|s| s["kind"].as_str().expect("kind").to_owned())
1628 .collect()
1629 }
1630
1631 #[test]
1632 fn sections_follow_the_text_report_order() {
1633 let mut s = state();
1634 s.tally = Some(tally(true));
1635 s.reviews.push(round(1));
1636 s.base_sync = Some(
1637 serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1638 .expect("sync"),
1639 );
1640 s.pre_gate.push(cmd(Some(0), false));
1641 s.jobs.push(
1642 serde_json::from_value(serde_json::json!({
1643 "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1644 "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1645 "exit_code": 0, "source": "codex"
1646 }))
1647 .expect("job"),
1648 );
1649 let v = build(&s, Liveness::Unknown);
1650 assert_eq!(
1651 kinds(&v),
1652 [
1653 "candidates",
1654 "tally",
1655 "review",
1656 "base_sync",
1657 "pre_gate",
1658 "jobs"
1659 ]
1660 );
1661 let json = serde_json::to_value(&v).expect("json");
1662 assert_eq!(json["schema"], SCHEMA);
1663 let jobs = json["sections"].as_array().expect("a").last().expect("l");
1664 assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1665 assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1666 }
1667
1668 #[test]
1669 fn a_stalled_tally_is_never_decided() {
1670 let mut s = state();
1671 s.tally = Some(tally(false));
1672 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1673 let t = json["sections"]
1674 .as_array()
1675 .expect("a")
1676 .iter()
1677 .find(|x| x["kind"] == "tally")
1678 .expect("tally");
1679 assert_eq!(t["tally"]["met_quorum"], false);
1680 assert_eq!(t["tally"]["decided"], false);
1681 assert_eq!(t["tally"]["provisional"], true);
1682 assert_eq!(t["tone"], "fail");
1683 }
1684
1685 #[test]
1686 fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1687 let mut s = state();
1688 let mut r = round(1);
1689 r.reviews[0].findings = vec![
1690 finding("R1-1-1", Severity::Blocker),
1691 finding("R1-1-2", Severity::Major),
1692 finding("R1-1-3", Severity::Minor),
1693 finding("R1-1-4", Severity::Nit),
1694 ];
1695 r.fix = Some(
1696 serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1697 );
1698 let fix = r.fix.as_mut().expect("fix");
1699 fix.addressed = vec!["R1-1-1".to_owned()];
1700 fix.rejected = vec![
1701 Rejection {
1702 id: "R1-1-2".to_owned(),
1703 why: "not a bug".to_owned(),
1704 },
1705 Rejection {
1706 id: "R9-9-9".to_owned(),
1707 why: "unknown id".to_owned(),
1708 },
1709 ];
1710 s.reviews.push(r);
1711 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1712 let review = json["sections"]
1713 .as_array()
1714 .expect("a")
1715 .iter()
1716 .find(|x| x["kind"] == "review")
1717 .expect("review");
1718 let f = &review["rounds"][0]["findings"];
1719 let at = |i: usize| {
1720 (
1721 f[i]["severity"].clone(),
1722 f[i]["blocking"].clone(),
1723 f[i]["state"].clone(),
1724 )
1725 };
1726 assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1727 assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1728 assert_eq!(f[1]["declined_why"], "not a bug");
1729 assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1730 assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1731 assert_eq!(
1732 f[4]["id"], "R9-9-9",
1733 "an unmatched declined id is not dropped"
1734 );
1735 assert_eq!(f[4]["state"], "declined");
1736 }
1737
1738 #[test]
1739 fn a_deferred_e2e_is_not_a_failed_one() {
1740 let mut s = state();
1741 let mut deferred = round(1);
1742 deferred.e2e_deferred = true;
1743 deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1744 let mut failed = round(2);
1745 failed.e2e = vec![cmd(Some(1), false)];
1746 let mut blocked = round(3);
1747 blocked.e2e = vec![cmd(None, true)];
1748 let none = round(4);
1749 s.reviews = vec![deferred, failed, blocked, none];
1750 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1751 let rounds = &json["sections"]
1752 .as_array()
1753 .expect("a")
1754 .iter()
1755 .find(|x| x["kind"] == "review")
1756 .expect("review")["rounds"];
1757 let st = |i: usize| {
1758 rounds[i]["e2e"]["state"]
1759 .as_str()
1760 .expect("state")
1761 .to_owned()
1762 };
1763 assert_eq!(
1764 [st(0), st(1), st(2), st(3)],
1765 ["deferred", "fail", "blocked", "not_configured"]
1766 );
1767 assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1768 let tail = rounds[1]["commands"][0]["output_tail"]
1769 .as_str()
1770 .expect("tail");
1771 assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1772 }
1773 fn graft_json(s: &RunState, rec: Option<&crate::graft_report::Recovered>) -> serde_json::Value {
1774 let json = serde_json::to_value(build_with(s, Liveness::Unknown, rec)).expect("json");
1775 json["sections"]
1776 .as_array()
1777 .expect("a")
1778 .iter()
1779 .find(|x| x["kind"] == "graft")
1780 .cloned()
1781 .unwrap_or(serde_json::Value::Null)
1782 }
1783
1784 fn graft_record(outcome: crate::run::GraftOutcome) -> crate::run::GraftRecord {
1785 use crate::run::{GraftAdoption, GraftRejection, GraftStrength};
1786 crate::run::GraftRecord {
1787 pre: "aaaaaaaaaaaaaaaa".to_owned(),
1788 post: Some("bbbbbbbbbbbbbbbb".to_owned()),
1789 lines: Some(25),
1790 limit: Some(227),
1791 agent: Some("astra".to_owned()),
1792 outcome,
1793 requested: vec![
1794 GraftStrength {
1795 id: "A1".into(),
1796 source: 'A',
1797 text: "Build Persona directly in the test".into(),
1798 },
1799 GraftStrength {
1800 id: "A2".into(),
1801 source: 'A',
1802 text: "Cache the lookup".into(),
1803 },
1804 GraftStrength {
1805 id: "A3".into(),
1806 source: 'A',
1807 text: "Rename the helper".into(),
1808 },
1809 ],
1810 adoption: Some(GraftAdoption {
1811 addressed: vec!["A1".into()],
1812 rejected: vec![GraftRejection {
1813 id: "A2".into(),
1814 why: "too large".into(),
1815 }],
1816 notes: "ported <script>alert(1)</script>".into(),
1817 reasoning: "thinking".into(),
1818 }),
1819 adoption_error: None,
1820 }
1821 }
1822
1823 #[test]
1824 fn an_applied_graft_separates_requested_from_confirmed() {
1825 let mut s = state();
1826 s.graft = Some(graft_record(crate::run::GraftOutcome::Applied));
1827 s.handovers.push(crate::run::Handover {
1828 at: jiff::Timestamp::now(),
1829 node: "graft".into(),
1830 seat: "graft".into(),
1831 from: "sonnet".into(),
1832 to: "astra".into(),
1833 reason: "timed out".into(),
1834 });
1835 let g = &graft_json(&s, None)["graft"];
1836 assert_eq!(g["outcome"], "applied");
1837 assert_eq!(g["agent"], "astra");
1838 assert_eq!(g["handovers"][0]["from"], "sonnet");
1839 assert_eq!(g["lines"], 25);
1840 assert_eq!(g["limit"], 227);
1841 let st: Vec<_> = g["requested"]
1842 .as_array()
1843 .expect("a")
1844 .iter()
1845 .map(|r| r["state"].as_str().expect("s").to_owned())
1846 .collect();
1847 assert_eq!(st, ["adopted", "rejected", "unreported"]);
1848 assert_eq!(g["requested"][1]["why"], "too large");
1849 assert_eq!(g["adopted_count"], 1);
1850 assert_eq!(g["unconfirmed_count"], 1);
1851 assert_eq!(g["diff_available"], true);
1852 }
1853
1854 #[test]
1855 fn a_rolled_back_graft_carries_its_reason_and_no_diff() {
1856 let mut s = state();
1857 let mut g = graft_record(crate::run::GraftOutcome::RolledBack("too big".into()));
1858 g.post = None;
1859 s.graft = Some(g);
1860 let v = graft_json(&s, None);
1861 assert_eq!(v["graft"]["outcome"], "rolled_back");
1862 assert_eq!(v["graft"]["reason"], "too big");
1863 assert_eq!(v["graft"]["diff_available"], false);
1864 assert_eq!(v["tone"], "warn");
1865 }
1866
1867 #[test]
1868 fn a_legacy_record_says_unavailable_and_claims_nothing() {
1869 let mut s = state();
1870 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1871 g.requested.clear();
1872 g.adoption = None;
1873 s.graft = Some(g);
1874 let v = graft_json(&s, None);
1875 let reasons: Vec<_> = v["graft"]["unavailable"]
1876 .as_array()
1877 .expect("a")
1878 .iter()
1879 .map(|u| u["reason"].as_str().expect("s"))
1880 .collect();
1881 assert_eq!(reasons, ["legacy_record", "legacy_record"]);
1882 assert_eq!(v["graft"]["requested"].as_array().expect("a").len(), 0);
1883 assert_eq!(v["graft"]["adopted_count"], 0);
1884 }
1885
1886 #[test]
1887 fn recovered_details_fill_a_legacy_record_and_are_marked() {
1888 let mut s = state();
1889 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1890 let requested = std::mem::take(&mut g.requested);
1891 let adoption = g.adoption.take();
1892 s.graft = Some(g);
1893 let rec = crate::graft_report::Recovered {
1894 requested,
1895 adoption,
1896 unavailable: Vec::new(),
1897 };
1898 let g = &graft_json(&s, Some(&rec))["graft"];
1899 assert_eq!(g["recovered"], true);
1900 assert_eq!(g["requested"][0]["state"], "adopted");
1901 }
1902
1903 #[test]
1904 fn agent_text_stays_text_in_the_view() {
1905 let mut s = state();
1906 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1907 g.requested[0].text = "<img src=x onerror=alert(1)>".into();
1908 s.graft = Some(g);
1909 let v = graft_json(&s, None);
1910 assert_eq!(
1911 v["graft"]["requested"][0]["text"],
1912 "<img src=x onerror=alert(1)>"
1913 );
1914 let notes = v["graft"]["notes_md"].to_string();
1916 assert!(!notes.contains("\"html\""), "{notes}");
1917 }
1918}