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    /// Findings not filed because another task already covers them, as
42    /// `(finding id, covering task id)`. Recomputed from the queue each pass.
43    pub covered: Vec<(String, String)>,
44}
45
46/// File follow-ups for a run that just merged, and comment on the pull
47/// request. Never fails: problems become run events.
48pub async fn after_merge(state: &mut RunState, pr_url: &str) {
49    if !state.config.graph.file_followups {
50        return;
51    }
52    let queue = Queue::open();
53    let outcome = match file(state, pr_url, &queue) {
54        Ok(o) => o,
55        Err(e) => {
56            state.event(NODE, format!("follow-up filing failed: {e:#}"));
57            return;
58        }
59    };
60    if outcome.filed.is_empty()
61        && (state.followup_commented || (state.followups.is_empty() && outcome.covered.is_empty()))
62    {
63        return;
64    }
65    let body = comment_body(state, &outcome);
66    match crate::bump::gh_pr_comment(state, pr_url, &body).await {
67        Ok(()) => state.followup_commented = true,
68        Err(e) => state.event(NODE, format!("follow-up comment not posted: {e:#}")),
69    }
70}
71
72/// The deterministic half of [`after_merge`]: select, group, and file into
73/// `queue`. Records the event and the run's own bookkeeping; the caller
74/// saves the state.
75pub fn file(state: &mut RunState, pr_url: &str, queue: &Queue) -> Result<Outcome> {
76    let mut out = Outcome::default();
77    let Some(round) = state.reviews.last().cloned() else {
78        return Ok(out);
79    };
80    let rejecters: Vec<usize> = round
81        .final_votes()
82        .into_iter()
83        .filter(|(_, _, v)| *v == ReviewVote::Reject)
84        .map(|(seat, _, _)| seat)
85        .collect();
86    let mut chosen: Vec<Finding> = Vec::new();
87    for rec in &round.reviews {
88        let rejected = rejecters.contains(&rec.reviewer);
89        for f in &rec.findings {
90            if f.severity.blocks() || rejected {
91                chosen.push(f.clone());
92            } else {
93                out.unfiled.push(f.id.clone());
94            }
95        }
96    }
97    if chosen.is_empty() {
98        return Ok(out);
99    }
100
101    let origin_task = state.origin.as_ref().and_then(|o| o.task.clone());
102    // The depth recorded when the run started wins; the queue is only a
103    // fallback for runs that predate it, and an unknown depth is read as
104    // unbounded-until-proven-otherwise only when there is no origin task.
105    let parent_gen = match state.followup_generation {
106        Some(g) => g,
107        None => match origin_task.as_deref().map(|t| queue.get(t)) {
108            Some(Ok(t)) => {
109                let g = t.followup.map_or(0, |f| f.generation);
110                state.followup_generation = Some(g);
111                g
112            }
113            Some(Err(_)) => MAX_FOLLOWUP_GENERATION,
114            None => 0,
115        },
116    };
117    if parent_gen >= MAX_FOLLOWUP_GENERATION {
118        out.capped = chosen.iter().map(|f| f.id.clone()).collect();
119        state.event(
120            NODE,
121            format!(
122                "generation cap reached ({parent_gen}); not filing follow-ups for open finding(s): {}",
123                out.capped.join(", ")
124            ),
125        );
126        return Ok(out);
127    }
128
129    let mut done: Vec<String> = state
130        .followups
131        .iter()
132        .flat_map(|r| r.findings.iter().cloned())
133        .collect();
134    for t in queue.list() {
135        if let Some(f) = t.followup
136            && f.run == state.id
137        {
138            done.extend(f.findings.iter().cloned());
139            if !state.followups.iter().any(|r| r.task == t.id) {
140                state.followups.push(FollowupRecord {
141                    task: t.id,
142                    findings: f.findings,
143                });
144            }
145        }
146    }
147
148    let tasks = queue.list();
149    let origin_chat = state.origin_chat.clone().or_else(|| {
150        let tasks = queue.list();
151        let parent = origin_task
152            .as_deref()
153            .and_then(|id| tasks.iter().find(|t| t.id == id))?;
154        crate::consult::chat_talk_of(&tasks, parent)
155    });
156    let mut failure = None;
157    for group in group_findings(chosen) {
158        let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
159        let own = task_id(&state.id, &ids);
160        let covers: Vec<Option<String>> = ids
161            .iter()
162            .map(|id| covering_task(&tasks, &own, &state.id, pr_url, id))
163            .collect();
164        if covers.iter().all(Option::is_some) {
165            for (id, by) in ids.iter().zip(covers.into_iter().flatten()) {
166                state.event(
167                    NODE,
168                    format!("not filing follow-up for {id}: already covered by task {by}"),
169                );
170                out.covered.push((id.clone(), by));
171            }
172            continue;
173        }
174        if ids.iter().any(|id| done.contains(id)) {
175            continue;
176        }
177        let mut task = build_task(state, pr_url, origin_task.clone(), &group, parent_gen + 1);
178        task.origin_chat = origin_chat.clone();
179        match queue.create_new(&mut task) {
180            Ok(created) => {
181                if created {
182                    out.filed.push(task.id.clone());
183                }
184                state.followups.push(FollowupRecord {
185                    task: task.id,
186                    findings: ids,
187                });
188            }
189            Err(e) => {
190                failure = Some(e);
191                break;
192            }
193        }
194    }
195    if !out.filed.is_empty() {
196        state.event(
197            NODE,
198            format!(
199                "filed {} follow-up task(s): ids {}",
200                out.filed.len(),
201                out.filed.join(", ")
202            ),
203        );
204    }
205    if let Some(e) = failure {
206        state.event(NODE, format!("follow-up filing stopped: {e:#}"));
207    }
208    Ok(out)
209}
210
211/// The id of a task, other than `own`, that already covers finding `id` of
212/// run `run` merged as `pr_url`, whatever its status. Concrete identifiers
213/// only: a follow-up of the same run listing the finding, or an instruction
214/// naming both the finding id and the pull request (URL or `#<number>`).
215fn covering_task(tasks: &[Task], own: &str, run: &str, pr_url: &str, id: &str) -> Option<String> {
216    tasks
217        .iter()
218        .find(|t| {
219            t.id != own
220                && (t
221                    .followup
222                    .as_ref()
223                    .is_some_and(|f| f.run == run && f.findings.iter().any(|x| x == id))
224                    || (names_word(&t.instruction, id) && names_pr(&t.instruction, pr_url)))
225        })
226        .map(|t| t.id.clone())
227}
228
229/// ASCII only: an id is often followed directly by prose in another script
230/// (`R3-1-1を修正`), which must not read as part of the id.
231fn is_name_char(c: char) -> bool {
232    c.is_ascii_alphanumeric() || matches!(c, '_' | '-')
233}
234
235/// `needle` in `text` with no name character on either side.
236fn names_word(text: &str, needle: &str) -> bool {
237    !needle.is_empty()
238        && text.match_indices(needle).any(|(i, _)| {
239            let before = text[..i].chars().next_back();
240            let after = text[i + needle.len()..].chars().next();
241            before.is_none_or(|c| !is_name_char(c)) && after.is_none_or(|c| !is_name_char(c))
242        })
243}
244
245/// Does `text` name the pull request by its URL or its `#<number>` form,
246/// with no further digit (or name character before `#`) so `#47` is not `#473`?
247fn names_pr(text: &str, pr_url: &str) -> bool {
248    let url = pr_url.trim_end_matches('/');
249    let digit_after = |rest: &str| rest.chars().next().is_some_and(|c| c.is_ascii_digit());
250    if !url.is_empty()
251        && text
252            .match_indices(url)
253            .any(|(i, _)| !digit_after(&text[i + url.len()..]))
254    {
255        return true;
256    }
257    let Some(n) = url
258        .rsplit('/')
259        .next()
260        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
261    else {
262        return false;
263    };
264    let tag = format!("#{n}");
265    text.match_indices(&tag).any(|(i, _)| {
266        let before = text[..i].chars().next_back();
267        before.is_none_or(|c| !is_name_char(c)) && !digit_after(&text[i + tag.len()..])
268    })
269}
270
271/// Group findings that are the same defect: same file with lines within
272/// [`LINE_WINDOW`] of each other, or, with no file, the same normalized
273/// title. Deterministic, so a re-run groups identically.
274fn group_findings(mut findings: Vec<Finding>) -> Vec<Vec<Finding>> {
275    findings.sort_by(|a, b| (&a.file, a.line, &a.id).cmp(&(&b.file, b.line, &b.id)));
276    let mut groups: Vec<Vec<Finding>> = Vec::new();
277    for f in findings {
278        let hit = groups
279            .iter()
280            .position(|g| g.iter().any(|o| same_defect(o, &f)));
281        match hit {
282            Some(i) => groups[i].push(f),
283            None => groups.push(vec![f]),
284        }
285    }
286    for g in &mut groups {
287        g.sort_by(|a, b| a.id.cmp(&b.id));
288    }
289    groups
290}
291
292fn same_defect(a: &Finding, b: &Finding) -> bool {
293    match (&a.file, &b.file) {
294        (Some(x), Some(y)) => {
295            x == y
296                && match (a.line, b.line) {
297                    (Some(l), Some(m)) => l.abs_diff(m) <= LINE_WINDOW,
298                    (None, None) => true,
299                    _ => false,
300                }
301        }
302        (None, None) => normalize(&a.title) == normalize(&b.title),
303        _ => false,
304    }
305}
306
307fn normalize(title: &str) -> String {
308    title
309        .to_lowercase()
310        .split(|c: char| !c.is_alphanumeric())
311        .filter(|w| !w.is_empty())
312        .collect::<Vec<_>>()
313        .join(" ")
314}
315
316/// Deterministic id: the run id plus a hash of the group's finding ids, so
317/// two passes name the same task and [`Queue::create_new`] can refuse the
318/// second. Ends in a `-` segment like every id, so `queue::short` works.
319fn task_id(run: &str, ids: &[String]) -> String {
320    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
321    for b in ids.join("+").bytes() {
322        h ^= u64::from(b);
323        h = h.wrapping_mul(0x0100_0000_01b3);
324    }
325    format!("{run}-f{:05x}", h & 0xf_ffff)
326}
327
328fn location(f: &Finding) -> String {
329    match (&f.file, f.line) {
330        (Some(p), Some(l)) => format!("{p}:{l}"),
331        (Some(p), None) => p.clone(),
332        _ => "(no location)".to_owned(),
333    }
334}
335
336fn build_task(
337    state: &RunState,
338    pr_url: &str,
339    origin_task: Option<String>,
340    group: &[Finding],
341    generation: u32,
342) -> Task {
343    let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
344    let first = &group[0];
345    let title = format!(
346        "follow-up: {} ({})",
347        crate::queue::title_from(&first.title, 80),
348        location(first)
349    );
350    let mut s = format!(
351        "This is a follow-up to a change that was already merged: run {run} merged \
352         {pr_url} while the review finding(s) below were still open. Branch from the \
353         current main - do not use the merged branch - and fix the defect described, \
354         keeping the change small. If a finding no longer applies to current main, say so \
355         and change nothing for it.\n\n\
356         Merged run: {run}\nPull request: {pr_url}\n",
357        run = state.id,
358    );
359    if let Some(t) = &origin_task {
360        s.push_str(&format!("Original task: {t}\n"));
361    }
362    s.push_str("\n## Findings\n");
363    for f in group {
364        s.push_str(&format!(
365            "\n### {} [{:?}] {}\n\nLocation: {}\n\n{}\n",
366            f.id,
367            f.severity,
368            f.title,
369            location(f),
370            f.detail.trim()
371        ));
372    }
373    let mut task = Task::new(
374        title,
375        s,
376        state.repo.clone(),
377        Source::Agent {
378            run: state.id.clone(),
379            node: NODE.to_owned(),
380        },
381    );
382    task.id = task_id(&state.id, &ids);
383    task.solo = true;
384    task.followup = Some(FollowUp {
385        run: state.id.clone(),
386        origin_task,
387        pr: pr_url.to_owned(),
388        findings: ids,
389        generation,
390    });
391    task
392}
393
394fn comment_body(state: &RunState, out: &Outcome) -> String {
395    let mut s = format!(
396        "<!-- magi-followup run={} -->\nThis pull request merged with review findings still open.",
397        state.id
398    );
399    if state.followups.is_empty() {
400        s.push_str(" Each is already covered by another task:\n");
401    } else {
402        s.push_str(" They were filed as follow-up tasks:\n\n");
403        for r in &state.followups {
404            s.push_str(&format!("- `{}`: {}\n", r.task, r.findings.join(", ")));
405        }
406        if !out.covered.is_empty() {
407            s.push_str("\nNot filed, already covered:\n");
408        }
409    }
410    if !out.covered.is_empty() {
411        s.push('\n');
412    }
413    for (id, by) in &out.covered {
414        s.push_str(&format!("- {id}: already covered by task `{by}`\n"));
415    }
416    if !out.unfiled.is_empty() {
417        s.push_str(&format!(
418            "\nMinor/nit findings that were only listed, not filed: {}\n",
419            out.unfiled.join(", ")
420        ));
421    }
422    s
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::config::Config;
429    use crate::queue::Queue;
430    use crate::run::ReviewRecord;
431    use crate::run::{Origin, ReviewRound, RunState};
432    use crate::verdict::{Finding, ReviewVote, Severity};
433    use std::path::PathBuf;
434
435    fn finding(id: &str, sev: Severity, file: &str, line: u32) -> Finding {
436        Finding {
437            id: id.to_owned(),
438            severity: sev,
439            file: Some(file.to_owned()),
440            line: Some(line),
441            title: format!("title {id}"),
442            detail: format!("detail of {id}"),
443        }
444    }
445
446    fn record(seat: usize, findings: Vec<Finding>, vote: ReviewVote) -> ReviewRecord {
447        ReviewRecord {
448            attempts: 0,
449            reviewer: seat,
450            agent: "alpha".to_owned(),
451            summary: String::new(),
452            findings,
453            vote: Some(vote),
454            failed: None,
455            duration_ms: 0,
456        }
457    }
458
459    fn round(reviews: Vec<ReviewRecord>) -> ReviewRound {
460        ReviewRound {
461            round: 3,
462            head: "deadbee".to_owned(),
463            verified_head: None,
464            verified_at: None,
465            reviews,
466            e2e: Vec::new(),
467            verify_retried: false,
468            e2e_deferred: false,
469            e2e_defer_reason: None,
470            fix: None,
471            blocking: 0,
472            answered: 2,
473            expected: 2,
474            clean: false,
475            progressed: false,
476            vote_split: false,
477            reconsideration: Vec::new(),
478            verdict: None,
479        }
480    }
481
482    fn merged(reviews: Vec<ReviewRecord>) -> RunState {
483        let mut s = RunState::new(
484            PathBuf::from("/repo"),
485            "main".to_owned(),
486            "abc1234".to_owned(),
487            "do it".to_owned(),
488            Config::default(),
489        );
490        s.reviews.push(round(reviews));
491        s
492    }
493
494    fn two_seats() -> Vec<ReviewRecord> {
495        vec![
496            record(
497                1,
498                vec![
499                    finding("R3-1-1", Severity::Major, "src/waiter.rs", 211),
500                    finding("R3-1-2", Severity::Minor, "src/a.rs", 1),
501                ],
502                ReviewVote::Reject,
503            ),
504            record(
505                2,
506                vec![
507                    finding("R3-2-1", Severity::Major, "src/waiter.rs", 213),
508                    finding("R3-2-2", Severity::Nit, "src/b.rs", 9),
509                ],
510                ReviewVote::Approve,
511            ),
512        ]
513    }
514
515    #[test]
516    fn one_task_per_distinct_defect_with_the_findings_in_full() {
517        let dir = tempfile::tempdir().unwrap();
518        let q = Queue::at(dir.path().join("queue"));
519        let mut s = merged(two_seats());
520        let out = file(&mut s, "https://example.invalid/o/r/pull/9", &q).unwrap();
521        // waiter.rs pair is one defect; seat 1 rejected so its Minor is filed
522        // too; seat 2's Nit is only listed.
523        assert_eq!(out.filed.len(), 2, "{out:?}");
524        assert_eq!(out.unfiled, vec!["R3-2-2".to_owned()]);
525        let tasks = q.list();
526        let t = tasks
527            .iter()
528            .find(|t| t.followup.as_ref().unwrap().findings.len() == 2)
529            .unwrap();
530        assert!(t.solo);
531        assert_eq!(t.followup.as_ref().unwrap().generation, 1);
532        assert_eq!(
533            t.source,
534            Source::Agent {
535                run: s.id.clone(),
536                node: NODE.to_owned()
537            }
538        );
539        for needle in [
540            "R3-1-1",
541            "R3-2-1",
542            "src/waiter.rs:211",
543            "detail of R3-2-1",
544            "pull/9",
545            &s.id,
546            "current main",
547        ] {
548            assert!(
549                t.instruction.contains(needle),
550                "{needle}: {}",
551                t.instruction
552            );
553        }
554        assert!(
555            s.events
556                .iter()
557                .any(|e| e.message.contains("filed 2 follow-up"))
558        );
559        assert_eq!(s.followups.len(), 2);
560    }
561
562    #[test]
563    fn filing_twice_does_not_duplicate() {
564        let dir = tempfile::tempdir().unwrap();
565        let q = Queue::at(dir.path().join("queue"));
566        let mut s = merged(two_seats());
567        file(&mut s, "u", &q).unwrap();
568        let again = file(&mut s, "u", &q).unwrap();
569        assert!(again.filed.is_empty());
570        assert_eq!(q.list().len(), 2);
571        // Even with the run's own record lost, the queue says what exists.
572        s.followups.clear();
573        let third = file(&mut s, "u", &q).unwrap();
574        assert!(third.filed.is_empty());
575        assert_eq!(q.list().len(), 2);
576        assert_eq!(s.followups.len(), 2, "records are restored from the queue");
577    }
578
579    #[test]
580    fn a_clean_merge_files_nothing() {
581        let dir = tempfile::tempdir().unwrap();
582        let q = Queue::at(dir.path().join("queue"));
583        let mut s = merged(vec![record(
584            1,
585            vec![finding("R1-1-1", Severity::Minor, "a.rs", 1)],
586            ReviewVote::Approve,
587        )]);
588        let out = file(&mut s, "u", &q).unwrap();
589        assert!(out.filed.is_empty());
590        assert!(q.list().is_empty());
591        assert!(s.followups.is_empty());
592    }
593
594    #[test]
595    fn a_failing_queue_is_an_error_event_not_a_status_change() {
596        let dir = tempfile::tempdir().unwrap();
597        let root = dir.path().join("queue");
598        std::fs::write(&root, "not a directory").unwrap();
599        let q = Queue::at(root);
600        let mut s = merged(two_seats());
601        s.status = crate::run::RunStatus::Merged;
602        let _ = file(&mut s, "u", &q);
603        assert_eq!(s.status, crate::run::RunStatus::Merged);
604        assert!(
605            s.events
606                .iter()
607                .any(|e| e.node == NODE && e.message.contains("stopped"))
608        );
609        assert!(s.followups.is_empty());
610    }
611
612    #[test]
613    fn the_generation_cap_stops_the_chain() {
614        let dir = tempfile::tempdir().unwrap();
615        let q = Queue::at(dir.path().join("queue"));
616        let mut parent = Task::new(
617            "p".to_owned(),
618            "i".to_owned(),
619            PathBuf::from("/repo"),
620            Source::Human,
621        );
622        parent.followup = Some(FollowUp {
623            run: "r0".to_owned(),
624            origin_task: None,
625            pr: "u".to_owned(),
626            findings: vec!["R1-1-1".to_owned()],
627            generation: MAX_FOLLOWUP_GENERATION,
628        });
629        q.put(&mut parent).unwrap();
630        let mut s = merged(two_seats());
631        s.origin = Some(Origin {
632            by: crate::run::StartedBy::Operator,
633            task: Some(parent.id.clone()),
634        });
635        s.origin = Some(Origin {
636            by: crate::run::StartedBy::Operator,
637            task: Some("gone".to_owned()),
638        });
639        s.followup_generation = Some(MAX_FOLLOWUP_GENERATION);
640        let out = file(&mut s, "u", &q).unwrap();
641        assert!(out.filed.is_empty());
642        assert!(!out.capped.is_empty());
643        assert_eq!(q.list().len(), 1);
644        assert!(
645            s.events
646                .iter()
647                .any(|e| e.message.contains("generation cap"))
648        );
649    }
650
651    #[test]
652    fn a_deeper_task_files_one_generation_further() {
653        let dir = tempfile::tempdir().unwrap();
654        let q = Queue::at(dir.path().join("queue"));
655        let mut parent = Task::new(
656            "p".to_owned(),
657            "i".to_owned(),
658            PathBuf::from("/repo"),
659            Source::Human,
660        );
661        q.put(&mut parent).unwrap();
662        let mut s = merged(two_seats());
663        s.origin = Some(Origin {
664            by: crate::run::StartedBy::Operator,
665            task: Some(parent.id.clone()),
666        });
667        file(&mut s, "u", &q).unwrap();
668        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
669        assert_eq!(t.followup.unwrap().origin_task, Some(parent.id));
670    }
671
672    #[test]
673    fn the_chat_is_inherited_from_the_run_even_when_the_parent_is_gone() {
674        let dir = tempfile::tempdir().unwrap();
675        let q = Queue::at(dir.path().join("queue"));
676        let mut s = merged(two_seats());
677        s.origin = Some(Origin {
678            by: crate::run::StartedBy::Operator,
679            task: Some("gone".to_owned()),
680        });
681        s.followup_generation = Some(1);
682        s.origin_chat = Some("talk-1".to_owned());
683        file(&mut s, "u", &q).unwrap();
684        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
685        assert_eq!(t.origin_chat.as_deref(), Some("talk-1"));
686    }
687
688    #[test]
689    fn a_parent_without_the_field_passes_on_what_its_ancestry_resolves() {
690        let dir = tempfile::tempdir().unwrap();
691        let q = Queue::at(dir.path().join("queue"));
692        let mut parent = Task::new(
693            "p".to_owned(),
694            "i".to_owned(),
695            PathBuf::from("/repo"),
696            Source::Agent {
697                run: "talk-2".to_owned(),
698                node: crate::queue::CHAT_NODE.to_owned(),
699            },
700        );
701        parent.origin_chat = None;
702        q.put(&mut parent).unwrap();
703        let mut s = merged(two_seats());
704        s.origin = Some(Origin {
705            by: crate::run::StartedBy::Operator,
706            task: Some(parent.id.clone()),
707        });
708        file(&mut s, "u", &q).unwrap();
709        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
710        assert_eq!(t.origin_chat.as_deref(), Some("talk-2"));
711    }
712
713    const PR: &str = "https://github.com/o/r/pull/473";
714
715    fn manual(text: &str) -> Task {
716        Task::new(
717            "manual".to_owned(),
718            text.to_owned(),
719            PathBuf::from("/repo"),
720            Source::Human,
721        )
722    }
723
724    /// File follow-ups with one manual task already in the queue.
725    fn filed_with(text: &str) -> (Outcome, RunState, Queue, String, tempfile::TempDir) {
726        let dir = tempfile::tempdir().unwrap();
727        let q = Queue::at(dir.path().join("queue"));
728        let mut t = manual(text);
729        t.status = crate::queue::TaskStatus::Done;
730        q.put(&mut t).unwrap();
731        let mut s = merged(two_seats());
732        let out = file(&mut s, PR, &q).unwrap();
733        (out, s, q, t.id, dir)
734    }
735
736    #[test]
737    fn a_task_naming_the_pr_url_and_ids_covers_the_group() {
738        let (out, s, _q, id, _d) = filed_with(&format!("Fix R3-1-1 and R3-2-1 from {PR}"));
739        // the waiter.rs group is covered; the other seat-1 Minor is still filed
740        assert_eq!(out.filed.len(), 1, "{out:?}");
741        assert_eq!(
742            out.covered,
743            vec![
744                ("R3-1-1".to_owned(), id.clone()),
745                ("R3-2-1".to_owned(), id.clone())
746            ]
747        );
748        assert!(out.capped.is_empty());
749        assert!(
750            s.events
751                .iter()
752                .any(|e| e.message.contains("already covered"))
753        );
754        let body = comment_body(&s, &out);
755        assert!(body.contains(&format!("R3-1-1: already covered by task `{id}`")));
756    }
757
758    #[test]
759    fn the_hash_form_covers_but_a_prefix_number_does_not() {
760        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in PR #473");
761        assert_eq!(out.covered.len(), 2);
762        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in #47");
763        assert!(out.covered.is_empty());
764        assert_eq!(out.filed.len(), 2);
765        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in #4731");
766        assert!(out.covered.is_empty());
767    }
768
769    #[test]
770    fn an_id_without_the_pr_does_not_cover() {
771        let (out, ..) = filed_with("R3-1-1 and R3-2-1 are bad");
772        assert!(out.covered.is_empty());
773        assert_eq!(out.filed.len(), 2);
774    }
775
776    #[test]
777    fn an_id_prefix_does_not_cover() {
778        let (out, ..) = filed_with(&format!("R3-1-10 R3-2-10 in {PR}"));
779        assert!(out.covered.is_empty());
780    }
781
782    #[test]
783    fn partial_coverage_still_files() {
784        let (out, ..) = filed_with(&format!("R3-1-1 in {PR}"));
785        assert!(out.covered.is_empty());
786        assert_eq!(out.filed.len(), 2);
787    }
788
789    #[test]
790    fn another_followups_findings_cover() {
791        let dir = tempfile::tempdir().unwrap();
792        let q = Queue::at(dir.path().join("queue"));
793        let mut s = merged(two_seats());
794        let mut other = manual("x");
795        other.followup = Some(FollowUp {
796            run: s.id.clone(),
797            origin_task: None,
798            pr: PR.to_owned(),
799            findings: vec!["R3-1-1".to_owned(), "R3-2-1".to_owned()],
800            generation: 1,
801        });
802        q.put(&mut other).unwrap();
803        let out = file(&mut s, PR, &q).unwrap();
804        assert_eq!(out.covered.len(), 2, "{out:?}");
805        assert_eq!(out.filed.len(), 1);
806    }
807
808    #[test]
809    fn github_text_fixed_followup_comment_passes() {
810        let state = merged(two_seats());
811        let body = comment_body(&state, &Outcome::default());
812        assert!(crate::github_text::check("", &body).is_empty());
813    }
814
815    #[test]
816    fn a_fully_covered_comment_lists_only_the_covered() {
817        let (out, mut s, ..) = filed_with(&format!("R3-1-1 R3-2-1 {PR}"));
818        s.followups.clear();
819        let body = comment_body(&s, &out);
820        assert!(body.contains("already covered by task"));
821        assert!(!body.contains("filed as follow-up"));
822    }
823
824    #[test]
825    fn an_id_next_to_japanese_prose_still_covers() {
826        let (out, ..) = filed_with("PR #473 の R3-1-1を修正、R3-2-1も対応");
827        assert_eq!(out.covered.len(), 2, "{out:?}");
828    }
829}