1use std::collections::{BTreeMap, BTreeSet, VecDeque};
11
12use myko::prelude::*;
13
14use crate::entities::{Dependency, Priority, Task};
15use crate::resolve::{ResolvedStatus, Status};
16
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct GraphNode {
19 pub task_id: String,
20 pub project_id: String,
21 pub title: String,
22 pub priority: Priority,
23 pub status: Status,
24 pub layer: usize,
26 pub stub: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct GraphEdge {
33 pub blocker_task_id: String,
34 pub blocked_task_id: String,
35 pub blocker_project_id: Option<String>,
37 pub via: Option<String>,
38 pub resolved: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
43pub struct IssueGraph {
44 pub nodes: Vec<GraphNode>,
45 pub edges: Vec<GraphEdge>,
46 pub unconnected: Vec<String>,
48 pub broken_cycles: Vec<(String, String)>,
50}
51
52#[myko_report_output]
56pub struct IssueGraphOut {
57 pub graph: IssueGraph,
58}
59
60#[myko_report(IssueGraphOut)]
64pub struct IssueGraphReport {}
65
66pub fn build(
70 tasks: &BTreeMap<String, Task>,
71 deps: &BTreeMap<String, Dependency>,
72 statuses: &BTreeMap<String, ResolvedStatus>,
73) -> IssueGraph {
74 let mut edges: Vec<GraphEdge> = Vec::new();
76 let mut edge_keys: BTreeSet<(String, String)> = BTreeSet::new();
77 for dep in deps.values() {
78 let key = (dep.blocker_task_id.clone(), dep.blocked_task_id.clone());
79 if !edge_keys.insert(key) {
80 continue;
81 }
82 let resolved = statuses
83 .get(&dep.blocker_task_id)
84 .is_some_and(|s| s.status == Status::Closed);
85 edges.push(GraphEdge {
86 blocker_task_id: dep.blocker_task_id.clone(),
87 blocked_task_id: dep.blocked_task_id.clone(),
88 blocker_project_id: dep.blocker_project_id.clone(),
89 via: dep.via.clone(),
90 resolved,
91 });
92 }
93
94 let mut in_graph: BTreeSet<String> = BTreeSet::new();
96 for e in &edges {
97 in_graph.insert(e.blocker_task_id.clone());
98 in_graph.insert(e.blocked_task_id.clone());
99 }
100
101 let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
103 let mut indeg: BTreeMap<String, usize> = in_graph.iter().map(|id| (id.clone(), 0)).collect();
104 for e in &edges {
105 children
106 .entry(e.blocker_task_id.clone())
107 .or_default()
108 .push(e.blocked_task_id.clone());
109 *indeg.get_mut(&e.blocked_task_id).unwrap() += 1;
110 }
111
112 let (layer, broken_cycles) = layer_nodes(&in_graph, &children, indeg, &edges);
113
114 let mut nodes: Vec<GraphNode> = in_graph
115 .iter()
116 .map(|id| match tasks.get(id) {
117 Some(t) => GraphNode {
118 task_id: id.clone(),
119 project_id: t.project_id.clone(),
120 title: t.title.clone(),
121 priority: t.priority,
122 status: statuses.get(id).map(|s| s.status).unwrap_or(Status::Open),
123 layer: layer[id],
124 stub: false,
125 },
126 None => GraphNode {
128 task_id: id.clone(),
129 project_id: blocker_project_of(id, deps).unwrap_or_default(),
130 title: format!("lv-{}", &id[..id.len().min(8)]),
131 priority: Priority::P2,
132 status: Status::Open,
133 layer: layer[id],
134 stub: true,
135 },
136 })
137 .collect();
138 nodes.sort_by(|a, b| {
140 (a.layer, &a.project_id, a.priority.rank(), &a.task_id).cmp(&(
141 b.layer,
142 &b.project_id,
143 b.priority.rank(),
144 &b.task_id,
145 ))
146 });
147
148 let unconnected: Vec<String> = tasks
149 .keys()
150 .filter(|id| !in_graph.contains(*id))
151 .cloned()
152 .collect();
153
154 IssueGraph {
155 nodes,
156 edges,
157 unconnected,
158 broken_cycles,
159 }
160}
161
162fn layer_nodes(
166 in_graph: &BTreeSet<String>,
167 children: &BTreeMap<String, Vec<String>>,
168 mut indeg: BTreeMap<String, usize>,
169 edges: &[GraphEdge],
170) -> (BTreeMap<String, usize>, Vec<(String, String)>) {
171 let mut layer: BTreeMap<String, usize> = in_graph.iter().map(|id| (id.clone(), 0)).collect();
172 let mut queue: VecDeque<String> = in_graph
173 .iter()
174 .filter(|id| indeg[*id] == 0)
175 .cloned()
176 .collect();
177 let mut settled: BTreeSet<String> = BTreeSet::new();
178
179 while let Some(id) = queue.pop_front() {
180 settled.insert(id.clone());
181 if let Some(kids) = children.get(&id) {
182 for kid in kids {
183 let next = layer[&id] + 1;
184 if next > layer[kid] {
185 layer.insert(kid.clone(), next);
186 }
187 let d = indeg.get_mut(kid).unwrap();
188 *d -= 1;
189 if *d == 0 {
190 queue.push_back(kid.clone());
191 }
192 }
193 }
194 }
195
196 let mut broken_cycles: Vec<(String, String)> = edges
199 .iter()
200 .filter(|e| !settled.contains(&e.blocked_task_id))
201 .map(|e| (e.blocker_task_id.clone(), e.blocked_task_id.clone()))
202 .collect();
203 broken_cycles.sort();
204 broken_cycles.dedup();
205 (layer, broken_cycles)
206}
207
208fn blocker_project_of(task_id: &str, deps: &BTreeMap<String, Dependency>) -> Option<String> {
209 deps.values()
210 .find(|d| d.blocker_task_id == task_id)
211 .and_then(|d| d.blocker_project_id.clone())
212}
213
214impl myko::report::ReportHandler for IssueGraphReport {
222 type Output = IssueGraphOut;
223
224 fn compute(
225 &self,
226 ctx: myko::report::ReportContext,
227 ) -> impl myko::hyphae::MaterializeDefinite<std::sync::Arc<Self::Output>> {
228 use myko::hyphae::JoinExt;
229 let tasks = ctx.query_map(crate::GetAllTasks {}).items();
230 let deps = ctx.query_map(crate::GetAllDependencys {}).items();
231 let changes = ctx.query_map(crate::GetAllStatusChanges {}).items();
232 tasks
233 .join(&deps)
234 .join(&changes)
235 .map(|((tasks, deps), changes)| {
236 std::sync::Arc::new(IssueGraphOut {
237 graph: build_from_live(tasks, deps, changes),
238 })
239 })
240 }
241}
242
243pub fn build_from_live(
246 tasks: &[std::sync::Arc<Task>],
247 deps: &[std::sync::Arc<Dependency>],
248 changes: &[std::sync::Arc<crate::StatusChange>],
249) -> IssueGraph {
250 let tasks: Vec<Task> = tasks.iter().map(|t| (**t).clone()).collect();
251 let changes: Vec<crate::StatusChange> = changes.iter().map(|c| (**c).clone()).collect();
252
253 let projects: BTreeSet<&str> = tasks.iter().map(|t| t.project_id.as_str()).collect();
254 let mut statuses = BTreeMap::new();
255 for pid in projects {
256 statuses.extend(crate::crossproject::statuses_unanchored(
257 &tasks, &changes, pid,
258 ));
259 }
260 let task_map: BTreeMap<String, Task> =
261 tasks.into_iter().map(|t| (t.id.to_string(), t)).collect();
262 let dep_map: BTreeMap<String, Dependency> = deps
263 .iter()
264 .map(|d| (d.id.to_string(), (**d).clone()))
265 .collect();
266 build(&task_map, &dep_map, &statuses)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::entities::StatusKind;
273 use crate::resolve::Resolution;
274
275 fn task(id: &str, project: &str) -> Task {
276 Task {
277 id: id.into(),
278 project_id: project.into(),
279 title: format!("task {id}"),
280 body: String::new(),
281 priority: Default::default(),
282 labels: vec![],
283 created_by_dev: "d".into(),
284 created_by_machine: "m".into(),
285 created: "2026-01-01T00:00:00Z".into(),
286 }
287 }
288
289 fn dep(id: &str, blocker: &str, blocked: &str) -> Dependency {
290 Dependency {
291 id: id.into(),
292 project_id: "p".into(),
293 blocker_task_id: blocker.into(),
294 blocked_task_id: blocked.into(),
295 blocker_project_id: None,
296 blocker_ref: None,
297 via: None,
298 }
299 }
300
301 fn open(id: &str) -> (String, ResolvedStatus) {
302 (
303 id.into(),
304 ResolvedStatus {
305 status: Status::Open,
306 resolution: Resolution::Exact,
307 },
308 )
309 }
310
311 fn tasks_of(ts: Vec<Task>) -> BTreeMap<String, Task> {
312 ts.into_iter().map(|t| (t.id.to_string(), t)).collect()
313 }
314 fn deps_of(ds: Vec<Dependency>) -> BTreeMap<String, Dependency> {
315 ds.into_iter().map(|d| (d.id.to_string(), d)).collect()
316 }
317
318 #[test]
319 fn chain_layers_0_1_2() {
320 let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("c", "p")]);
321 let deps = deps_of(vec![dep("d1", "a", "b"), dep("d2", "b", "c")]);
322 let statuses = [open("a"), open("b"), open("c")].into_iter().collect();
323 let g = build(&tasks, &deps, &statuses);
324 let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
325 assert_eq!(layer("a"), 0);
326 assert_eq!(layer("b"), 1);
327 assert_eq!(layer("c"), 2);
328 assert!(g.unconnected.is_empty());
329 assert!(g.broken_cycles.is_empty());
330 }
331
332 #[test]
333 fn diamond_takes_longest_path() {
334 let tasks = tasks_of(vec![
336 task("a", "p"),
337 task("b", "p"),
338 task("c", "p"),
339 task("d", "p"),
340 ]);
341 let deps = deps_of(vec![
342 dep("1", "a", "b"),
343 dep("2", "a", "c"),
344 dep("3", "b", "d"),
345 dep("4", "c", "d"),
346 ]);
347 let statuses = ["a", "b", "c", "d"].iter().map(|i| open(i)).collect();
348 let g = build(&tasks, &deps, &statuses);
349 let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
350 assert_eq!(layer("d"), 2);
351 }
352
353 #[test]
354 fn cycle_is_broken_and_recorded_without_hanging() {
355 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
356 let deps = deps_of(vec![dep("1", "a", "b"), dep("2", "b", "a")]);
357 let statuses = [open("a"), open("b")].into_iter().collect();
358 let g = build(&tasks, &deps, &statuses);
359 assert!(!g.broken_cycles.is_empty(), "cycle must be reported");
360 assert_eq!(g.nodes.len(), 2, "both nodes still present");
361 }
362
363 #[test]
364 fn unconnected_tasks_are_excluded_from_nodes() {
365 let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("lonely", "p")]);
366 let deps = deps_of(vec![dep("1", "a", "b")]);
367 let statuses = ["a", "b", "lonely"].iter().map(|i| open(i)).collect();
368 let g = build(&tasks, &deps, &statuses);
369 assert!(!g.nodes.iter().any(|n| n.task_id == "lonely"));
370 assert_eq!(g.unconnected, vec!["lonely".to_string()]);
371 }
372
373 #[test]
374 fn closed_blocker_marks_edge_resolved() {
375 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
376 let deps = deps_of(vec![dep("1", "a", "b")]);
377 let statuses = [
378 (
379 "a".to_string(),
380 ResolvedStatus {
381 status: Status::Closed,
382 resolution: Resolution::Facts,
383 },
384 ),
385 open("b"),
386 ]
387 .into_iter()
388 .collect();
389 let g = build(&tasks, &deps, &statuses);
390 assert!(g.edges[0].resolved);
391 }
392
393 #[test]
394 fn cross_project_edge_carries_project_and_via() {
395 let tasks = tasks_of(vec![task("local", "downstream")]);
396 let mut d = dep("1", "foreign", "local");
397 d.blocker_project_id = Some("upstream".into());
398 d.via = Some("cargo: crates.io myko >=4.24.4".into());
399 let deps = deps_of(vec![d]);
400 let statuses = [open("local")].into_iter().collect();
401 let g = build(&tasks, &deps, &statuses);
402 assert_eq!(g.edges[0].blocker_project_id.as_deref(), Some("upstream"));
403 assert_eq!(
404 g.edges[0].via.as_deref(),
405 Some("cargo: crates.io myko >=4.24.4")
406 );
407 let stub = g.nodes.iter().find(|n| n.task_id == "foreign").unwrap();
409 assert!(stub.stub);
410 assert_eq!(stub.project_id, "upstream");
411 }
412
413 #[test]
414 fn no_dependencies_yields_empty_graph_all_unconnected() {
415 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
416 let g = build(
417 &tasks,
418 &deps_of(vec![]),
419 &[open("a"), open("b")].into_iter().collect(),
420 );
421 assert!(g.nodes.is_empty());
422 assert!(g.edges.is_empty());
423 assert_eq!(g.unconnected.len(), 2);
424 }
425
426 #[allow(dead_code)]
428 fn _uses_status_kind() -> StatusKind {
429 StatusKind::Closed
430 }
431}