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