Skip to main content

magi/
pr_outcome.rs

1//! What a pull request description may say about commits and verification.
2//!
3//! A fixer's report is written inside its own sandbox, at one moment: "could
4//! not commit", "HTTP tests could not run". magi may then commit the work
5//! itself and verify a later commit successfully, so copying the report into
6//! the description verbatim makes it contradict the final state. These
7//! functions are pure: they read the structured records (`FixRecord::committed`,
8//! `ReviewRound::verified_head`, `RunState::gate` / `gate_head`) and the final
9//! commit, and build public facts without copying historical reports. The
10//! raw report stays in `RunState` and the run's local artifacts untouched.
11//! Everything generated here is English (the GitHub text gate).
12
13use crate::run::{E2eStatus, ReviewRound, RunState};
14
15/// The commit the pull request will carry, as far as the caller knows.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FinalHead<'a> {
18    /// Not asked (previews, tests): no verification section added.
19    Unreported,
20    /// Asked, but it could not be read: nothing is judged verified.
21    Unknown,
22    /// The final commit.
23    At(&'a str),
24}
25
26fn short(sha: &str) -> &str {
27    sha.get(..7).unwrap_or(sha)
28}
29
30fn all_ok(outcomes: &[crate::run::CommandOutcome]) -> bool {
31    !outcomes.is_empty() && outcomes.iter().all(|o| o.ok())
32}
33
34/// Public review history contains structured facts only. Operator reports,
35/// finding prose and rejection reasons remain untouched in local records.
36pub fn review_rounds(state: &RunState) -> String {
37    let mut out = String::new();
38    for r in &state.reviews {
39        let Some(fix) = &r.fix else { continue };
40        if out.is_empty() {
41            out.push_str("## Review rounds\n\n");
42        }
43        out.push_str(&format!("### Round {}\n\n", r.round));
44        out.push_str(if fix.committed {
45            "The fix is committed on the branch (including work committed by magi).\n\n"
46        } else {
47            "This round produced no commit.\n\n"
48        });
49        for id in &fix.addressed {
50            out.push_str(&format!("- Addressed: `{id}`\n"));
51        }
52        for rejection in &fix.rejected {
53            out.push_str(&format!("- Declined in this round: `{}`\n", rejection.id));
54        }
55        out.push('\n');
56    }
57    out
58}
59
60/// Evidence for the independent public description. Raw reports are never
61/// input: only open findings and explicit decline reasons need paraphrasing.
62pub fn summary_evidence(state: &RunState, head: FinalHead<'_>) -> String {
63    let open = state.last_round_findings();
64    let declined: Vec<_> = state
65        .reviews
66        .iter()
67        .filter_map(|r| r.fix.as_ref())
68        .flat_map(|f| &f.rejected)
69        .map(|rejection| {
70            let finding = state
71                .reviews
72                .iter()
73                .flat_map(|r| &r.reviews)
74                .flat_map(|r| &r.findings)
75                .find(|f| f.id == rejection.id);
76            serde_json::json!({"id": rejection.id, "reason": rejection.why,
77                "finding": finding})
78        })
79        .collect();
80    let verification = serde_json::json!({
81        "gate": state.gate.iter().map(|o| (&o.command, o.code, o.resource_blocked)).collect::<Vec<_>>(),
82        "tests": state.reviews.iter().map(|r| (r.round, &r.verified_head,
83            r.e2e.iter().map(|o| (&o.command, o.code, o.resource_blocked)).collect::<Vec<_>>())).collect::<Vec<_>>()
84    });
85    format!(
86        "{}{}\nOpen findings (local source material):\n{}\nDeclined findings (local source material):\n{}\nVerification commands and outcomes:\n{verification}",
87        review_rounds(state),
88        final_verification(state, head),
89        serde_json::to_string(&open).unwrap_or_default(),
90        serde_json::to_string(&declined).unwrap_or_default()
91    )
92}
93
94/// A generation policy change or a new evidence snapshot invalidates old prose.
95pub fn summary_fingerprint(state: &RunState, evidence: &str) -> String {
96    crate::github_text::fingerprint(
97        &format!(
98            "public-description-v3:{}",
99            state.config.graph.github_language
100        ),
101        evidence,
102    )
103}
104
105/// Every finding requiring public context must be covered by the generator.
106pub fn required_finding_ids(state: &RunState) -> Vec<String> {
107    let mut ids: Vec<_> = state
108        .last_round_findings()
109        .iter()
110        .map(|f| f.id.clone())
111        .collect();
112    ids.extend(
113        state
114            .reviews
115            .iter()
116            .filter_map(|r| r.fix.as_ref())
117            .flat_map(|f| f.rejected.iter().map(|r| r.id.clone())),
118    );
119    ids.sort();
120    ids.dedup();
121    ids
122}
123
124/// Check the prose before appending structural facts: an ID in the appended
125/// finding list cannot substitute for the generator's explanation of it.
126pub fn covers_required_findings(state: &RunState, prose: &str) -> bool {
127    required_finding_ids(state).iter().all(|id| {
128        prose
129            .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
130            .any(|word| word == id)
131    })
132}
133
134fn last_checked(state: &RunState) -> Option<&ReviewRound> {
135    state
136        .reviews
137        .iter()
138        .rev()
139        .find(|r| r.e2e_status() != E2eStatus::NotConfigured)
140}
141
142/// The `## Final verification` section, from the records alone. Empty for
143/// [`FinalHead::Unreported`].
144pub fn final_verification(state: &RunState, head: FinalHead<'_>) -> String {
145    if head == FinalHead::Unreported {
146        return String::new();
147    }
148    let at = match head {
149        FinalHead::At(h) => Some(h),
150        _ => None,
151    };
152    let mut lines = vec![match at {
153        Some(h) => format!("- Final commit: `{}`", short(h)),
154        None => "- Final commit: unknown, so no check below is claimed for it".to_owned(),
155    }];
156    let ran_on = |recorded: Option<&str>| -> String {
157        match (at, recorded) {
158            (Some(h), Some(r)) if h == r => "on the final commit".to_owned(),
159            (_, Some(r)) => format!(
160                "on an earlier commit (`{}`); later changes were not re-verified by it",
161                short(r)
162            ),
163            (_, None) => "on a commit that was not recorded".to_owned(),
164        }
165    };
166    match last_checked(state) {
167        None => lines.push("- Tests (`verify.e2e`): not configured or never run".to_owned()),
168        Some(r) => lines.push(match r.e2e_status() {
169            E2eStatus::Passed => format!(
170                "- Tests (`verify.e2e`): passed {} (round {})",
171                ran_on(r.verified_head.as_deref()),
172                r.round
173            ),
174            E2eStatus::Failed => format!(
175                "- Tests (`verify.e2e`): FAILED {} (round {})",
176                ran_on(r.verified_head.as_deref()),
177                r.round
178            ),
179            E2eStatus::Deferred => format!(
180                "- Tests (`verify.e2e`): deferred in round {}, not run",
181                r.round
182            ),
183            E2eStatus::ResourceBlocked => format!(
184                "- Tests (`verify.e2e`): not run in round {} (blocked on a shared resource)",
185                r.round
186            ),
187            E2eStatus::NotConfigured => unreachable!(),
188        }),
189    }
190    lines.push(if !state.gate_ran {
191        "- Gate (`verify.gate`): not run".to_owned()
192    } else if state.gate.is_empty() {
193        "- Gate (`verify.gate`): no commands configured".to_owned()
194    } else if !all_ok(&state.gate) {
195        format!(
196            "- Gate (`verify.gate`): FAILED {}",
197            ran_on(state.gate_head.as_deref())
198        )
199    } else {
200        format!(
201            "- Gate (`verify.gate`): passed {}",
202            ran_on(state.gate_head.as_deref())
203        )
204    });
205    if !state.gate_fixes.is_empty() {
206        lines.push(format!(
207            "- Gate-fix rounds spent: {}",
208            state.gate_fixes.len()
209        ));
210    }
211    if !state.rebase_fixes.is_empty() {
212        lines.push(format!(
213            "- Rebase conflict rounds spent: {}",
214            state.rebase_fixes.len()
215        ));
216    }
217    format!("## Final verification\n\n{}\n\n", lines.join("\n"))
218}