1use std::collections::BTreeMap;
10
11use crate::agent::Agent;
12use crate::config::Config;
13use crate::error::Result;
14use crate::model::{
15 Complexity, ContestedItem, Issue, Plan, PlanItem, Risk, SkippedItem, TriageResponse,
16 TriageVerdict,
17};
18use crate::repo::Repo;
19use crate::{log, logwarn, schema, spar_err};
20
21const TRIAGE_PROMPT: &str = "\
22You are triaging GitHub issues for the repository in your working directory.
23Read the codebase as needed before judging. Do not modify anything.
24
25Each issue below is its number, title, URL, and body as filed. The discussion
26since it was filed is not included. Where a body leaves the judgement genuinely
27unclear, read that one issue's thread before deciding; read the ones that need
28it rather than all of them, because the queue is long and most will not. If you
29cannot reach the network, judge on what is here.
30
31For each issue decide:
32- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
33 duplicates, stale requests, things already fixed, vague reports with nothing
34 reproducible, or changes that would make the codebase worse.
35- complexity: s, m, or l.
36- depends_on: issue numbers from this same list that should land first.
37- risk: how likely a change here is to break something.
38
39Judge independently. Be willing to say an issue is not worth doing. Your reason
40is posted on the issue when the other reviewer agrees with you, so write one
41sentence a maintainer would be happy to have their name on.
42
43Issues:
44";
45
46struct Rendered {
48 text: String,
49 deferred: Vec<i64>,
51 shortened: Vec<i64>,
53}
54
55fn render(issues: &[Issue], cfg: &Config) -> Rendered {
69 let mut parts: Vec<String> = Vec::new();
70 let mut deferred = Vec::new();
71 let mut shortened = Vec::new();
72 let mut total = 0usize;
73
74 for issue in issues {
75 if !deferred.is_empty() {
76 deferred.push(issue.number);
77 continue;
78 }
79 let (body, cut) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
80 let entry = format!("#{}: {}\n{}\n{body}", issue.number, issue.title, issue.url);
84 let len = entry.chars().count();
85 if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
88 deferred.push(issue.number);
89 continue;
90 }
91 if cut {
92 shortened.push(issue.number);
93 }
94 total += len;
95 parts.push(entry);
96 }
97
98 Rendered {
99 text: parts.join("\n\n"),
100 deferred,
101 shortened,
102 }
103}
104
105fn numbers(items: &[i64]) -> String {
106 items
107 .iter()
108 .map(|n| format!("#{n}"))
109 .collect::<Vec<_>>()
110 .join(", ")
111}
112
113pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
115 let rendered = render(issues, cfg);
116 if !rendered.shortened.is_empty() {
119 logwarn!(
120 "issue body shortened to fit the prompt: {}. Raise max_issue_chars if these matter.",
121 numbers(&rendered.shortened)
122 );
123 }
124 if !rendered.deferred.is_empty() {
125 logwarn!(
126 "the queue did not fit in one triage prompt, so {} were left for a later run: {}",
127 rendered.deferred.len(),
128 numbers(&rendered.deferred)
129 );
130 }
131 let prompt = format!("{TRIAGE_PROMPT}{}", rendered.text);
132 let schema = schema::triage();
133
134 let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
135 ask_together(agents, cfg, repo, &prompt, &schema)
136 } else {
137 ask_in_turn(agents, cfg, repo, &prompt, &schema)
138 };
139
140 let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
141 for (name, answer) in answers {
142 let response = answer?;
143 let mut by_issue = BTreeMap::new();
144 for verdict in response.issues {
145 by_issue.insert(verdict.issue, verdict);
146 }
147 verdicts.push((name, by_issue));
148 }
149
150 Ok(reconcile(issues, &verdicts))
151}
152
153type Answer = (String, Result<TriageResponse>);
154
155fn ask_one(
156 agent: &Agent,
157 cfg: &Config,
158 repo: &Repo,
159 prompt: &str,
160 schema: &serde_json::Value,
161) -> Answer {
162 let effort = cfg.effort_for_round(&agent.spec, 1);
163 let out = agent.ask_json::<TriageResponse>(prompt, schema, repo.root(), effort.as_deref());
164 (agent.name().to_string(), out)
165}
166
167fn ask_together(
170 agents: &[Agent],
171 cfg: &Config,
172 repo: &Repo,
173 prompt: &str,
174 schema: &serde_json::Value,
175) -> Vec<Answer> {
176 log!("triage: asking {} in parallel", names(agents));
177 std::thread::scope(|scope| {
178 let handles: Vec<_> = agents
179 .iter()
180 .map(|agent| scope.spawn(move || ask_one(agent, cfg, repo, prompt, schema)))
181 .collect();
182 handles
183 .into_iter()
184 .zip(agents)
185 .map(|(handle, agent)| {
186 handle.join().unwrap_or_else(|_| {
187 (
188 agent.name().to_string(),
189 Err(spar_err!("triage thread for '{}' panicked", agent.name())),
190 )
191 })
192 })
193 .collect()
194 })
195}
196
197fn ask_in_turn(
198 agents: &[Agent],
199 cfg: &Config,
200 repo: &Repo,
201 prompt: &str,
202 schema: &serde_json::Value,
203) -> Vec<Answer> {
204 agents
205 .iter()
206 .map(|agent| {
207 log!(
208 "triage: asking {} ({})",
209 agent.name(),
210 agent.spec.describe()
211 );
212 ask_one(agent, cfg, repo, prompt, schema)
213 })
214 .collect()
215}
216
217fn names(agents: &[Agent]) -> String {
218 agents
219 .iter()
220 .map(Agent::name)
221 .collect::<Vec<_>>()
222 .join(" and ")
223}
224
225fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
230 let mut agreed = Vec::new();
231 let mut skipped = Vec::new();
232 let mut contested = Vec::new();
233
234 for issue in issues {
235 let number = issue.number;
236 let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
237 .iter()
238 .map(|(name, map)| (name, map.get(&number)))
239 .collect();
240
241 if seen.iter().any(|(_, v)| v.is_none()) {
242 let missing: Vec<&str> = seen
243 .iter()
244 .filter(|(_, v)| v.is_none())
245 .map(|(n, _)| n.as_str())
246 .collect();
247 contested.push(ContestedItem {
248 issue: number,
249 title: issue.title.clone(),
250 positions: BTreeMap::new(),
251 reasons: BTreeMap::new(),
252 note: Some(format!("no verdict from {}", missing.join(", "))),
253 });
254 continue;
255 }
256
257 let all: Vec<(&String, &TriageVerdict)> = seen
258 .into_iter()
259 .map(|(n, v)| (n, v.expect("checked")))
260 .collect();
261
262 if all.iter().all(|(_, v)| v.worth_doing) {
263 let complexity = all
264 .iter()
265 .map(|(_, v)| v.complexity)
266 .max_by_key(|c| c.rank())
267 .unwrap_or(Complexity::M);
268 let risk = all
269 .iter()
270 .map(|(_, v)| v.risk)
271 .max_by_key(|r| r.rank())
272 .unwrap_or(Risk::Med);
273 let mut depends: Vec<i64> =
274 all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
275 depends.sort_unstable();
276 depends.dedup();
277 agreed.push(PlanItem {
278 issue: number,
279 title: issue.title.clone(),
280 complexity,
281 risk,
282 depends_on: depends,
283 reason: all[0].1.reason.clone(),
284 });
285 } else if all.iter().all(|(_, v)| !v.worth_doing) {
286 skipped.push(SkippedItem {
287 issue: number,
288 title: issue.title.clone(),
289 reasons: all
290 .iter()
291 .map(|(n, v)| ((*n).clone(), v.reason.clone()))
292 .collect(),
293 tracker: all.iter().any(|(_, v)| v.tracker),
296 });
297 } else {
298 contested.push(ContestedItem {
299 issue: number,
300 title: issue.title.clone(),
301 positions: all
302 .iter()
303 .map(|(n, v)| {
304 (
305 (*n).clone(),
306 if v.worth_doing { "do" } else { "skip" }.to_string(),
307 )
308 })
309 .collect(),
310 reasons: all
311 .iter()
312 .map(|(n, v)| ((*n).clone(), v.reason.clone()))
313 .collect(),
314 note: None,
315 });
316 }
317 }
318
319 Plan {
320 order: order(agreed),
321 skipped,
322 contested,
323 }
324}
325
326pub fn order(items: Vec<PlanItem>) -> Vec<PlanItem> {
329 let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
330
331 let mut entry: Vec<&PlanItem> = items.iter().collect();
332 entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
333
334 let mut ordered = Vec::new();
335 let mut done: Vec<i64> = Vec::new();
336 let mut visiting: Vec<i64> = Vec::new();
337
338 fn visit(
339 number: i64,
340 by_number: &BTreeMap<i64, PlanItem>,
341 done: &mut Vec<i64>,
342 visiting: &mut Vec<i64>,
343 ordered: &mut Vec<PlanItem>,
344 ) {
345 if done.contains(&number) {
346 return;
347 }
348 let Some(item) = by_number.get(&number) else {
349 return; };
351 if visiting.contains(&number) {
352 return; }
354 visiting.push(number);
355 let mut deps = item.depends_on.clone();
356 deps.sort_unstable();
357 for dep in deps {
358 visit(dep, by_number, done, visiting, ordered);
359 }
360 visiting.retain(|n| *n != number);
361 done.push(number);
362 ordered.push(item.clone());
363 }
364
365 for item in entry {
366 visit(
367 item.issue,
368 &by_number,
369 &mut done,
370 &mut visiting,
371 &mut ordered,
372 );
373 }
374 ordered
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn cfg_with(max_issue: usize, max_total: usize) -> Config {
382 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
383 let mut cfg = crate::config::parse(text).expect("a config");
384 cfg.loop_cfg.max_issue_chars = max_issue;
385 cfg.loop_cfg.max_triage_chars = max_total;
386 cfg
387 }
388
389 fn issue_of(number: i64, body: &str) -> Issue {
390 let mut i: Issue = serde_json::from_value(serde_json::json!({
391 "number": number, "title": "t", "state": "open", "url": "u"
392 }))
393 .expect("an issue");
394 i.body = Some(body.to_string());
395 i
396 }
397
398 #[test]
401 fn an_ordinary_queue_is_rendered_whole() {
402 let issues = vec![issue_of(1, "first body"), issue_of(2, "second body")];
403 let out = render(&issues, &cfg_with(60_000, 200_000));
404 assert!(out.deferred.is_empty() && out.shortened.is_empty());
405 assert!(out.text.contains("first body") && out.text.contains("second body"));
406 }
407
408 #[test]
413 fn every_issue_carries_its_link_as_well_as_its_body() {
414 let mut issue = issue_of(1, "the body");
415 issue.url = "https://github.com/o/r/issues/1".into();
416 let out = render(&[issue], &cfg_with(60_000, 200_000));
417 assert!(
418 out.text.contains("https://github.com/o/r/issues/1"),
419 "{}",
420 out.text
421 );
422 assert!(out.text.contains("the body"), "{}", out.text);
423 }
424
425 #[test]
429 fn a_queue_that_does_not_fit_defers_whole_issues() {
430 let issues = vec![
431 issue_of(1, &"a".repeat(80)),
432 issue_of(2, &"b".repeat(80)),
433 issue_of(3, &"c".repeat(80)),
434 ];
435 let out = render(&issues, &cfg_with(60_000, 120));
436 assert_eq!(vec![2, 3], out.deferred);
437 assert!(out.shortened.is_empty(), "no issue lost its tail");
438 assert!(out.text.contains(&"a".repeat(80)));
439 assert!(!out.text.contains(&"b".repeat(80)));
440 }
441
442 #[test]
446 fn deferral_is_a_prefix_and_does_not_pick_the_small_ones() {
447 let issues = vec![
448 issue_of(1, &"a".repeat(80)),
449 issue_of(2, &"b".repeat(500)),
450 issue_of(3, "tiny"),
451 ];
452 let out = render(&issues, &cfg_with(60_000, 200));
453 assert_eq!(vec![2, 3], out.deferred);
454 assert!(
455 !out.text.contains("tiny"),
456 "a later small issue must not jump the queue"
457 );
458 }
459
460 #[test]
463 fn the_first_issue_is_never_deferred() {
464 let issues = vec![issue_of(1, &"a".repeat(500))];
465 let out = render(&issues, &cfg_with(60_000, 10));
466 assert!(out.deferred.is_empty());
467 assert!(out.text.contains(&"a".repeat(500)));
468 }
469
470 #[test]
473 fn an_oversized_body_is_shortened_and_reported() {
474 let issues = vec![issue_of(7, &"word\n".repeat(400))];
475 let out = render(&issues, &cfg_with(100, 200_000));
476 assert_eq!(vec![7], out.shortened);
477 assert!(out.text.contains("Shortened to fit"), "{}", out.text);
478 }
479
480 fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
481 PlanItem {
482 issue: n,
483 title: format!("i{n}"),
484 complexity: Complexity::parse_lenient(complexity).unwrap(),
485 risk: Risk::Low,
486 depends_on: deps.to_vec(),
487 reason: String::new(),
488 }
489 }
490
491 fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
492 order(items).into_iter().map(|i| i.issue).collect()
493 }
494
495 #[test]
496 fn a_dependency_precedes_its_dependent() {
497 let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
498 let (a, b) = (
499 out.iter().position(|n| *n == 2).unwrap(),
500 out.iter().position(|n| *n == 1).unwrap(),
501 );
502 assert!(a < b, "{out:?}");
503 }
504
505 #[test]
506 fn cheapest_first_without_dependencies() {
507 assert_eq!(
508 vec![2, 3, 1],
509 numbers(vec![
510 item(1, "l", &[]),
511 item(2, "s", &[]),
512 item(3, "m", &[])
513 ])
514 );
515 }
516
517 #[test]
518 fn a_cycle_does_not_hang() {
519 assert_eq!(
520 2,
521 numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
522 );
523 }
524
525 #[test]
526 fn an_unknown_dependency_is_ignored() {
527 assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
528 }
529
530 #[test]
531 fn every_item_appears_exactly_once() {
532 let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
533 let out = numbers(items);
534 assert_eq!(vec![1, 2, 3, 4, 5], out);
535 }
536
537 #[test]
538 fn a_dependency_chain_is_ordered_end_to_end() {
539 let items: Vec<PlanItem> = (1..=5)
540 .map(|n| {
541 let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
542 PlanItem {
543 depends_on: deps,
544 ..item(n, "m", &[])
545 }
546 })
547 .collect();
548 assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
549 }
550
551 fn issue(n: i64) -> Issue {
554 Issue {
555 number: n,
556 title: format!("issue {n}"),
557 body: Some("body".into()),
558 state: "OPEN".into(),
559 url: String::new(),
560 labels: vec![],
561 }
562 }
563
564 fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
565 TriageVerdict {
566 issue: n,
567 worth_doing: worth,
568 tracker: false,
569 reason: format!("because {n}"),
570 complexity: Complexity::parse_lenient(complexity).unwrap(),
571 depends_on: deps.to_vec(),
572 risk: Risk::parse_lenient(risk).unwrap(),
573 }
574 }
575
576 fn pair(
577 a: Vec<TriageVerdict>,
578 b: Vec<TriageVerdict>,
579 ) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
580 vec![
581 (
582 "claude".to_string(),
583 a.into_iter().map(|v| (v.issue, v)).collect(),
584 ),
585 (
586 "codex".to_string(),
587 b.into_iter().map(|v| (v.issue, v)).collect(),
588 ),
589 ]
590 }
591
592 #[test]
593 fn both_agreeing_to_do_schedules_it() {
594 let plan = reconcile(
595 &[issue(1)],
596 &pair(
597 vec![verdict(1, true, "s", "low", &[])],
598 vec![verdict(1, true, "s", "low", &[])],
599 ),
600 );
601 assert_eq!(1, plan.order.len());
602 assert!(plan.skipped.is_empty() && plan.contested.is_empty());
603 }
604
605 #[test]
606 fn both_agreeing_to_skip_records_both_reasons() {
607 let plan = reconcile(
608 &[issue(1)],
609 &pair(
610 vec![verdict(1, false, "s", "low", &[])],
611 vec![verdict(1, false, "s", "low", &[])],
612 ),
613 );
614 assert_eq!(1, plan.skipped.len());
615 assert_eq!(2, plan.skipped[0].reasons.len());
616 assert!(plan.skipped[0].reasons.contains_key("claude"));
617 assert!(plan.skipped[0].reasons.contains_key("codex"));
618 }
619
620 #[test]
622 fn a_disagreement_is_contested_not_averaged() {
623 let plan = reconcile(
624 &[issue(1)],
625 &pair(
626 vec![verdict(1, true, "s", "low", &[])],
627 vec![verdict(1, false, "s", "low", &[])],
628 ),
629 );
630 assert!(plan.order.is_empty() && plan.skipped.is_empty());
631 assert_eq!(1, plan.contested.len());
632 assert_eq!(
633 Some(&"do".to_string()),
634 plan.contested[0].positions.get("claude")
635 );
636 assert_eq!(
637 Some(&"skip".to_string()),
638 plan.contested[0].positions.get("codex")
639 );
640 }
641
642 #[test]
643 fn a_missing_verdict_is_contested_and_says_who_was_silent() {
644 let plan = reconcile(
645 &[issue(1)],
646 &pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
647 );
648 assert_eq!(1, plan.contested.len());
649 assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
650 }
651
652 #[test]
653 fn the_pessimistic_estimate_wins() {
654 let plan = reconcile(
655 &[issue(1)],
656 &pair(
657 vec![verdict(1, true, "s", "low", &[])],
658 vec![verdict(1, true, "l", "high", &[])],
659 ),
660 );
661 assert_eq!(Complexity::L, plan.order[0].complexity);
662 assert_eq!(Risk::High, plan.order[0].risk);
663 }
664
665 #[test]
666 fn dependencies_from_both_agents_are_unioned() {
667 let plan = reconcile(
668 &[issue(1)],
669 &pair(
670 vec![verdict(1, true, "s", "low", &[2, 3])],
671 vec![verdict(1, true, "s", "low", &[3, 4])],
672 ),
673 );
674 assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
675 }
676
677 #[test]
684 fn an_umbrella_both_agents_declined_is_skipped_but_held_open() {
685 let plan = reconcile(
686 &[issue(1)],
687 &pair(vec![tracker_verdict(1)], vec![tracker_verdict(1)]),
688 );
689 assert!(plan.order.is_empty());
690 assert_eq!(1, plan.skipped.len());
691 assert!(plan.skipped[0].tracker, "a tracker must not be closeable");
692 }
693
694 #[test]
699 fn one_agent_calling_it_a_tracker_is_enough_to_hold_it_open() {
700 let plan = reconcile(
701 &[issue(1)],
702 &pair(
703 vec![tracker_verdict(1)],
704 vec![verdict(1, false, "s", "low", &[])],
705 ),
706 );
707 assert!(plan.skipped[0].tracker);
708 }
709
710 #[test]
713 fn an_ordinary_decline_is_not_held_open() {
714 let plan = reconcile(
715 &[issue(1)],
716 &pair(
717 vec![verdict(1, false, "s", "low", &[])],
718 vec![verdict(1, false, "s", "low", &[])],
719 ),
720 );
721 assert!(!plan.skipped[0].tracker);
722 }
723
724 fn tracker_verdict(n: i64) -> TriageVerdict {
725 let mut v = verdict(n, false, "m", "low", &[]);
726 v.tracker = true;
727 v
728 }
729}