Skip to main content

magi/
followup.rs

1//! Follow-up tasks for the findings a merged run left open.
2//!
3//! A run can hand off after its review budget is spent with findings still
4//! open (and, with `land_approval` off, merge unattended). Those findings
5//! live in the pull request body, which nobody reads after the merge. This
6//! module files them as queue tasks instead, once the merge is confirmed.
7//!
8//! What is filed: every Major-or-above finding of the last review round, and
9//! every finding of a seat whose final vote was reject (a vote carries no
10//! per-finding reasoning, so the seat's Minor/Nit findings come along). Other
11//! Minor/Nit findings are only listed, in the pull-request comment.
12//!
13//! Best-effort by construction, like `bump::after_merge`: [`after_merge`]
14//! returns `()`, records every failure as a run event and never touches
15//! `status`.
16
17use anyhow::Result;
18
19use crate::queue::{FollowUp, Queue, Source, Task};
20use crate::run::{FollowupRecord, RunState};
21use crate::verdict::{Finding, ReviewVote};
22
23/// Node name carried by [`Source::Agent`] on a filed follow-up.
24pub const NODE: &str = "followup";
25
26/// A task of this generation (or deeper) does not file follow-ups of its own.
27pub const MAX_FOLLOWUP_GENERATION: u32 = 2;
28
29/// Two findings in one file this many lines apart are one defect.
30const LINE_WINDOW: u32 = 5;
31
32/// What one pass did.
33#[derive(Debug, Default, PartialEq, Eq)]
34pub struct Outcome {
35    /// Task ids created by this pass.
36    pub filed: Vec<String>,
37    /// Findings selected but not filed (cap reached), as ids.
38    pub capped: Vec<String>,
39    /// Minor/Nit findings of the last round that were only listed.
40    pub unfiled: Vec<String>,
41}
42
43/// File follow-ups for a run that just merged, and comment on the pull
44/// request. Never fails: problems become run events.
45pub async fn after_merge(state: &mut RunState, pr_url: &str) {
46    if !state.config.graph.file_followups {
47        return;
48    }
49    let queue = Queue::open();
50    let outcome = match file(state, pr_url, &queue) {
51        Ok(o) => o,
52        Err(e) => {
53            state.event(NODE, format!("follow-up filing failed: {e:#}"));
54            return;
55        }
56    };
57    if outcome.filed.is_empty() && (state.followups.is_empty() || state.followup_commented) {
58        return;
59    }
60    let body = comment_body(state, &outcome);
61    match crate::bump::gh_pr_comment(&state.repo, pr_url, &body).await {
62        Ok(()) => state.followup_commented = true,
63        Err(e) => state.event(NODE, format!("follow-up comment not posted: {e:#}")),
64    }
65}
66
67/// The deterministic half of [`after_merge`]: select, group, and file into
68/// `queue`. Records the event and the run's own bookkeeping; the caller
69/// saves the state.
70pub fn file(state: &mut RunState, pr_url: &str, queue: &Queue) -> Result<Outcome> {
71    let mut out = Outcome::default();
72    let Some(round) = state.reviews.last().cloned() else {
73        return Ok(out);
74    };
75    let rejecters: Vec<usize> = round
76        .final_votes()
77        .into_iter()
78        .filter(|(_, _, v)| *v == ReviewVote::Reject)
79        .map(|(seat, _, _)| seat)
80        .collect();
81    let mut chosen: Vec<Finding> = Vec::new();
82    for rec in &round.reviews {
83        let rejected = rejecters.contains(&rec.reviewer);
84        for f in &rec.findings {
85            if f.severity.blocks() || rejected {
86                chosen.push(f.clone());
87            } else {
88                out.unfiled.push(f.id.clone());
89            }
90        }
91    }
92    if chosen.is_empty() {
93        return Ok(out);
94    }
95
96    let origin_task = state.origin.as_ref().and_then(|o| o.task.clone());
97    // The depth recorded when the run started wins; the queue is only a
98    // fallback for runs that predate it, and an unknown depth is read as
99    // unbounded-until-proven-otherwise only when there is no origin task.
100    let parent_gen = match state.followup_generation {
101        Some(g) => g,
102        None => match origin_task.as_deref().map(|t| queue.get(t)) {
103            Some(Ok(t)) => {
104                let g = t.followup.map_or(0, |f| f.generation);
105                state.followup_generation = Some(g);
106                g
107            }
108            Some(Err(_)) => MAX_FOLLOWUP_GENERATION,
109            None => 0,
110        },
111    };
112    if parent_gen >= MAX_FOLLOWUP_GENERATION {
113        out.capped = chosen.iter().map(|f| f.id.clone()).collect();
114        state.event(
115            NODE,
116            format!(
117                "generation cap reached ({parent_gen}); not filing follow-ups for open finding(s): {}",
118                out.capped.join(", ")
119            ),
120        );
121        return Ok(out);
122    }
123
124    let mut done: Vec<String> = state
125        .followups
126        .iter()
127        .flat_map(|r| r.findings.iter().cloned())
128        .collect();
129    for t in queue.list() {
130        if let Some(f) = t.followup
131            && f.run == state.id
132        {
133            done.extend(f.findings.iter().cloned());
134            if !state.followups.iter().any(|r| r.task == t.id) {
135                state.followups.push(FollowupRecord {
136                    task: t.id,
137                    findings: f.findings,
138                });
139            }
140        }
141    }
142
143    let mut failure = None;
144    for group in group_findings(chosen) {
145        let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
146        if ids.iter().any(|id| done.contains(id)) {
147            continue;
148        }
149        let mut task = build_task(state, pr_url, origin_task.clone(), &group, parent_gen + 1);
150        match queue.create_new(&mut task) {
151            Ok(created) => {
152                if created {
153                    out.filed.push(task.id.clone());
154                }
155                state.followups.push(FollowupRecord {
156                    task: task.id,
157                    findings: ids,
158                });
159            }
160            Err(e) => {
161                failure = Some(e);
162                break;
163            }
164        }
165    }
166    if !out.filed.is_empty() {
167        state.event(
168            NODE,
169            format!(
170                "filed {} follow-up task(s): ids {}",
171                out.filed.len(),
172                out.filed.join(", ")
173            ),
174        );
175    }
176    if let Some(e) = failure {
177        state.event(NODE, format!("follow-up filing stopped: {e:#}"));
178    }
179    Ok(out)
180}
181
182/// Group findings that are the same defect: same file with lines within
183/// [`LINE_WINDOW`] of each other, or, with no file, the same normalized
184/// title. Deterministic, so a re-run groups identically.
185fn group_findings(mut findings: Vec<Finding>) -> Vec<Vec<Finding>> {
186    findings.sort_by(|a, b| (&a.file, a.line, &a.id).cmp(&(&b.file, b.line, &b.id)));
187    let mut groups: Vec<Vec<Finding>> = Vec::new();
188    for f in findings {
189        let hit = groups
190            .iter()
191            .position(|g| g.iter().any(|o| same_defect(o, &f)));
192        match hit {
193            Some(i) => groups[i].push(f),
194            None => groups.push(vec![f]),
195        }
196    }
197    for g in &mut groups {
198        g.sort_by(|a, b| a.id.cmp(&b.id));
199    }
200    groups
201}
202
203fn same_defect(a: &Finding, b: &Finding) -> bool {
204    match (&a.file, &b.file) {
205        (Some(x), Some(y)) => {
206            x == y
207                && match (a.line, b.line) {
208                    (Some(l), Some(m)) => l.abs_diff(m) <= LINE_WINDOW,
209                    (None, None) => true,
210                    _ => false,
211                }
212        }
213        (None, None) => normalize(&a.title) == normalize(&b.title),
214        _ => false,
215    }
216}
217
218fn normalize(title: &str) -> String {
219    title
220        .to_lowercase()
221        .split(|c: char| !c.is_alphanumeric())
222        .filter(|w| !w.is_empty())
223        .collect::<Vec<_>>()
224        .join(" ")
225}
226
227/// Deterministic id: the run id plus a hash of the group's finding ids, so
228/// two passes name the same task and [`Queue::create_new`] can refuse the
229/// second. Ends in a `-` segment like every id, so `queue::short` works.
230fn task_id(run: &str, ids: &[String]) -> String {
231    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
232    for b in ids.join("+").bytes() {
233        h ^= u64::from(b);
234        h = h.wrapping_mul(0x0100_0000_01b3);
235    }
236    format!("{run}-f{:05x}", h & 0xf_ffff)
237}
238
239fn location(f: &Finding) -> String {
240    match (&f.file, f.line) {
241        (Some(p), Some(l)) => format!("{p}:{l}"),
242        (Some(p), None) => p.clone(),
243        _ => "(no location)".to_owned(),
244    }
245}
246
247fn build_task(
248    state: &RunState,
249    pr_url: &str,
250    origin_task: Option<String>,
251    group: &[Finding],
252    generation: u32,
253) -> Task {
254    let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
255    let first = &group[0];
256    let title = format!(
257        "follow-up: {} ({})",
258        crate::queue::title_from(&first.title, 80),
259        location(first)
260    );
261    let mut s = format!(
262        "This is a follow-up to a change that was already merged: run {run} merged \
263         {pr_url} while the review finding(s) below were still open. Branch from the \
264         current main - do not use the merged branch - and fix the defect described, \
265         keeping the change small. If a finding no longer applies to current main, say so \
266         and change nothing for it.\n\n\
267         Merged run: {run}\nPull request: {pr_url}\n",
268        run = state.id,
269    );
270    if let Some(t) = &origin_task {
271        s.push_str(&format!("Original task: {t}\n"));
272    }
273    s.push_str("\n## Findings\n");
274    for f in group {
275        s.push_str(&format!(
276            "\n### {} [{:?}] {}\n\nLocation: {}\n\n{}\n",
277            f.id,
278            f.severity,
279            f.title,
280            location(f),
281            f.detail.trim()
282        ));
283    }
284    let mut task = Task::new(
285        title,
286        s,
287        state.repo.clone(),
288        Source::Agent {
289            run: state.id.clone(),
290            node: NODE.to_owned(),
291        },
292    );
293    task.id = task_id(&state.id, &ids);
294    task.solo = true;
295    task.followup = Some(FollowUp {
296        run: state.id.clone(),
297        origin_task,
298        pr: pr_url.to_owned(),
299        findings: ids,
300        generation,
301    });
302    task
303}
304
305fn comment_body(state: &RunState, out: &Outcome) -> String {
306    let mut s = format!(
307        "<!-- magi-followup run={} -->\nThis pull request merged with review findings still \
308         open. They were filed as follow-up tasks:\n\n",
309        state.id
310    );
311    for r in &state.followups {
312        s.push_str(&format!("- `{}`: {}\n", r.task, r.findings.join(", ")));
313    }
314    if !out.unfiled.is_empty() {
315        s.push_str(&format!(
316            "\nMinor/nit findings that were only listed, not filed: {}\n",
317            out.unfiled.join(", ")
318        ));
319    }
320    s
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::config::Config;
327    use crate::queue::Queue;
328    use crate::run::ReviewRecord;
329    use crate::run::{Origin, ReviewRound, RunState};
330    use crate::verdict::{Finding, ReviewVote, Severity};
331    use std::path::PathBuf;
332
333    fn finding(id: &str, sev: Severity, file: &str, line: u32) -> Finding {
334        Finding {
335            id: id.to_owned(),
336            severity: sev,
337            file: Some(file.to_owned()),
338            line: Some(line),
339            title: format!("title {id}"),
340            detail: format!("detail of {id}"),
341        }
342    }
343
344    fn record(seat: usize, findings: Vec<Finding>, vote: ReviewVote) -> ReviewRecord {
345        ReviewRecord {
346            attempts: 0,
347            reviewer: seat,
348            agent: "alpha".to_owned(),
349            summary: String::new(),
350            findings,
351            vote: Some(vote),
352            failed: None,
353            duration_ms: 0,
354        }
355    }
356
357    fn round(reviews: Vec<ReviewRecord>) -> ReviewRound {
358        ReviewRound {
359            round: 3,
360            head: "deadbee".to_owned(),
361            verified_head: None,
362            verified_at: None,
363            reviews,
364            e2e: Vec::new(),
365            verify_retried: false,
366            e2e_deferred: false,
367            e2e_defer_reason: None,
368            fix: None,
369            blocking: 0,
370            answered: 2,
371            expected: 2,
372            clean: false,
373            progressed: false,
374            vote_split: false,
375            reconsideration: Vec::new(),
376            verdict: None,
377        }
378    }
379
380    fn merged(reviews: Vec<ReviewRecord>) -> RunState {
381        let mut s = RunState::new(
382            PathBuf::from("/repo"),
383            "main".to_owned(),
384            "abc1234".to_owned(),
385            "do it".to_owned(),
386            Config::default(),
387        );
388        s.reviews.push(round(reviews));
389        s
390    }
391
392    fn two_seats() -> Vec<ReviewRecord> {
393        vec![
394            record(
395                1,
396                vec![
397                    finding("R3-1-1", Severity::Major, "src/waiter.rs", 211),
398                    finding("R3-1-2", Severity::Minor, "src/a.rs", 1),
399                ],
400                ReviewVote::Reject,
401            ),
402            record(
403                2,
404                vec![
405                    finding("R3-2-1", Severity::Major, "src/waiter.rs", 213),
406                    finding("R3-2-2", Severity::Nit, "src/b.rs", 9),
407                ],
408                ReviewVote::Approve,
409            ),
410        ]
411    }
412
413    #[test]
414    fn one_task_per_distinct_defect_with_the_findings_in_full() {
415        let dir = tempfile::tempdir().unwrap();
416        let q = Queue::at(dir.path().join("queue"));
417        let mut s = merged(two_seats());
418        let out = file(&mut s, "https://example.invalid/o/r/pull/9", &q).unwrap();
419        // waiter.rs pair is one defect; seat 1 rejected so its Minor is filed
420        // too; seat 2's Nit is only listed.
421        assert_eq!(out.filed.len(), 2, "{out:?}");
422        assert_eq!(out.unfiled, vec!["R3-2-2".to_owned()]);
423        let tasks = q.list();
424        let t = tasks
425            .iter()
426            .find(|t| t.followup.as_ref().unwrap().findings.len() == 2)
427            .unwrap();
428        assert!(t.solo);
429        assert_eq!(t.followup.as_ref().unwrap().generation, 1);
430        assert_eq!(
431            t.source,
432            Source::Agent {
433                run: s.id.clone(),
434                node: NODE.to_owned()
435            }
436        );
437        for needle in [
438            "R3-1-1",
439            "R3-2-1",
440            "src/waiter.rs:211",
441            "detail of R3-2-1",
442            "pull/9",
443            &s.id,
444            "current main",
445        ] {
446            assert!(
447                t.instruction.contains(needle),
448                "{needle}: {}",
449                t.instruction
450            );
451        }
452        assert!(
453            s.events
454                .iter()
455                .any(|e| e.message.contains("filed 2 follow-up"))
456        );
457        assert_eq!(s.followups.len(), 2);
458    }
459
460    #[test]
461    fn filing_twice_does_not_duplicate() {
462        let dir = tempfile::tempdir().unwrap();
463        let q = Queue::at(dir.path().join("queue"));
464        let mut s = merged(two_seats());
465        file(&mut s, "u", &q).unwrap();
466        let again = file(&mut s, "u", &q).unwrap();
467        assert!(again.filed.is_empty());
468        assert_eq!(q.list().len(), 2);
469        // Even with the run's own record lost, the queue says what exists.
470        s.followups.clear();
471        let third = file(&mut s, "u", &q).unwrap();
472        assert!(third.filed.is_empty());
473        assert_eq!(q.list().len(), 2);
474        assert_eq!(s.followups.len(), 2, "records are restored from the queue");
475    }
476
477    #[test]
478    fn a_clean_merge_files_nothing() {
479        let dir = tempfile::tempdir().unwrap();
480        let q = Queue::at(dir.path().join("queue"));
481        let mut s = merged(vec![record(
482            1,
483            vec![finding("R1-1-1", Severity::Minor, "a.rs", 1)],
484            ReviewVote::Approve,
485        )]);
486        let out = file(&mut s, "u", &q).unwrap();
487        assert!(out.filed.is_empty());
488        assert!(q.list().is_empty());
489        assert!(s.followups.is_empty());
490    }
491
492    #[test]
493    fn a_failing_queue_is_an_error_event_not_a_status_change() {
494        let dir = tempfile::tempdir().unwrap();
495        let root = dir.path().join("queue");
496        std::fs::write(&root, "not a directory").unwrap();
497        let q = Queue::at(root);
498        let mut s = merged(two_seats());
499        s.status = crate::run::RunStatus::Merged;
500        let _ = file(&mut s, "u", &q);
501        assert_eq!(s.status, crate::run::RunStatus::Merged);
502        assert!(
503            s.events
504                .iter()
505                .any(|e| e.node == NODE && e.message.contains("stopped"))
506        );
507        assert!(s.followups.is_empty());
508    }
509
510    #[test]
511    fn the_generation_cap_stops_the_chain() {
512        let dir = tempfile::tempdir().unwrap();
513        let q = Queue::at(dir.path().join("queue"));
514        let mut parent = Task::new(
515            "p".to_owned(),
516            "i".to_owned(),
517            PathBuf::from("/repo"),
518            Source::Human,
519        );
520        parent.followup = Some(FollowUp {
521            run: "r0".to_owned(),
522            origin_task: None,
523            pr: "u".to_owned(),
524            findings: vec!["R1-1-1".to_owned()],
525            generation: MAX_FOLLOWUP_GENERATION,
526        });
527        q.put(&mut parent).unwrap();
528        let mut s = merged(two_seats());
529        s.origin = Some(Origin {
530            by: crate::run::StartedBy::Operator,
531            task: Some(parent.id.clone()),
532        });
533        s.origin = Some(Origin {
534            by: crate::run::StartedBy::Operator,
535            task: Some("gone".to_owned()),
536        });
537        s.followup_generation = Some(MAX_FOLLOWUP_GENERATION);
538        let out = file(&mut s, "u", &q).unwrap();
539        assert!(out.filed.is_empty());
540        assert!(!out.capped.is_empty());
541        assert_eq!(q.list().len(), 1);
542        assert!(
543            s.events
544                .iter()
545                .any(|e| e.message.contains("generation cap"))
546        );
547    }
548
549    #[test]
550    fn a_deeper_task_files_one_generation_further() {
551        let dir = tempfile::tempdir().unwrap();
552        let q = Queue::at(dir.path().join("queue"));
553        let mut parent = Task::new(
554            "p".to_owned(),
555            "i".to_owned(),
556            PathBuf::from("/repo"),
557            Source::Human,
558        );
559        q.put(&mut parent).unwrap();
560        let mut s = merged(two_seats());
561        s.origin = Some(Origin {
562            by: crate::run::StartedBy::Operator,
563            task: Some(parent.id.clone()),
564        });
565        file(&mut s, "u", &q).unwrap();
566        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
567        assert_eq!(t.followup.unwrap().origin_task, Some(parent.id));
568    }
569}