1use std::collections::BTreeSet;
18use std::ops::Range;
19use std::path::Path;
20use std::sync::LazyLock;
21
22use regex::Regex;
23
24use crate::agent::Agent;
25use crate::config::{Config, Followups};
26use crate::error::Result;
27use crate::model::{Finding, ScreenResponse, ScreenVerdict, Screened};
28use crate::repo::{Repo, FOLLOWUP_MARKER};
29use crate::{log, logwarn, schema, spar_err};
30
31static BLANK_RUN: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Entry {
41 pub title: String,
43 pub body: String,
46 pub span: Range<usize>,
51}
52
53fn report_headings() -> Vec<&'static str> {
59 Finding {
60 problem: Some("x".into()),
61 reproduction: Some("x".into()),
62 impact: Some("x".into()),
63 expected: Some("x".into()),
64 ..Finding::default()
65 }
66 .report_sections()
67 .into_iter()
68 .map(|(heading, _)| heading)
69 .collect()
70}
71
72fn is_section_heading(text: &str) -> bool {
79 let got = text.trim().trim_end_matches(':').trim().to_lowercase();
80 report_headings().iter().any(|h| h.to_lowercase() == got)
81 || matches!(
82 got.as_str(),
83 "expected behaviour"
84 | "expected"
85 | "actual result"
86 | "actual results"
87 | "actual behavior"
88 | "actual behaviour"
89 | "steps to reproduce"
90 | "summary"
91 )
92}
93
94fn lines_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
96 let mut at = 0usize;
97 text.split_inclusive('\n').map(move |line| {
98 let start = at;
99 at += line.len();
100 (start, line.trim_end_matches(['\n', '\r']))
101 })
102}
103
104pub fn parse(text: &str) -> Vec<Entry> {
132 let mut opens: Vec<(usize, Option<usize>)> = Vec::new();
134 let mut open = false;
135 let mut awaiting_title = false;
136 let mut fenced = false;
137
138 for (offset, line) in lines_with_offsets(text) {
139 let trimmed = line.trim_start();
140 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
141 fenced = !fenced;
142 continue;
143 }
144 if fenced {
145 continue;
146 }
147 if trimmed.starts_with(FOLLOWUP_MARKER) {
148 opens.push((offset, None));
149 open = true;
150 awaiting_title = true;
151 continue;
152 }
153 let Some(heading) = trimmed.strip_prefix("## ") else {
154 continue;
155 };
156 if awaiting_title {
157 if let Some(last) = opens.last_mut() {
158 last.1 = Some(offset);
159 }
160 awaiting_title = false;
161 continue;
162 }
163 if open && is_section_heading(heading) {
166 continue;
167 }
168 opens.push((offset, Some(offset)));
169 open = true;
170 }
171
172 let mut out = Vec::with_capacity(opens.len());
173 for (i, (start, title_at)) in opens.iter().enumerate() {
174 let end = opens.get(i + 1).map(|(s, _)| *s).unwrap_or(text.len());
175 let (title, body_from) = match title_at {
176 Some(at) => {
177 let line_end = text[*at..end].find('\n').map(|n| at + n + 1).unwrap_or(end);
178 let heading = text[*at..line_end]
179 .trim()
180 .trim_start_matches("## ")
181 .trim()
182 .to_string();
183 (heading, line_end)
184 }
185 None => (String::new(), *start),
188 };
189 out.push(Entry {
190 title,
191 body: text[body_from..end].trim().to_string(),
192 span: *start..end,
193 });
194 }
195 out
196}
197
198pub fn without(text: &str, removed: &[Entry]) -> String {
210 let mut spans: Vec<Range<usize>> = removed.iter().map(|e| e.span.clone()).collect();
211 spans.sort_by_key(|s| s.start);
212
213 let mut out = String::with_capacity(text.len());
214 let mut cursor = 0usize;
215 for span in spans {
216 if span.start < cursor {
219 cursor = cursor.max(span.end);
220 continue;
221 }
222 out.push_str(&text[cursor..span.start]);
223 cursor = span.end;
224 }
225 out.push_str(&text[cursor..]);
226
227 let joined = BLANK_RUN.replace_all(out.trim_end(), "\n\n").to_string();
228 if joined.trim().is_empty() {
229 String::new()
230 } else {
231 format!("{joined}\n")
232 }
233}
234
235const SCREEN_PROMPT: &str = "\
240Below are follow-ups recorded against this repository while other work was going
241on. Each was a real finding when it was written. Time has passed and the code has
242moved: some are already fixed, some describe behaviour that no longer exists, and
243some were never worth the interruption.
244
245Read the code in your working directory before judging each one. Do not modify
246anything. The current checkout is what \"now\" means. Judge against it, not
247against what the entry says the code used to do.
248
249For each entry decide:
250- verdict: still_relevant, already_fixed, not_worth_it, or duplicate.
251 - still_relevant: the defect is still there. It becomes a GitHub issue.
252 - already_fixed: go and look. Name the function or the change that fixed it,
253 so somebody reading this can check you.
254 - not_worth_it: real, still there, and not worth a maintainer's queue.
255 - duplicate: an open issue, or an earlier entry in this list, already covers
256 it. Put that number in duplicate_of.
257- reason: one sentence. For anything but still_relevant this is the only record
258 of why the entry was dropped, so give the reason rather than the verdict
259 restated.
260- title: the entry's title, which becomes the issue title. Copy it across unless
261 it is wrong or says nothing.
262
263Say still_relevant when you are unsure. What survives is triaged by both agents
264afterwards and can still be declined there. What you drop here is dropped.
265
266Entries:
267";
268
269struct Rendered {
271 text: String,
272 deferred: usize,
275}
276
277fn render(entries: &[Entry], cfg: &Config) -> Rendered {
288 let mut parts: Vec<String> = Vec::new();
289 let mut total = 0usize;
290 let mut deferred = 0usize;
291
292 for (i, entry) in entries.iter().enumerate() {
293 if deferred > 0 {
294 deferred += 1;
295 continue;
296 }
297 let block = format!("{}. {}\n{}", i + 1, entry.title, entry.body);
298 let len = block.chars().count();
299 if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
302 deferred += 1;
303 continue;
304 }
305 total += len;
306 parts.push(block);
307 }
308
309 Rendered {
310 text: parts.join("\n\n"),
311 deferred,
312 }
313}
314
315pub fn screen(
322 agent: &Agent,
323 cfg: &Config,
324 repo: &Repo,
325 entries: &[Entry],
326) -> Result<Vec<ScreenVerdict>> {
327 let rendered = render(entries, cfg);
328 if rendered.deferred > 0 {
329 logwarn!(
330 "the queue did not fit in one screening prompt, so {} entry(s) were left in the file \
331 for a later run",
332 rendered.deferred
333 );
334 }
335 let prompt = format!("{SCREEN_PROMPT}{}", rendered.text);
336 let effort = cfg.effort_for_round(&agent.spec, 1);
337 let answer: ScreenResponse =
338 agent.ask_json(&prompt, &schema::screen(), repo.root(), effort.as_deref())?;
339 Ok(answer.entries)
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum Mode {
349 ScreenOnly,
351 FileOnly,
353 Work,
355}
356
357#[derive(Debug, Default)]
359pub struct Outcome {
360 pub issues: Vec<i64>,
362 pub held: usize,
364}
365
366pub fn run(
369 agents: &[Agent],
370 cfg: &Config,
371 repo: &Repo,
372 path: &Path,
373 limit: usize,
374 mode: Mode,
375) -> Result<Outcome> {
376 let mut outcome = Outcome::default();
377
378 let Ok(original) = std::fs::read_to_string(path) else {
380 log!("no follow-ups recorded in {}", path.display());
381 if repo.followups != Followups::Local {
382 log!(
383 "followups = \"{}\" is configured, so nothing is written to that file.",
384 repo.followups
385 );
386 }
387 return Ok(outcome);
388 };
389 if original.trim().is_empty() {
390 log!("{} is there and empty", path.display());
391 return Ok(outcome);
392 }
393
394 let entries = parse(&original);
395 if entries.is_empty() {
396 log!(
398 "{} has no `## ` headings, so there is nothing to work. An entry is a `## Title` line \
399 and the text under it.",
400 path.display()
401 );
402 return Ok(outcome);
403 }
404
405 let taken: Vec<Entry> = entries.iter().take(limit).cloned().collect();
406 outcome.held += entries.len() - taken.len();
407 if outcome.held > 0 {
408 log!(
409 "{} follow-up(s) recorded, taking the first {limit}. Raise --limit for the rest.",
410 entries.len()
411 );
412 }
413
414 let agent = crate::agent::find(agents, &cfg.first_implementor)?;
415 log!(
418 "screening {} follow-up(s) with {} against {} at {}",
419 taken.len(),
420 agent.name(),
421 repo.git_try(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
422 repo.git_try(&["rev-parse", "--short", "HEAD"]).trim(),
423 );
424
425 let verdicts = screen(agent, cfg, repo, &taken)?;
428
429 if mode == Mode::ScreenOnly {
430 print_verdicts(&taken, &verdicts);
431 return Ok(outcome);
432 }
433
434 let mut disposed: Vec<Entry> = Vec::new();
435 for (i, entry) in taken.iter().enumerate() {
436 let number = i as i64 + 1;
437 let Some(verdict) = verdicts.iter().find(|v| v.entry == number) else {
438 logwarn!(
441 "no verdict for '{}', leaving it in the file",
442 first_line(&entry.title)
443 );
444 outcome.held += 1;
445 continue;
446 };
447
448 let files = verdict.verdict == Screened::StillRelevant
451 || (verdict.verdict == Screened::Duplicate && verdict.duplicate_of.is_none());
452
453 if files {
454 let title = if verdict.title.trim().is_empty() {
455 entry.title.as_str()
456 } else {
457 verdict.title.as_str()
458 };
459 match crate::review::file_as_issue(repo, title, &entry.body) {
460 Ok(filed) => {
461 log!(" {}", filed.describe(title));
462 if let Some(n) = filed.number() {
463 outcome.issues.push(n);
464 }
465 repo.archive_followup(title, &entry.body, &format!("Filed: {}", filed.note()));
466 }
467 Err(e) => {
468 logwarn!("could not file '{}': {e}", first_line(title));
469 outcome.held += 1;
470 continue;
471 }
472 }
473 } else {
474 let why = dropped_note(verdict);
475 log!(" dropped '{}': {why}", first_line(&entry.title));
476 repo.archive_followup(&entry.title, &entry.body, &format!("Dropped: {why}"));
477 }
478
479 disposed.push(entry.clone());
480 crate::repo::write_text_atomic(path, &without(&original, &disposed)).map_err(|e| {
486 spar_err!(
487 "{e}\n{} follow-up(s) were already dealt with. Remove them from {} by hand before \
488 running this again, or they will be filed twice.",
489 disposed.len(),
490 path.display()
491 )
492 })?;
493 }
494
495 let filed = outcome.issues.len();
496 println!(
497 "\nfollowups: {} screened, {filed} filed{}",
498 taken.len(),
499 summarise(&taken, &verdicts)
500 );
501 if outcome.held > 0 {
502 println!("{} entry(s) left in {}", outcome.held, path.display());
503 }
504 if !disposed.is_empty() {
505 println!(
506 "what was dealt with is in {}",
507 repo.worked_followups_path().display()
508 );
509 }
510 Ok(outcome)
511}
512
513fn first_line(text: &str) -> String {
514 crate::style::clip(text.trim().lines().next().unwrap_or("").trim(), 80)
515}
516
517fn dropped_note(v: &ScreenVerdict) -> String {
518 let reason = v.reason.trim();
519 match (v.verdict, v.duplicate_of) {
520 (Screened::Duplicate, Some(n)) if reason.is_empty() => format!("#{n} already covers it"),
521 (Screened::Duplicate, Some(n)) => format!("#{n} already covers it. {reason}"),
522 (_, _) if reason.is_empty() => v.verdict.to_string(),
523 _ => format!("{}. {reason}", v.verdict),
524 }
525}
526
527fn summarise(taken: &[Entry], verdicts: &[ScreenVerdict]) -> String {
529 let mut counts: Vec<(Screened, usize)> = Vec::new();
530 for v in verdicts {
531 if v.entry < 1 || v.entry as usize > taken.len() {
532 continue;
533 }
534 match counts.iter_mut().find(|(k, _)| *k == v.verdict) {
535 Some((_, n)) => *n += 1,
536 None => counts.push((v.verdict, 1)),
537 }
538 }
539 counts.retain(|(k, _)| *k != Screened::StillRelevant);
540 if counts.is_empty() {
541 return String::new();
542 }
543 let listed: Vec<String> = counts
544 .iter()
545 .map(|(k, n)| format!("{n} {}", k.as_str().replace('_', " ")))
546 .collect();
547 format!(", {}", listed.join(", "))
548}
549
550fn print_verdicts(taken: &[Entry], verdicts: &[ScreenVerdict]) {
554 println!();
555 for (i, entry) in taken.iter().enumerate() {
556 let number = i as i64 + 1;
557 match verdicts.iter().find(|v| v.entry == number) {
558 Some(v) => println!(
559 " {:<14} {}\n {}",
560 v.verdict.as_str(),
561 first_line(&entry.title),
562 v.reason.trim()
563 ),
564 None => println!(" {:<14} {}", "no verdict", first_line(&entry.title)),
565 }
566 }
567 let filed = verdicts
568 .iter()
569 .filter(|v| v.verdict == Screened::StillRelevant)
570 .count();
571 println!(
572 "\n{filed} of {} would be filed. Nothing was written.",
573 taken.len()
574 );
575}
576
577pub fn wave(outcome: &Outcome) -> Vec<i64> {
579 outcome
580 .issues
581 .iter()
582 .copied()
583 .collect::<BTreeSet<_>>()
584 .into_iter()
585 .collect()
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 const REAL: &str = "\
595## Backend headers never drive commitment CPFP retries
596
597The production ChainWatcher advances monitors and emits block.
598
599## Problem
600
601Configured chain backends route accepted headers through handleNewBlock.
602
603## Reproduction
604
6051. Configure a node with a watcher backend.
6062. Deliver height 101.
607
608## Impact
609
610Nodes do not retry stuck commitment packages on new blocks.
611
612## Expected behavior
613
614Run the pass exactly once for each accepted backend header.
615
616Found while working on #589.
617
618## Overlapping scans can move a recorded spend height backward
619
620## Problem
621
622checkOutputSpend applies its result with no arbitration against a later scan.
623
624## Impact
625
626A stale verdict can overwrite a newer one.
627
628Found while working on #590.
629";
630
631 #[test]
635 fn an_entry_and_its_sections_are_not_confused_for_each_other() {
636 let entries = parse(REAL);
637 assert_eq!(
638 2,
639 entries.len(),
640 "{:#?}",
641 entries.iter().map(|e| &e.title).collect::<Vec<_>>()
642 );
643 assert!(entries[0].title.starts_with("Backend headers"));
644 assert!(entries[1].title.starts_with("Overlapping scans"));
645 assert!(
647 entries[0].body.contains("## Reproduction"),
648 "{}",
649 entries[0].body
650 );
651 assert!(entries[0].body.contains("Found while working on #589."));
652 }
653
654 #[test]
658 fn a_marker_makes_the_boundary_exact() {
659 let text = format!(
660 "{FOLLOWUP_MARKER}\n## Impact\n\nThe first one.\n\n\
661 {FOLLOWUP_MARKER}\n## Problem\n\nThe second one.\n"
662 );
663 let entries = parse(&text);
664 assert_eq!(2, entries.len());
665 assert_eq!("Impact", entries[0].title);
666 assert_eq!("Problem", entries[1].title);
667 }
668
669 #[test]
672 fn a_hand_written_file_with_no_markers_still_parses() {
673 let text =
674 "## One thing\n\nprose\n\n## Another thing\n\nmore prose\n\n## A third\n\nyet more\n";
675 let entries = parse(text);
676 assert_eq!(3, entries.len());
677 assert_eq!("Another thing", entries[1].title);
678 }
679
680 #[test]
684 fn a_heading_inside_a_fenced_block_does_not_start_an_entry() {
685 let text = "## Real title\n\n```md\n## Problem\n## Not a title either\n```\n\nprose\n";
686 let entries = parse(text);
687 assert_eq!(1, entries.len(), "{:?}", entries);
688 assert_eq!("Real title", entries[0].title);
689 }
690
691 #[test]
694 fn an_entry_whose_title_opens_with_a_section_word_is_still_a_title() {
695 let text =
696 "## First\n\nprose\n\n## Reproduction steps are missing from the docs\n\nprose\n";
697 assert_eq!(2, parse(text).len());
698 }
699
700 #[test]
703 fn the_section_list_covers_every_heading_a_report_writes() {
704 for heading in report_headings() {
705 assert!(
706 is_section_heading(heading),
707 "`## {heading}` would be read as the start of a new follow-up"
708 );
709 }
710 }
711
712 #[test]
715 fn text_the_parser_does_not_own_survives_a_rewrite() {
716 let text = "A note I keep at the top.\n\n\
717 ## One\n\nfirst\n\n\
718 ## Two\n\nsecond\n\n\
719 ## Three\n\nthird\n";
720 let entries = parse(text);
721 assert_eq!(3, entries.len());
722 let out = without(text, &[entries[1].clone()]);
723 assert!(out.starts_with("A note I keep at the top."), "{out}");
724 assert!(out.contains("## One"), "{out}");
725 assert!(!out.contains("## Two"), "{out}");
726 assert!(out.contains("## Three"), "{out}");
727 assert!(out.contains("third"), "{out}");
728 }
729
730 #[test]
734 fn removing_entries_one_at_a_time_matches_removing_them_at_once() {
735 let entries = parse(REAL);
736 let all_at_once = without(REAL, &entries);
737
738 let mut done = Vec::new();
739 let mut last = String::new();
740 for entry in &entries {
741 done.push(entry.clone());
742 last = without(REAL, &done);
743 }
744 assert_eq!(all_at_once, last);
745 assert!(last.is_empty(), "{last:?}");
746 }
747
748 #[test]
751 fn without_tolerates_a_repeated_or_unordered_span() {
752 let entries = parse(REAL);
753 let once = without(REAL, &[entries[0].clone()]);
754 let twice = without(REAL, &[entries[0].clone(), entries[0].clone()]);
755 assert_eq!(once, twice);
756
757 let forwards = without(REAL, &[entries[0].clone(), entries[1].clone()]);
758 let backwards = without(REAL, &[entries[1].clone(), entries[0].clone()]);
759 assert_eq!(forwards, backwards);
760 }
761
762 #[test]
765 fn removing_every_entry_leaves_an_empty_file() {
766 let entries = parse(REAL);
767 assert_eq!("", without(REAL, &entries));
768 }
769
770 #[test]
773 fn an_entry_keeps_the_provenance_it_was_written_with() {
774 let entries = parse(REAL);
775 assert!(entries[1].body.ends_with("Found while working on #590."));
776 }
777
778 #[test]
780 fn crlf_line_endings_parse_the_same_as_lf() {
781 let lf = "## One\n\nfirst\n\n## Two\n\nsecond\n";
782 let crlf = lf.replace('\n', "\r\n");
783 let a = parse(lf);
784 let b = parse(&crlf);
785 assert_eq!(a.len(), b.len());
786 assert_eq!(a[1].title, b[1].title);
787 }
788
789 #[test]
792 fn a_file_that_opens_with_a_section_name_still_holds_an_entry() {
793 let entries = parse("## Problem\n\nsomething is wrong\n");
794 assert_eq!(1, entries.len());
795 assert_eq!("Problem", entries[0].title);
796 }
797
798 fn verdict(entry: i64, v: Screened, dup: Option<i64>) -> ScreenVerdict {
799 ScreenVerdict {
800 entry,
801 verdict: v,
802 title: String::new(),
803 reason: "because".into(),
804 duplicate_of: dup,
805 }
806 }
807
808 #[test]
812 fn a_duplicate_verdict_with_nothing_to_point_at_would_still_be_filed() {
813 let with_number = verdict(1, Screened::Duplicate, Some(412));
814 let without_number = verdict(1, Screened::Duplicate, None);
815 let files = |v: &ScreenVerdict| {
816 v.verdict == Screened::StillRelevant
817 || (v.verdict == Screened::Duplicate && v.duplicate_of.is_none())
818 };
819 assert!(!files(&with_number));
820 assert!(files(&without_number));
821 }
822
823 #[test]
826 fn an_entry_with_no_verdict_is_not_disposed_of() {
827 let entries = parse(REAL);
828 let verdicts = [verdict(1, Screened::AlreadyFixed, None)];
829 let unruled: Vec<usize> = (1..=entries.len())
830 .filter(|n| !verdicts.iter().any(|v| v.entry == *n as i64))
831 .collect();
832 assert_eq!(vec![2], unruled);
833 }
834
835 #[test]
838 fn a_verdict_naming_an_entry_that_does_not_exist_is_ignored() {
839 let entries = parse(REAL);
840 let verdicts = [verdict(9, Screened::AlreadyFixed, None)];
841 assert_eq!("", summarise(&entries, &verdicts));
842 }
843
844 #[test]
847 fn the_summary_names_each_verdict_that_dropped_something() {
848 let entries = parse(REAL);
849 let verdicts = vec![
850 verdict(1, Screened::AlreadyFixed, None),
851 verdict(2, Screened::StillRelevant, None),
852 ];
853 let out = summarise(&entries, &verdicts);
854 assert!(out.contains("1 already fixed"), "{out}");
855 assert!(!out.contains("still relevant"), "{out}");
856 }
857
858 #[test]
861 fn a_dropped_entry_carries_its_reason_and_the_issue_it_duplicates() {
862 let note = dropped_note(&verdict(1, Screened::Duplicate, Some(412)));
863 assert!(note.contains("#412"), "{note}");
864 assert!(note.contains("because"), "{note}");
865 }
866}
867
868#[cfg(test)]
869mod real_file {
870 use super::*;
871
872 const CORPUS: &str = include_str!("../tests/fixtures/local_followups.md");
876
877 #[test]
878 fn the_real_queue_parses_as_five_follow_ups_not_twenty_five() {
879 let entries = parse(CORPUS);
880 assert_eq!(
881 5,
882 entries.len(),
883 "{:#?}",
884 entries.iter().map(|e| e.title.as_str()).collect::<Vec<_>>()
885 );
886 for entry in &entries {
887 assert!(
888 !is_section_heading(&entry.title),
889 "a section was filed as a follow-up: {}",
890 entry.title
891 );
892 assert!(!entry.body.trim().is_empty(), "{} has no body", entry.title);
893 }
894 }
895
896 #[test]
900 fn every_entry_in_the_real_queue_keeps_its_provenance() {
901 for entry in parse(CORPUS) {
902 assert!(
903 entry.body.contains("Found while working on #"),
904 "{} lost its provenance",
905 entry.title
906 );
907 }
908 }
909
910 #[test]
913 fn the_real_queue_drains_to_nothing_one_entry_at_a_time() {
914 let entries = parse(CORPUS);
915 let mut done = Vec::new();
916 let mut text = CORPUS.to_string();
917 for entry in &entries {
918 done.push(entry.clone());
919 text = without(CORPUS, &done);
920 }
921 assert_eq!("", text);
922 assert_eq!(without(CORPUS, &entries), text);
923 }
924
925 #[test]
928 fn draining_one_entry_leaves_the_rest_byte_for_byte() {
929 let entries = parse(CORPUS);
930 let out = without(CORPUS, &[entries[2].clone()]);
931 let left = parse(&out);
932 assert_eq!(4, left.len());
933 for (before, after) in [(0, 0), (1, 1), (3, 2), (4, 3)] {
934 assert_eq!(entries[before].title, left[after].title);
935 assert_eq!(entries[before].body, left[after].body);
936 }
937 }
938}