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::Materialize<std::sync::Arc<Self::Output>, myko::hyphae::Definite> {
228 use myko::hyphae::JoinExt;
229 let tasks = ctx.query_map(crate::GetAllTasks {}).items().materialize();
230 let deps = ctx
231 .query_map(crate::GetAllDependencys {})
232 .items()
233 .materialize();
234 let changes = ctx
235 .query_map(crate::GetAllStatusChanges {})
236 .items()
237 .materialize();
238 tasks
239 .join(deps)
240 .materialize()
241 .join(changes)
242 .map(|((tasks, deps), changes)| {
243 std::sync::Arc::new(IssueGraphOut {
244 graph: build_from_live(tasks, deps, changes),
245 })
246 })
247 }
248}
249
250pub fn build_from_live(
253 tasks: &[std::sync::Arc<Task>],
254 deps: &[std::sync::Arc<Dependency>],
255 changes: &[std::sync::Arc<crate::StatusChange>],
256) -> IssueGraph {
257 let tasks: Vec<Task> = tasks.iter().map(|t| (**t).clone()).collect();
258 let changes: Vec<crate::StatusChange> = changes.iter().map(|c| (**c).clone()).collect();
259
260 let projects: BTreeSet<&str> = tasks.iter().map(|t| t.project_id.as_str()).collect();
261 let mut statuses = BTreeMap::new();
262 for pid in projects {
263 statuses.extend(crate::crossproject::statuses_unanchored(
264 &tasks, &changes, pid,
265 ));
266 }
267 let task_map: BTreeMap<String, Task> =
268 tasks.into_iter().map(|t| (t.id.to_string(), t)).collect();
269 let dep_map: BTreeMap<String, Dependency> = deps
270 .iter()
271 .map(|d| (d.id.to_string(), (**d).clone()))
272 .collect();
273 build(&task_map, &dep_map, &statuses)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::entities::StatusKind;
280 use crate::resolve::Resolution;
281
282 fn task(id: &str, project: &str) -> Task {
283 Task {
284 id: id.into(),
285 project_id: project.into(),
286 title: format!("task {id}"),
287 body: String::new(),
288 priority: Default::default(),
289 labels: vec![],
290 created_by_dev: "d".into(),
291 created_by_machine: "m".into(),
292 created: "2026-01-01T00:00:00Z".into(),
293 }
294 }
295
296 fn dep(id: &str, blocker: &str, blocked: &str) -> Dependency {
297 Dependency {
298 id: id.into(),
299 project_id: "p".into(),
300 blocker_task_id: blocker.into(),
301 blocked_task_id: blocked.into(),
302 blocker_project_id: None,
303 blocker_ref: None,
304 via: None,
305 }
306 }
307
308 fn open(id: &str) -> (String, ResolvedStatus) {
309 (
310 id.into(),
311 ResolvedStatus {
312 status: Status::Open,
313 resolution: Resolution::Exact,
314 },
315 )
316 }
317
318 fn tasks_of(ts: Vec<Task>) -> BTreeMap<String, Task> {
319 ts.into_iter().map(|t| (t.id.to_string(), t)).collect()
320 }
321 fn deps_of(ds: Vec<Dependency>) -> BTreeMap<String, Dependency> {
322 ds.into_iter().map(|d| (d.id.to_string(), d)).collect()
323 }
324
325 #[test]
326 fn chain_layers_0_1_2() {
327 let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("c", "p")]);
328 let deps = deps_of(vec![dep("d1", "a", "b"), dep("d2", "b", "c")]);
329 let statuses = [open("a"), open("b"), open("c")].into_iter().collect();
330 let g = build(&tasks, &deps, &statuses);
331 let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
332 assert_eq!(layer("a"), 0);
333 assert_eq!(layer("b"), 1);
334 assert_eq!(layer("c"), 2);
335 assert!(g.unconnected.is_empty());
336 assert!(g.broken_cycles.is_empty());
337 }
338
339 #[test]
340 fn diamond_takes_longest_path() {
341 let tasks = tasks_of(vec![
343 task("a", "p"),
344 task("b", "p"),
345 task("c", "p"),
346 task("d", "p"),
347 ]);
348 let deps = deps_of(vec![
349 dep("1", "a", "b"),
350 dep("2", "a", "c"),
351 dep("3", "b", "d"),
352 dep("4", "c", "d"),
353 ]);
354 let statuses = ["a", "b", "c", "d"].iter().map(|i| open(i)).collect();
355 let g = build(&tasks, &deps, &statuses);
356 let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
357 assert_eq!(layer("d"), 2);
358 }
359
360 #[test]
361 fn cycle_is_broken_and_recorded_without_hanging() {
362 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
363 let deps = deps_of(vec![dep("1", "a", "b"), dep("2", "b", "a")]);
364 let statuses = [open("a"), open("b")].into_iter().collect();
365 let g = build(&tasks, &deps, &statuses);
366 assert!(!g.broken_cycles.is_empty(), "cycle must be reported");
367 assert_eq!(g.nodes.len(), 2, "both nodes still present");
368 }
369
370 #[test]
371 fn unconnected_tasks_are_excluded_from_nodes() {
372 let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("lonely", "p")]);
373 let deps = deps_of(vec![dep("1", "a", "b")]);
374 let statuses = ["a", "b", "lonely"].iter().map(|i| open(i)).collect();
375 let g = build(&tasks, &deps, &statuses);
376 assert!(!g.nodes.iter().any(|n| n.task_id == "lonely"));
377 assert_eq!(g.unconnected, vec!["lonely".to_string()]);
378 }
379
380 #[test]
381 fn closed_blocker_marks_edge_resolved() {
382 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
383 let deps = deps_of(vec![dep("1", "a", "b")]);
384 let statuses = [
385 (
386 "a".to_string(),
387 ResolvedStatus {
388 status: Status::Closed,
389 resolution: Resolution::Facts,
390 },
391 ),
392 open("b"),
393 ]
394 .into_iter()
395 .collect();
396 let g = build(&tasks, &deps, &statuses);
397 assert!(g.edges[0].resolved);
398 }
399
400 #[test]
401 fn cross_project_edge_carries_project_and_via() {
402 let tasks = tasks_of(vec![task("local", "downstream")]);
403 let mut d = dep("1", "foreign", "local");
404 d.blocker_project_id = Some("upstream".into());
405 d.via = Some("cargo: crates.io myko >=4.24.4".into());
406 let deps = deps_of(vec![d]);
407 let statuses = [open("local")].into_iter().collect();
408 let g = build(&tasks, &deps, &statuses);
409 assert_eq!(g.edges[0].blocker_project_id.as_deref(), Some("upstream"));
410 assert_eq!(
411 g.edges[0].via.as_deref(),
412 Some("cargo: crates.io myko >=4.24.4")
413 );
414 let stub = g.nodes.iter().find(|n| n.task_id == "foreign").unwrap();
416 assert!(stub.stub);
417 assert_eq!(stub.project_id, "upstream");
418 }
419
420 #[test]
421 fn no_dependencies_yields_empty_graph_all_unconnected() {
422 let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
423 let g = build(
424 &tasks,
425 &deps_of(vec![]),
426 &[open("a"), open("b")].into_iter().collect(),
427 );
428 assert!(g.nodes.is_empty());
429 assert!(g.edges.is_empty());
430 assert_eq!(g.unconnected.len(), 2);
431 }
432
433 #[allow(dead_code)]
435 fn _uses_status_kind() -> StatusKind {
436 StatusKind::Closed
437 }
438}