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}
42
43pub async fn after_merge(state: &mut RunState, pr_url: &str) {
46 if !state.config.graph.file_followups {
47 return;
48 }
49 let queue = Queue::open();
50 let outcome = match file(state, pr_url, &queue) {
51 Ok(o) => o,
52 Err(e) => {
53 state.event(NODE, format!("follow-up filing failed: {e:#}"));
54 return;
55 }
56 };
57 if outcome.filed.is_empty() && (state.followups.is_empty() || state.followup_commented) {
58 return;
59 }
60 let body = comment_body(state, &outcome);
61 match crate::bump::gh_pr_comment(&state.repo, pr_url, &body).await {
62 Ok(()) => state.followup_commented = true,
63 Err(e) => state.event(NODE, format!("follow-up comment not posted: {e:#}")),
64 }
65}
66
67pub fn file(state: &mut RunState, pr_url: &str, queue: &Queue) -> Result<Outcome> {
71 let mut out = Outcome::default();
72 let Some(round) = state.reviews.last().cloned() else {
73 return Ok(out);
74 };
75 let rejecters: Vec<usize> = round
76 .final_votes()
77 .into_iter()
78 .filter(|(_, _, v)| *v == ReviewVote::Reject)
79 .map(|(seat, _, _)| seat)
80 .collect();
81 let mut chosen: Vec<Finding> = Vec::new();
82 for rec in &round.reviews {
83 let rejected = rejecters.contains(&rec.reviewer);
84 for f in &rec.findings {
85 if f.severity.blocks() || rejected {
86 chosen.push(f.clone());
87 } else {
88 out.unfiled.push(f.id.clone());
89 }
90 }
91 }
92 if chosen.is_empty() {
93 return Ok(out);
94 }
95
96 let origin_task = state.origin.as_ref().and_then(|o| o.task.clone());
97 let parent_gen = match state.followup_generation {
101 Some(g) => g,
102 None => match origin_task.as_deref().map(|t| queue.get(t)) {
103 Some(Ok(t)) => {
104 let g = t.followup.map_or(0, |f| f.generation);
105 state.followup_generation = Some(g);
106 g
107 }
108 Some(Err(_)) => MAX_FOLLOWUP_GENERATION,
109 None => 0,
110 },
111 };
112 if parent_gen >= MAX_FOLLOWUP_GENERATION {
113 out.capped = chosen.iter().map(|f| f.id.clone()).collect();
114 state.event(
115 NODE,
116 format!(
117 "generation cap reached ({parent_gen}); not filing follow-ups for open finding(s): {}",
118 out.capped.join(", ")
119 ),
120 );
121 return Ok(out);
122 }
123
124 let mut done: Vec<String> = state
125 .followups
126 .iter()
127 .flat_map(|r| r.findings.iter().cloned())
128 .collect();
129 for t in queue.list() {
130 if let Some(f) = t.followup
131 && f.run == state.id
132 {
133 done.extend(f.findings.iter().cloned());
134 if !state.followups.iter().any(|r| r.task == t.id) {
135 state.followups.push(FollowupRecord {
136 task: t.id,
137 findings: f.findings,
138 });
139 }
140 }
141 }
142
143 let mut failure = None;
144 for group in group_findings(chosen) {
145 let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
146 if ids.iter().any(|id| done.contains(id)) {
147 continue;
148 }
149 let mut task = build_task(state, pr_url, origin_task.clone(), &group, parent_gen + 1);
150 match queue.create_new(&mut task) {
151 Ok(created) => {
152 if created {
153 out.filed.push(task.id.clone());
154 }
155 state.followups.push(FollowupRecord {
156 task: task.id,
157 findings: ids,
158 });
159 }
160 Err(e) => {
161 failure = Some(e);
162 break;
163 }
164 }
165 }
166 if !out.filed.is_empty() {
167 state.event(
168 NODE,
169 format!(
170 "filed {} follow-up task(s): ids {}",
171 out.filed.len(),
172 out.filed.join(", ")
173 ),
174 );
175 }
176 if let Some(e) = failure {
177 state.event(NODE, format!("follow-up filing stopped: {e:#}"));
178 }
179 Ok(out)
180}
181
182fn group_findings(mut findings: Vec<Finding>) -> Vec<Vec<Finding>> {
186 findings.sort_by(|a, b| (&a.file, a.line, &a.id).cmp(&(&b.file, b.line, &b.id)));
187 let mut groups: Vec<Vec<Finding>> = Vec::new();
188 for f in findings {
189 let hit = groups
190 .iter()
191 .position(|g| g.iter().any(|o| same_defect(o, &f)));
192 match hit {
193 Some(i) => groups[i].push(f),
194 None => groups.push(vec![f]),
195 }
196 }
197 for g in &mut groups {
198 g.sort_by(|a, b| a.id.cmp(&b.id));
199 }
200 groups
201}
202
203fn same_defect(a: &Finding, b: &Finding) -> bool {
204 match (&a.file, &b.file) {
205 (Some(x), Some(y)) => {
206 x == y
207 && match (a.line, b.line) {
208 (Some(l), Some(m)) => l.abs_diff(m) <= LINE_WINDOW,
209 (None, None) => true,
210 _ => false,
211 }
212 }
213 (None, None) => normalize(&a.title) == normalize(&b.title),
214 _ => false,
215 }
216}
217
218fn normalize(title: &str) -> String {
219 title
220 .to_lowercase()
221 .split(|c: char| !c.is_alphanumeric())
222 .filter(|w| !w.is_empty())
223 .collect::<Vec<_>>()
224 .join(" ")
225}
226
227fn task_id(run: &str, ids: &[String]) -> String {
231 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
232 for b in ids.join("+").bytes() {
233 h ^= u64::from(b);
234 h = h.wrapping_mul(0x0100_0000_01b3);
235 }
236 format!("{run}-f{:05x}", h & 0xf_ffff)
237}
238
239fn location(f: &Finding) -> String {
240 match (&f.file, f.line) {
241 (Some(p), Some(l)) => format!("{p}:{l}"),
242 (Some(p), None) => p.clone(),
243 _ => "(no location)".to_owned(),
244 }
245}
246
247fn build_task(
248 state: &RunState,
249 pr_url: &str,
250 origin_task: Option<String>,
251 group: &[Finding],
252 generation: u32,
253) -> Task {
254 let ids: Vec<String> = group.iter().map(|f| f.id.clone()).collect();
255 let first = &group[0];
256 let title = format!(
257 "follow-up: {} ({})",
258 crate::queue::title_from(&first.title, 80),
259 location(first)
260 );
261 let mut s = format!(
262 "This is a follow-up to a change that was already merged: run {run} merged \
263 {pr_url} while the review finding(s) below were still open. Branch from the \
264 current main - do not use the merged branch - and fix the defect described, \
265 keeping the change small. If a finding no longer applies to current main, say so \
266 and change nothing for it.\n\n\
267 Merged run: {run}\nPull request: {pr_url}\n",
268 run = state.id,
269 );
270 if let Some(t) = &origin_task {
271 s.push_str(&format!("Original task: {t}\n"));
272 }
273 s.push_str("\n## Findings\n");
274 for f in group {
275 s.push_str(&format!(
276 "\n### {} [{:?}] {}\n\nLocation: {}\n\n{}\n",
277 f.id,
278 f.severity,
279 f.title,
280 location(f),
281 f.detail.trim()
282 ));
283 }
284 let mut task = Task::new(
285 title,
286 s,
287 state.repo.clone(),
288 Source::Agent {
289 run: state.id.clone(),
290 node: NODE.to_owned(),
291 },
292 );
293 task.id = task_id(&state.id, &ids);
294 task.solo = true;
295 task.followup = Some(FollowUp {
296 run: state.id.clone(),
297 origin_task,
298 pr: pr_url.to_owned(),
299 findings: ids,
300 generation,
301 });
302 task
303}
304
305fn comment_body(state: &RunState, out: &Outcome) -> String {
306 let mut s = format!(
307 "<!-- magi-followup run={} -->\nThis pull request merged with review findings still \
308 open. They were filed as follow-up tasks:\n\n",
309 state.id
310 );
311 for r in &state.followups {
312 s.push_str(&format!("- `{}`: {}\n", r.task, r.findings.join(", ")));
313 }
314 if !out.unfiled.is_empty() {
315 s.push_str(&format!(
316 "\nMinor/nit findings that were only listed, not filed: {}\n",
317 out.unfiled.join(", ")
318 ));
319 }
320 s
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::config::Config;
327 use crate::queue::Queue;
328 use crate::run::ReviewRecord;
329 use crate::run::{Origin, ReviewRound, RunState};
330 use crate::verdict::{Finding, ReviewVote, Severity};
331 use std::path::PathBuf;
332
333 fn finding(id: &str, sev: Severity, file: &str, line: u32) -> Finding {
334 Finding {
335 id: id.to_owned(),
336 severity: sev,
337 file: Some(file.to_owned()),
338 line: Some(line),
339 title: format!("title {id}"),
340 detail: format!("detail of {id}"),
341 }
342 }
343
344 fn record(seat: usize, findings: Vec<Finding>, vote: ReviewVote) -> ReviewRecord {
345 ReviewRecord {
346 attempts: 0,
347 reviewer: seat,
348 agent: "alpha".to_owned(),
349 summary: String::new(),
350 findings,
351 vote: Some(vote),
352 failed: None,
353 duration_ms: 0,
354 }
355 }
356
357 fn round(reviews: Vec<ReviewRecord>) -> ReviewRound {
358 ReviewRound {
359 round: 3,
360 head: "deadbee".to_owned(),
361 verified_head: None,
362 verified_at: None,
363 reviews,
364 e2e: Vec::new(),
365 verify_retried: false,
366 e2e_deferred: false,
367 e2e_defer_reason: None,
368 fix: None,
369 blocking: 0,
370 answered: 2,
371 expected: 2,
372 clean: false,
373 progressed: false,
374 vote_split: false,
375 reconsideration: Vec::new(),
376 verdict: None,
377 }
378 }
379
380 fn merged(reviews: Vec<ReviewRecord>) -> RunState {
381 let mut s = RunState::new(
382 PathBuf::from("/repo"),
383 "main".to_owned(),
384 "abc1234".to_owned(),
385 "do it".to_owned(),
386 Config::default(),
387 );
388 s.reviews.push(round(reviews));
389 s
390 }
391
392 fn two_seats() -> Vec<ReviewRecord> {
393 vec![
394 record(
395 1,
396 vec![
397 finding("R3-1-1", Severity::Major, "src/waiter.rs", 211),
398 finding("R3-1-2", Severity::Minor, "src/a.rs", 1),
399 ],
400 ReviewVote::Reject,
401 ),
402 record(
403 2,
404 vec![
405 finding("R3-2-1", Severity::Major, "src/waiter.rs", 213),
406 finding("R3-2-2", Severity::Nit, "src/b.rs", 9),
407 ],
408 ReviewVote::Approve,
409 ),
410 ]
411 }
412
413 #[test]
414 fn one_task_per_distinct_defect_with_the_findings_in_full() {
415 let dir = tempfile::tempdir().unwrap();
416 let q = Queue::at(dir.path().join("queue"));
417 let mut s = merged(two_seats());
418 let out = file(&mut s, "https://example.invalid/o/r/pull/9", &q).unwrap();
419 assert_eq!(out.filed.len(), 2, "{out:?}");
422 assert_eq!(out.unfiled, vec!["R3-2-2".to_owned()]);
423 let tasks = q.list();
424 let t = tasks
425 .iter()
426 .find(|t| t.followup.as_ref().unwrap().findings.len() == 2)
427 .unwrap();
428 assert!(t.solo);
429 assert_eq!(t.followup.as_ref().unwrap().generation, 1);
430 assert_eq!(
431 t.source,
432 Source::Agent {
433 run: s.id.clone(),
434 node: NODE.to_owned()
435 }
436 );
437 for needle in [
438 "R3-1-1",
439 "R3-2-1",
440 "src/waiter.rs:211",
441 "detail of R3-2-1",
442 "pull/9",
443 &s.id,
444 "current main",
445 ] {
446 assert!(
447 t.instruction.contains(needle),
448 "{needle}: {}",
449 t.instruction
450 );
451 }
452 assert!(
453 s.events
454 .iter()
455 .any(|e| e.message.contains("filed 2 follow-up"))
456 );
457 assert_eq!(s.followups.len(), 2);
458 }
459
460 #[test]
461 fn filing_twice_does_not_duplicate() {
462 let dir = tempfile::tempdir().unwrap();
463 let q = Queue::at(dir.path().join("queue"));
464 let mut s = merged(two_seats());
465 file(&mut s, "u", &q).unwrap();
466 let again = file(&mut s, "u", &q).unwrap();
467 assert!(again.filed.is_empty());
468 assert_eq!(q.list().len(), 2);
469 s.followups.clear();
471 let third = file(&mut s, "u", &q).unwrap();
472 assert!(third.filed.is_empty());
473 assert_eq!(q.list().len(), 2);
474 assert_eq!(s.followups.len(), 2, "records are restored from the queue");
475 }
476
477 #[test]
478 fn a_clean_merge_files_nothing() {
479 let dir = tempfile::tempdir().unwrap();
480 let q = Queue::at(dir.path().join("queue"));
481 let mut s = merged(vec![record(
482 1,
483 vec![finding("R1-1-1", Severity::Minor, "a.rs", 1)],
484 ReviewVote::Approve,
485 )]);
486 let out = file(&mut s, "u", &q).unwrap();
487 assert!(out.filed.is_empty());
488 assert!(q.list().is_empty());
489 assert!(s.followups.is_empty());
490 }
491
492 #[test]
493 fn a_failing_queue_is_an_error_event_not_a_status_change() {
494 let dir = tempfile::tempdir().unwrap();
495 let root = dir.path().join("queue");
496 std::fs::write(&root, "not a directory").unwrap();
497 let q = Queue::at(root);
498 let mut s = merged(two_seats());
499 s.status = crate::run::RunStatus::Merged;
500 let _ = file(&mut s, "u", &q);
501 assert_eq!(s.status, crate::run::RunStatus::Merged);
502 assert!(
503 s.events
504 .iter()
505 .any(|e| e.node == NODE && e.message.contains("stopped"))
506 );
507 assert!(s.followups.is_empty());
508 }
509
510 #[test]
511 fn the_generation_cap_stops_the_chain() {
512 let dir = tempfile::tempdir().unwrap();
513 let q = Queue::at(dir.path().join("queue"));
514 let mut parent = Task::new(
515 "p".to_owned(),
516 "i".to_owned(),
517 PathBuf::from("/repo"),
518 Source::Human,
519 );
520 parent.followup = Some(FollowUp {
521 run: "r0".to_owned(),
522 origin_task: None,
523 pr: "u".to_owned(),
524 findings: vec!["R1-1-1".to_owned()],
525 generation: MAX_FOLLOWUP_GENERATION,
526 });
527 q.put(&mut parent).unwrap();
528 let mut s = merged(two_seats());
529 s.origin = Some(Origin {
530 by: crate::run::StartedBy::Operator,
531 task: Some(parent.id.clone()),
532 });
533 s.origin = Some(Origin {
534 by: crate::run::StartedBy::Operator,
535 task: Some("gone".to_owned()),
536 });
537 s.followup_generation = Some(MAX_FOLLOWUP_GENERATION);
538 let out = file(&mut s, "u", &q).unwrap();
539 assert!(out.filed.is_empty());
540 assert!(!out.capped.is_empty());
541 assert_eq!(q.list().len(), 1);
542 assert!(
543 s.events
544 .iter()
545 .any(|e| e.message.contains("generation cap"))
546 );
547 }
548
549 #[test]
550 fn a_deeper_task_files_one_generation_further() {
551 let dir = tempfile::tempdir().unwrap();
552 let q = Queue::at(dir.path().join("queue"));
553 let mut parent = Task::new(
554 "p".to_owned(),
555 "i".to_owned(),
556 PathBuf::from("/repo"),
557 Source::Human,
558 );
559 q.put(&mut parent).unwrap();
560 let mut s = merged(two_seats());
561 s.origin = Some(Origin {
562 by: crate::run::StartedBy::Operator,
563 task: Some(parent.id.clone()),
564 });
565 file(&mut s, "u", &q).unwrap();
566 let t = q.list().into_iter().find(|t| t.followup.is_some()).unwrap();
567 assert_eq!(t.followup.unwrap().origin_task, Some(parent.id));
568 }
569}