1use std::collections::{HashMap, HashSet};
2
3use serde::Serialize;
4
5use crate::analysis::graph::IssueGraph;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct ExecutionItem {
9 pub id: String,
10 pub title: String,
11 pub status: String,
12 pub priority: i32,
13 pub score: f64,
14 pub unblocks: Vec<String>,
15 pub claim_command: String,
16 pub show_command: String,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct ExecutionTrack {
21 #[serde(rename = "track_id")]
22 pub id: String,
23 pub items: Vec<ExecutionItem>,
24 pub reason: String,
25}
26
27#[derive(Debug, Clone, Serialize, Default)]
28pub struct PlanSummary {
29 pub track_count: usize,
30 pub actionable_count: usize,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub unblocks_count: Option<usize>,
33 pub highest_impact: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub impact_reason: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct ExecutionPlan {
40 pub total_actionable: usize,
41 pub total_blocked: usize,
42 pub tracks: Vec<ExecutionTrack>,
43 pub summary: PlanSummary,
44}
45
46fn unique_unblocks_count<'a>(items: impl IntoIterator<Item = &'a ExecutionItem>) -> usize {
47 items
48 .into_iter()
49 .flat_map(|item| item.unblocks.iter().cloned())
50 .collect::<HashSet<_>>()
51 .len()
52}
53
54pub fn compute_execution_plan(
55 graph: &IssueGraph,
56 score_by_id: &HashMap<String, f64>,
57) -> ExecutionPlan {
58 let components = graph.connected_open_components();
59 let actionable: HashSet<String> = graph.actionable_ids().into_iter().collect();
60
61 let mut tracks = Vec::<ExecutionTrack>::new();
62 let mut track_number: usize = 0;
63
64 for component in &components {
65 let mut items = Vec::<ExecutionItem>::new();
66
67 for issue_id in component {
68 if !actionable.contains(issue_id) {
69 continue;
70 }
71 let Some(issue) = graph.issue(issue_id) else {
72 continue;
73 };
74
75 let mut unblocks = graph
76 .dependents(issue_id)
77 .into_iter()
78 .filter(|dependent_id| {
79 graph
80 .issue(dependent_id)
81 .is_some_and(crate::model::Issue::is_open_like)
82 })
83 .collect::<Vec<_>>();
84 unblocks.sort();
85
86 items.push(ExecutionItem {
87 id: issue.id.clone(),
88 title: issue.title.clone(),
89 status: issue.status.clone(),
90 priority: issue.priority,
91 score: score_by_id.get(issue_id).copied().unwrap_or_default(),
92 unblocks,
93 claim_command: format!("br update {} --status=in_progress", issue.id),
94 show_command: format!("br show {}", issue.id),
95 });
96 }
97
98 items.sort_by(|left, right| {
99 right
100 .score
101 .total_cmp(&left.score)
102 .then_with(|| left.id.cmp(&right.id))
103 });
104
105 if items.is_empty() {
106 continue;
107 }
108
109 let total_unblocks = unique_unblocks_count(items.iter());
111 let reason = if items.len() == 1 {
112 let item = &items[0];
113 if item.unblocks.is_empty() {
114 "independent issue — can execute in parallel".to_string()
115 } else {
116 format!(
117 "completing {} unblocks {} issue(s)",
118 item.id,
119 item.unblocks.len()
120 )
121 }
122 } else if total_unblocks > 0 {
123 format!(
124 "connected component of {} actionable items unblocking {} downstream issue(s)",
125 items.len(),
126 total_unblocks
127 )
128 } else {
129 format!("connected component of {} independent items", items.len())
130 };
131
132 track_number += 1;
133 tracks.push(ExecutionTrack {
134 id: format!("track-{track_number}"),
135 items,
136 reason,
137 });
138 }
139
140 tracks.sort_by(|left, right| {
141 let left_score = left
142 .items
143 .first()
144 .map(|item| item.score)
145 .unwrap_or_default();
146 let right_score = right
147 .items
148 .first()
149 .map(|item| item.score)
150 .unwrap_or_default();
151 right_score
152 .total_cmp(&left_score)
153 .then_with(|| left.id.cmp(&right.id))
154 });
155
156 let highest_impact_item = tracks
159 .iter()
160 .flat_map(|track| track.items.iter())
161 .find(|item| {
162 graph
163 .issue(&item.id)
164 .is_none_or(|issue| issue.normalized_status() != "in_progress")
165 });
166
167 let highest_impact = highest_impact_item.map(|item| item.id.clone());
168
169 let impact_reason = highest_impact_item.map(|item| {
170 let mut parts = Vec::new();
171 parts.push(format!("score {:.2}", item.score));
172 if !item.unblocks.is_empty() {
173 parts.push(format!("unblocks {} issue(s)", item.unblocks.len()));
174 }
175 format!("highest impact: {} ({})", item.id, parts.join(", "))
176 });
177
178 let actionable_count: usize = tracks.iter().map(|track| track.items.len()).sum();
179 let total_blocked = graph
180 .issues
181 .iter()
182 .filter(|issue| issue.is_open_like() && !graph.open_blockers(&issue.id).is_empty())
183 .count();
184 let total_unblocks = unique_unblocks_count(tracks.iter().flat_map(|track| track.items.iter()));
185 let track_count = tracks.len();
186
187 ExecutionPlan {
188 total_actionable: actionable_count,
189 total_blocked,
190 tracks,
191 summary: PlanSummary {
192 track_count,
193 actionable_count,
194 unblocks_count: Some(total_unblocks),
195 highest_impact,
196 impact_reason,
197 },
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use std::collections::HashMap;
204
205 use crate::analysis::graph::IssueGraph;
206 use crate::model::{Dependency, Issue};
207
208 use super::{compute_execution_plan, unique_unblocks_count};
209
210 #[test]
211 fn plan_groups_by_components() {
212 let issues = vec![
213 Issue {
214 id: "A".to_string(),
215 title: "A".to_string(),
216 status: "open".to_string(),
217 issue_type: "task".to_string(),
218 priority: 1,
219 ..Issue::default()
220 },
221 Issue {
222 id: "B".to_string(),
223 title: "B".to_string(),
224 status: "open".to_string(),
225 issue_type: "task".to_string(),
226 priority: 1,
227 ..Issue::default()
228 },
229 Issue {
230 id: "C".to_string(),
231 title: "C".to_string(),
232 status: "blocked".to_string(),
233 issue_type: "task".to_string(),
234 dependencies: vec![Dependency {
235 depends_on_id: "A".to_string(),
236 dep_type: "blocks".to_string(),
237 ..Dependency::default()
238 }],
239 ..Issue::default()
240 },
241 ];
242
243 let graph = IssueGraph::build(&issues);
244 let mut scores = HashMap::new();
245 scores.insert("A".to_string(), 0.8);
246 scores.insert("B".to_string(), 0.7);
247
248 let plan = compute_execution_plan(&graph, &scores);
249 assert_eq!(plan.summary.actionable_count, 2);
250 assert!(plan.summary.track_count >= 1);
251 assert_eq!(plan.summary.track_count, plan.tracks.len());
252 }
253
254 #[test]
255 fn plan_track_reason_describes_component() {
256 let issues = vec![
257 Issue {
258 id: "A".to_string(),
259 title: "Root".to_string(),
260 status: "open".to_string(),
261 issue_type: "task".to_string(),
262 priority: 1,
263 ..Issue::default()
264 },
265 Issue {
266 id: "B".to_string(),
267 title: "Depends".to_string(),
268 status: "open".to_string(),
269 issue_type: "task".to_string(),
270 dependencies: vec![Dependency {
271 depends_on_id: "A".to_string(),
272 dep_type: "blocks".to_string(),
273 ..Dependency::default()
274 }],
275 ..Issue::default()
276 },
277 Issue {
278 id: "C".to_string(),
279 title: "Independent".to_string(),
280 status: "open".to_string(),
281 issue_type: "task".to_string(),
282 ..Issue::default()
283 },
284 ];
285
286 let graph = IssueGraph::build(&issues);
287 let mut scores = HashMap::new();
288 scores.insert("A".to_string(), 0.8);
289 scores.insert("C".to_string(), 0.5);
290
291 let plan = compute_execution_plan(&graph, &scores);
292
293 let track_a = plan
295 .tracks
296 .iter()
297 .find(|t| t.items.iter().any(|i| i.id == "A"));
298 assert!(track_a.is_some());
299 assert!(
300 !track_a.unwrap().reason.is_empty(),
301 "track reason should not be empty"
302 );
303
304 let track_c = plan
306 .tracks
307 .iter()
308 .find(|t| t.items.iter().any(|i| i.id == "C"));
309 assert!(track_c.is_some());
310 assert!(track_c.unwrap().reason.contains("independent"));
311 }
312
313 #[test]
314 fn plan_impact_reason_present_when_tracks_exist() {
315 let issues = vec![Issue {
316 id: "A".to_string(),
317 title: "Only".to_string(),
318 status: "open".to_string(),
319 issue_type: "task".to_string(),
320 priority: 1,
321 ..Issue::default()
322 }];
323 let graph = IssueGraph::build(&issues);
324 let mut scores = HashMap::new();
325 scores.insert("A".to_string(), 0.9);
326
327 let plan = compute_execution_plan(&graph, &scores);
328 assert!(plan.summary.impact_reason.is_some());
329 let reason = plan.summary.impact_reason.unwrap();
330 assert!(reason.contains("A"), "should mention the issue ID");
331 assert!(reason.contains("0.90"), "should mention the score");
332 }
333
334 #[test]
335 fn plan_impact_reason_none_when_no_tracks() {
336 let issues: Vec<Issue> = vec![];
337 let graph = IssueGraph::build(&issues);
338 let plan = compute_execution_plan(&graph, &HashMap::new());
339 assert!(plan.summary.impact_reason.is_none());
340 }
341
342 #[test]
343 fn plan_reason_serializes_to_json() {
344 let issues = vec![Issue {
345 id: "A".to_string(),
346 title: "A".to_string(),
347 status: "open".to_string(),
348 issue_type: "task".to_string(),
349 ..Issue::default()
350 }];
351 let graph = IssueGraph::build(&issues);
352 let mut scores = HashMap::new();
353 scores.insert("A".to_string(), 0.5);
354 let plan = compute_execution_plan(&graph, &scores);
355
356 let json = serde_json::to_string(&plan).unwrap();
357 assert!(json.contains("\"reason\""));
358 assert!(json.contains("\"impact_reason\""));
359 }
360
361 #[test]
362 fn plan_summary_track_count_reflects_non_empty_tracks_only() {
363 let issues = vec![
364 Issue {
365 id: "A".to_string(),
366 title: "A".to_string(),
367 status: "blocked".to_string(),
368 issue_type: "task".to_string(),
369 dependencies: vec![Dependency {
370 depends_on_id: "B".to_string(),
371 dep_type: "blocks".to_string(),
372 ..Dependency::default()
373 }],
374 ..Issue::default()
375 },
376 Issue {
377 id: "B".to_string(),
378 title: "B".to_string(),
379 status: "blocked".to_string(),
380 issue_type: "task".to_string(),
381 dependencies: vec![Dependency {
382 depends_on_id: "A".to_string(),
383 dep_type: "blocks".to_string(),
384 ..Dependency::default()
385 }],
386 ..Issue::default()
387 },
388 ];
389
390 let graph = IssueGraph::build(&issues);
391 let scores = HashMap::new();
392 let plan = compute_execution_plan(&graph, &scores);
393
394 assert_eq!(plan.tracks.len(), 0);
395 assert_eq!(plan.summary.track_count, 0);
396 assert_eq!(plan.summary.actionable_count, 0);
397 assert!(plan.summary.highest_impact.is_none());
398 }
399
400 #[test]
401 fn plan_track_ids_are_contiguous() {
402 let issues = vec![
405 Issue {
406 id: "A".to_string(),
407 title: "A".to_string(),
408 status: "open".to_string(),
409 issue_type: "task".to_string(),
410 priority: 1,
411 ..Issue::default()
412 },
413 Issue {
415 id: "B".to_string(),
416 title: "B".to_string(),
417 status: "blocked".to_string(),
418 issue_type: "task".to_string(),
419 dependencies: vec![Dependency {
420 depends_on_id: "C".to_string(),
421 dep_type: "blocks".to_string(),
422 ..Dependency::default()
423 }],
424 ..Issue::default()
425 },
426 Issue {
427 id: "C".to_string(),
428 title: "C".to_string(),
429 status: "blocked".to_string(),
430 issue_type: "task".to_string(),
431 dependencies: vec![Dependency {
432 depends_on_id: "B".to_string(),
433 dep_type: "blocks".to_string(),
434 ..Dependency::default()
435 }],
436 ..Issue::default()
437 },
438 Issue {
439 id: "D".to_string(),
440 title: "D".to_string(),
441 status: "open".to_string(),
442 issue_type: "task".to_string(),
443 priority: 1,
444 ..Issue::default()
445 },
446 ];
447
448 let graph = IssueGraph::build(&issues);
449 let mut scores = HashMap::new();
450 scores.insert("A".to_string(), 0.8);
451 scores.insert("D".to_string(), 0.7);
452
453 let plan = compute_execution_plan(&graph, &scores);
454 assert_eq!(plan.tracks.len(), 2);
455 let mut ids: Vec<&str> = plan.tracks.iter().map(|t| t.id.as_str()).collect();
457 ids.sort();
458 assert_eq!(ids, vec!["track-1", "track-2"]);
459 }
460
461 #[test]
462 fn unique_unblocks_are_counted_once_in_summary_and_reason() {
463 let issues = vec![
464 Issue {
465 id: "A".to_string(),
466 title: "A".to_string(),
467 status: "open".to_string(),
468 issue_type: "task".to_string(),
469 ..Issue::default()
470 },
471 Issue {
472 id: "B".to_string(),
473 title: "B".to_string(),
474 status: "open".to_string(),
475 issue_type: "task".to_string(),
476 ..Issue::default()
477 },
478 Issue {
479 id: "C".to_string(),
480 title: "C".to_string(),
481 status: "blocked".to_string(),
482 issue_type: "task".to_string(),
483 dependencies: vec![
484 Dependency {
485 depends_on_id: "A".to_string(),
486 dep_type: "blocks".to_string(),
487 ..Dependency::default()
488 },
489 Dependency {
490 depends_on_id: "B".to_string(),
491 dep_type: "blocks".to_string(),
492 ..Dependency::default()
493 },
494 ],
495 ..Issue::default()
496 },
497 ];
498
499 let graph = IssueGraph::build(&issues);
500 let mut scores = HashMap::new();
501 scores.insert("A".to_string(), 0.8);
502 scores.insert("B".to_string(), 0.7);
503
504 let plan = compute_execution_plan(&graph, &scores);
505 assert_eq!(plan.summary.unblocks_count, Some(1));
506 assert_eq!(plan.tracks.len(), 1);
507 assert!(
508 plan.tracks[0]
509 .reason
510 .contains("unblocking 1 downstream issue")
511 );
512 assert_eq!(unique_unblocks_count(plan.tracks[0].items.iter()), 1);
513 }
514}