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    let queue = Queue::open();
50    let release = state.config.graph.release_deputy_followups;
51    // The deputy's tasks are settled first and again after filing, so the
52    // outcome is the same whichever side got to the queue first.
53    settle_deputy_tasks(state, pr_url, &queue, release);
54    let outcome = if state.config.graph.file_followups {
55        match file(state, pr_url, &queue) {
56            Ok(o) => o,
57            Err(e) => {
58                state.event(NODE, format!("follow-up filing failed: {e:#}"));
59                return;
60            }
61        }
62    } else {
63        Outcome::default()
64    };
65    if state.config.graph.file_followups && !release {
66        settle_deputy_tasks(state, pr_url, &queue, false);
67    }
68    if outcome.filed.is_empty()
69        && (state.followup_commented
70            || (state.followups.is_empty()
71                && outcome.covered.is_empty()
72                && state.deputy_followups.is_empty()))
73    {
74        return;
75    }
76    let body = comment_body(state, &outcome);
77    match crate::bump::gh_pr_comment(state, pr_url, &body).await {
78        Ok(()) => state.followup_commented = true,
79        Err(e) => state.event(NODE, format!("follow-up comment not posted: {e:#}")),
80    }
81}
82
83/// Reason prefixes that mark a deputy task this module already judged and
84/// left held. Both stop a second pass from rewriting the reason again.
85const SUPERSEDED: &str = "[superseded] ";
86const CAPPED: &str = "[generation cap] ";
87
88/// A task the merge approval's deputy filed for run `run` that is still held
89/// for pull request `pr_url`: the only kind [`settle_deputy_tasks`] touches.
90/// Concrete identifiers only - the filer is the deputy seat of this run and
91/// the hold reason names the pull request - never text similarity alone.
92fn is_deputy_candidate(t: &Task, run: &str, pr_url: &str) -> bool {
93    t.status == crate::queue::TaskStatus::Held
94        && t.followup.is_none()
95        && matches!(&t.source, Source::Agent { run: r, node }
96            if r == run && node == crate::deputy::NODE)
97        && t.hold_reason.as_deref().is_some_and(|r| {
98            !r.starts_with(SUPERSEDED) && !r.starts_with(CAPPED) && names_pr(r, pr_url)
99        })
100}
101
102/// Does deputy task `t` talk about finding `f`: its id as a word, a
103/// `file:line` within [`LINE_WINDOW`] of the finding, or the finding's title
104/// (normalized; contained only when long enough not to match by accident).
105fn task_matches_finding(t: &Task, f: &Finding) -> bool {
106    let text = format!("{}\n{}", t.title, t.instruction);
107    if names_word(&text, &f.id) {
108        return true;
109    }
110    if let (Some(file), Some(line)) = (&f.file, f.line)
111        && text.match_indices(file.as_str()).any(|(i, _)| {
112            text[i + file.len()..]
113                .strip_prefix(':')
114                .map(|r| {
115                    r.chars()
116                        .take_while(char::is_ascii_digit)
117                        .collect::<String>()
118                })
119                .and_then(|d| d.parse::<u32>().ok())
120                .is_some_and(|n| n.abs_diff(line) <= LINE_WINDOW)
121        })
122    {
123        return true;
124    }
125    let (nt, nf) = (normalize(&t.title), normalize(&f.title));
126    !nf.is_empty() && (nt == nf || (nf.split(' ').count() >= 3 && nt.contains(&nf)))
127}
128
129/// The follow-up depth of the task the run served; see [`file`].
130fn parent_generation(state: &mut RunState, queue: &Queue, origin_task: Option<&str>) -> u32 {
131    match state.followup_generation {
132        Some(g) => g,
133        None => match origin_task.map(|t| queue.get(t)) {
134            Some(Ok(t)) => {
135                let g = t.followup.map_or(0, |f| f.generation);
136                state.followup_generation = Some(g);
137                g
138            }
139            Some(Err(_)) => MAX_FOLLOWUP_GENERATION,
140            None => 0,
141        },
142    }
143}
144
145/// Reconcile the follow-up tasks the merge approval's deputy filed (held,
146/// reason naming this pull request) with the automatic ones, once the merge
147/// is confirmed. Idempotent, and the same whichever side reached the queue
148/// first:
149///
150/// - a deputy task that covers a finding an automatic follow-up already
151///   covers (same finding, same file within [`LINE_WINDOW`] lines, or the same
152///   normalized title) never runs twice: if the automatic one is queued the
153///   owner's task wins and the automatic one is held with the reason, else
154///   the deputy task stays held with a `[superseded]` reason. Nothing is
155///   deleted;
156/// - otherwise, with `release`, the task is released in the same queue write
157///   that stamps it with a [`FollowUp`] (so it is released once, counts
158///   toward the generation cap, and covers its findings for [`file`]);
159/// - a task at the generation cap stays held with a `[generation cap]`
160///   reason.
161///
162/// Only [`is_deputy_candidate`] tasks are touched, re-checked under the
163/// queue's lock. Best-effort: failures are run events.
164pub fn settle_deputy_tasks(state: &mut RunState, pr_url: &str, queue: &Queue, release: bool) {
165    let tasks = queue.list();
166    // The queue is the record: restore what a crash after the write lost.
167    for t in &tasks {
168        if t.followup.as_ref().is_some_and(|f| f.run == state.id)
169            && matches!(&t.source, Source::Agent { node, .. } if node == crate::deputy::NODE)
170            && !state.deputy_followups.contains(&t.id)
171        {
172            state.deputy_followups.push(t.id.clone());
173        }
174    }
175    let cands: Vec<&Task> = tasks
176        .iter()
177        .filter(|t| is_deputy_candidate(t, &state.id, pr_url))
178        .collect();
179    if cands.is_empty() {
180        return;
181    }
182    let chosen = state
183        .reviews
184        .last()
185        .map(|r| select(r).0)
186        .unwrap_or_default();
187    let groups = group_findings(chosen);
188    let autos: Vec<&Task> = tasks
189        .iter()
190        .filter(|t| {
191            t.followup.as_ref().is_some_and(|f| f.run == state.id)
192                && matches!(&t.source, Source::Agent { node, .. } if node == NODE)
193        })
194        .collect();
195    let origin_task = state.origin.as_ref().and_then(|o| o.task.clone());
196    let mut gen_of_parent = None;
197    for cand in cands {
198        // Findings the task covers, widened to whole defect groups.
199        let matched: Vec<String> = groups
200            .iter()
201            .filter(|g| g.iter().any(|f| task_matches_finding(cand, f)))
202            .flat_map(|g| g.iter().map(|f| f.id.clone()))
203            .collect();
204        let rivals: Vec<&&Task> = autos
205            .iter()
206            .filter(|a| {
207                a.followup
208                    .as_ref()
209                    .is_some_and(|f| f.findings.iter().any(|x| matched.contains(x)))
210            })
211            .collect();
212        if release {
213            let parent_gen = *gen_of_parent
214                .get_or_insert_with(|| parent_generation(state, queue, origin_task.as_deref()));
215            if parent_gen >= MAX_FOLLOWUP_GENERATION {
216                let why = format!(
217                    "{CAPPED}generation {parent_gen} reached, not released after {pr_url} merged; was: {}",
218                    cand.hold_reason.as_deref().unwrap_or_default()
219                );
220                mark_held(state, queue, cand, why, pr_url, "generation cap");
221                continue;
222            }
223        }
224        if !rivals.is_empty() {
225            // Hold every idle rival under its claim, so the daemon cannot be
226            // between reading it and starting it. Any rival that is not
227            // idle, or cannot be claimed, means the automatic path owns the
228            // finding and the deputy task is the one left held.
229            let mut guards = Vec::new();
230            let mut blocker: Option<&Task> = None;
231            let takeover = format!(
232                "{SUPERSEDED}the owner's approval deputy filed task {} for the same finding(s) of {pr_url}",
233                cand.id
234            );
235            // Held by an earlier pass of this same takeover that could not
236            // finish (or be rolled back): it is ours to restore on failure.
237            let mut carried: Vec<String> = Vec::new();
238            if release {
239                for a in &rivals {
240                    let ours = a.status == crate::queue::TaskStatus::Held
241                        && a.hold_reason.as_deref() == Some(takeover.as_str());
242                    if a.status == crate::queue::TaskStatus::Held && !ours {
243                        continue;
244                    }
245                    match queue.claim(&a.id) {
246                        Ok(g) => {
247                            if ours {
248                                carried.push(a.id.clone());
249                            }
250                            guards.push((a.id.clone(), g));
251                        }
252                        Err(_) => {
253                            blocker = Some(a);
254                            break;
255                        }
256                    }
257                }
258                if blocker.is_none() {
259                    blocker = rivals.iter().map(|a| &***a).find(|a| {
260                        !matches!(
261                            queue.get(&a.id).map(|t| t.status),
262                            Ok(crate::queue::TaskStatus::Queued | crate::queue::TaskStatus::Held)
263                        )
264                    });
265                }
266            } else {
267                blocker = Some(&***rivals.first().expect("non-empty"));
268            }
269            if let Some(a) = blocker {
270                // A hold left by an interrupted takeover is not covered by
271                // the blocker: give it back before superseding the deputy.
272                let mut stuck = false;
273                for r in &rivals {
274                    let held_by_us = queue.get(&r.id).is_ok_and(|t| {
275                        t.status == crate::queue::TaskStatus::Held
276                            && t.hold_reason.as_deref() == Some(takeover.as_str())
277                    });
278                    if !held_by_us {
279                        continue;
280                    }
281                    let _own;
282                    if !guards.iter().any(|(g, _)| g == &r.id) {
283                        match queue.claim(&r.id) {
284                            Ok(g) => _own = g,
285                            Err(_) => {
286                                stuck = true;
287                                continue;
288                            }
289                        }
290                    }
291                    stuck |= !restore_takeover_hold(state, queue, &r.id, &takeover, &cand.id);
292                }
293                drop(guards);
294                if stuck {
295                    continue;
296                }
297                let why = format!(
298                    "{SUPERSEDED}follow-up task {} ({:?}) already covers {} of {pr_url}; was: {}",
299                    a.id,
300                    a.status,
301                    matched.join(", "),
302                    cand.hold_reason.as_deref().unwrap_or_default()
303                );
304                mark_held(state, queue, cand, why, pr_url, "superseded");
305                continue;
306            }
307            // The owner's task wins; the idle automatic ones are held. Only
308            // when every one is confirmed held is the deputy task released.
309            let mut failed: Option<String> = None;
310            // What each held task looked like before, for a rollback.
311            let mut done: Vec<(String, Task, String)> = Vec::new();
312            for (id, _guard) in &guards {
313                if carried.contains(id) {
314                    continue;
315                }
316                let why = takeover.clone();
317                let mut before: Option<Task> = None;
318                let held = queue.modify(id, |t| {
319                    if t.status != crate::queue::TaskStatus::Queued {
320                        return false;
321                    }
322                    before = Some(t.clone());
323                    t.hold_manual(Some(why.clone()));
324                    true
325                });
326                match held {
327                    Ok(true) => {
328                        done.extend(before.map(|b| (id.clone(), b, why)));
329                        state.event(
330                            NODE,
331                            format!(
332                                "follow-up task {id} held: superseded by deputy task {}",
333                                cand.id
334                            ),
335                        );
336                    }
337                    Ok(false) => {
338                        failed = Some(id.clone());
339                        break;
340                    }
341                    Err(e) => {
342                        state.event(NODE, format!("could not hold follow-up {id}: {e:#}"));
343                        failed = Some(id.clone());
344                        break;
345                    }
346                }
347            }
348            if let Some(id) = failed {
349                // Give back what was already held, still under its claim, so
350                // the automatic path keeps covering every finding. Only a
351                // task still carrying our hold is touched.
352                let mut stuck = false;
353                for tid in &carried {
354                    stuck |= !restore_takeover_hold(state, queue, tid, &takeover, &cand.id);
355                }
356                for (tid, before, why) in done.into_iter().rev() {
357                    let back = queue.modify(&tid, |t| {
358                        if t.status != crate::queue::TaskStatus::Held
359                            || t.hold_reason.as_deref() != Some(why.as_str())
360                        {
361                            return false;
362                        }
363                        *t = before;
364                        true
365                    });
366                    match back {
367                        Ok(true) => state.event(
368                            NODE,
369                            format!(
370                                "follow-up task {tid} restored: deputy task {} could not take it over",
371                                cand.id
372                            ),
373                        ),
374                        Ok(false) => {}
375                        Err(e) => {
376                            stuck = true;
377                            state.event(
378                                NODE,
379                                format!(
380                                    "rollback of {tid} failed, deputy task {} left as a candidate for the next settle: {e:#}",
381                                    cand.id
382                                ),
383                            );
384                        }
385                    }
386                }
387                drop(guards);
388                if stuck {
389                    continue;
390                }
391                let why = format!(
392                    "{SUPERSEDED}follow-up task {id} could not be held, so it may still run for {} of {pr_url}; was: {}",
393                    matched.join(", "),
394                    cand.hold_reason.as_deref().unwrap_or_default()
395                );
396                mark_held(state, queue, cand, why, pr_url, "superseded");
397                continue;
398            }
399        } else if !release {
400            continue;
401        }
402        let parent_gen = gen_of_parent.expect("computed when releasing");
403        let stamp = FollowUp {
404            run: state.id.clone(),
405            origin_task: origin_task.clone(),
406            pr: pr_url.to_owned(),
407            findings: matched,
408            generation: parent_gen + 1,
409        };
410        let id = cand.id.clone();
411        let run = state.id.clone();
412        let result = queue.modify(&id, |t| {
413            if !is_deputy_candidate(t, &run, pr_url) {
414                return false;
415            }
416            t.release();
417            t.followup = Some(stamp);
418            true
419        });
420        match result {
421            Ok(true) => {
422                if !state.deputy_followups.contains(&id) {
423                    state.deputy_followups.push(id.clone());
424                }
425                state.event(
426                    NODE,
427                    format!("released deputy follow-up task {id} after {pr_url} merged"),
428                );
429            }
430            Ok(false) => {}
431            Err(e) => state.event(NODE, format!("could not release deputy task {id}: {e:#}")),
432        }
433    }
434}
435
436/// Give a follow-up task held by an interrupted takeover back to the
437/// automatic path. `false` when the write failed (the caller keeps the deputy
438/// task a candidate); a task no longer carrying `takeover` is left alone.
439fn restore_takeover_hold(
440    state: &mut RunState,
441    queue: &Queue,
442    tid: &str,
443    takeover: &str,
444    cand_id: &str,
445) -> bool {
446    let back = queue.modify(tid, |t| {
447        if t.status != crate::queue::TaskStatus::Held || t.hold_reason.as_deref() != Some(takeover)
448        {
449            return false;
450        }
451        // No snapshot survives a restart; an automatic follow-up is filed
452        // Queued with nothing else set.
453        t.status = crate::queue::TaskStatus::Queued;
454        t.held_at = None;
455        t.hold_reason = None;
456        t.hold_source = None;
457        true
458    });
459    match back {
460        Ok(true) => {
461            state.event(
462                NODE,
463                format!(
464                    "follow-up task {tid} restored: deputy task {cand_id} could not take it over"
465                ),
466            );
467            true
468        }
469        Ok(false) => true,
470        Err(e) => {
471            state.event(
472                NODE,
473                format!(
474                    "rollback of {tid} failed, deputy task {cand_id} left as a candidate for the next settle: {e:#}"
475                ),
476            );
477            false
478        }
479    }
480}
481
482/// Leave a replaced run's deputy tasks held instead of releasing stale findings.
483pub(crate) fn supersede_deputy_tasks(
484    state: &mut RunState,
485    pr_url: &str,
486    queue: &Queue,
487    successor: &str,
488) {
489    for task in queue.list() {
490        if is_deputy_candidate(&task, &state.id, pr_url) {
491            mark_held(
492                state,
493                queue,
494                &task,
495                format!(
496                    "{SUPERSEDED}pull request ownership moved to run {}",
497                    crate::run::short_of(successor)
498                ),
499                pr_url,
500                "superseded",
501            );
502        }
503    }
504}
505
506/// Rewrite a still-candidate deputy task's hold reason, leaving it held.
507fn mark_held(
508    state: &mut RunState,
509    queue: &Queue,
510    cand: &Task,
511    why: String,
512    pr_url: &str,
513    kind: &str,
514) {
515    let run = state.id.clone();
516    let reason = why.clone();
517    match queue.modify(&cand.id, |t| {
518        if !is_deputy_candidate(t, &run, pr_url) {
519            return false;
520        }
521        t.hold_reason = Some(reason);
522        true
523    }) {
524        Ok(true) => state.event(
525            NODE,
526            format!("deputy task {} left held ({kind}): {why}", cand.id),
527        ),
528        Ok(false) => {}
529        Err(e) => state.event(
530            NODE,
531            format!("could not mark deputy task {}: {e:#}", cand.id),
532        ),
533    }
534}
535
536/// Findings the last review round leaves to be followed up, and the ids of
537/// those only listed: every Major-or-above finding, plus all findings of a
538/// seat whose final vote was reject.
539fn select(round: &crate::run::ReviewRound) -> (Vec<Finding>, Vec<String>) {
540    let rejecters: Vec<usize> = round
541        .final_votes()
542        .into_iter()
543        .filter(|(_, _, v)| *v == ReviewVote::Reject)
544        .map(|(seat, _, _)| seat)
545        .collect();
546    let (mut chosen, mut unfiled) = (Vec::new(), Vec::new());
547    for rec in &round.reviews {
548        let rejected = rejecters.contains(&rec.reviewer);
549        for f in &rec.findings {
550            if f.severity.blocks() || rejected {
551                chosen.push(f.clone());
552            } else {
553                unfiled.push(f.id.clone());
554            }
555        }
556    }
557    (chosen, unfiled)
558}
559
560/// The deterministic half of [`after_merge`]: select, group, and file into
561/// `queue`. Records the event and the run's own bookkeeping; the caller
562/// saves the state.
563pub fn file(state: &mut RunState, pr_url: &str, queue: &Queue) -> Result<Outcome> {
564    let mut out = Outcome::default();
565    let Some(round) = state.reviews.last().cloned() else {
566        return Ok(out);
567    };
568    let (chosen, unfiled) = select(&round);
569    out.unfiled = unfiled;
570    if chosen.is_empty() {
571        return Ok(out);
572    }
573
574    let origin_task = state.origin.as_ref().and_then(|o| o.task.clone());
575    // The depth recorded when the run started wins; the queue is only a
576    // fallback for runs that predate it, and an unknown depth is read as
577    // unbounded-until-proven-otherwise only when there is no origin task.
578    let parent_gen = parent_generation(state, queue, origin_task.as_deref());
579    if parent_gen >= MAX_FOLLOWUP_GENERATION {
580        out.capped = chosen.iter().map(|f| f.id.clone()).collect();
581        state.event(
582            NODE,
583            format!(
584                "generation cap reached ({parent_gen}); not filing follow-ups for open finding(s): {}",
585                out.capped.join(", ")
586            ),
587        );
588        return Ok(out);
589    }
590
591    let mut done: Vec<String> = state
592        .followups
593        .iter()
594        .flat_map(|r| r.findings.iter().cloned())
595        .collect();
596    for t in queue.list() {
597        if let Some(f) = t.followup
598            && f.run == state.id
599        {
600            done.extend(f.findings.iter().cloned());
601            let by_deputy =
602                matches!(&t.source, Source::Agent { node, .. } if node == crate::deputy::NODE);
603            if !by_deputy && !state.followups.iter().any(|r| r.task == t.id) {
604                state.followups.push(FollowupRecord {
605                    task: t.id,
606                    findings: f.findings,
607                });
608            }
609        }
610    }
611
612    let tasks = queue.list();
613    let origin_chat = state.origin_chat.clone().or_else(|| {
614        let tasks = queue.list();
615        let parent = origin_task
616            .as_deref()
617            .and_then(|id| tasks.iter().find(|t| t.id == id))?;
618        crate::consult::chat_talk_of(&tasks, parent)
619    });
620    let mut failure = None;
621    for group in group_findings(chosen) {
622        let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
623        let own = task_id(&state.id, &ids);
624        let covers: Vec<Option<String>> = ids
625            .iter()
626            .map(|id| covering_task(&tasks, &own, &state.id, pr_url, id))
627            .collect();
628        if covers.iter().all(Option::is_some) {
629            for (id, by) in ids.iter().zip(covers.into_iter().flatten()) {
630                state.event(
631                    NODE,
632                    format!("not filing follow-up for {id}: already covered by task {by}"),
633                );
634                out.covered.push((id.clone(), by));
635            }
636            continue;
637        }
638        if ids.iter().any(|id| done.contains(id)) {
639            continue;
640        }
641        let mut task = build_task(state, pr_url, origin_task.clone(), &group, parent_gen + 1);
642        task.origin_chat = origin_chat.clone();
643        match queue.create_new(&mut task) {
644            Ok(created) => {
645                if created {
646                    out.filed.push(task.id.clone());
647                }
648                state.followups.push(FollowupRecord {
649                    task: task.id,
650                    findings: ids,
651                });
652            }
653            Err(e) => {
654                failure = Some(e);
655                break;
656            }
657        }
658    }
659    if !out.filed.is_empty() {
660        state.event(
661            NODE,
662            format!(
663                "filed {} follow-up task(s): ids {}",
664                out.filed.len(),
665                out.filed.join(", ")
666            ),
667        );
668    }
669    if let Some(e) = failure {
670        state.event(NODE, format!("follow-up filing stopped: {e:#}"));
671    }
672    Ok(out)
673}
674
675/// The id of a task, other than `own`, that already covers finding `id` of
676/// run `run` merged as `pr_url`, whatever its status. Concrete identifiers
677/// only: a follow-up of the same run listing the finding, or an instruction
678/// naming both the finding id and the pull request (URL or `#<number>`).
679fn covering_task(tasks: &[Task], own: &str, run: &str, pr_url: &str, id: &str) -> Option<String> {
680    tasks
681        .iter()
682        .find(|t| {
683            t.id != own
684                && (t
685                    .followup
686                    .as_ref()
687                    .is_some_and(|f| f.run == run && f.findings.iter().any(|x| x == id))
688                    || (names_word(&t.instruction, id) && names_pr(&t.instruction, pr_url)))
689        })
690        .map(|t| t.id.clone())
691}
692
693/// ASCII only: an id is often followed directly by prose in another script
694/// (`R3-1-1を修正`), which must not read as part of the id.
695fn is_name_char(c: char) -> bool {
696    c.is_ascii_alphanumeric() || matches!(c, '_' | '-')
697}
698
699/// `needle` in `text` with no name character on either side.
700fn names_word(text: &str, needle: &str) -> bool {
701    !needle.is_empty()
702        && text.match_indices(needle).any(|(i, _)| {
703            let before = text[..i].chars().next_back();
704            let after = text[i + needle.len()..].chars().next();
705            before.is_none_or(|c| !is_name_char(c)) && after.is_none_or(|c| !is_name_char(c))
706        })
707}
708
709/// Does `text` name the pull request by its URL or its `#<number>` form,
710/// with no further digit (or name character before `#`) so `#47` is not `#473`?
711fn names_pr(text: &str, pr_url: &str) -> bool {
712    let url = pr_url.trim_end_matches('/');
713    let digit_after = |rest: &str| rest.chars().next().is_some_and(|c| c.is_ascii_digit());
714    if !url.is_empty()
715        && text
716            .match_indices(url)
717            .any(|(i, _)| !digit_after(&text[i + url.len()..]))
718    {
719        return true;
720    }
721    let Some(n) = url
722        .rsplit('/')
723        .next()
724        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
725    else {
726        return false;
727    };
728    let tag = format!("#{n}");
729    text.match_indices(&tag).any(|(i, _)| {
730        let before = text[..i].chars().next_back();
731        before.is_none_or(|c| !is_name_char(c)) && !digit_after(&text[i + tag.len()..])
732    })
733}
734
735/// Group findings that are the same defect: same file with lines within
736/// [`LINE_WINDOW`] of each other, or, with no file, the same normalized
737/// title. Deterministic, so a re-run groups identically.
738fn group_findings(mut findings: Vec<Finding>) -> Vec<Vec<Finding>> {
739    findings.sort_by(|a, b| (&a.file, a.line, &a.id).cmp(&(&b.file, b.line, &b.id)));
740    let mut groups: Vec<Vec<Finding>> = Vec::new();
741    for f in findings {
742        let hit = groups
743            .iter()
744            .position(|g| g.iter().any(|o| same_defect(o, &f)));
745        match hit {
746            Some(i) => groups[i].push(f),
747            None => groups.push(vec![f]),
748        }
749    }
750    for g in &mut groups {
751        g.sort_by(|a, b| a.id.cmp(&b.id));
752    }
753    groups
754}
755
756fn same_defect(a: &Finding, b: &Finding) -> bool {
757    match (&a.file, &b.file) {
758        (Some(x), Some(y)) => {
759            x == y
760                && match (a.line, b.line) {
761                    (Some(l), Some(m)) => l.abs_diff(m) <= LINE_WINDOW,
762                    (None, None) => true,
763                    _ => false,
764                }
765        }
766        (None, None) => normalize(&a.title) == normalize(&b.title),
767        _ => false,
768    }
769}
770
771fn normalize(title: &str) -> String {
772    title
773        .to_lowercase()
774        .split(|c: char| !c.is_alphanumeric())
775        .filter(|w| !w.is_empty())
776        .collect::<Vec<_>>()
777        .join(" ")
778}
779
780/// Deterministic id: the run id plus a hash of the group's finding ids, so
781/// two passes name the same task and [`Queue::create_new`] can refuse the
782/// second. Ends in a `-` segment like every id, so `queue::short` works.
783fn task_id(run: &str, ids: &[String]) -> String {
784    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
785    for b in ids.join("+").bytes() {
786        h ^= u64::from(b);
787        h = h.wrapping_mul(0x0100_0000_01b3);
788    }
789    format!("{run}-f{:05x}", h & 0xf_ffff)
790}
791
792fn location(f: &Finding) -> String {
793    match (&f.file, f.line) {
794        (Some(p), Some(l)) => format!("{p}:{l}"),
795        (Some(p), None) => p.clone(),
796        _ => "(no location)".to_owned(),
797    }
798}
799
800fn build_task(
801    state: &RunState,
802    pr_url: &str,
803    origin_task: Option<String>,
804    group: &[Finding],
805    generation: u32,
806) -> Task {
807    let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
808    let first = &group[0];
809    let title = format!(
810        "follow-up: {} ({})",
811        crate::queue::title_from(&first.title, 80),
812        location(first)
813    );
814    let mut s = format!(
815        "This is a follow-up to a change that was already merged: run {run} merged \
816         {pr_url} while the review finding(s) below were still open. Branch from the \
817         current main - do not use the merged branch - and fix the defect described, \
818         keeping the change small. If a finding no longer applies to current main, say so \
819         and change nothing for it.\n\n\
820         Merged run: {run}\nPull request: {pr_url}\n",
821        run = state.id,
822    );
823    if let Some(t) = &origin_task {
824        s.push_str(&format!("Original task: {t}\n"));
825    }
826    s.push_str("\n## Findings\n");
827    for f in group {
828        s.push_str(&format!(
829            "\n### {} [{:?}] {}\n\nLocation: {}\n\n{}\n",
830            f.id,
831            f.severity,
832            f.title,
833            location(f),
834            f.detail.trim()
835        ));
836    }
837    let mut task = Task::new(
838        title,
839        s,
840        state.repo.clone(),
841        Source::Agent {
842            run: state.id.clone(),
843            node: NODE.to_owned(),
844        },
845    );
846    task.id = task_id(&state.id, &ids);
847    task.solo = true;
848    task.followup = Some(FollowUp {
849        run: state.id.clone(),
850        origin_task,
851        pr: pr_url.to_owned(),
852        findings: ids,
853        generation,
854    });
855    task
856}
857
858fn comment_body(state: &RunState, out: &Outcome) -> String {
859    let mut s = format!(
860        "<!-- magi-followup run={} -->\nThis pull request merged with review findings still open.",
861        state.id
862    );
863    if state.followups.is_empty() {
864        if !out.covered.is_empty() || state.deputy_followups.is_empty() {
865            s.push_str(" Each is already covered by another task:\n");
866        }
867    } else {
868        s.push_str(" They were filed as follow-up tasks:\n\n");
869        for r in &state.followups {
870            s.push_str(&format!("- `{}`: {}\n", r.task, r.findings.join(", ")));
871        }
872        if !out.covered.is_empty() {
873            s.push_str("\nNot filed, already covered:\n");
874        }
875    }
876    if !out.covered.is_empty() {
877        s.push('\n');
878    }
879    for (id, by) in &out.covered {
880        s.push_str(&format!("- {id}: already covered by task `{by}`\n"));
881    }
882    if !state.deputy_followups.is_empty() {
883        s.push_str(
884            "\nFollow-up tasks filed by the approval deputy and released after the merge:\n\n",
885        );
886        for id in &state.deputy_followups {
887            s.push_str(&format!("- `{id}`\n"));
888        }
889    }
890    if !out.unfiled.is_empty() {
891        s.push_str(&format!(
892            "\nMinor/nit findings that were only listed, not filed: {}\n",
893            out.unfiled.join(", ")
894        ));
895    }
896    s
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use crate::config::Config;
903    use crate::queue::Queue;
904    use crate::run::ReviewRecord;
905    use crate::run::{Origin, ReviewRound, RunState};
906    use crate::verdict::{Finding, ReviewVote, Severity};
907    use std::path::PathBuf;
908
909    fn finding(id: &str, sev: Severity, file: &str, line: u32) -> Finding {
910        Finding {
911            id: id.to_owned(),
912            severity: sev,
913            file: Some(file.to_owned()),
914            line: Some(line),
915            title: format!("title {id}"),
916            detail: format!("detail of {id}"),
917        }
918    }
919
920    fn record(seat: usize, findings: Vec<Finding>, vote: ReviewVote) -> ReviewRecord {
921        ReviewRecord {
922            attempts: 0,
923            reviewer: seat,
924            agent: "alpha".to_owned(),
925            summary: String::new(),
926            findings,
927            vote: Some(vote),
928            failed: None,
929            duration_ms: 0,
930        }
931    }
932
933    fn round(reviews: Vec<ReviewRecord>) -> ReviewRound {
934        ReviewRound {
935            round: 3,
936            head: "deadbee".to_owned(),
937            verified_head: None,
938            verified_at: None,
939            reviews,
940            e2e: Vec::new(),
941            verify_retried: false,
942            e2e_deferred: false,
943            e2e_defer_reason: None,
944            fix: None,
945            blocking: 0,
946            answered: 2,
947            expected: 2,
948            clean: false,
949            progressed: false,
950            vote_split: false,
951            reconsideration: Vec::new(),
952            verdict: None,
953        }
954    }
955
956    fn merged(reviews: Vec<ReviewRecord>) -> RunState {
957        let mut s = RunState::new(
958            PathBuf::from("/repo"),
959            "main".to_owned(),
960            "abc1234".to_owned(),
961            "do it".to_owned(),
962            Config::default(),
963        );
964        s.reviews.push(round(reviews));
965        s
966    }
967
968    fn two_seats() -> Vec<ReviewRecord> {
969        vec![
970            record(
971                1,
972                vec![
973                    finding("R3-1-1", Severity::Major, "src/waiter.rs", 211),
974                    finding("R3-1-2", Severity::Minor, "src/a.rs", 1),
975                ],
976                ReviewVote::Reject,
977            ),
978            record(
979                2,
980                vec![
981                    finding("R3-2-1", Severity::Major, "src/waiter.rs", 213),
982                    finding("R3-2-2", Severity::Nit, "src/b.rs", 9),
983                ],
984                ReviewVote::Approve,
985            ),
986        ]
987    }
988
989    #[test]
990    fn one_task_per_distinct_defect_with_the_findings_in_full() {
991        let dir = tempfile::tempdir().unwrap();
992        let q = Queue::at(dir.path().join("queue"));
993        let mut s = merged(two_seats());
994        let out = file(&mut s, "https://example.invalid/o/r/pull/9", &q).unwrap();
995        // waiter.rs pair is one defect; seat 1 rejected so its Minor is filed
996        // too; seat 2's Nit is only listed.
997        assert_eq!(out.filed.len(), 2, "{out:?}");
998        assert_eq!(out.unfiled, vec!["R3-2-2".to_owned()]);
999        let tasks = q.list();
1000        let t = tasks
1001            .iter()
1002            .find(|t| t.followup.as_ref().unwrap().findings.len() == 2)
1003            .unwrap();
1004        assert!(t.solo);
1005        assert_eq!(t.followup.as_ref().unwrap().generation, 1);
1006        assert_eq!(
1007            t.source,
1008            Source::Agent {
1009                run: s.id.clone(),
1010                node: NODE.to_owned()
1011            }
1012        );
1013        for needle in [
1014            "R3-1-1",
1015            "R3-2-1",
1016            "src/waiter.rs:211",
1017            "detail of R3-2-1",
1018            "pull/9",
1019            &s.id,
1020            "current main",
1021        ] {
1022            assert!(
1023                t.instruction.contains(needle),
1024                "{needle}: {}",
1025                t.instruction
1026            );
1027        }
1028        assert!(
1029            s.events
1030                .iter()
1031                .any(|e| e.message.contains("filed 2 follow-up"))
1032        );
1033        assert_eq!(s.followups.len(), 2);
1034    }
1035
1036    #[test]
1037    fn filing_twice_does_not_duplicate() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let q = Queue::at(dir.path().join("queue"));
1040        let mut s = merged(two_seats());
1041        file(&mut s, "u", &q).unwrap();
1042        let again = file(&mut s, "u", &q).unwrap();
1043        assert!(again.filed.is_empty());
1044        assert_eq!(q.list().len(), 2);
1045        // Even with the run's own record lost, the queue says what exists.
1046        s.followups.clear();
1047        let third = file(&mut s, "u", &q).unwrap();
1048        assert!(third.filed.is_empty());
1049        assert_eq!(q.list().len(), 2);
1050        assert_eq!(s.followups.len(), 2, "records are restored from the queue");
1051    }
1052
1053    #[test]
1054    fn a_clean_merge_files_nothing() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let q = Queue::at(dir.path().join("queue"));
1057        let mut s = merged(vec![record(
1058            1,
1059            vec![finding("R1-1-1", Severity::Minor, "a.rs", 1)],
1060            ReviewVote::Approve,
1061        )]);
1062        let out = file(&mut s, "u", &q).unwrap();
1063        assert!(out.filed.is_empty());
1064        assert!(q.list().is_empty());
1065        assert!(s.followups.is_empty());
1066    }
1067
1068    #[test]
1069    fn a_failing_queue_is_an_error_event_not_a_status_change() {
1070        let dir = tempfile::tempdir().unwrap();
1071        let root = dir.path().join("queue");
1072        std::fs::write(&root, "not a directory").unwrap();
1073        let q = Queue::at(root);
1074        let mut s = merged(two_seats());
1075        s.status = crate::run::RunStatus::Merged;
1076        let _ = file(&mut s, "u", &q);
1077        assert_eq!(s.status, crate::run::RunStatus::Merged);
1078        assert!(
1079            s.events
1080                .iter()
1081                .any(|e| e.node == NODE && e.message.contains("stopped"))
1082        );
1083        assert!(s.followups.is_empty());
1084    }
1085
1086    #[test]
1087    fn the_generation_cap_stops_the_chain() {
1088        let dir = tempfile::tempdir().unwrap();
1089        let q = Queue::at(dir.path().join("queue"));
1090        let mut parent = Task::new(
1091            "p".to_owned(),
1092            "i".to_owned(),
1093            PathBuf::from("/repo"),
1094            Source::Human,
1095        );
1096        parent.followup = Some(FollowUp {
1097            run: "r0".to_owned(),
1098            origin_task: None,
1099            pr: "u".to_owned(),
1100            findings: vec!["R1-1-1".to_owned()],
1101            generation: MAX_FOLLOWUP_GENERATION,
1102        });
1103        q.put(&mut parent).unwrap();
1104        let mut s = merged(two_seats());
1105        s.origin = Some(Origin {
1106            by: crate::run::StartedBy::Operator,
1107            task: Some(parent.id.clone()),
1108        });
1109        s.origin = Some(Origin {
1110            by: crate::run::StartedBy::Operator,
1111            task: Some("gone".to_owned()),
1112        });
1113        s.followup_generation = Some(MAX_FOLLOWUP_GENERATION);
1114        let out = file(&mut s, "u", &q).unwrap();
1115        assert!(out.filed.is_empty());
1116        assert!(!out.capped.is_empty());
1117        assert_eq!(q.list().len(), 1);
1118        assert!(
1119            s.events
1120                .iter()
1121                .any(|e| e.message.contains("generation cap"))
1122        );
1123    }
1124
1125    #[test]
1126    fn a_deeper_task_files_one_generation_further() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let q = Queue::at(dir.path().join("queue"));
1129        let mut parent = Task::new(
1130            "p".to_owned(),
1131            "i".to_owned(),
1132            PathBuf::from("/repo"),
1133            Source::Human,
1134        );
1135        q.put(&mut parent).unwrap();
1136        let mut s = merged(two_seats());
1137        s.origin = Some(Origin {
1138            by: crate::run::StartedBy::Operator,
1139            task: Some(parent.id.clone()),
1140        });
1141        file(&mut s, "u", &q).unwrap();
1142        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
1143        assert_eq!(t.followup.unwrap().origin_task, Some(parent.id));
1144    }
1145
1146    #[test]
1147    fn the_chat_is_inherited_from_the_run_even_when_the_parent_is_gone() {
1148        let dir = tempfile::tempdir().unwrap();
1149        let q = Queue::at(dir.path().join("queue"));
1150        let mut s = merged(two_seats());
1151        s.origin = Some(Origin {
1152            by: crate::run::StartedBy::Operator,
1153            task: Some("gone".to_owned()),
1154        });
1155        s.followup_generation = Some(1);
1156        s.origin_chat = Some("talk-1".to_owned());
1157        file(&mut s, "u", &q).unwrap();
1158        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
1159        assert_eq!(t.origin_chat.as_deref(), Some("talk-1"));
1160    }
1161
1162    #[test]
1163    fn a_parent_without_the_field_passes_on_what_its_ancestry_resolves() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let q = Queue::at(dir.path().join("queue"));
1166        let mut parent = Task::new(
1167            "p".to_owned(),
1168            "i".to_owned(),
1169            PathBuf::from("/repo"),
1170            Source::Agent {
1171                run: "talk-2".to_owned(),
1172                node: crate::queue::CHAT_NODE.to_owned(),
1173            },
1174        );
1175        parent.origin_chat = None;
1176        q.put(&mut parent).unwrap();
1177        let mut s = merged(two_seats());
1178        s.origin = Some(Origin {
1179            by: crate::run::StartedBy::Operator,
1180            task: Some(parent.id.clone()),
1181        });
1182        file(&mut s, "u", &q).unwrap();
1183        let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
1184        assert_eq!(t.origin_chat.as_deref(), Some("talk-2"));
1185    }
1186
1187    const PR: &str = "https://github.com/o/r/pull/473";
1188
1189    fn manual(text: &str) -> Task {
1190        Task::new(
1191            "manual".to_owned(),
1192            text.to_owned(),
1193            PathBuf::from("/repo"),
1194            Source::Human,
1195        )
1196    }
1197
1198    /// File follow-ups with one manual task already in the queue.
1199    fn filed_with(text: &str) -> (Outcome, RunState, Queue, String, tempfile::TempDir) {
1200        let dir = tempfile::tempdir().unwrap();
1201        let q = Queue::at(dir.path().join("queue"));
1202        let mut t = manual(text);
1203        t.status = crate::queue::TaskStatus::Done;
1204        q.put(&mut t).unwrap();
1205        let mut s = merged(two_seats());
1206        let out = file(&mut s, PR, &q).unwrap();
1207        (out, s, q, t.id, dir)
1208    }
1209
1210    #[test]
1211    fn a_task_naming_the_pr_url_and_ids_covers_the_group() {
1212        let (out, s, _q, id, _d) = filed_with(&format!("Fix R3-1-1 and R3-2-1 from {PR}"));
1213        // the waiter.rs group is covered; the other seat-1 Minor is still filed
1214        assert_eq!(out.filed.len(), 1, "{out:?}");
1215        assert_eq!(
1216            out.covered,
1217            vec![
1218                ("R3-1-1".to_owned(), id.clone()),
1219                ("R3-2-1".to_owned(), id.clone())
1220            ]
1221        );
1222        assert!(out.capped.is_empty());
1223        assert!(
1224            s.events
1225                .iter()
1226                .any(|e| e.message.contains("already covered"))
1227        );
1228        let body = comment_body(&s, &out);
1229        assert!(body.contains(&format!("R3-1-1: already covered by task `{id}`")));
1230    }
1231
1232    #[test]
1233    fn the_hash_form_covers_but_a_prefix_number_does_not() {
1234        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in PR #473");
1235        assert_eq!(out.covered.len(), 2);
1236        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in #47");
1237        assert!(out.covered.is_empty());
1238        assert_eq!(out.filed.len(), 2);
1239        let (out, ..) = filed_with("R3-1-1 R3-2-1 fixed in #4731");
1240        assert!(out.covered.is_empty());
1241    }
1242
1243    #[test]
1244    fn an_id_without_the_pr_does_not_cover() {
1245        let (out, ..) = filed_with("R3-1-1 and R3-2-1 are bad");
1246        assert!(out.covered.is_empty());
1247        assert_eq!(out.filed.len(), 2);
1248    }
1249
1250    #[test]
1251    fn an_id_prefix_does_not_cover() {
1252        let (out, ..) = filed_with(&format!("R3-1-10 R3-2-10 in {PR}"));
1253        assert!(out.covered.is_empty());
1254    }
1255
1256    #[test]
1257    fn partial_coverage_still_files() {
1258        let (out, ..) = filed_with(&format!("R3-1-1 in {PR}"));
1259        assert!(out.covered.is_empty());
1260        assert_eq!(out.filed.len(), 2);
1261    }
1262
1263    #[test]
1264    fn another_followups_findings_cover() {
1265        let dir = tempfile::tempdir().unwrap();
1266        let q = Queue::at(dir.path().join("queue"));
1267        let mut s = merged(two_seats());
1268        let mut other = manual("x");
1269        other.followup = Some(FollowUp {
1270            run: s.id.clone(),
1271            origin_task: None,
1272            pr: PR.to_owned(),
1273            findings: vec!["R3-1-1".to_owned(), "R3-2-1".to_owned()],
1274            generation: 1,
1275        });
1276        q.put(&mut other).unwrap();
1277        let out = file(&mut s, PR, &q).unwrap();
1278        assert_eq!(out.covered.len(), 2, "{out:?}");
1279        assert_eq!(out.filed.len(), 1);
1280    }
1281
1282    #[test]
1283    fn github_text_fixed_followup_comment_passes() {
1284        let state = merged(two_seats());
1285        let body = comment_body(&state, &Outcome::default());
1286        assert!(crate::github_text::check("", &body).is_empty());
1287    }
1288
1289    #[test]
1290    fn a_fully_covered_comment_lists_only_the_covered() {
1291        let (out, mut s, ..) = filed_with(&format!("R3-1-1 R3-2-1 {PR}"));
1292        s.followups.clear();
1293        let body = comment_body(&s, &out);
1294        assert!(body.contains("already covered by task"));
1295        assert!(!body.contains("filed as follow-up"));
1296    }
1297
1298    #[test]
1299    fn an_id_next_to_japanese_prose_still_covers() {
1300        let (out, ..) = filed_with("PR #473 の R3-1-1を修正、R3-2-1も対応");
1301        assert_eq!(out.covered.len(), 2, "{out:?}");
1302    }
1303
1304    // ---- deputy tasks released after the merge ----
1305
1306    fn deputy_task(run: &str, title: &str, text: &str, reason: &str) -> Task {
1307        let mut t = Task::new(
1308            title.to_owned(),
1309            text.to_owned(),
1310            PathBuf::from("/repo"),
1311            Source::Agent {
1312                run: run.to_owned(),
1313                node: crate::deputy::NODE.to_owned(),
1314            },
1315        );
1316        t.hold_manual(Some(reason.to_owned()));
1317        t
1318    }
1319
1320    fn status_of(q: &Queue, id: &str) -> crate::queue::TaskStatus {
1321        q.get(id).unwrap().status
1322    }
1323
1324    #[test]
1325    fn a_deputy_task_is_released_once_and_stamped() {
1326        let dir = tempfile::tempdir().unwrap();
1327        let q = Queue::at(dir.path().join("queue"));
1328        let mut s = merged(two_seats());
1329        s.followup_generation = Some(0);
1330        let mut t = deputy_task(
1331            &s.id,
1332            "Unrelated work",
1333            "do the thing",
1334            &format!("after {PR}"),
1335        );
1336        q.put(&mut t).unwrap();
1337        settle_deputy_tasks(&mut s, PR, &q, true);
1338        let got = q.get(&t.id).unwrap();
1339        assert_eq!(got.status, crate::queue::TaskStatus::Queued);
1340        assert!(got.hold_reason.is_none());
1341        let f = got.followup.unwrap();
1342        assert_eq!((f.run.as_str(), f.generation), (s.id.as_str(), 1));
1343        assert_eq!(s.deputy_followups, vec![t.id.clone()]);
1344        let events = s.events.len();
1345        // A second pass, or a restart that lost the run's record, changes nothing.
1346        settle_deputy_tasks(&mut s, PR, &q, true);
1347        assert_eq!(s.events.len(), events);
1348        s.deputy_followups.clear();
1349        settle_deputy_tasks(&mut s, PR, &q, true);
1350        assert_eq!(
1351            s.deputy_followups,
1352            vec![t.id.clone()],
1353            "rebuilt from the queue"
1354        );
1355        assert_eq!(status_of(&q, &t.id), crate::queue::TaskStatus::Queued);
1356    }
1357
1358    #[test]
1359    fn unrelated_held_tasks_are_never_released() {
1360        let dir = tempfile::tempdir().unwrap();
1361        let q = Queue::at(dir.path().join("queue"));
1362        let mut s = merged(two_seats());
1363        s.followup_generation = Some(0);
1364        let mut other_run = deputy_task("someone-else", "a", "b", &format!("after {PR}"));
1365        let mut other_pr = deputy_task(&s.id, "c", "d", "after https://github.com/o/r/pull/4731");
1366        let mut no_reason = deputy_task(&s.id, "e", "f", "x");
1367        no_reason.hold_reason = None;
1368        let mut human = manual("g");
1369        human.hold_manual(Some(format!("waiting for {PR}")));
1370        let mut queued = deputy_task(&s.id, "h", "i", &format!("after {PR}"));
1371        queued.release();
1372        let ids: Vec<String> = [
1373            &mut other_run,
1374            &mut other_pr,
1375            &mut no_reason,
1376            &mut human,
1377            &mut queued,
1378        ]
1379        .into_iter()
1380        .map(|t| {
1381            q.put(t).unwrap();
1382            t.id.clone()
1383        })
1384        .collect();
1385        settle_deputy_tasks(&mut s, PR, &q, true);
1386        for id in &ids[..4] {
1387            assert_eq!(status_of(&q, id), crate::queue::TaskStatus::Held, "{id}");
1388            assert!(q.get(id).unwrap().followup.is_none());
1389        }
1390        assert!(s.deputy_followups.is_empty());
1391    }
1392
1393    #[test]
1394    fn the_switch_off_leaves_the_task_held() {
1395        let dir = tempfile::tempdir().unwrap();
1396        let q = Queue::at(dir.path().join("queue"));
1397        let mut s = merged(two_seats());
1398        s.followup_generation = Some(0);
1399        let mut t = deputy_task(&s.id, "x", "y", &format!("after {PR}"));
1400        q.put(&mut t).unwrap();
1401        settle_deputy_tasks(&mut s, PR, &q, false);
1402        assert_eq!(status_of(&q, &t.id), crate::queue::TaskStatus::Held);
1403    }
1404
1405    #[test]
1406    fn the_generation_cap_applies_to_released_tasks() {
1407        let dir = tempfile::tempdir().unwrap();
1408        let q = Queue::at(dir.path().join("queue"));
1409        let mut s = merged(two_seats());
1410        s.followup_generation = Some(MAX_FOLLOWUP_GENERATION);
1411        let mut t = deputy_task(&s.id, "x", "y", &format!("after {PR}"));
1412        q.put(&mut t).unwrap();
1413        settle_deputy_tasks(&mut s, PR, &q, true);
1414        let got = q.get(&t.id).unwrap();
1415        assert_eq!(got.status, crate::queue::TaskStatus::Held);
1416        assert!(got.hold_reason.unwrap().starts_with(CAPPED));
1417        let events = s.events.len();
1418        settle_deputy_tasks(&mut s, PR, &q, true);
1419        assert_eq!(s.events.len(), events, "marked once");
1420        let mut s2 = merged(two_seats());
1421        s2.followup_generation = Some(MAX_FOLLOWUP_GENERATION - 1);
1422        let mut t2 = deputy_task(&s2.id, "x", "y", &format!("after {PR}"));
1423        q.put(&mut t2).unwrap();
1424        settle_deputy_tasks(&mut s2, PR, &q, true);
1425        assert_eq!(
1426            q.get(&t2.id).unwrap().followup.unwrap().generation,
1427            MAX_FOLLOWUP_GENERATION
1428        );
1429    }
1430
1431    #[test]
1432    fn deputy_first_the_owners_task_runs_and_the_automatic_one_is_not_filed() {
1433        let dir = tempfile::tempdir().unwrap();
1434        let q = Queue::at(dir.path().join("queue"));
1435        let mut s = merged(two_seats());
1436        s.followup_generation = Some(0);
1437        let mut t = deputy_task(
1438            &s.id,
1439            "Fix the waiter",
1440            "R3-1-1 at src/waiter.rs:212 loses replies",
1441            &format!("after {PR}"),
1442        );
1443        q.put(&mut t).unwrap();
1444        settle_deputy_tasks(&mut s, PR, &q, true);
1445        let out = file(&mut s, PR, &q).unwrap();
1446        assert_eq!(out.covered.len(), 2, "{out:?}");
1447        // Only the seat-1 Minor group is filed; the waiter.rs defect has one task.
1448        assert_eq!(out.filed.len(), 1, "{out:?}");
1449        let runnable: Vec<_> = q
1450            .list()
1451            .into_iter()
1452            .filter(|t| {
1453                t.status == crate::queue::TaskStatus::Queued
1454                    && t.followup
1455                        .as_ref()
1456                        .is_some_and(|f| f.findings.iter().any(|x| x == "R3-1-1"))
1457            })
1458            .collect();
1459        assert_eq!(runnable.len(), 1);
1460        assert_eq!(runnable[0].id, t.id);
1461    }
1462
1463    #[test]
1464    fn followup_first_a_queued_automatic_task_gives_way_to_the_owners() {
1465        let dir = tempfile::tempdir().unwrap();
1466        let q = Queue::at(dir.path().join("queue"));
1467        let mut s = merged(two_seats());
1468        s.followup_generation = Some(0);
1469        file(&mut s, PR, &q).unwrap();
1470        let mut t = deputy_task(
1471            &s.id,
1472            "Waiter replies",
1473            "see R3-2-1",
1474            &format!("after {PR}"),
1475        );
1476        q.put(&mut t).unwrap();
1477        settle_deputy_tasks(&mut s, PR, &q, true);
1478        let all = q.list();
1479        let live: Vec<_> = all
1480            .iter()
1481            .filter(|t| {
1482                t.status == crate::queue::TaskStatus::Queued
1483                    && t.followup
1484                        .as_ref()
1485                        .is_some_and(|f| f.findings.iter().any(|x| x == "R3-2-1"))
1486            })
1487            .collect();
1488        assert_eq!(live.len(), 1);
1489        assert_eq!(live[0].id, t.id);
1490        let dropped = all
1491            .iter()
1492            .find(|a| {
1493                a.id != t.id
1494                    && a.followup
1495                        .as_ref()
1496                        .is_some_and(|f| f.findings.iter().any(|x| x == "R3-2-1"))
1497            })
1498            .unwrap();
1499        assert_eq!(dropped.status, crate::queue::TaskStatus::Held);
1500        assert!(
1501            dropped
1502                .hold_reason
1503                .as_ref()
1504                .unwrap()
1505                .starts_with(SUPERSEDED)
1506        );
1507    }
1508
1509    #[test]
1510    fn followup_first_a_finished_automatic_task_supersedes_the_deputys() {
1511        let dir = tempfile::tempdir().unwrap();
1512        let q = Queue::at(dir.path().join("queue"));
1513        let mut s = merged(two_seats());
1514        s.followup_generation = Some(0);
1515        file(&mut s, PR, &q).unwrap();
1516        for mut a in q.list() {
1517            a.status = crate::queue::TaskStatus::Done;
1518            q.put(&mut a).unwrap();
1519        }
1520        // Matched by the finding's title, not its id.
1521        let mut t = deputy_task(&s.id, "title R3-1-1", "no ids here", &format!("after {PR}"));
1522        t.instruction = "nothing".to_owned();
1523        q.put(&mut t).unwrap();
1524        settle_deputy_tasks(&mut s, PR, &q, true);
1525        let got = q.get(&t.id).unwrap();
1526        assert_eq!(got.status, crate::queue::TaskStatus::Held);
1527        assert!(got.followup.is_none());
1528        let reason = got.hold_reason.unwrap();
1529        assert!(reason.starts_with(SUPERSEDED), "{reason}");
1530        assert!(reason.contains(PR));
1531        let events = s.events.len();
1532        settle_deputy_tasks(&mut s, PR, &q, true);
1533        assert_eq!(s.events.len(), events, "judged once");
1534    }
1535
1536    #[test]
1537    fn a_held_deputy_task_is_superseded_by_a_later_filing_when_the_switch_is_off() {
1538        let dir = tempfile::tempdir().unwrap();
1539        let q = Queue::at(dir.path().join("queue"));
1540        let mut s = merged(two_seats());
1541        s.followup_generation = Some(0);
1542        let mut t = deputy_task(&s.id, "x", "R3-1-1 here", &format!("after {PR}"));
1543        q.put(&mut t).unwrap();
1544        settle_deputy_tasks(&mut s, PR, &q, false);
1545        assert!(
1546            !q.get(&t.id)
1547                .unwrap()
1548                .hold_reason
1549                .unwrap()
1550                .starts_with(SUPERSEDED)
1551        );
1552        file(&mut s, PR, &q).unwrap();
1553        settle_deputy_tasks(&mut s, PR, &q, false);
1554        let got = q.get(&t.id).unwrap();
1555        assert_eq!(got.status, crate::queue::TaskStatus::Held);
1556        assert!(got.hold_reason.unwrap().starts_with(SUPERSEDED));
1557    }
1558
1559    #[test]
1560    fn the_comment_lists_released_deputy_tasks() {
1561        let mut s = merged(two_seats());
1562        s.deputy_followups.push("abcd-1".to_owned());
1563        let body = comment_body(&s, &Outcome::default());
1564        assert!(body.contains("`abcd-1`"));
1565        assert!(!body.contains("already covered"));
1566        assert!(crate::github_text::check("", &body).is_empty());
1567    }
1568
1569    #[test]
1570    fn a_deputy_task_over_two_automatic_ones_holds_both() {
1571        let dir = tempfile::tempdir().unwrap();
1572        let q = Queue::at(dir.path().join("queue"));
1573        let mut s = merged(two_seats());
1574        s.followup_generation = Some(0);
1575        file(&mut s, PR, &q).unwrap();
1576        let mut t = deputy_task(&s.id, "both", "R3-1-1 and R3-1-2", &format!("after {PR}"));
1577        q.put(&mut t).unwrap();
1578        settle_deputy_tasks(&mut s, PR, &q, true);
1579        assert_eq!(status_of(&q, &t.id), crate::queue::TaskStatus::Queued);
1580        let live = q
1581            .list()
1582            .into_iter()
1583            .filter(|x| x.status == crate::queue::TaskStatus::Queued)
1584            .count();
1585        assert_eq!(live, 1, "only the owner's task still runs");
1586    }
1587
1588    #[test]
1589    fn a_claimed_automatic_task_is_not_touched_and_the_deputys_stays_held() {
1590        let dir = tempfile::tempdir().unwrap();
1591        let q = Queue::at(dir.path().join("queue"));
1592        let mut s = merged(two_seats());
1593        s.followup_generation = Some(0);
1594        file(&mut s, PR, &q).unwrap();
1595        let auto = q
1596            .list()
1597            .into_iter()
1598            .find(|x| {
1599                x.followup
1600                    .as_ref()
1601                    .is_some_and(|f| f.findings.iter().any(|i| i == "R3-1-1"))
1602            })
1603            .unwrap();
1604        let _claim = q.claim(&auto.id).unwrap();
1605        let mut t = deputy_task(&s.id, "x", "R3-1-1", &format!("after {PR}"));
1606        q.put(&mut t).unwrap();
1607        settle_deputy_tasks(&mut s, PR, &q, true);
1608        assert_eq!(status_of(&q, &auto.id), crate::queue::TaskStatus::Queued);
1609        let got = q.get(&t.id).unwrap();
1610        assert_eq!(got.status, crate::queue::TaskStatus::Held);
1611        assert!(got.hold_reason.unwrap().starts_with(SUPERSEDED));
1612    }
1613
1614    #[test]
1615    fn a_failed_hold_restores_the_automatic_tasks_already_held() {
1616        let dir = tempfile::tempdir().unwrap();
1617        let q = Queue::at(dir.path().join("queue"));
1618        let mut s = merged(two_seats());
1619        s.followup_generation = Some(0);
1620        file(&mut s, PR, &q).unwrap();
1621        let autos = q.list();
1622        assert_eq!(autos.len(), 2);
1623        let mut t = deputy_task(&s.id, "both", "R3-1-1 and R3-1-2", &format!("after {PR}"));
1624        q.put(&mut t).unwrap();
1625        // Make the save of the second automatic task fail.
1626        let last = autos.last().unwrap().id.clone();
1627        let tmp = dir.path().join("queue").join(format!("{last}.json.tmp"));
1628        std::fs::create_dir_all(&tmp).unwrap();
1629        settle_deputy_tasks(&mut s, PR, &q, true);
1630        for a in &autos {
1631            let got = q.get(&a.id).unwrap();
1632            assert_eq!(got.status, crate::queue::TaskStatus::Queued, "{}", a.id);
1633            assert_eq!(got.attempts, a.attempts);
1634        }
1635        let got = q.get(&t.id).unwrap();
1636        assert_eq!(got.status, crate::queue::TaskStatus::Held);
1637        assert!(got.hold_reason.unwrap().starts_with(SUPERSEDED));
1638    }
1639
1640    #[test]
1641    fn a_hold_left_by_an_earlier_failed_pass_is_restored_on_the_next_failure() {
1642        let dir = tempfile::tempdir().unwrap();
1643        let q = Queue::at(dir.path().join("queue"));
1644        let mut s = merged(two_seats());
1645        s.followup_generation = Some(0);
1646        file(&mut s, PR, &q).unwrap();
1647        let autos = q.list();
1648        let mut t = deputy_task(&s.id, "both", "R3-1-1 and R3-1-2", &format!("after {PR}"));
1649        q.put(&mut t).unwrap();
1650        // The first auto task is left held by an earlier, interrupted pass.
1651        let why = format!(
1652            "{SUPERSEDED}the owner's approval deputy filed task {} for the same finding(s) of {PR}",
1653            t.id
1654        );
1655        q.modify(&autos[0].id, |x| {
1656            x.hold_manual(Some(why));
1657            true
1658        })
1659        .unwrap();
1660        let tmp = dir
1661            .path()
1662            .join("queue")
1663            .join(format!("{}.json.tmp", autos[1].id));
1664        std::fs::create_dir_all(&tmp).unwrap();
1665        settle_deputy_tasks(&mut s, PR, &q, true);
1666        for a in &autos {
1667            let got = q.get(&a.id).unwrap();
1668            assert_eq!(got.status, crate::queue::TaskStatus::Queued, "{}", a.id);
1669            assert!(got.hold_reason.is_none());
1670        }
1671        assert!(
1672            q.get(&t.id)
1673                .unwrap()
1674                .hold_reason
1675                .unwrap()
1676                .starts_with(SUPERSEDED)
1677        );
1678    }
1679
1680    #[test]
1681    fn a_blocked_takeover_restores_a_hold_left_by_an_earlier_pass() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let q = Queue::at(dir.path().join("queue"));
1684        let mut s = merged(two_seats());
1685        s.followup_generation = Some(0);
1686        file(&mut s, PR, &q).unwrap();
1687        let autos = q.list();
1688        let mut t = deputy_task(&s.id, "both", "R3-1-1 and R3-1-2", &format!("after {PR}"));
1689        q.put(&mut t).unwrap();
1690        let why = format!(
1691            "{SUPERSEDED}the owner's approval deputy filed task {} for the same finding(s) of {PR}",
1692            t.id
1693        );
1694        q.modify(&autos[0].id, |x| {
1695            x.hold_manual(Some(why));
1696            true
1697        })
1698        .unwrap();
1699        let _claim = q.claim(&autos[1].id).unwrap();
1700        settle_deputy_tasks(&mut s, PR, &q, true);
1701        assert_eq!(
1702            status_of(&q, &autos[0].id),
1703            crate::queue::TaskStatus::Queued
1704        );
1705        assert!(
1706            q.get(&t.id)
1707                .unwrap()
1708                .hold_reason
1709                .unwrap()
1710                .starts_with(SUPERSEDED)
1711        );
1712    }
1713}