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, 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
25For each issue decide:
26- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
27 duplicates, stale requests, things already fixed, vague reports with nothing
28 reproducible, or changes that would make the codebase worse.
29- complexity: s, m, or l.
30- depends_on: issue numbers from this same list that should land first.
31- risk: how likely a change here is to break something.
32
33Judge independently. Be willing to say an issue is not worth doing. Your reason
34is posted on the issue when the other reviewer agrees with you, so write one
35sentence a maintainer would be happy to have their name on.
36
37Issues:
38";
39
40pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
42 let rendered: String = issues
43 .iter()
44 .map(|i| {
45 let body: String = i.body_text().trim().chars().take(2000).collect();
46 format!("#{}: {}\n{body}", i.number, i.title)
47 })
48 .collect::<Vec<_>>()
49 .join("\n\n");
50 let prompt = format!("{TRIAGE_PROMPT}{rendered}");
51 let schema = schema::triage();
52
53 let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
54 ask_together(agents, cfg, repo, &prompt, &schema)
55 } else {
56 ask_in_turn(agents, cfg, repo, &prompt, &schema)
57 };
58
59 let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
60 for (name, answer) in answers {
61 let response = answer?;
62 let mut by_issue = BTreeMap::new();
63 for verdict in response.issues {
64 by_issue.insert(verdict.issue, verdict);
65 }
66 verdicts.push((name, by_issue));
67 }
68
69 Ok(reconcile(issues, &verdicts))
70}
71
72type Answer = (String, Result<TriageResponse>);
73
74fn ask_one(
75 agent: &Agent,
76 cfg: &Config,
77 repo: &Repo,
78 prompt: &str,
79 schema: &serde_json::Value,
80) -> Answer {
81 let effort = cfg.effort_for_round(&agent.spec, 1);
82 let out = agent.ask_json::<TriageResponse>(prompt, schema, repo.root(), effort.as_deref());
83 (agent.name().to_string(), out)
84}
85
86fn ask_together(
89 agents: &[Agent],
90 cfg: &Config,
91 repo: &Repo,
92 prompt: &str,
93 schema: &serde_json::Value,
94) -> Vec<Answer> {
95 log!("triage: asking {} in parallel", names(agents));
96 std::thread::scope(|scope| {
97 let handles: Vec<_> = agents
98 .iter()
99 .map(|agent| scope.spawn(move || ask_one(agent, cfg, repo, prompt, schema)))
100 .collect();
101 handles
102 .into_iter()
103 .zip(agents)
104 .map(|(handle, agent)| {
105 handle.join().unwrap_or_else(|_| {
106 (
107 agent.name().to_string(),
108 Err(spar_err!("triage thread for '{}' panicked", agent.name())),
109 )
110 })
111 })
112 .collect()
113 })
114}
115
116fn ask_in_turn(
117 agents: &[Agent],
118 cfg: &Config,
119 repo: &Repo,
120 prompt: &str,
121 schema: &serde_json::Value,
122) -> Vec<Answer> {
123 agents
124 .iter()
125 .map(|agent| {
126 log!(
127 "triage: asking {} ({})",
128 agent.name(),
129 agent.spec.describe()
130 );
131 ask_one(agent, cfg, repo, prompt, schema)
132 })
133 .collect()
134}
135
136fn names(agents: &[Agent]) -> String {
137 agents
138 .iter()
139 .map(Agent::name)
140 .collect::<Vec<_>>()
141 .join(" and ")
142}
143
144fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
149 let mut agreed = Vec::new();
150 let mut skipped = Vec::new();
151 let mut contested = Vec::new();
152
153 for issue in issues {
154 let number = issue.number;
155 let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
156 .iter()
157 .map(|(name, map)| (name, map.get(&number)))
158 .collect();
159
160 if seen.iter().any(|(_, v)| v.is_none()) {
161 let missing: Vec<&str> = seen
162 .iter()
163 .filter(|(_, v)| v.is_none())
164 .map(|(n, _)| n.as_str())
165 .collect();
166 contested.push(ContestedItem {
167 issue: number,
168 title: issue.title.clone(),
169 positions: BTreeMap::new(),
170 reasons: BTreeMap::new(),
171 note: Some(format!("no verdict from {}", missing.join(", "))),
172 });
173 continue;
174 }
175
176 let all: Vec<(&String, &TriageVerdict)> = seen
177 .into_iter()
178 .map(|(n, v)| (n, v.expect("checked")))
179 .collect();
180
181 if all.iter().all(|(_, v)| v.worth_doing) {
182 let complexity = all
183 .iter()
184 .map(|(_, v)| v.complexity)
185 .max_by_key(|c| c.rank())
186 .unwrap_or(Complexity::M);
187 let risk = all
188 .iter()
189 .map(|(_, v)| v.risk)
190 .max_by_key(|r| r.rank())
191 .unwrap_or(Risk::Med);
192 let mut depends: Vec<i64> =
193 all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
194 depends.sort_unstable();
195 depends.dedup();
196 agreed.push(PlanItem {
197 issue: number,
198 title: issue.title.clone(),
199 complexity,
200 risk,
201 depends_on: depends,
202 reason: all[0].1.reason.clone(),
203 });
204 } else if all.iter().all(|(_, v)| !v.worth_doing) {
205 skipped.push(SkippedItem {
206 issue: number,
207 title: issue.title.clone(),
208 reasons: all
209 .iter()
210 .map(|(n, v)| ((*n).clone(), v.reason.clone()))
211 .collect(),
212 });
213 } else {
214 contested.push(ContestedItem {
215 issue: number,
216 title: issue.title.clone(),
217 positions: all
218 .iter()
219 .map(|(n, v)| {
220 (
221 (*n).clone(),
222 if v.worth_doing { "do" } else { "skip" }.to_string(),
223 )
224 })
225 .collect(),
226 reasons: all
227 .iter()
228 .map(|(n, v)| ((*n).clone(), v.reason.clone()))
229 .collect(),
230 note: None,
231 });
232 }
233 }
234
235 Plan {
236 order: order(agreed),
237 skipped,
238 contested,
239 }
240}
241
242pub fn order(items: Vec<PlanItem>) -> Vec<PlanItem> {
245 let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
246
247 let mut entry: Vec<&PlanItem> = items.iter().collect();
248 entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
249
250 let mut ordered = Vec::new();
251 let mut done: Vec<i64> = Vec::new();
252 let mut visiting: Vec<i64> = Vec::new();
253
254 fn visit(
255 number: i64,
256 by_number: &BTreeMap<i64, PlanItem>,
257 done: &mut Vec<i64>,
258 visiting: &mut Vec<i64>,
259 ordered: &mut Vec<PlanItem>,
260 ) {
261 if done.contains(&number) {
262 return;
263 }
264 let Some(item) = by_number.get(&number) else {
265 return; };
267 if visiting.contains(&number) {
268 return; }
270 visiting.push(number);
271 let mut deps = item.depends_on.clone();
272 deps.sort_unstable();
273 for dep in deps {
274 visit(dep, by_number, done, visiting, ordered);
275 }
276 visiting.retain(|n| *n != number);
277 done.push(number);
278 ordered.push(item.clone());
279 }
280
281 for item in entry {
282 visit(
283 item.issue,
284 &by_number,
285 &mut done,
286 &mut visiting,
287 &mut ordered,
288 );
289 }
290 ordered
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
298 PlanItem {
299 issue: n,
300 title: format!("i{n}"),
301 complexity: Complexity::parse_lenient(complexity).unwrap(),
302 risk: Risk::Low,
303 depends_on: deps.to_vec(),
304 reason: String::new(),
305 }
306 }
307
308 fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
309 order(items).into_iter().map(|i| i.issue).collect()
310 }
311
312 #[test]
313 fn a_dependency_precedes_its_dependent() {
314 let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
315 let (a, b) = (
316 out.iter().position(|n| *n == 2).unwrap(),
317 out.iter().position(|n| *n == 1).unwrap(),
318 );
319 assert!(a < b, "{out:?}");
320 }
321
322 #[test]
323 fn cheapest_first_without_dependencies() {
324 assert_eq!(
325 vec![2, 3, 1],
326 numbers(vec![
327 item(1, "l", &[]),
328 item(2, "s", &[]),
329 item(3, "m", &[])
330 ])
331 );
332 }
333
334 #[test]
335 fn a_cycle_does_not_hang() {
336 assert_eq!(
337 2,
338 numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
339 );
340 }
341
342 #[test]
343 fn an_unknown_dependency_is_ignored() {
344 assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
345 }
346
347 #[test]
348 fn every_item_appears_exactly_once() {
349 let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
350 let out = numbers(items);
351 assert_eq!(vec![1, 2, 3, 4, 5], out);
352 }
353
354 #[test]
355 fn a_dependency_chain_is_ordered_end_to_end() {
356 let items: Vec<PlanItem> = (1..=5)
357 .map(|n| {
358 let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
359 PlanItem {
360 depends_on: deps,
361 ..item(n, "m", &[])
362 }
363 })
364 .collect();
365 assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
366 }
367
368 fn issue(n: i64) -> Issue {
371 Issue {
372 number: n,
373 title: format!("issue {n}"),
374 body: Some("body".into()),
375 state: "OPEN".into(),
376 url: String::new(),
377 labels: vec![],
378 }
379 }
380
381 fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
382 TriageVerdict {
383 issue: n,
384 worth_doing: worth,
385 reason: format!("because {n}"),
386 complexity: Complexity::parse_lenient(complexity).unwrap(),
387 depends_on: deps.to_vec(),
388 risk: Risk::parse_lenient(risk).unwrap(),
389 }
390 }
391
392 fn pair(
393 a: Vec<TriageVerdict>,
394 b: Vec<TriageVerdict>,
395 ) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
396 vec![
397 (
398 "claude".to_string(),
399 a.into_iter().map(|v| (v.issue, v)).collect(),
400 ),
401 (
402 "codex".to_string(),
403 b.into_iter().map(|v| (v.issue, v)).collect(),
404 ),
405 ]
406 }
407
408 #[test]
409 fn both_agreeing_to_do_schedules_it() {
410 let plan = reconcile(
411 &[issue(1)],
412 &pair(
413 vec![verdict(1, true, "s", "low", &[])],
414 vec![verdict(1, true, "s", "low", &[])],
415 ),
416 );
417 assert_eq!(1, plan.order.len());
418 assert!(plan.skipped.is_empty() && plan.contested.is_empty());
419 }
420
421 #[test]
422 fn both_agreeing_to_skip_records_both_reasons() {
423 let plan = reconcile(
424 &[issue(1)],
425 &pair(
426 vec![verdict(1, false, "s", "low", &[])],
427 vec![verdict(1, false, "s", "low", &[])],
428 ),
429 );
430 assert_eq!(1, plan.skipped.len());
431 assert_eq!(2, plan.skipped[0].reasons.len());
432 assert!(plan.skipped[0].reasons.contains_key("claude"));
433 assert!(plan.skipped[0].reasons.contains_key("codex"));
434 }
435
436 #[test]
438 fn a_disagreement_is_contested_not_averaged() {
439 let plan = reconcile(
440 &[issue(1)],
441 &pair(
442 vec![verdict(1, true, "s", "low", &[])],
443 vec![verdict(1, false, "s", "low", &[])],
444 ),
445 );
446 assert!(plan.order.is_empty() && plan.skipped.is_empty());
447 assert_eq!(1, plan.contested.len());
448 assert_eq!(
449 Some(&"do".to_string()),
450 plan.contested[0].positions.get("claude")
451 );
452 assert_eq!(
453 Some(&"skip".to_string()),
454 plan.contested[0].positions.get("codex")
455 );
456 }
457
458 #[test]
459 fn a_missing_verdict_is_contested_and_says_who_was_silent() {
460 let plan = reconcile(
461 &[issue(1)],
462 &pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
463 );
464 assert_eq!(1, plan.contested.len());
465 assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
466 }
467
468 #[test]
469 fn the_pessimistic_estimate_wins() {
470 let plan = reconcile(
471 &[issue(1)],
472 &pair(
473 vec![verdict(1, true, "s", "low", &[])],
474 vec![verdict(1, true, "l", "high", &[])],
475 ),
476 );
477 assert_eq!(Complexity::L, plan.order[0].complexity);
478 assert_eq!(Risk::High, plan.order[0].risk);
479 }
480
481 #[test]
482 fn dependencies_from_both_agents_are_unioned() {
483 let plan = reconcile(
484 &[issue(1)],
485 &pair(
486 vec![verdict(1, true, "s", "low", &[2, 3])],
487 vec![verdict(1, true, "s", "low", &[3, 4])],
488 ),
489 );
490 assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
491 }
492}