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}