1use anyhow::Result;
18
19use crate::queue::{FollowUp, Queue, Source, Task};
20use crate::run::{FollowupRecord, RunState};
21use crate::verdict::{Finding, ReviewVote};
22
23pub const NODE: &str = "followup";
25
26pub const MAX_FOLLOWUP_GENERATION: u32 = 2;
28
29const LINE_WINDOW: u32 = 5;
31
32#[derive(Debug, Default, PartialEq, Eq)]
34pub struct Outcome {
35 pub filed: Vec<String>,
37 pub capped: Vec<String>,
39 pub unfiled: Vec<String>,
41 pub covered: Vec<(String, String)>,
44}
45
46pub 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 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
83const SUPERSEDED: &str = "[superseded] ";
86const CAPPED: &str = "[generation cap] ";
87
88fn 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
102fn 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
129fn 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
145pub fn settle_deputy_tasks(state: &mut RunState, pr_url: &str, queue: &Queue, release: bool) {
165 let tasks = queue.list();
166 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 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 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 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 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 let mut failed: Option<String> = None;
310 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 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
436fn 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 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
482pub(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
506fn 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
536fn 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
560pub 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 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
675fn 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
693fn is_name_char(c: char) -> bool {
696 c.is_ascii_alphanumeric() || matches!(c, '_' | '-')
697}
698
699fn 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
709fn 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
735fn 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
780fn 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 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 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 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 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 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 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 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 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 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 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}