Skip to main content

bvr/analysis/
plan.rs

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        // Build descriptive reason for this track.
110        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    // Exclude in_progress issues from highest_impact to match legacy behavior:
157    // the "highest impact" pick should surface new work, not work already claimed.
158    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        // Track with A should mention unblocking.
294        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        // Independent track should mention independence.
305        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        // Create 3 components where the middle one has no actionable items,
403        // so it gets skipped. Track IDs should still be contiguous (1, 2).
404        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            // B and C form a component where both are blocked (no actionable items).
414            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        // Track IDs should be contiguous: track-1, track-2 (no gaps).
456        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}