Skip to main content

bvr/analysis/
history.rs

1use serde::Serialize;
2
3use crate::model::Issue;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct HistoryEvent {
7    pub kind: String,
8    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
9    pub details: String,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct IssueHistory {
14    pub id: String,
15    pub title: String,
16    pub status: String,
17    pub events: Vec<HistoryEvent>,
18}
19
20fn history_event_order(event: &HistoryEvent) -> (u8, Option<chrono::DateTime<chrono::Utc>>, u8) {
21    match event.kind.as_str() {
22        "created" => (0, event.timestamp, 0),
23        "updated" => (1, event.timestamp, 1),
24        "closed" => (1, event.timestamp, 2),
25        "dependency" => (2, event.timestamp, 3),
26        _ => (1, event.timestamp, 4),
27    }
28}
29
30#[must_use]
31pub fn build_histories(
32    issues: &[Issue],
33    only_issue_id: Option<&str>,
34    limit: usize,
35) -> Vec<IssueHistory> {
36    let mut histories = Vec::<IssueHistory>::new();
37
38    for issue in issues {
39        if only_issue_id.is_some_and(|id| id != issue.id) {
40            continue;
41        }
42
43        let mut events = Vec::<HistoryEvent>::new();
44        events.push(HistoryEvent {
45            kind: "created".to_string(),
46            timestamp: issue.created_at.clone(),
47            details: format!("Issue {} created", issue.id),
48        });
49
50        if issue.updated_at.is_some() {
51            events.push(HistoryEvent {
52                kind: "updated".to_string(),
53                timestamp: issue.updated_at.clone(),
54                details: format!("Current status: {}", issue.status),
55            });
56        }
57
58        if issue.closed_at.is_some() || issue.is_closed_like() {
59            events.push(HistoryEvent {
60                kind: "closed".to_string(),
61                timestamp: issue.closed_at.clone().or_else(|| issue.updated_at.clone()),
62                details: format!("Issue {} is in closed-like status", issue.id),
63            });
64        }
65
66        for dep in &issue.dependencies {
67            if dep.is_blocking() {
68                events.push(HistoryEvent {
69                    kind: "dependency".to_string(),
70                    timestamp: None,
71                    details: format!("Blocked by {}", dep.depends_on_id),
72                });
73            }
74        }
75
76        events.sort_by_key(history_event_order);
77
78        histories.push(IssueHistory {
79            id: issue.id.clone(),
80            title: issue.title.clone(),
81            status: issue.status.clone(),
82            events,
83        });
84    }
85
86    histories.sort_by(|left, right| left.id.cmp(&right.id));
87    if limit > 0 {
88        histories.truncate(limit);
89    }
90
91    histories
92}
93
94#[cfg(test)]
95mod tests {
96    use crate::model::{Dependency, Issue, ts};
97
98    use super::build_histories;
99
100    #[test]
101    fn builds_history_for_single_issue() {
102        let issues = vec![Issue {
103            id: "A".to_string(),
104            title: "A".to_string(),
105            status: "open".to_string(),
106            issue_type: "task".to_string(),
107            created_at: ts("2026-01-01T00:00:00Z"),
108            updated_at: ts("2026-01-02T00:00:00Z"),
109            ..Issue::default()
110        }];
111
112        let histories = build_histories(&issues, Some("A"), 10);
113        assert_eq!(histories.len(), 1);
114        assert!(histories[0].events.len() >= 2);
115    }
116
117    #[test]
118    fn includes_dependency_events_for_blocked_issue() {
119        let issues = vec![
120            Issue {
121                id: "bd-3q0".to_string(),
122                title: "Primary blocker".to_string(),
123                status: "in_progress".to_string(),
124                issue_type: "feature".to_string(),
125                created_at: ts("2026-02-18T03:00:00Z"),
126                updated_at: ts("2026-02-18T03:05:00Z"),
127                ..Issue::default()
128            },
129            Issue {
130                id: "bd-3q1".to_string(),
131                title: "Follow-on work".to_string(),
132                status: "blocked".to_string(),
133                issue_type: "task".to_string(),
134                created_at: ts("2026-02-18T03:01:00Z"),
135                updated_at: ts("2026-02-18T03:06:00Z"),
136                dependencies: vec![Dependency {
137                    issue_id: "bd-3q1".to_string(),
138                    depends_on_id: "bd-3q0".to_string(),
139                    dep_type: "blocks".to_string(),
140                    ..Dependency::default()
141                }],
142                ..Issue::default()
143            },
144        ];
145
146        let histories = build_histories(&issues, Some("bd-3q1"), 10);
147        assert_eq!(histories.len(), 1);
148        assert!(
149            histories[0].events.iter().any(|event| {
150                event.kind == "dependency" && event.details == "Blocked by bd-3q0"
151            })
152        );
153    }
154
155    #[test]
156    fn untimestamped_dependency_events_sort_after_timestamped_events() {
157        let issues = vec![Issue {
158            id: "bd-4z1".to_string(),
159            title: "Blocked follow-on".to_string(),
160            status: "blocked".to_string(),
161            issue_type: "task".to_string(),
162            created_at: ts("2026-02-18T03:01:00Z"),
163            updated_at: ts("2026-02-18T03:06:00Z"),
164            dependencies: vec![Dependency {
165                issue_id: "bd-4z1".to_string(),
166                depends_on_id: "bd-4z0".to_string(),
167                dep_type: "blocks".to_string(),
168                ..Dependency::default()
169            }],
170            ..Issue::default()
171        }];
172
173        let histories = build_histories(&issues, Some("bd-4z1"), 10);
174        let events = &histories[0].events;
175        assert_eq!(events[0].kind, "created");
176        assert_eq!(events[1].kind, "updated");
177        assert_eq!(events[2].kind, "dependency");
178    }
179
180    #[test]
181    fn created_event_stays_first_even_without_created_timestamp() {
182        let issues = vec![Issue {
183            id: "bd-5a1".to_string(),
184            title: "Timestamp gap".to_string(),
185            status: "blocked".to_string(),
186            issue_type: "task".to_string(),
187            created_at: None,
188            updated_at: ts("2026-02-18T03:06:00Z"),
189            dependencies: vec![Dependency {
190                issue_id: "bd-5a1".to_string(),
191                depends_on_id: "bd-5a0".to_string(),
192                dep_type: "blocks".to_string(),
193                ..Dependency::default()
194            }],
195            ..Issue::default()
196        }];
197
198        let histories = build_histories(&issues, Some("bd-5a1"), 10);
199        let events = &histories[0].events;
200        assert_eq!(events[0].kind, "created");
201        assert_eq!(events[1].kind, "updated");
202        assert_eq!(events[2].kind, "dependency");
203    }
204
205    #[test]
206    fn empty_input_returns_empty() {
207        let histories = build_histories(&[], None, 10);
208        assert!(histories.is_empty());
209    }
210
211    #[test]
212    fn limit_zero_returns_all() {
213        let issues = vec![
214            Issue {
215                id: "A".into(),
216                title: "A".into(),
217                status: "open".into(),
218                issue_type: "task".into(),
219                ..Issue::default()
220            },
221            Issue {
222                id: "B".into(),
223                title: "B".into(),
224                status: "open".into(),
225                issue_type: "task".into(),
226                ..Issue::default()
227            },
228        ];
229        let histories = build_histories(&issues, None, 0);
230        assert_eq!(histories.len(), 2);
231    }
232
233    #[test]
234    fn limit_one_truncates() {
235        let issues = vec![
236            Issue {
237                id: "A".into(),
238                title: "A".into(),
239                status: "open".into(),
240                issue_type: "task".into(),
241                ..Issue::default()
242            },
243            Issue {
244                id: "B".into(),
245                title: "B".into(),
246                status: "open".into(),
247                issue_type: "task".into(),
248                ..Issue::default()
249            },
250        ];
251        let histories = build_histories(&issues, None, 1);
252        assert_eq!(histories.len(), 1);
253    }
254
255    #[test]
256    fn nonexistent_filter_id_returns_empty() {
257        let issues = vec![Issue {
258            id: "A".into(),
259            title: "A".into(),
260            status: "open".into(),
261            issue_type: "task".into(),
262            ..Issue::default()
263        }];
264        let histories = build_histories(&issues, Some("Z"), 10);
265        assert!(histories.is_empty());
266    }
267
268    #[test]
269    fn closed_like_issue_without_closed_at_uses_updated_at() {
270        let issues = vec![Issue {
271            id: "C".into(),
272            title: "Closed".into(),
273            status: "closed".into(),
274            issue_type: "task".into(),
275            closed_at: None,
276            updated_at: ts("2026-03-01T00:00:00Z"),
277            ..Issue::default()
278        }];
279        let histories = build_histories(&issues, None, 10);
280        let closed_event = histories[0]
281            .events
282            .iter()
283            .find(|e| e.kind == "closed")
284            .unwrap();
285        assert_eq!(closed_event.timestamp, ts("2026-03-01T00:00:00Z"));
286    }
287
288    #[test]
289    fn results_sorted_by_id() {
290        let issues = vec![
291            Issue {
292                id: "C".into(),
293                title: "C".into(),
294                status: "open".into(),
295                issue_type: "task".into(),
296                ..Issue::default()
297            },
298            Issue {
299                id: "A".into(),
300                title: "A".into(),
301                status: "open".into(),
302                issue_type: "task".into(),
303                ..Issue::default()
304            },
305            Issue {
306                id: "B".into(),
307                title: "B".into(),
308                status: "open".into(),
309                issue_type: "task".into(),
310                ..Issue::default()
311            },
312        ];
313        let histories = build_histories(&issues, None, 0);
314        let ids: Vec<&str> = histories.iter().map(|h| h.id.as_str()).collect();
315        assert_eq!(ids, vec!["A", "B", "C"]);
316    }
317}