Skip to main content

levi_core/
crossproject.rs

1//! Cross-project status resolution over hub entities (spec
2//! 2026-07-21-cross-project-graph-design).
3//!
4//! Two precisions, deliberately:
5//!
6//! - [`statuses_unanchored`] resolves from StatusChanges alone — no ancestry,
7//!   so no CommitFacts cross the wire. A task with no close is open; one with
8//!   a close is "closed somewhere" (`Resolution::Partial`), honest that the
9//!   branch containing it was not established. This is what the cross-project
10//!   *listing* uses: enough to pick a blocker, cheap regardless of history.
11//! - [`statuses_for_project`] resolves exactly against a branch head by
12//!   walking the CommitFact graph (moved here from levi-dash so the dashboard
13//!   and any future hub report share one implementation).
14
15use std::collections::BTreeMap;
16
17use crate::entities::{CommitFact, RefFact, StatusChange, StatusKind, Task};
18use crate::resolve::{FactsAncestors, Resolution, ResolvedStatus, Status, effective_status};
19
20/// Status from StatusChanges alone — no ancestry walk, no facts. Open when a
21/// task has no close event; Closed + `Partial` when it has one (a close
22/// exists, but which branches contain it is not established here).
23pub fn statuses_unanchored(
24    tasks: &[Task],
25    changes: &[StatusChange],
26    project_id: &str,
27) -> BTreeMap<String, ResolvedStatus> {
28    // Latest change per task in (created, id) order decides the status; any
29    // close at all marks the resolution Partial.
30    let mut by_task: BTreeMap<&str, Vec<&StatusChange>> = BTreeMap::new();
31    for c in changes.iter().filter(|c| c.project_id == project_id) {
32        by_task.entry(&c.task_id).or_default().push(c);
33    }
34    tasks
35        .iter()
36        .filter(|t| t.project_id == project_id)
37        .map(|t| {
38            let id = t.id.to_string();
39            let mut mine = by_task.get(id.as_str()).cloned().unwrap_or_default();
40            mine.sort_by(|a, b| {
41                (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0))
42            });
43            let mut status = Status::Open;
44            let mut ever_closed = false;
45            for c in &mine {
46                status = match c.to_status {
47                    StatusKind::Closed => {
48                        ever_closed = true;
49                        Status::Closed
50                    }
51                    StatusKind::Reopened => Status::Open,
52                };
53            }
54            // Any anchored history means we cannot claim exactness here.
55            let resolution = if ever_closed {
56                Resolution::Partial
57            } else {
58                Resolution::Exact
59            };
60            (id, ResolvedStatus { status, resolution })
61        })
62        .collect()
63}
64
65/// The branch a project resolves against when none is named: its `main`
66/// RefFact if present, else the most recently observed.
67pub fn default_head(refs: &[RefFact], project_id: &str) -> Option<String> {
68    let mine: Vec<&RefFact> = refs.iter().filter(|r| r.project_id == project_id).collect();
69    mine.iter()
70        .find(|r| r.branch == "main")
71        .or_else(|| mine.iter().max_by(|a, b| a.observed.cmp(&b.observed)))
72        .map(|r| r.head.clone())
73}
74
75/// Exact per-branch resolution over the CommitFact graph (moved from
76/// `levi-dash/src/resolve_client.rs`). `None` head resolves anchored changes
77/// as `Partial` rather than guessing.
78pub fn statuses_for_project(
79    tasks: &[Task],
80    changes: &[StatusChange],
81    facts: &[CommitFact],
82    project_id: &str,
83    head: Option<&str>,
84) -> BTreeMap<String, ResolvedStatus> {
85    let fact_map: BTreeMap<String, CommitFact> = facts
86        .iter()
87        .filter(|f| f.project_id == project_id)
88        .map(|f| (f.id.to_string(), f.clone()))
89        .collect();
90    let mut sorted: Vec<&StatusChange> = changes
91        .iter()
92        .filter(|c| c.project_id == project_id)
93        .collect();
94    sorted.sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
95
96    let mut anc = head.map(|h| FactsAncestors::new(&fact_map, h));
97    tasks
98        .iter()
99        .filter(|t| t.project_id == project_id)
100        .map(|t| {
101            let id = t.id.to_string();
102            let mine: Vec<&StatusChange> = sorted
103                .iter()
104                .copied()
105                .filter(|c| *c.task_id == id)
106                .collect();
107            let resolved = match &mut anc {
108                Some(anc) => effective_status(&mine, anc, Resolution::Facts),
109                None => {
110                    // No head to resolve against: unanchored changes apply,
111                    // anchored ones are unknown -> Partial.
112                    let mut none = NoAncestry;
113                    effective_status(&mine, &mut none, Resolution::Partial)
114                }
115            };
116            (id, resolved)
117        })
118        .collect()
119}
120
121/// An ancestor set that knows nothing: every anchored sha is `Unknown`.
122struct NoAncestry;
123impl crate::resolve::AncestorSet for NoAncestry {
124    fn contains(&mut self, _sha: &str) -> crate::resolve::Ancestry {
125        crate::resolve::Ancestry::Unknown
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn task(id: &str, project: &str) -> Task {
134        Task {
135            id: id.into(),
136            project_id: project.into(),
137            title: format!("task {id}"),
138            body: String::new(),
139            priority: Default::default(),
140            labels: vec![],
141            created_by_dev: "d".into(),
142            created_by_machine: "m".into(),
143            created: "2026-01-01T00:00:00Z".into(),
144        }
145    }
146
147    fn change(id: &str, task: &str, project: &str, to: StatusKind, at: &str) -> StatusChange {
148        StatusChange {
149            id: id.into(),
150            project_id: project.into(),
151            task_id: task.into(),
152            to_status: to,
153            anchor_commit: Some("deadbeef".into()),
154            created: at.into(),
155            by_dev: "d".into(),
156            by_machine: "m".into(),
157        }
158    }
159
160    #[test]
161    fn open_when_no_change() {
162        let tasks = vec![task("t1", "p")];
163        let got = statuses_unanchored(&tasks, &[], "p");
164        assert_eq!(got["t1"].status, Status::Open);
165        assert_eq!(got["t1"].resolution, Resolution::Exact);
166    }
167
168    #[test]
169    fn closed_is_partial_ignoring_anchors() {
170        let tasks = vec![task("t1", "p")];
171        // An anchored close: statuses_unanchored must not need the anchor's
172        // ancestry — it reports Closed + Partial regardless.
173        let changes = vec![change(
174            "c1",
175            "t1",
176            "p",
177            StatusKind::Closed,
178            "2026-02-01T00:00:00Z",
179        )];
180        let got = statuses_unanchored(&tasks, &changes, "p");
181        assert_eq!(got["t1"].status, Status::Closed);
182        assert_eq!(got["t1"].resolution, Resolution::Partial);
183    }
184
185    #[test]
186    fn latest_change_wins() {
187        let tasks = vec![task("t1", "p")];
188        let changes = vec![
189            change("c1", "t1", "p", StatusKind::Closed, "2026-02-01T00:00:00Z"),
190            change(
191                "c2",
192                "t1",
193                "p",
194                StatusKind::Reopened,
195                "2026-03-01T00:00:00Z",
196            ),
197        ];
198        let got = statuses_unanchored(&tasks, &changes, "p");
199        assert_eq!(got["t1"].status, Status::Open);
200        // Still Partial: a close event exists in history.
201        assert_eq!(got["t1"].resolution, Resolution::Partial);
202    }
203
204    #[test]
205    fn other_projects_ignored() {
206        let tasks = vec![task("t1", "p"), task("t2", "other")];
207        let got = statuses_unanchored(&tasks, &[], "p");
208        assert!(got.contains_key("t1"));
209        assert!(!got.contains_key("t2"));
210    }
211
212    #[test]
213    fn default_head_prefers_main() {
214        let refs = vec![
215            RefFact {
216                id: "p:feature".into(),
217                project_id: "p".into(),
218                branch: "feature".into(),
219                head: "aaaa".into(),
220                observed: "2026-05-01T00:00:00Z".into(),
221            },
222            RefFact {
223                id: "p:main".into(),
224                project_id: "p".into(),
225                branch: "main".into(),
226                head: "bbbb".into(),
227                observed: "2026-01-01T00:00:00Z".into(),
228            },
229        ];
230        assert_eq!(default_head(&refs, "p").as_deref(), Some("bbbb"));
231    }
232
233    #[test]
234    fn default_head_falls_back_to_latest_observed() {
235        let refs = vec![
236            RefFact {
237                id: "p:a".into(),
238                project_id: "p".into(),
239                branch: "a".into(),
240                head: "aaaa".into(),
241                observed: "2026-05-01T00:00:00Z".into(),
242            },
243            RefFact {
244                id: "p:b".into(),
245                project_id: "p".into(),
246                branch: "b".into(),
247                head: "bbbb".into(),
248                observed: "2026-06-01T00:00:00Z".into(),
249            },
250        ];
251        assert_eq!(default_head(&refs, "p").as_deref(), Some("bbbb"));
252    }
253
254    #[test]
255    fn default_head_none_without_refs() {
256        assert_eq!(default_head(&[], "p"), None);
257    }
258
259    fn commit(id: &str, project: &str, parents: &[&str]) -> CommitFact {
260        CommitFact {
261            id: id.into(),
262            project_id: project.into(),
263            parents: parents.iter().map(|p| p.to_string()).collect(),
264            patch_id: None,
265        }
266    }
267
268    #[test]
269    fn statuses_for_project_resolves_against_head_via_facts() {
270        // c1 <- c2 <- c3 ; a close anchored at c2.
271        let tasks = vec![task("t1", "p")];
272        let changes = vec![StatusChange {
273            anchor_commit: Some("c2".into()),
274            ..change("x1", "t1", "p", StatusKind::Closed, "2026-02-01T00:00:00Z")
275        }];
276        let facts = vec![
277            commit("c1", "p", &[]),
278            commit("c2", "p", &["c1"]),
279            commit("c3", "p", &["c2"]),
280        ];
281
282        // Against c3 (c2 is an ancestor): closed, exactly (Facts).
283        let at_c3 = statuses_for_project(&tasks, &changes, &facts, "p", Some("c3"));
284        assert_eq!(at_c3["t1"].status, Status::Closed);
285        assert_eq!(at_c3["t1"].resolution, Resolution::Facts);
286
287        // Against c1 (before the fix): open.
288        let at_c1 = statuses_for_project(&tasks, &changes, &facts, "p", Some("c1"));
289        assert_eq!(at_c1["t1"].status, Status::Open);
290
291        // No head: the anchored change is unknown -> open + Partial.
292        let no_head = statuses_for_project(&tasks, &changes, &facts, "p", None);
293        assert_eq!(no_head["t1"].status, Status::Open);
294        assert_eq!(no_head["t1"].resolution, Resolution::Partial);
295    }
296}