1use chrono::{DateTime, Utc};
20use serde::Serialize;
21
22use crate::model::Issue;
23
24pub const DELIVERY_SCHEMA_VERSION: &str = "1";
28
29const RISK_LABEL_TOKENS: &[&str] = &["risk", "security", "compliance", "safety"];
34
35const DEBT_LABEL_TOKENS: &[&str] = &["debt", "tech-debt", "techdebt", "refactor", "cleanup"];
39
40const EXPEDITE_LABEL_TOKENS: &[&str] = &["expedite", "critical", "hotfix", "p0"];
43
44const INTANGIBLE_LABEL_TOKENS: &[&str] = &["intangible", "research", "spike", "explore"];
48
49const FIXED_DATE_PRESSURE_WINDOW_DAYS: i64 = 14;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum FlowCategory {
60 Risk,
61 Debt,
62 Defects,
63 Features,
64}
65
66impl FlowCategory {
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::Risk => "risk",
70 Self::Debt => "debt",
71 Self::Defects => "defects",
72 Self::Features => "features",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum UrgencyCategory {
80 Expedite,
81 FixedDate,
82 Intangible,
83 Standard,
84}
85
86impl UrgencyCategory {
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::Expedite => "expedite",
90 Self::FixedDate => "fixed_date",
91 Self::Intangible => "intangible",
92 Self::Standard => "standard",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize)]
98pub struct FlowBucket {
99 pub category: FlowCategory,
100 pub count: usize,
101 pub pct: f64,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct UrgencyBucket {
106 pub category: UrgencyCategory,
107 pub count: usize,
108 pub pct: f64,
109}
110
111#[derive(Debug, Clone, Serialize)]
112pub struct MilestoneSignal {
113 pub id: String,
114 pub title: String,
115 pub due_date: DateTime<Utc>,
116 pub days_until_due: i64,
117 pub is_overdue: bool,
118 pub is_blocked: bool,
119}
120
121#[derive(Debug, Clone, Serialize)]
125pub struct RobotDeliveryOutput {
126 #[serde(flatten)]
127 pub envelope: crate::robot::RobotEnvelope,
128 pub schema_version: &'static str,
129 pub open_issues: usize,
130 pub flow_distribution: Vec<FlowBucket>,
131 pub urgency_profile: Vec<UrgencyBucket>,
132 pub milestone_pressure: Vec<MilestoneSignal>,
133 pub window_days: i64,
134}
135
136pub struct DeliveryComputation<'a> {
142 pub issues: &'a [Issue],
143 pub blocked_ids: &'a std::collections::HashSet<String>,
144 pub now: DateTime<Utc>,
145 pub milestone_pressure_limit: usize,
148}
149
150pub fn compute_delivery(computation: DeliveryComputation<'_>) -> RobotDeliveryOutput {
151 let DeliveryComputation {
152 issues,
153 blocked_ids,
154 now,
155 milestone_pressure_limit,
156 } = computation;
157
158 let open_issues: Vec<&Issue> = issues.iter().filter(|issue| issue.is_open_like()).collect();
159 let open_count = open_issues.len();
160
161 let mut flow_counts: [usize; 4] = [0; 4];
164 let mut urgency_counts: [usize; 4] = [0; 4];
165 for issue in &open_issues {
166 flow_counts[flow_index(classify_flow(issue))] += 1;
167 urgency_counts[urgency_index(classify_urgency(issue))] += 1;
168 }
169
170 let flow_distribution = [
171 FlowCategory::Risk,
172 FlowCategory::Debt,
173 FlowCategory::Defects,
174 FlowCategory::Features,
175 ]
176 .into_iter()
177 .map(|category| FlowBucket {
178 category,
179 count: flow_counts[flow_index(category)],
180 pct: pct(flow_counts[flow_index(category)], open_count),
181 })
182 .collect::<Vec<_>>();
183
184 let urgency_profile = [
185 UrgencyCategory::Expedite,
186 UrgencyCategory::FixedDate,
187 UrgencyCategory::Intangible,
188 UrgencyCategory::Standard,
189 ]
190 .into_iter()
191 .map(|category| UrgencyBucket {
192 category,
193 count: urgency_counts[urgency_index(category)],
194 pct: pct(urgency_counts[urgency_index(category)], open_count),
195 })
196 .collect::<Vec<_>>();
197
198 let mut milestone_pressure: Vec<MilestoneSignal> = open_issues
203 .iter()
204 .filter_map(|issue| {
205 let due_date = issue.due_date?;
206 let days_until_due = (due_date - now).num_days();
207 Some(MilestoneSignal {
208 id: issue.id.clone(),
209 title: issue.title.clone(),
210 due_date,
211 days_until_due,
212 is_overdue: due_date < now,
213 is_blocked: blocked_ids.contains(&issue.id),
214 })
215 })
216 .collect();
217 milestone_pressure.sort_by(|left, right| {
218 left.due_date
219 .cmp(&right.due_date)
220 .then_with(|| left.id.cmp(&right.id))
221 });
222 milestone_pressure.truncate(milestone_pressure_limit);
223
224 RobotDeliveryOutput {
225 envelope: crate::robot::envelope(issues),
226 schema_version: DELIVERY_SCHEMA_VERSION,
227 open_issues: open_count,
228 flow_distribution,
229 urgency_profile,
230 milestone_pressure,
231 window_days: FIXED_DATE_PRESSURE_WINDOW_DAYS,
232 }
233}
234
235fn classify_flow(issue: &Issue) -> FlowCategory {
236 if labels_match_any(&issue.labels, RISK_LABEL_TOKENS)
239 || matches_token(&issue.issue_type, "risk")
240 {
241 return FlowCategory::Risk;
242 }
243 if labels_match_any(&issue.labels, DEBT_LABEL_TOKENS)
246 || matches_any_token(&issue.issue_type, DEBT_LABEL_TOKENS)
247 {
248 return FlowCategory::Debt;
249 }
250 if matches_token(&issue.issue_type, "bug")
254 || matches_token(&issue.issue_type, "defect")
255 || labels_match_any(&issue.labels, &["bug", "defect"])
256 {
257 return FlowCategory::Defects;
258 }
259 FlowCategory::Features
260}
261
262fn classify_urgency(issue: &Issue) -> UrgencyCategory {
263 if issue.priority == 0 || labels_match_any(&issue.labels, EXPEDITE_LABEL_TOKENS) {
266 return UrgencyCategory::Expedite;
267 }
268 if issue.due_date.is_some() {
273 return UrgencyCategory::FixedDate;
274 }
275 if labels_match_any(&issue.labels, INTANGIBLE_LABEL_TOKENS) {
276 return UrgencyCategory::Intangible;
277 }
278 UrgencyCategory::Standard
279}
280
281const fn flow_index(category: FlowCategory) -> usize {
282 match category {
283 FlowCategory::Risk => 0,
284 FlowCategory::Debt => 1,
285 FlowCategory::Defects => 2,
286 FlowCategory::Features => 3,
287 }
288}
289
290const fn urgency_index(category: UrgencyCategory) -> usize {
291 match category {
292 UrgencyCategory::Expedite => 0,
293 UrgencyCategory::FixedDate => 1,
294 UrgencyCategory::Intangible => 2,
295 UrgencyCategory::Standard => 3,
296 }
297}
298
299fn labels_match_any(labels: &[String], tokens: &[&str]) -> bool {
300 labels
301 .iter()
302 .any(|label| matches_any_token(label.trim(), tokens))
303}
304
305fn matches_any_token(raw: &str, tokens: &[&str]) -> bool {
306 tokens.iter().any(|token| matches_token(raw, token))
307}
308
309fn matches_token(raw: &str, token: &str) -> bool {
310 raw.trim().eq_ignore_ascii_case(token)
311}
312
313fn pct(count: usize, total: usize) -> f64 {
314 if total == 0 {
315 0.0
316 } else {
317 (count as f64 / total as f64) * 100.0
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use chrono::{Duration, TimeZone};
325 use std::collections::HashSet;
326
327 fn open(id: &str, issue_type: &str, priority: i32, labels: &[&str]) -> Issue {
328 Issue {
329 id: id.to_string(),
330 title: format!("title of {id}"),
331 status: "open".to_string(),
332 priority,
333 issue_type: issue_type.to_string(),
334 labels: labels.iter().map(|l| (*l).to_string()).collect(),
335 ..Issue::default()
336 }
337 }
338
339 fn now_fixture() -> DateTime<Utc> {
340 Utc.with_ymd_and_hms(2026, 4, 20, 0, 0, 0).unwrap()
341 }
342
343 fn empty_blocked() -> HashSet<String> {
344 HashSet::new()
345 }
346
347 #[test]
348 fn flow_distribution_is_priority_ordered_each_issue_counted_once() {
349 let issues = vec![
352 open("A-1", "task", 1, &["security", "tech-debt"]),
353 open("A-2", "bug", 1, &[]),
354 open("A-3", "task", 1, &["refactor"]),
355 open("A-4", "task", 1, &[]),
356 ];
357 let output = compute_delivery(DeliveryComputation {
358 issues: &issues,
359 blocked_ids: &empty_blocked(),
360 now: now_fixture(),
361 milestone_pressure_limit: 20,
362 });
363 let count_for = |cat: FlowCategory| -> usize {
364 output
365 .flow_distribution
366 .iter()
367 .find(|b| b.category == cat)
368 .map(|b| b.count)
369 .unwrap_or(0)
370 };
371 assert_eq!(count_for(FlowCategory::Risk), 1);
372 assert_eq!(count_for(FlowCategory::Debt), 1);
373 assert_eq!(count_for(FlowCategory::Defects), 1);
374 assert_eq!(count_for(FlowCategory::Features), 1);
375 let total_pct: f64 = output.flow_distribution.iter().map(|b| b.pct).sum();
376 assert!((total_pct - 100.0).abs() < 1e-9, "got {total_pct}");
377 }
378
379 #[test]
380 fn flow_distribution_sums_to_100_across_arbitrary_mixes() {
381 let labels = [
384 vec!["security", "bug"],
385 vec!["tech-debt"],
386 vec!["refactor", "security"],
387 vec!["feature"],
388 vec!["bug", "refactor"],
389 vec![],
390 vec!["risk"],
391 vec!["compliance"],
392 ];
393 let issues: Vec<Issue> = labels
394 .iter()
395 .enumerate()
396 .map(|(i, ls)| open(&format!("X-{i}"), "task", 1, ls))
397 .collect();
398 let output = compute_delivery(DeliveryComputation {
399 issues: &issues,
400 blocked_ids: &empty_blocked(),
401 now: now_fixture(),
402 milestone_pressure_limit: 20,
403 });
404 let total_count: usize = output.flow_distribution.iter().map(|b| b.count).sum();
405 assert_eq!(total_count, issues.len());
406 let total_pct: f64 = output.flow_distribution.iter().map(|b| b.pct).sum();
407 assert!((total_pct - 100.0).abs() < 1e-9, "got {total_pct}");
408 }
409
410 #[test]
411 fn urgency_profile_expedite_beats_fixed_date() {
412 let mut p0_with_due = open("A-1", "task", 0, &[]);
416 p0_with_due.due_date = Some(now_fixture() + Duration::days(3));
417 let issues = vec![p0_with_due];
418 let output = compute_delivery(DeliveryComputation {
419 issues: &issues,
420 blocked_ids: &empty_blocked(),
421 now: now_fixture(),
422 milestone_pressure_limit: 20,
423 });
424 let get = |cat: UrgencyCategory| -> usize {
425 output
426 .urgency_profile
427 .iter()
428 .find(|b| b.category == cat)
429 .map(|b| b.count)
430 .unwrap_or(0)
431 };
432 assert_eq!(get(UrgencyCategory::Expedite), 1);
433 assert_eq!(get(UrgencyCategory::FixedDate), 0);
434 }
435
436 #[test]
437 fn urgency_profile_intangible_does_not_swallow_fixed_date() {
438 let mut issue = open("A-1", "task", 1, &["research"]);
441 issue.due_date = Some(now_fixture() + Duration::days(10));
442 let output = compute_delivery(DeliveryComputation {
443 issues: &[issue],
444 blocked_ids: &empty_blocked(),
445 now: now_fixture(),
446 milestone_pressure_limit: 20,
447 });
448 let get = |cat: UrgencyCategory| -> usize {
449 output
450 .urgency_profile
451 .iter()
452 .find(|b| b.category == cat)
453 .map(|b| b.count)
454 .unwrap_or(0)
455 };
456 assert_eq!(get(UrgencyCategory::FixedDate), 1);
457 assert_eq!(get(UrgencyCategory::Intangible), 0);
458 }
459
460 #[test]
461 fn urgency_profile_sums_to_100_when_open_issues_exist() {
462 let mut p0 = open("A-1", "task", 0, &[]);
463 p0.due_date = Some(now_fixture() + Duration::days(1));
464 let mut due = open("A-2", "task", 1, &[]);
465 due.due_date = Some(now_fixture() + Duration::days(20));
466 let intangible = open("A-3", "task", 2, &["research"]);
467 let standard_a = open("A-4", "task", 2, &[]);
468 let standard_b = open("A-5", "feature", 2, &[]);
469 let output = compute_delivery(DeliveryComputation {
470 issues: &[p0, due, intangible, standard_a, standard_b],
471 blocked_ids: &empty_blocked(),
472 now: now_fixture(),
473 milestone_pressure_limit: 20,
474 });
475 let total_pct: f64 = output.urgency_profile.iter().map(|b| b.pct).sum();
476 assert!((total_pct - 100.0).abs() < 1e-9, "got {total_pct}");
477 }
478
479 #[test]
480 fn closed_issues_are_excluded_from_every_bucket() {
481 let mut closed = open("A-1", "bug", 0, &["security"]);
482 closed.status = "closed".to_string();
483 let output = compute_delivery(DeliveryComputation {
484 issues: &[closed],
485 blocked_ids: &empty_blocked(),
486 now: now_fixture(),
487 milestone_pressure_limit: 20,
488 });
489 assert_eq!(output.open_issues, 0);
490 assert!(output.flow_distribution.iter().all(|b| b.count == 0));
491 assert!(output.urgency_profile.iter().all(|b| b.count == 0));
492 assert!(output.milestone_pressure.is_empty());
493 }
494
495 #[test]
496 fn milestone_pressure_sorted_by_due_date_then_id() {
497 let now = now_fixture();
498 let issue = |id: &str, days: i64| -> Issue {
499 let mut i = open(id, "task", 1, &[]);
500 i.due_date = Some(now + Duration::days(days));
501 i
502 };
503 let output = compute_delivery(DeliveryComputation {
504 issues: &[issue("Z-1", 10), issue("A-1", 5), issue("B-1", 5)],
505 blocked_ids: &empty_blocked(),
506 now,
507 milestone_pressure_limit: 20,
508 });
509 let ids: Vec<&str> = output
510 .milestone_pressure
511 .iter()
512 .map(|m| m.id.as_str())
513 .collect();
514 assert_eq!(ids, vec!["A-1", "B-1", "Z-1"]);
515 }
516
517 #[test]
518 fn milestone_pressure_marks_overdue_and_blocked() {
519 let now = now_fixture();
520 let mut overdue = open("A-1", "task", 1, &[]);
521 overdue.due_date = Some(now - Duration::days(3));
522 let mut future_blocked = open("A-2", "task", 1, &[]);
523 future_blocked.due_date = Some(now + Duration::days(7));
524
525 let mut blocked_ids = HashSet::new();
526 blocked_ids.insert("A-2".to_string());
527
528 let output = compute_delivery(DeliveryComputation {
529 issues: &[overdue, future_blocked],
530 blocked_ids: &blocked_ids,
531 now,
532 milestone_pressure_limit: 20,
533 });
534 assert!(output.milestone_pressure[0].is_overdue);
535 assert!(!output.milestone_pressure[0].is_blocked);
536 assert!(!output.milestone_pressure[1].is_overdue);
537 assert!(output.milestone_pressure[1].is_blocked);
538 assert_eq!(output.milestone_pressure[0].days_until_due, -3);
539 assert_eq!(output.milestone_pressure[1].days_until_due, 7);
540 }
541
542 #[test]
543 fn milestone_pressure_respects_limit() {
544 let now = now_fixture();
545 let issues: Vec<Issue> = (0..10)
546 .map(|i| {
547 let mut issue = open(&format!("A-{i}"), "task", 1, &[]);
548 issue.due_date = Some(now + Duration::days(i));
549 issue
550 })
551 .collect();
552 let output = compute_delivery(DeliveryComputation {
553 issues: &issues,
554 blocked_ids: &empty_blocked(),
555 now,
556 milestone_pressure_limit: 3,
557 });
558 assert_eq!(output.milestone_pressure.len(), 3);
559 }
560
561 #[test]
562 fn label_matching_is_case_insensitive_and_trim_safe() {
563 let issues = vec![
566 open("A-1", "task", 1, &[" SECURITY "]),
567 open("A-2", "task", 1, &["Tech-Debt"]),
568 ];
569 let output = compute_delivery(DeliveryComputation {
570 issues: &issues,
571 blocked_ids: &empty_blocked(),
572 now: now_fixture(),
573 milestone_pressure_limit: 20,
574 });
575 let get = |cat: FlowCategory| -> usize {
576 output
577 .flow_distribution
578 .iter()
579 .find(|b| b.category == cat)
580 .map(|b| b.count)
581 .unwrap_or(0)
582 };
583 assert_eq!(get(FlowCategory::Risk), 1);
584 assert_eq!(get(FlowCategory::Debt), 1);
585 }
586
587 #[test]
588 fn zero_open_issues_yields_zero_counts_without_panics() {
589 let output = compute_delivery(DeliveryComputation {
590 issues: &[],
591 blocked_ids: &empty_blocked(),
592 now: now_fixture(),
593 milestone_pressure_limit: 20,
594 });
595 assert_eq!(output.open_issues, 0);
596 assert!(output.flow_distribution.iter().all(|b| b.pct == 0.0));
597 assert!(output.urgency_profile.iter().all(|b| b.pct == 0.0));
598 }
599
600 #[test]
601 fn schema_version_is_pinned_to_v1() {
602 let output = compute_delivery(DeliveryComputation {
606 issues: &[],
607 blocked_ids: &empty_blocked(),
608 now: now_fixture(),
609 milestone_pressure_limit: 20,
610 });
611 assert_eq!(output.schema_version, "1");
612 }
613}