Skip to main content

bvr/analysis/
delivery.rs

1//! `--robot-delivery` — classification-only projection of the *delivery posture*
2//! of the open work graph. No overlay, no graph traversal beyond what the
3//! analyzer already computed.
4//!
5//! Two classifications, both priority-ordered (first match wins — so
6//! percentages sum to 100 without double-counting):
7//!
8//! - **flow_distribution**: Risk > Debt > Defects > Features
9//!   The Reinertsen capacity-split vocabulary already used in delivery-team
10//!   analytics. Operators want to know whether the graph is mostly reactive
11//!   (defects + risk) or investment (debt + features).
12//!
13//! - **urgency_profile**: Expedite > Fixed-Date > Intangible > Standard
14//!   Reinertsen urgency cohorts, keyed off priority, due dates, and labels.
15//!
16//! Plus a `milestone_pressure` list built from issues that carry a
17//! `due_date`, keeping cross-surface coherence with `--robot-alerts`.
18
19use chrono::{DateTime, Utc};
20use serde::Serialize;
21
22use crate::model::Issue;
23
24/// Schema version for the robot-delivery payload. See
25/// [`crate::analysis::economics::ECONOMICS_SCHEMA_VERSION`] for the bump
26/// rules; same contract here.
27pub const DELIVERY_SCHEMA_VERSION: &str = "1";
28
29/// Labels that, when present on an issue, classify it as `Risk` for the
30/// flow-distribution mix. Matched case-insensitively on the trimmed label.
31/// The list is small on purpose — operators can standardize on these four
32/// without forking the tool; anything exotic falls through to Features.
33const RISK_LABEL_TOKENS: &[&str] = &["risk", "security", "compliance", "safety"];
34
35/// Labels that classify as `Debt`. `refactor` is intentionally in here —
36/// it's the one category where the label and the issue_type both carry
37/// meaningful signal.
38const DEBT_LABEL_TOKENS: &[&str] = &["debt", "tech-debt", "techdebt", "refactor", "cleanup"];
39
40/// Labels that upgrade an otherwise-Standard issue to Expedite. `critical`
41/// mirrors the `--robot-alerts` severity vocabulary.
42const EXPEDITE_LABEL_TOKENS: &[&str] = &["expedite", "critical", "hotfix", "p0"];
43
44/// Labels that classify an issue as Intangible urgency (research, spikes).
45/// These are deliberately not counted as Standard so the "how much Standard
46/// work is shippable" number stays honest.
47const INTANGIBLE_LABEL_TOKENS: &[&str] = &["intangible", "research", "spike", "explore"];
48
49/// Fixed-Date window: due dates within this many days of `now` qualify
50/// for Fixed-Date urgency. Due dates further out still count as Fixed-Date
51/// (they have a committed date); the window just drives the
52/// `milestone_pressure` surface below.
53const FIXED_DATE_PRESSURE_WINDOW_DAYS: i64 = 14;
54
55/// Flow category. Ordered by `classify_flow` precedence — not by rendering
56/// order in the output (which is alphabetical-by-enum for stability).
57#[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/// Flattens [`crate::robot::RobotEnvelope`] at top level, mirroring every
122/// other `--robot-*` output. Adds `schema_version` as an explicit payload
123/// field for downstream pinning (see GH#12).
124#[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
136/// Inputs to `compute_delivery`.
137///
138/// `blocked_ids` is passed in so this module stays free of a direct
139/// dependency on the `Analyzer`/`IssueGraph` surface; the caller supplies
140/// the set of issue IDs that have at least one open blocker.
141pub struct DeliveryComputation<'a> {
142    pub issues: &'a [Issue],
143    pub blocked_ids: &'a std::collections::HashSet<String>,
144    pub now: DateTime<Utc>,
145    /// Cap on the `milestone_pressure` list. Matches the existing
146    /// `--insight-limit` default so callers can reuse the same ceiling.
147    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    // Tally flow categories by first-match-wins precedence so percentages
162    // sum to exactly 100 (modulo rounding).
163    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    // Milestone pressure: open issues with a due_date. Anchor the ordering on
199    // soonest-due (overdue items land first, positive days_until_due ascend
200    // from there) with id as tiebreaker so repeated runs produce byte-stable
201    // output for identical input.
202    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    // Risk first: security/risk labels outrank the issue_type because a
237    // security-labelled bug is more usefully categorized as Risk than Defects.
238    if labels_match_any(&issue.labels, RISK_LABEL_TOKENS)
239        || matches_token(&issue.issue_type, "risk")
240    {
241        return FlowCategory::Risk;
242    }
243    // Debt next: covers both the tech-debt labels and the refactor/cleanup
244    // issue_types that some teams use instead of labels.
245    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    // Defects: issue_type == "bug" is the primary signal because beads spec
251    // uses that type name; the bug/defect labels are a fallback for teams
252    // that don't set issue_type.
253    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    // Expedite: P0 or explicit hotfix label. P0 is the strongest signal
264    // beads has; hotfix/critical are the vocabulary --robot-alerts uses.
265    if issue.priority == 0 || labels_match_any(&issue.labels, EXPEDITE_LABEL_TOKENS) {
266        return UrgencyCategory::Expedite;
267    }
268    // Fixed-Date: any open issue with a due_date (future or past). Overdue
269    // items still count as fixed-date — they have a commitment attached,
270    // which is what distinguishes them from Standard. The milestone_pressure
271    // surface below is where `now` becomes relevant (overdue flagging).
272    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        // A single issue tagged with BOTH security and tech-debt must count
350        // as Risk only (priority order wins), so percentages sum to 100%.
351        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        // Stress: many issues with overlapping labels. Percentages must
382        // still sum to 100 because first-match-wins ensures no double count.
383        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        // A P0 issue with a due_date must land in Expedite, not Fixed-Date
413        // (priority ordering) — otherwise the "Expedite" cohort underrepresents
414        // the work that actually needs immediate attention.
415        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        // An issue with both a "research" label and a due date is Fixed-Date
439        // (date commitment beats classification label).
440        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        // Freeze the label token contract so classification does not quietly
564        // start missing work labelled "SECURITY " or "Tech-Debt".
565        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        // Bumping this value is a schema contract change. Any renamed or
603        // removed field must bump the schema. Adding new optional fields
604        // does not.
605        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}