1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result};
4use rusqlite::Connection;
5use tracing::{info, warn};
6
7use crate::{
8 agents::{PlannedNode, PlannerGraphOutput},
9 db::graph::{self, GraphNodeRow, NodeState},
10 issues::PipelineIssue,
11};
12
13#[derive(Debug, Clone)]
15pub struct GraphNode {
16 pub issue_number: u32,
17 pub title: String,
18 pub area: String,
19 pub predicted_files: Vec<String>,
20 pub has_migration: bool,
21 pub complexity: String,
22 pub state: NodeState,
23 pub pr_number: Option<u32>,
24 pub run_id: Option<String>,
25 pub issue: Option<PipelineIssue>,
26 pub target_repo: Option<String>,
29}
30
31pub struct DependencyGraph {
36 session_id: String,
37 nodes: HashMap<u32, GraphNode>,
38 edges: HashMap<u32, HashSet<u32>>,
40 reverse_edges: HashMap<u32, HashSet<u32>>,
42}
43
44impl DependencyGraph {
45 pub fn new(session_id: &str) -> Self {
46 Self {
47 session_id: session_id.to_string(),
48 nodes: HashMap::new(),
49 edges: HashMap::new(),
50 reverse_edges: HashMap::new(),
51 }
52 }
53
54 pub fn session_id(&self) -> &str {
55 &self.session_id
56 }
57
58 pub fn contains(&self, issue: u32) -> bool {
59 self.nodes.contains_key(&issue)
60 }
61
62 pub fn node(&self, issue: u32) -> Option<&GraphNode> {
63 self.nodes.get(&issue)
64 }
65
66 pub fn node_count(&self) -> usize {
67 self.nodes.len()
68 }
69
70 pub fn add_node(&mut self, node: GraphNode) {
71 let num = node.issue_number;
72 self.nodes.insert(num, node);
73 self.edges.entry(num).or_default();
74 self.reverse_edges.entry(num).or_default();
75 }
76
77 pub fn add_edge(&mut self, from: u32, to: u32) -> bool {
81 if from == to || self.would_create_cycle(from, to) {
82 return false;
83 }
84 self.edges.entry(from).or_default().insert(to);
85 self.reverse_edges.entry(to).or_default().insert(from);
86 true
87 }
88
89 pub fn would_create_cycle(&self, from: u32, to: u32) -> bool {
93 let mut visited = HashSet::new();
94 let mut stack = vec![to];
95 while let Some(current) = stack.pop() {
96 if current == from {
97 return true;
98 }
99 if visited.insert(current) {
100 if let Some(deps) = self.edges.get(¤t) {
101 for &dep in deps {
102 if !visited.contains(&dep) {
103 stack.push(dep);
104 }
105 }
106 }
107 }
108 }
109 false
110 }
111
112 pub fn ready_issues(&self) -> Vec<u32> {
114 self.nodes
115 .iter()
116 .filter(|(_, node)| node.state == NodeState::Pending)
117 .filter(|(num, _)| {
118 self.edges.get(num).is_none_or(|deps| {
119 deps.iter()
120 .all(|d| self.nodes.get(d).is_some_and(|n| n.state == NodeState::Merged))
121 })
122 })
123 .map(|(num, _)| *num)
124 .collect()
125 }
126
127 pub fn awaiting_merge(&self) -> Vec<u32> {
129 self.nodes
130 .iter()
131 .filter(|(_, node)| node.state == NodeState::AwaitingMerge)
132 .map(|(num, _)| *num)
133 .collect()
134 }
135
136 pub fn transition(&mut self, issue: u32, state: NodeState) {
138 if let Some(node) = self.nodes.get_mut(&issue) {
139 info!(
140 issue,
141 from = %node.state,
142 to = %state,
143 "graph node state transition"
144 );
145 node.state = state;
146 }
147 }
148
149 pub fn set_pr_number(&mut self, issue: u32, pr_number: u32) {
151 if let Some(node) = self.nodes.get_mut(&issue) {
152 node.pr_number = Some(pr_number);
153 }
154 }
155
156 pub fn set_run_id(&mut self, issue: u32, run_id: &str) {
158 if let Some(node) = self.nodes.get_mut(&issue) {
159 node.run_id = Some(run_id.to_string());
160 }
161 }
162
163 pub fn dependencies(&self, issue: u32) -> HashSet<u32> {
165 self.edges.get(&issue).cloned().unwrap_or_default()
166 }
167
168 pub fn dependents(&self, issue: u32) -> HashSet<u32> {
170 self.reverse_edges.get(&issue).cloned().unwrap_or_default()
171 }
172
173 pub fn all_terminal(&self) -> bool {
175 self.nodes.values().all(|n| matches!(n.state, NodeState::Merged | NodeState::Failed))
176 }
177
178 pub fn is_blocked(&self, issue: u32) -> bool {
180 self.edges.get(&issue).is_some_and(|deps| {
181 deps.iter().any(|d| self.nodes.get(d).is_some_and(|n| n.state == NodeState::Failed))
182 })
183 }
184
185 pub fn propagate_failure(&mut self, issue: u32) -> Vec<u32> {
191 use std::collections::VecDeque;
192
193 let mut queue = VecDeque::new();
194 let mut newly_failed = Vec::new();
195
196 if let Some(dependents) = self.reverse_edges.get(&issue) {
198 queue.extend(dependents.iter().copied());
199 }
200
201 let mut visited = HashSet::new();
202 visited.insert(issue);
203
204 while let Some(current) = queue.pop_front() {
205 if !visited.insert(current) {
206 continue;
207 }
208 let dominated = self
209 .nodes
210 .get(¤t)
211 .is_some_and(|n| matches!(n.state, NodeState::Pending | NodeState::InFlight));
212 if !dominated {
213 continue;
214 }
215 self.transition(current, NodeState::Failed);
216 newly_failed.push(current);
217 if let Some(dependents) = self.reverse_edges.get(¤t) {
218 queue.extend(dependents.iter().copied());
219 }
220 }
221
222 newly_failed
223 }
224
225 pub fn remove_node(&mut self, issue: u32) {
227 self.nodes.remove(&issue);
228 if let Some(deps) = self.edges.remove(&issue) {
229 for dep in &deps {
230 if let Some(rev) = self.reverse_edges.get_mut(dep) {
231 rev.remove(&issue);
232 }
233 }
234 }
235 if let Some(dependents) = self.reverse_edges.remove(&issue) {
236 for dependent in &dependents {
237 if let Some(fwd) = self.edges.get_mut(dependent) {
238 fwd.remove(&issue);
239 }
240 }
241 }
242 }
243
244 pub fn all_issues(&self) -> Vec<u32> {
246 let mut nums: Vec<u32> = self.nodes.keys().copied().collect();
247 nums.sort_unstable();
248 nums
249 }
250
251 pub fn from_db(conn: &Connection, session_id: &str) -> Result<Self> {
253 let db_nodes = graph::get_nodes(conn, session_id).context("loading graph nodes")?;
254 let db_edges = graph::get_edges(conn, session_id).context("loading graph edges")?;
255
256 let mut g = Self::new(session_id);
257 for row in &db_nodes {
258 g.add_node(GraphNode {
259 issue_number: row.issue_number,
260 title: row.title.clone(),
261 area: row.area.clone(),
262 predicted_files: row.predicted_files.clone(),
263 has_migration: row.has_migration,
264 complexity: row.complexity.clone(),
265 state: row.state,
266 pr_number: row.pr_number,
267 run_id: row.run_id.clone(),
268 issue: None,
269 target_repo: row.target_repo.clone(),
270 });
271 }
272 for (from, to) in &db_edges {
273 if !g.add_edge(*from, *to) {
274 warn!(from, to, "skipping persisted edge that would create cycle");
275 }
276 }
277
278 Ok(g)
279 }
280
281 pub fn save_to_db(&self, conn: &Connection) -> Result<()> {
285 let tx = conn.unchecked_transaction().context("starting graph save transaction")?;
286
287 graph::delete_session(&tx, &self.session_id)?;
288 for node in self.nodes.values() {
289 let row = GraphNodeRow {
290 issue_number: node.issue_number,
291 session_id: self.session_id.clone(),
292 state: node.state,
293 pr_number: node.pr_number,
294 run_id: node.run_id.clone(),
295 title: node.title.clone(),
296 area: node.area.clone(),
297 predicted_files: node.predicted_files.clone(),
298 has_migration: node.has_migration,
299 complexity: node.complexity.clone(),
300 target_repo: node.target_repo.clone(),
301 };
302 graph::insert_node(&tx, &self.session_id, &row)?;
303 }
304 for (&from, deps) in &self.edges {
305 for &to in deps {
306 graph::insert_edge(&tx, &self.session_id, from, to)?;
307 }
308 }
309
310 tx.commit().context("committing graph save transaction")?;
311 Ok(())
312 }
313
314 pub fn from_planner_output(
316 session_id: &str,
317 plan: &PlannerGraphOutput,
318 issues: &[PipelineIssue],
319 ) -> Self {
320 let issue_map: HashMap<u32, &PipelineIssue> =
321 issues.iter().map(|i| (i.number, i)).collect();
322 let mut g = Self::new(session_id);
323 for node in &plan.nodes {
324 g.add_node(node_from_planned(node, issue_map.get(&node.number).copied()));
325 }
326 add_planned_edges(&mut g, &plan.nodes);
327 g
328 }
329
330 pub fn merge_planner_output(&mut self, plan: &PlannerGraphOutput, issues: &[PipelineIssue]) {
335 let issue_map: HashMap<u32, &PipelineIssue> =
336 issues.iter().map(|i| (i.number, i)).collect();
337 let new_nodes: Vec<&PlannedNode> =
338 plan.nodes.iter().filter(|n| !self.contains(n.number)).collect();
339 for node in &new_nodes {
340 self.add_node(node_from_planned(node, issue_map.get(&node.number).copied()));
341 }
342 add_planned_edges(self, &new_nodes);
343 }
344
345 fn compute_layers(&self) -> Vec<Vec<u32>> {
347 fn compute_depth(
348 node: u32,
349 edges: &HashMap<u32, HashSet<u32>>,
350 depth: &mut HashMap<u32, usize>,
351 ) -> usize {
352 if let Some(&d) = depth.get(&node) {
353 return d;
354 }
355 let d = edges.get(&node).map_or(0, |deps| {
356 deps.iter().map(|&dep| compute_depth(dep, edges, depth) + 1).max().unwrap_or(0)
357 });
358 depth.insert(node, d);
359 d
360 }
361
362 let mut depth: HashMap<u32, usize> = HashMap::new();
363 let issues = self.all_issues();
364
365 for &num in &issues {
366 compute_depth(num, &self.edges, &mut depth);
367 }
368
369 let max_depth = depth.values().copied().max().unwrap_or(0);
370 let mut layers: Vec<Vec<u32>> = vec![vec![]; max_depth + 1];
371 for &num in &issues {
372 let d = depth[&num];
373 layers[d].push(num);
374 }
375 for layer in &mut layers {
376 layer.sort_unstable();
377 }
378 layers
379 }
380
381 pub fn display_layered(&self) -> Vec<String> {
383 if self.nodes.is_empty() {
384 return vec![" (empty graph)".to_string()];
385 }
386
387 let layers = self.compute_layers();
388 let mut lines = Vec::new();
389
390 let total = self.nodes.len();
392 let merged = self.nodes.values().filter(|n| n.state == NodeState::Merged).count();
393 let in_flight = self.nodes.values().filter(|n| n.state == NodeState::InFlight).count();
394 let failed = self.nodes.values().filter(|n| n.state == NodeState::Failed).count();
395
396 let mut summary_parts = vec![format!("{total} issues")];
397 if merged > 0 {
398 summary_parts.push(format!("{merged} merged"));
399 }
400 if in_flight > 0 {
401 summary_parts.push(format!("{in_flight} in flight"));
402 }
403 if failed > 0 {
404 summary_parts.push(format!("{failed} failed"));
405 }
406 lines.push(format!(" {}", summary_parts.join(", ")));
407 lines.push(String::new());
408
409 let box_width = self.compute_box_width();
410
411 for (layer_idx, layer) in layers.iter().enumerate() {
412 let label = if layer_idx == 0 { "no deps" } else { "" };
414 if label.is_empty() {
415 lines.push(format!(" Layer {layer_idx}"));
416 } else {
417 lines.push(format!(" Layer {layer_idx} ({label})"));
418 }
419 lines.push(String::new());
420
421 let boxes: Vec<Vec<String>> =
423 layer.iter().map(|&num| self.render_box(num, box_width)).collect();
424
425 let max_height = boxes.iter().map(Vec::len).max().unwrap_or(0);
427 for row in 0..max_height {
428 let mut line = String::from(" ");
429 for (i, b) in boxes.iter().enumerate() {
430 if i > 0 {
431 line.push_str(" ");
432 }
433 if row < b.len() {
434 line.push_str(&b[row]);
435 } else {
436 line.push_str(&" ".repeat(box_width + 2));
438 }
439 }
440 lines.push(line);
441 }
442
443 if layer_idx + 1 < layers.len() {
445 let next_layer = &layers[layer_idx + 1];
446 let connector_lines = self.render_connectors(layer, next_layer, box_width);
447 lines.extend(connector_lines);
448 }
449 }
450
451 lines.push(String::new());
453 lines.push(
454 " Legend: [*] merged [~] in flight [ ] pending [?] awaiting merge [!] failed"
455 .to_string(),
456 );
457
458 lines
459 }
460
461 fn compute_box_width(&self) -> usize {
463 let min_width = 30;
464 let max_width = 44;
465 let longest = self
466 .nodes
467 .values()
468 .map(|n| {
469 let issue_label = format!("#{}", n.issue_number);
470 3 + 1 + issue_label.len() + 2 + n.title.len()
472 })
473 .max()
474 .unwrap_or(min_width);
475 longest.clamp(min_width, max_width)
476 }
477
478 fn render_box(&self, issue: u32, width: usize) -> Vec<String> {
480 let Some(node) = self.nodes.get(&issue) else {
481 return vec![];
482 };
483
484 let state_char = match node.state {
485 NodeState::Merged => '*',
486 NodeState::InFlight => '~',
487 NodeState::Pending => ' ',
488 NodeState::AwaitingMerge => '?',
489 NodeState::Failed => '!',
490 };
491
492 let blocked = if self.is_blocked(issue) { " BLOCKED" } else { "" };
493
494 let issue_label = format!("#{issue}");
495 let title_line = format!("[{state_char}] {issue_label} {}", node.title);
496 let title_truncated = if title_line.len() > width {
497 format!("{}..", &title_line[..width - 2])
498 } else {
499 title_line
500 };
501
502 let mut detail_parts = vec![node.area.clone()];
504 if let Some(pr) = node.pr_number {
505 detail_parts.push(format!("PR #{pr}"));
506 }
507 let mut detail_line = detail_parts.join(" ");
508 if !blocked.is_empty() {
509 detail_line.push_str(blocked);
510 }
511 let detail_truncated = if detail_line.len() > width {
512 format!("{}..", &detail_line[..width - 2])
513 } else {
514 detail_line
515 };
516
517 let top = format!("\u{250c}{}\u{2510}", "\u{2500}".repeat(width));
518 let mid = format!("\u{2502}{title_truncated:<width$}\u{2502}");
519 let mid2 = format!("\u{2502}{detail_truncated:<width$}\u{2502}");
520 let bot = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(width));
521
522 vec![top, mid, mid2, bot]
523 }
524
525 fn render_connectors(
527 &self,
528 from_layer: &[u32],
529 to_layer: &[u32],
530 box_width: usize,
531 ) -> Vec<String> {
532 let full_box_width = box_width + 2; let spacing = 2usize;
536
537 let from_centers: Vec<(u32, usize)> = from_layer
539 .iter()
540 .enumerate()
541 .map(|(i, &num)| {
542 let x = i * (full_box_width + spacing) + full_box_width / 2;
543 (num, x)
544 })
545 .collect();
546 let to_centers: Vec<(u32, usize)> = to_layer
547 .iter()
548 .enumerate()
549 .map(|(i, &num)| {
550 let x = i * (full_box_width + spacing) + full_box_width / 2;
551 (num, x)
552 })
553 .collect();
554
555 let mut connections: Vec<(usize, usize)> = Vec::new(); for &(to_num, to_x) in &to_centers {
558 let deps = self.dependencies(to_num);
559 for &(from_num, from_x) in &from_centers {
560 if deps.contains(&from_num) {
561 connections.push((from_x, to_x));
562 }
563 }
564 }
565
566 if connections.is_empty() {
567 return lines_with_gap(1);
568 }
569
570 let max_x =
571 from_centers.iter().chain(to_centers.iter()).map(|(_, x)| *x).max().unwrap_or(0)
572 + full_box_width;
573 let width = max_x + 4;
574
575 let mut source_cols: HashSet<usize> = HashSet::new();
577 let mut target_cols: HashSet<usize> = HashSet::new();
578 for &(from_x, to_x) in &connections {
579 source_cols.insert(from_x);
580 target_cols.insert(to_x);
581 }
582
583 let mut row1 = vec![' '; width];
585 for &col in &source_cols {
586 row1[col + 2] = '\u{2502}'; }
588
589 let row2 = build_connector_row(&connections, &source_cols, &target_cols, width);
591
592 let mut row3 = vec![' '; width];
594 for &col in &target_cols {
595 row3[col + 2] = '\u{25bc}'; }
597
598 vec![
599 format!(" {}", row1.iter().collect::<String>().trim_end()),
600 format!(" {}", row2.trim_end()),
601 format!(" {}", row3.iter().collect::<String>().trim_end()),
602 String::new(),
603 ]
604 }
605
606 pub fn to_graph_context(&self) -> Vec<crate::agents::GraphContextNode> {
611 self.all_issues()
612 .into_iter()
613 .filter_map(|num| {
614 let node = self.nodes.get(&num)?;
615 let depends_on: Vec<u32> = self.edges.get(&num).map_or_else(Vec::new, |deps| {
616 let mut v: Vec<u32> = deps.iter().copied().collect();
617 v.sort_unstable();
618 v
619 });
620 Some(crate::agents::GraphContextNode {
621 number: num,
622 title: node.title.clone(),
623 state: node.state,
624 area: node.area.clone(),
625 predicted_files: node.predicted_files.clone(),
626 has_migration: node.has_migration,
627 depends_on,
628 target_repo: node.target_repo.clone(),
629 })
630 })
631 .collect()
632 }
633}
634
635fn build_connector_row(
640 connections: &[(usize, usize)],
641 source_cols: &HashSet<usize>,
642 target_cols: &HashSet<usize>,
643 width: usize,
644) -> String {
645 let mut dirs = vec![0u8; width];
646
647 for &col in source_cols {
648 dirs[col + 2] |= DIR_UP;
649 }
650 for &col in target_cols {
651 dirs[col + 2] |= DIR_DOWN;
652 }
653
654 for &(from_x, to_x) in connections {
655 if from_x == to_x {
656 continue;
657 }
658 let (lo, hi) = if from_x < to_x { (from_x, to_x) } else { (to_x, from_x) };
659 dirs[lo + 2] |= DIR_RIGHT;
660 dirs[hi + 2] |= DIR_LEFT;
661 for col in (lo + 1)..hi {
662 dirs[col + 2] |= DIR_LEFT | DIR_RIGHT;
663 }
664 }
665
666 dirs.iter().map(|&d| box_drawing_char(d)).collect()
667}
668
669const DIR_UP: u8 = 0b1000;
670const DIR_DOWN: u8 = 0b0100;
671const DIR_LEFT: u8 = 0b0010;
672const DIR_RIGHT: u8 = 0b0001;
673
674const fn box_drawing_char(dirs: u8) -> char {
676 match dirs {
677 0b1100 => '\u{2502}', 0b0011 => '\u{2500}', 0b1001 => '\u{2514}', 0b1010 => '\u{2518}', 0b0101 => '\u{250c}', 0b0110 => '\u{2510}', 0b1110 => '\u{2524}', 0b1101 => '\u{251c}', 0b0111 => '\u{252c}', 0b1011 => '\u{2534}', 0b1111 => '\u{253c}', 0b1000 => '\u{2575}', 0b0100 => '\u{2577}', 0b0010 => '\u{2574}', 0b0001 => '\u{2576}', _ => ' ',
693 }
694}
695
696fn lines_with_gap(count: usize) -> Vec<String> {
697 vec![String::new(); count]
698}
699
700fn node_from_planned(node: &PlannedNode, issue: Option<&PipelineIssue>) -> GraphNode {
701 GraphNode {
702 issue_number: node.number,
703 title: node.title.clone(),
704 area: node.area.clone(),
705 predicted_files: node.predicted_files.clone(),
706 has_migration: node.has_migration,
707 complexity: node.complexity.to_string(),
708 state: NodeState::Pending,
709 pr_number: None,
710 run_id: None,
711 target_repo: issue.and_then(|i| i.target_repo.clone()),
712 issue: issue.cloned(),
713 }
714}
715
716fn add_planned_edges(graph: &mut DependencyGraph, nodes: &[impl std::borrow::Borrow<PlannedNode>]) {
717 for node in nodes {
718 let node = node.borrow();
719 for &dep in &node.depends_on {
720 if !graph.add_edge(node.number, dep) {
721 warn!(
722 from = node.number,
723 to = dep,
724 "skipping planner edge that would create cycle"
725 );
726 }
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 fn make_node(num: u32) -> GraphNode {
736 GraphNode {
737 issue_number: num,
738 title: format!("Issue #{num}"),
739 area: "test".to_string(),
740 predicted_files: vec![],
741 has_migration: false,
742 complexity: "full".to_string(),
743 state: NodeState::Pending,
744 pr_number: None,
745 run_id: None,
746 issue: None,
747 target_repo: None,
748 }
749 }
750
751 #[test]
752 fn add_node_and_check() {
753 let mut g = DependencyGraph::new("test");
754 g.add_node(make_node(1));
755 assert!(g.contains(1));
756 assert!(!g.contains(2));
757 assert_eq!(g.node_count(), 1);
758 }
759
760 #[test]
761 fn add_edge_and_check() {
762 let mut g = DependencyGraph::new("test");
763 g.add_node(make_node(1));
764 g.add_node(make_node(2));
765 assert!(g.add_edge(2, 1)); assert_eq!(g.dependencies(2), HashSet::from([1]));
768 assert_eq!(g.dependents(1), HashSet::from([2]));
769 }
770
771 #[test]
772 fn self_edge_rejected() {
773 let mut g = DependencyGraph::new("test");
774 g.add_node(make_node(1));
775 assert!(!g.add_edge(1, 1));
776 }
777
778 #[test]
779 fn direct_cycle_detected() {
780 let mut g = DependencyGraph::new("test");
781 g.add_node(make_node(1));
782 g.add_node(make_node(2));
783 assert!(g.add_edge(2, 1)); assert!(!g.add_edge(1, 2)); }
786
787 #[test]
788 fn indirect_cycle_detected() {
789 let mut g = DependencyGraph::new("test");
790 g.add_node(make_node(1));
791 g.add_node(make_node(2));
792 g.add_node(make_node(3));
793 assert!(g.add_edge(2, 1)); assert!(g.add_edge(3, 2)); assert!(!g.add_edge(1, 3)); }
797
798 #[test]
799 fn valid_dag_no_false_cycle() {
800 let mut g = DependencyGraph::new("test");
801 g.add_node(make_node(1));
802 g.add_node(make_node(2));
803 g.add_node(make_node(3));
804 assert!(g.add_edge(2, 1));
805 assert!(g.add_edge(3, 1)); assert!(g.add_edge(3, 2)); }
808
809 #[test]
810 fn ready_issues_returns_pending_with_merged_deps() {
811 let mut g = DependencyGraph::new("test");
812 g.add_node(make_node(1));
813 g.add_node(make_node(2));
814 g.add_edge(2, 1);
815
816 assert_eq!(g.ready_issues(), vec![1]);
818
819 g.transition(1, NodeState::Merged);
821 let ready = g.ready_issues();
822 assert_eq!(ready, vec![2]);
823 }
824
825 #[test]
826 fn ready_issues_empty_when_deps_in_flight() {
827 let mut g = DependencyGraph::new("test");
828 g.add_node(make_node(1));
829 g.add_node(make_node(2));
830 g.add_edge(2, 1);
831 g.transition(1, NodeState::InFlight);
832 assert!(g.ready_issues().is_empty());
833 }
834
835 #[test]
836 fn ready_issues_empty_when_deps_awaiting_merge() {
837 let mut g = DependencyGraph::new("test");
838 g.add_node(make_node(1));
839 g.add_node(make_node(2));
840 g.add_edge(2, 1);
841 g.transition(1, NodeState::AwaitingMerge);
842 assert!(g.ready_issues().is_empty());
843 }
844
845 #[test]
846 fn awaiting_merge_returns_correct_nodes() {
847 let mut g = DependencyGraph::new("test");
848 g.add_node(make_node(1));
849 g.add_node(make_node(2));
850 g.transition(1, NodeState::AwaitingMerge);
851 let awaiting = g.awaiting_merge();
852 assert_eq!(awaiting, vec![1]);
853 }
854
855 #[test]
856 fn all_terminal_checks_all_nodes() {
857 let mut g = DependencyGraph::new("test");
858 g.add_node(make_node(1));
859 g.add_node(make_node(2));
860 assert!(!g.all_terminal());
861
862 g.transition(1, NodeState::Merged);
863 assert!(!g.all_terminal());
864
865 g.transition(2, NodeState::Failed);
866 assert!(g.all_terminal());
867 }
868
869 #[test]
870 fn is_blocked_when_dep_failed() {
871 let mut g = DependencyGraph::new("test");
872 g.add_node(make_node(1));
873 g.add_node(make_node(2));
874 g.add_edge(2, 1);
875 g.transition(1, NodeState::Failed);
876 assert!(g.is_blocked(2));
877 assert!(!g.is_blocked(1));
878 }
879
880 #[test]
881 fn remove_node_cleans_edges() {
882 let mut g = DependencyGraph::new("test");
883 g.add_node(make_node(1));
884 g.add_node(make_node(2));
885 g.add_node(make_node(3));
886 g.add_edge(2, 1);
887 g.add_edge(3, 2);
888
889 g.remove_node(2);
890 assert!(!g.contains(2));
891 assert!(g.dependencies(3).is_empty());
893 assert!(g.dependents(1).is_empty());
895 }
896
897 #[test]
898 fn db_roundtrip() {
899 let conn = crate::db::open_in_memory().unwrap();
900 let mut g = DependencyGraph::new("test-session");
901 g.add_node(make_node(1));
902 g.add_node(make_node(2));
903 g.add_node(make_node(3));
904 g.add_edge(2, 1);
905 g.add_edge(3, 1);
906 g.add_edge(3, 2);
907 g.transition(1, NodeState::Merged);
908 g.set_pr_number(1, 99);
909 g.set_run_id(1, "abc");
910
911 g.save_to_db(&conn).unwrap();
912
913 let loaded = DependencyGraph::from_db(&conn, "test-session").unwrap();
914 assert_eq!(loaded.node_count(), 3);
915 assert_eq!(loaded.dependencies(2), HashSet::from([1]));
916 assert_eq!(loaded.dependencies(3), HashSet::from([1, 2]));
917 assert_eq!(loaded.node(1).unwrap().state, NodeState::Merged);
918 assert_eq!(loaded.node(1).unwrap().pr_number, Some(99));
919 assert_eq!(loaded.node(1).unwrap().run_id.as_deref(), Some("abc"));
920 }
921
922 #[test]
923 fn diamond_graph_ready_ordering() {
924 let mut g = DependencyGraph::new("test");
926 g.add_node(make_node(1)); g.add_node(make_node(2)); g.add_node(make_node(3)); g.add_node(make_node(4)); g.add_edge(2, 1); g.add_edge(3, 1); g.add_edge(4, 2); g.add_edge(4, 3); assert_eq!(g.ready_issues(), vec![1]);
938
939 g.transition(1, NodeState::Merged);
941 let mut ready = g.ready_issues();
942 ready.sort_unstable();
943 assert_eq!(ready, vec![2, 3]);
944
945 g.transition(2, NodeState::Merged);
947 assert_eq!(g.ready_issues(), vec![3]);
948
949 g.transition(3, NodeState::Merged);
951 assert_eq!(g.ready_issues(), vec![4]);
952 }
953
954 #[test]
955 fn empty_graph_is_all_terminal() {
956 let g = DependencyGraph::new("test");
957 assert!(g.all_terminal());
958 }
959
960 #[test]
961 fn independent_nodes_all_ready() {
962 let mut g = DependencyGraph::new("test");
963 g.add_node(make_node(1));
964 g.add_node(make_node(2));
965 g.add_node(make_node(3));
966
967 let mut ready = g.ready_issues();
968 ready.sort_unstable();
969 assert_eq!(ready, vec![1, 2, 3]);
970 }
971
972 fn make_planned(number: u32, depends_on: Vec<u32>) -> crate::agents::PlannedNode {
973 crate::agents::PlannedNode {
974 number,
975 title: format!("Issue #{number}"),
976 area: "test".to_string(),
977 predicted_files: vec![],
978 has_migration: false,
979 complexity: crate::agents::Complexity::Full,
980 depends_on,
981 reasoning: String::new(),
982 }
983 }
984
985 fn make_issue(number: u32) -> PipelineIssue {
986 PipelineIssue {
987 number,
988 title: format!("Issue #{number}"),
989 body: String::new(),
990 source: crate::issues::IssueOrigin::Github,
991 target_repo: None,
992 author: None,
993 }
994 }
995
996 #[test]
997 fn from_planner_output_basic() {
998 let plan = crate::agents::PlannerGraphOutput {
999 nodes: vec![
1000 make_planned(1, vec![]),
1001 make_planned(2, vec![]),
1002 make_planned(3, vec![1, 2]),
1003 ],
1004 total_issues: 3,
1005 parallel_capacity: 2,
1006 };
1007 let issues = vec![make_issue(1), make_issue(2), make_issue(3)];
1008
1009 let g = DependencyGraph::from_planner_output("sess", &plan, &issues);
1010 assert_eq!(g.node_count(), 3);
1011 assert_eq!(g.dependencies(3), HashSet::from([1, 2]));
1012 assert!(g.dependencies(1).is_empty());
1013 assert!(g.node(1).unwrap().issue.is_some());
1015 assert!(g.node(2).unwrap().issue.is_some());
1016 }
1017
1018 #[test]
1019 fn from_planner_output_skips_cycle() {
1020 let plan = crate::agents::PlannerGraphOutput {
1021 nodes: vec![make_planned(1, vec![2]), make_planned(2, vec![1])],
1022 total_issues: 2,
1023 parallel_capacity: 1,
1024 };
1025
1026 let g = DependencyGraph::from_planner_output("sess", &plan, &[]);
1027 assert_eq!(g.node_count(), 2);
1029 let total_edges: usize = [1, 2].iter().map(|n| g.dependencies(*n).len()).sum();
1030 assert_eq!(total_edges, 1);
1031 }
1032
1033 #[test]
1034 fn merge_planner_output_adds_new_only() {
1035 let plan1 = crate::agents::PlannerGraphOutput {
1036 nodes: vec![make_planned(1, vec![])],
1037 total_issues: 1,
1038 parallel_capacity: 1,
1039 };
1040 let mut g = DependencyGraph::from_planner_output("sess", &plan1, &[make_issue(1)]);
1041 g.transition(1, NodeState::InFlight);
1042
1043 let plan2 = crate::agents::PlannerGraphOutput {
1045 nodes: vec![make_planned(1, vec![]), make_planned(2, vec![1])],
1046 total_issues: 2,
1047 parallel_capacity: 1,
1048 };
1049 g.merge_planner_output(&plan2, &[make_issue(2)]);
1050
1051 assert_eq!(g.node_count(), 2);
1052 assert_eq!(g.node(1).unwrap().state, NodeState::InFlight);
1054 assert_eq!(g.node(2).unwrap().state, NodeState::Pending);
1056 assert_eq!(g.dependencies(2), HashSet::from([1]));
1057 }
1058
1059 #[test]
1060 fn merge_planner_output_cross_edges() {
1061 let mut g = DependencyGraph::new("sess");
1062 g.add_node(make_node(1));
1063 g.transition(1, NodeState::Merged);
1064
1065 let plan = crate::agents::PlannerGraphOutput {
1066 nodes: vec![make_planned(2, vec![1])],
1067 total_issues: 1,
1068 parallel_capacity: 1,
1069 };
1070 g.merge_planner_output(&plan, &[make_issue(2)]);
1071
1072 assert_eq!(g.dependencies(2), HashSet::from([1]));
1073 assert_eq!(g.ready_issues(), vec![2]);
1075 }
1076
1077 #[test]
1078 fn propagate_failure_linear_chain() {
1079 let mut g = DependencyGraph::new("test");
1080 g.add_node(make_node(1));
1081 g.add_node(make_node(2));
1082 g.add_node(make_node(3));
1083 g.add_edge(2, 1);
1084 g.add_edge(3, 2);
1085
1086 g.transition(1, NodeState::Failed);
1087 let mut failed = g.propagate_failure(1);
1088 failed.sort_unstable();
1089 assert_eq!(failed, vec![2, 3]);
1090 assert_eq!(g.node(2).unwrap().state, NodeState::Failed);
1091 assert_eq!(g.node(3).unwrap().state, NodeState::Failed);
1092 }
1093
1094 #[test]
1095 fn propagate_failure_diamond() {
1096 let mut g = DependencyGraph::new("test");
1098 for i in 1..=4 {
1099 g.add_node(make_node(i));
1100 }
1101 g.add_edge(2, 1);
1102 g.add_edge(3, 1);
1103 g.add_edge(4, 2);
1104 g.add_edge(4, 3);
1105
1106 g.transition(1, NodeState::Failed);
1107 let mut failed = g.propagate_failure(1);
1108 failed.sort_unstable();
1109 assert_eq!(failed, vec![2, 3, 4]);
1110 }
1111
1112 #[test]
1113 fn propagate_failure_partial_branch() {
1114 let mut g = DependencyGraph::new("test");
1116 for i in 1..=4 {
1117 g.add_node(make_node(i));
1118 }
1119 g.add_edge(3, 1);
1120 g.add_edge(4, 2);
1121
1122 g.transition(1, NodeState::Failed);
1123 let failed = g.propagate_failure(1);
1124 assert_eq!(failed, vec![3]);
1125 assert_eq!(g.node(4).unwrap().state, NodeState::Pending);
1127 }
1128
1129 #[test]
1130 fn propagate_failure_skips_merged() {
1131 let mut g = DependencyGraph::new("test");
1132 g.add_node(make_node(1));
1133 g.add_node(make_node(2));
1134 g.add_node(make_node(3));
1135 g.add_edge(2, 1);
1136 g.add_edge(3, 2);
1137 g.transition(2, NodeState::Merged);
1139
1140 g.transition(1, NodeState::Failed);
1141 let failed = g.propagate_failure(1);
1142 assert!(failed.is_empty());
1144 assert_eq!(g.node(2).unwrap().state, NodeState::Merged);
1145 assert_eq!(g.node(3).unwrap().state, NodeState::Pending);
1146 }
1147
1148 #[test]
1149 fn propagate_failure_returns_newly_failed() {
1150 let mut g = DependencyGraph::new("test");
1151 g.add_node(make_node(1));
1152 g.add_node(make_node(2));
1153 g.add_node(make_node(3));
1154 g.add_edge(2, 1);
1155 g.add_edge(3, 1);
1156
1157 g.transition(1, NodeState::Failed);
1158 let mut failed = g.propagate_failure(1);
1159 failed.sort_unstable();
1160 assert_eq!(failed, vec![2, 3]);
1161 let failed2 = g.propagate_failure(1);
1163 assert!(failed2.is_empty());
1164 }
1165
1166 #[test]
1167 fn to_graph_context_includes_all_nodes() {
1168 let mut g = DependencyGraph::new("test");
1169 g.add_node(make_node(1));
1170 g.add_node(make_node(2));
1171 g.add_node(make_node(3));
1172 g.add_edge(2, 1);
1173 g.add_edge(3, 1);
1174 g.add_edge(3, 2);
1175 g.transition(1, NodeState::InFlight);
1176
1177 let ctx = g.to_graph_context();
1178 assert_eq!(ctx.len(), 3);
1179
1180 let ctx_map: HashMap<u32, &crate::agents::GraphContextNode> =
1181 ctx.iter().map(|c| (c.number, c)).collect();
1182
1183 let c1 = ctx_map[&1];
1184 assert_eq!(c1.state, NodeState::InFlight);
1185 assert!(c1.depends_on.is_empty());
1186
1187 let c2 = ctx_map[&2];
1188 assert_eq!(c2.state, NodeState::Pending);
1189 assert_eq!(c2.depends_on, vec![1]);
1190
1191 let c3 = ctx_map[&3];
1192 assert_eq!(c3.state, NodeState::Pending);
1193 assert_eq!(c3.depends_on, vec![1, 2]);
1194 }
1195
1196 #[test]
1197 fn save_to_db_is_atomic_on_success() {
1198 let conn = crate::db::open_in_memory().unwrap();
1199 let mut g = DependencyGraph::new("atomic-test");
1200 g.add_node(make_node(1));
1201 g.add_node(make_node(2));
1202 g.add_edge(2, 1);
1203
1204 g.save_to_db(&conn).unwrap();
1205
1206 let mut g2 = DependencyGraph::new("atomic-test");
1208 g2.add_node(make_node(10));
1209 g2.save_to_db(&conn).unwrap();
1210
1211 let loaded = DependencyGraph::from_db(&conn, "atomic-test").unwrap();
1212 assert_eq!(loaded.node_count(), 1);
1214 assert!(loaded.contains(10));
1215 assert!(!loaded.contains(1));
1216 assert!(!loaded.contains(2));
1217 }
1218
1219 fn make_named_node(num: u32, title: &str, area: &str) -> GraphNode {
1220 GraphNode {
1221 issue_number: num,
1222 title: title.to_string(),
1223 area: area.to_string(),
1224 predicted_files: vec![],
1225 has_migration: false,
1226 complexity: "full".to_string(),
1227 state: NodeState::Pending,
1228 pr_number: None,
1229 run_id: None,
1230 issue: None,
1231 target_repo: None,
1232 }
1233 }
1234
1235 #[test]
1238 fn display_layered_empty_graph() {
1239 let g = DependencyGraph::new("test");
1240 let lines = g.display_layered();
1241 assert_eq!(lines.len(), 1);
1242 assert!(lines[0].contains("empty graph"));
1243 }
1244
1245 #[test]
1246 fn display_layered_single_node() {
1247 let mut g = DependencyGraph::new("test");
1248 g.add_node(make_named_node(1, "Add auth", "backend"));
1249 let lines = g.display_layered();
1250 let text = lines.join("\n");
1251 assert!(text.contains("1 issues"));
1252 assert!(text.contains("Layer 0"));
1253 assert!(text.contains("#1"));
1254 assert!(text.contains("Add auth"));
1255 assert!(text.contains("Legend"));
1256 }
1257
1258 #[test]
1259 fn display_layered_linear_chain() {
1260 let mut g = DependencyGraph::new("test");
1261 g.add_node(make_named_node(1, "Database schema", "db"));
1262 g.add_node(make_named_node(2, "API endpoints", "backend"));
1263 g.add_node(make_named_node(3, "Frontend views", "frontend"));
1264 g.add_edge(2, 1);
1265 g.add_edge(3, 2);
1266
1267 let lines = g.display_layered();
1268 let text = lines.join("\n");
1269 assert!(text.contains("Layer 0"));
1271 assert!(text.contains("Layer 1"));
1272 assert!(text.contains("Layer 2"));
1273 assert!(text.contains("#1"));
1275 assert!(text.contains("#2"));
1276 assert!(text.contains("#3"));
1277 }
1278
1279 #[test]
1280 fn display_layered_diamond_dag() {
1281 let mut g = DependencyGraph::new("test");
1283 g.add_node(make_named_node(1, "Core lib", "core"));
1284 g.add_node(make_named_node(2, "Auth module", "auth"));
1285 g.add_node(make_named_node(3, "Logging module", "infra"));
1286 g.add_node(make_named_node(4, "Integration", "all"));
1287 g.add_edge(2, 1);
1288 g.add_edge(3, 1);
1289 g.add_edge(4, 2);
1290 g.add_edge(4, 3);
1291
1292 let lines = g.display_layered();
1293 let text = lines.join("\n");
1294 assert!(text.contains("Layer 0"));
1295 assert!(text.contains("Layer 1"));
1296 assert!(text.contains("Layer 2"));
1297 assert!(text.contains("4 issues"));
1298 assert!(text.contains("#2"));
1300 assert!(text.contains("#3"));
1301 }
1302
1303 #[test]
1304 fn display_layered_independent_nodes() {
1305 let mut g = DependencyGraph::new("test");
1306 g.add_node(make_named_node(1, "Fix typo", "docs"));
1307 g.add_node(make_named_node(2, "Add lint", "ci"));
1308 g.add_node(make_named_node(3, "Bump deps", "deps"));
1309
1310 let lines = g.display_layered();
1311 let text = lines.join("\n");
1312 assert!(text.contains("Layer 0 (no deps)"));
1314 assert!(!text.contains("Layer 1"));
1315 assert!(text.contains("#1"));
1317 assert!(text.contains("#2"));
1318 assert!(text.contains("#3"));
1319 }
1320
1321 #[test]
1322 fn display_layered_shows_state_indicators() {
1323 let mut g = DependencyGraph::new("test");
1324 g.add_node(make_named_node(1, "Done thing", "core"));
1325 g.add_node(make_named_node(2, "Running thing", "core"));
1326 g.add_node(make_named_node(3, "Broken thing", "core"));
1327 g.add_node(make_named_node(4, "Waiting thing", "core"));
1328 g.add_node(make_named_node(5, "Pending thing", "core"));
1329 g.transition(1, NodeState::Merged);
1330 g.transition(2, NodeState::InFlight);
1331 g.transition(3, NodeState::Failed);
1332 g.transition(4, NodeState::AwaitingMerge);
1333
1334 let lines = g.display_layered();
1335 let text = lines.join("\n");
1336 assert!(text.contains("[*]")); assert!(text.contains("[~]")); assert!(text.contains("[!]")); assert!(text.contains("[?]")); assert!(text.contains("[ ]")); }
1342
1343 #[test]
1344 fn display_layered_shows_pr_number() {
1345 let mut g = DependencyGraph::new("test");
1346 g.add_node(make_named_node(1, "Has PR", "core"));
1347 g.set_pr_number(1, 42);
1348
1349 let lines = g.display_layered();
1350 let text = lines.join("\n");
1351 assert!(text.contains("PR #42"));
1352 }
1353
1354 #[test]
1355 fn display_layered_summary_counts() {
1356 let mut g = DependencyGraph::new("test");
1357 g.add_node(make_named_node(1, "A", "core"));
1358 g.add_node(make_named_node(2, "B", "core"));
1359 g.add_node(make_named_node(3, "C", "core"));
1360 g.transition(1, NodeState::Merged);
1361 g.transition(2, NodeState::InFlight);
1362 g.transition(3, NodeState::Failed);
1363
1364 let lines = g.display_layered();
1365 let text = lines.join("\n");
1366 assert!(text.contains("3 issues"));
1367 assert!(text.contains("1 merged"));
1368 assert!(text.contains("1 in flight"));
1369 assert!(text.contains("1 failed"));
1370 }
1371
1372 #[test]
1373 fn display_layered_shows_blocked() {
1374 let mut g = DependencyGraph::new("test");
1375 g.add_node(make_named_node(1, "Root", "core"));
1376 g.add_node(make_named_node(2, "Blocked child", "core"));
1377 g.add_edge(2, 1);
1378 g.transition(1, NodeState::Failed);
1379
1380 let lines = g.display_layered();
1381 let text = lines.join("\n");
1382 assert!(text.contains("BLOCKED"));
1383 }
1384
1385 #[test]
1386 fn display_layered_has_connectors_between_layers() {
1387 let mut g = DependencyGraph::new("test");
1388 g.add_node(make_named_node(1, "Root", "core"));
1389 g.add_node(make_named_node(2, "Child", "core"));
1390 g.add_edge(2, 1);
1391
1392 let lines = g.display_layered();
1393 let text = lines.join("\n");
1394 assert!(text.contains('\u{25bc}')); }
1397
1398 #[test]
1399 fn display_layered_box_drawing_chars() {
1400 let mut g = DependencyGraph::new("test");
1401 g.add_node(make_named_node(1, "Test node", "core"));
1402
1403 let lines = g.display_layered();
1404 let text = lines.join("\n");
1405 assert!(text.contains('\u{250c}')); assert!(text.contains('\u{2510}')); assert!(text.contains('\u{2514}')); assert!(text.contains('\u{2518}')); assert!(text.contains('\u{2500}')); assert!(text.contains('\u{2502}')); }
1413
1414 #[test]
1415 fn compute_layers_linear() {
1416 let mut g = DependencyGraph::new("test");
1417 g.add_node(make_node(1));
1418 g.add_node(make_node(2));
1419 g.add_node(make_node(3));
1420 g.add_edge(2, 1);
1421 g.add_edge(3, 2);
1422
1423 let layers = g.compute_layers();
1424 assert_eq!(layers.len(), 3);
1425 assert_eq!(layers[0], vec![1]);
1426 assert_eq!(layers[1], vec![2]);
1427 assert_eq!(layers[2], vec![3]);
1428 }
1429
1430 #[test]
1431 fn compute_layers_diamond() {
1432 let mut g = DependencyGraph::new("test");
1433 g.add_node(make_node(1));
1434 g.add_node(make_node(2));
1435 g.add_node(make_node(3));
1436 g.add_node(make_node(4));
1437 g.add_edge(2, 1);
1438 g.add_edge(3, 1);
1439 g.add_edge(4, 2);
1440 g.add_edge(4, 3);
1441
1442 let layers = g.compute_layers();
1443 assert_eq!(layers.len(), 3);
1444 assert_eq!(layers[0], vec![1]);
1445 assert_eq!(layers[1], vec![2, 3]);
1446 assert_eq!(layers[2], vec![4]);
1447 }
1448
1449 #[test]
1450 fn compute_layers_independent() {
1451 let mut g = DependencyGraph::new("test");
1452 g.add_node(make_node(1));
1453 g.add_node(make_node(2));
1454 g.add_node(make_node(3));
1455
1456 let layers = g.compute_layers();
1457 assert_eq!(layers.len(), 1);
1458 assert_eq!(layers[0], vec![1, 2, 3]);
1459 }
1460}