Skip to main content

batty_cli/team/
policy.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3use super::config::{RoleType, WorkflowPolicy};
4
5pub fn check_wip_limit(policy: &WorkflowPolicy, role_type: RoleType, active_count: u32) -> bool {
6    let limit = match role_type {
7        RoleType::Engineer => policy.wip_limit_per_engineer,
8        RoleType::Architect | RoleType::Manager => policy.wip_limit_per_reviewer,
9        RoleType::User => None,
10    };
11
12    match limit {
13        Some(limit) => active_count < limit,
14        None => true,
15    }
16}
17
18pub fn is_review_nudge_due(policy: &WorkflowPolicy, review_age_secs: u64) -> bool {
19    review_age_secs >= policy.review_nudge_threshold_secs
20}
21
22pub fn is_review_stale(policy: &WorkflowPolicy, review_age_secs: u64) -> bool {
23    review_age_secs >= policy.review_timeout_secs
24}
25
26pub fn should_escalate(policy: &WorkflowPolicy, blocked_age_secs: u64) -> bool {
27    blocked_age_secs >= policy.escalation_threshold_secs
28}
29
30/// Returns the effective nudge threshold for a task with the given priority.
31/// Uses per-priority override if configured, otherwise falls back to global.
32pub fn effective_nudge_threshold(policy: &WorkflowPolicy, priority: &str) -> u64 {
33    if !priority.is_empty() {
34        if let Some(ovr) = policy.review_timeout_overrides.get(priority) {
35            if let Some(secs) = ovr.review_nudge_threshold_secs {
36                return secs;
37            }
38        }
39    }
40    policy.review_nudge_threshold_secs
41}
42
43/// Returns the effective escalation (review timeout) threshold for a task with
44/// the given priority. Uses per-priority override if configured, otherwise
45/// falls back to global.
46pub fn effective_escalation_threshold(policy: &WorkflowPolicy, priority: &str) -> u64 {
47    if !priority.is_empty() {
48        if let Some(ovr) = policy.review_timeout_overrides.get(priority) {
49            if let Some(secs) = ovr.review_timeout_secs {
50                return secs;
51            }
52        }
53    }
54    policy.review_timeout_secs
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn default_workflow_policy_has_sensible_defaults() {
63        let policy = WorkflowPolicy::default();
64        assert_eq!(policy.wip_limit_per_engineer, None);
65        assert_eq!(policy.wip_limit_per_reviewer, None);
66        assert_eq!(policy.pipeline_starvation_threshold, Some(1));
67        assert_eq!(policy.escalation_threshold_secs, 3600);
68        assert_eq!(policy.review_nudge_threshold_secs, 1800);
69        assert_eq!(policy.review_timeout_secs, 7200);
70        assert_eq!(policy.auto_archive_done_after_secs, None);
71        assert!(policy.capability_overrides.is_empty());
72    }
73
74    #[test]
75    fn check_wip_limit_enforces_limits() {
76        let policy = WorkflowPolicy {
77            wip_limit_per_engineer: Some(2),
78            wip_limit_per_reviewer: Some(1),
79            ..WorkflowPolicy::default()
80        };
81
82        assert!(check_wip_limit(&policy, RoleType::Engineer, 0));
83        assert!(check_wip_limit(&policy, RoleType::Engineer, 1));
84        assert!(!check_wip_limit(&policy, RoleType::Engineer, 2));
85        assert!(check_wip_limit(&policy, RoleType::Manager, 0));
86        assert!(!check_wip_limit(&policy, RoleType::Manager, 1));
87        assert!(check_wip_limit(&policy, RoleType::User, 99));
88    }
89
90    #[test]
91    fn stale_and_escalation_threshold_checks_are_inclusive() {
92        let policy = WorkflowPolicy {
93            escalation_threshold_secs: 120,
94            review_timeout_secs: 300,
95            ..WorkflowPolicy::default()
96        };
97
98        assert!(!should_escalate(&policy, 119));
99        assert!(should_escalate(&policy, 120));
100        assert!(!is_review_stale(&policy, 299));
101        assert!(is_review_stale(&policy, 300));
102    }
103
104    #[test]
105    fn review_nudge_threshold_check_is_inclusive() {
106        let policy = WorkflowPolicy {
107            review_nudge_threshold_secs: 1800,
108            ..WorkflowPolicy::default()
109        };
110
111        assert!(!is_review_nudge_due(&policy, 1799));
112        assert!(is_review_nudge_due(&policy, 1800));
113        assert!(is_review_nudge_due(&policy, 1801));
114    }
115
116    #[test]
117    fn effective_nudge_threshold_uses_global_when_no_override() {
118        let policy = WorkflowPolicy {
119            review_nudge_threshold_secs: 1800,
120            ..WorkflowPolicy::default()
121        };
122        assert_eq!(effective_nudge_threshold(&policy, "high"), 1800);
123        assert_eq!(effective_nudge_threshold(&policy, ""), 1800);
124    }
125
126    #[test]
127    fn effective_nudge_threshold_uses_priority_override() {
128        use super::super::config::ReviewTimeoutOverride;
129        let mut overrides = std::collections::HashMap::new();
130        overrides.insert(
131            "critical".to_string(),
132            ReviewTimeoutOverride {
133                review_nudge_threshold_secs: Some(300),
134                review_timeout_secs: Some(600),
135            },
136        );
137        let policy = WorkflowPolicy {
138            review_nudge_threshold_secs: 1800,
139            review_timeout_overrides: overrides,
140            ..WorkflowPolicy::default()
141        };
142        // Critical uses override
143        assert_eq!(effective_nudge_threshold(&policy, "critical"), 300);
144        // High falls back to global
145        assert_eq!(effective_nudge_threshold(&policy, "high"), 1800);
146    }
147
148    #[test]
149    fn effective_escalation_threshold_uses_priority_override() {
150        use super::super::config::ReviewTimeoutOverride;
151        let mut overrides = std::collections::HashMap::new();
152        overrides.insert(
153            "critical".to_string(),
154            ReviewTimeoutOverride {
155                review_nudge_threshold_secs: Some(300),
156                review_timeout_secs: Some(600),
157            },
158        );
159        overrides.insert(
160            "high".to_string(),
161            ReviewTimeoutOverride {
162                review_nudge_threshold_secs: None,
163                review_timeout_secs: Some(3600),
164            },
165        );
166        let policy = WorkflowPolicy {
167            review_timeout_secs: 7200,
168            review_timeout_overrides: overrides,
169            ..WorkflowPolicy::default()
170        };
171        // Critical uses override
172        assert_eq!(effective_escalation_threshold(&policy, "critical"), 600);
173        // High uses override
174        assert_eq!(effective_escalation_threshold(&policy, "high"), 3600);
175        // Medium falls back to global
176        assert_eq!(effective_escalation_threshold(&policy, "medium"), 7200);
177    }
178
179    #[test]
180    fn partial_override_falls_back_per_field() {
181        use super::super::config::ReviewTimeoutOverride;
182        let mut overrides = std::collections::HashMap::new();
183        overrides.insert(
184            "high".to_string(),
185            ReviewTimeoutOverride {
186                review_nudge_threshold_secs: Some(900),
187                review_timeout_secs: None, // no escalation override
188            },
189        );
190        let policy = WorkflowPolicy {
191            review_nudge_threshold_secs: 1800,
192            review_timeout_secs: 7200,
193            review_timeout_overrides: overrides,
194            ..WorkflowPolicy::default()
195        };
196        // Nudge uses override
197        assert_eq!(effective_nudge_threshold(&policy, "high"), 900);
198        // Escalation falls back to global (no override for this field)
199        assert_eq!(effective_escalation_threshold(&policy, "high"), 7200);
200    }
201
202    // --- New tests for task #261 ---
203
204    #[test]
205    fn wip_limit_none_means_unlimited() {
206        let policy = WorkflowPolicy {
207            wip_limit_per_engineer: None,
208            wip_limit_per_reviewer: None,
209            ..WorkflowPolicy::default()
210        };
211
212        // Even very large counts should be allowed
213        assert!(check_wip_limit(&policy, RoleType::Engineer, 100));
214        assert!(check_wip_limit(&policy, RoleType::Engineer, u32::MAX));
215        assert!(check_wip_limit(&policy, RoleType::Manager, 100));
216        assert!(check_wip_limit(&policy, RoleType::Architect, u32::MAX));
217    }
218
219    #[test]
220    fn wip_limit_zero_blocks_all_work() {
221        let policy = WorkflowPolicy {
222            wip_limit_per_engineer: Some(0),
223            wip_limit_per_reviewer: Some(0),
224            ..WorkflowPolicy::default()
225        };
226
227        assert!(!check_wip_limit(&policy, RoleType::Engineer, 0));
228        assert!(!check_wip_limit(&policy, RoleType::Manager, 0));
229        assert!(!check_wip_limit(&policy, RoleType::Architect, 0));
230    }
231
232    #[test]
233    fn architect_uses_reviewer_wip_limit() {
234        let policy = WorkflowPolicy {
235            wip_limit_per_engineer: Some(5),
236            wip_limit_per_reviewer: Some(2),
237            ..WorkflowPolicy::default()
238        };
239
240        // Architect should use reviewer limit (2), not engineer limit (5)
241        assert!(check_wip_limit(&policy, RoleType::Architect, 1));
242        assert!(!check_wip_limit(&policy, RoleType::Architect, 2));
243        assert!(!check_wip_limit(&policy, RoleType::Architect, 3));
244    }
245
246    #[test]
247    fn user_role_always_passes_wip_check() {
248        let policy = WorkflowPolicy {
249            wip_limit_per_engineer: Some(1),
250            wip_limit_per_reviewer: Some(1),
251            ..WorkflowPolicy::default()
252        };
253
254        assert!(check_wip_limit(&policy, RoleType::User, 0));
255        assert!(check_wip_limit(&policy, RoleType::User, 100));
256        assert!(check_wip_limit(&policy, RoleType::User, u32::MAX));
257    }
258
259    #[test]
260    fn escalation_boundary_values() {
261        let policy = WorkflowPolicy {
262            escalation_threshold_secs: 0,
263            ..WorkflowPolicy::default()
264        };
265
266        // Zero threshold means always escalate
267        assert!(should_escalate(&policy, 0));
268        assert!(should_escalate(&policy, 1));
269    }
270
271    #[test]
272    fn review_nudge_at_zero_threshold() {
273        let policy = WorkflowPolicy {
274            review_nudge_threshold_secs: 0,
275            ..WorkflowPolicy::default()
276        };
277
278        assert!(is_review_nudge_due(&policy, 0));
279        assert!(is_review_nudge_due(&policy, 1));
280    }
281
282    #[test]
283    fn review_stale_at_zero_threshold() {
284        let policy = WorkflowPolicy {
285            review_timeout_secs: 0,
286            ..WorkflowPolicy::default()
287        };
288
289        assert!(is_review_stale(&policy, 0));
290        assert!(is_review_stale(&policy, 1));
291    }
292
293    #[test]
294    fn review_nudge_well_before_threshold() {
295        let policy = WorkflowPolicy {
296            review_nudge_threshold_secs: 10000,
297            ..WorkflowPolicy::default()
298        };
299
300        assert!(!is_review_nudge_due(&policy, 0));
301        assert!(!is_review_nudge_due(&policy, 5000));
302        assert!(!is_review_nudge_due(&policy, 9999));
303    }
304
305    #[test]
306    fn override_with_both_fields_none_falls_back_to_global() {
307        use super::super::config::ReviewTimeoutOverride;
308        let mut overrides = std::collections::HashMap::new();
309        overrides.insert(
310            "low".to_string(),
311            ReviewTimeoutOverride {
312                review_nudge_threshold_secs: None,
313                review_timeout_secs: None,
314            },
315        );
316        let policy = WorkflowPolicy {
317            review_nudge_threshold_secs: 1800,
318            review_timeout_secs: 7200,
319            review_timeout_overrides: overrides,
320            ..WorkflowPolicy::default()
321        };
322
323        // Both should fall back to global
324        assert_eq!(effective_nudge_threshold(&policy, "low"), 1800);
325        assert_eq!(effective_escalation_threshold(&policy, "low"), 7200);
326    }
327
328    #[test]
329    fn multiple_priority_overrides_are_independent() {
330        use super::super::config::ReviewTimeoutOverride;
331        let mut overrides = std::collections::HashMap::new();
332        overrides.insert(
333            "critical".to_string(),
334            ReviewTimeoutOverride {
335                review_nudge_threshold_secs: Some(60),
336                review_timeout_secs: Some(120),
337            },
338        );
339        overrides.insert(
340            "high".to_string(),
341            ReviewTimeoutOverride {
342                review_nudge_threshold_secs: Some(300),
343                review_timeout_secs: Some(600),
344            },
345        );
346        overrides.insert(
347            "low".to_string(),
348            ReviewTimeoutOverride {
349                review_nudge_threshold_secs: Some(3600),
350                review_timeout_secs: Some(14400),
351            },
352        );
353        let policy = WorkflowPolicy {
354            review_nudge_threshold_secs: 1800,
355            review_timeout_secs: 7200,
356            review_timeout_overrides: overrides,
357            ..WorkflowPolicy::default()
358        };
359
360        assert_eq!(effective_nudge_threshold(&policy, "critical"), 60);
361        assert_eq!(effective_escalation_threshold(&policy, "critical"), 120);
362        assert_eq!(effective_nudge_threshold(&policy, "high"), 300);
363        assert_eq!(effective_escalation_threshold(&policy, "high"), 600);
364        assert_eq!(effective_nudge_threshold(&policy, "low"), 3600);
365        assert_eq!(effective_escalation_threshold(&policy, "low"), 14400);
366        // Unknown priority falls back to global
367        assert_eq!(effective_nudge_threshold(&policy, "medium"), 1800);
368        assert_eq!(effective_escalation_threshold(&policy, "medium"), 7200);
369    }
370
371    #[test]
372    fn override_with_only_escalation_set() {
373        use super::super::config::ReviewTimeoutOverride;
374        let mut overrides = std::collections::HashMap::new();
375        overrides.insert(
376            "urgent".to_string(),
377            ReviewTimeoutOverride {
378                review_nudge_threshold_secs: None,
379                review_timeout_secs: Some(300),
380            },
381        );
382        let policy = WorkflowPolicy {
383            review_nudge_threshold_secs: 1800,
384            review_timeout_secs: 7200,
385            review_timeout_overrides: overrides,
386            ..WorkflowPolicy::default()
387        };
388
389        // Nudge falls back to global, escalation uses override
390        assert_eq!(effective_nudge_threshold(&policy, "urgent"), 1800);
391        assert_eq!(effective_escalation_threshold(&policy, "urgent"), 300);
392    }
393
394    #[test]
395    fn wip_limit_at_exact_boundary() {
396        let policy = WorkflowPolicy {
397            wip_limit_per_engineer: Some(3),
398            ..WorkflowPolicy::default()
399        };
400
401        assert!(check_wip_limit(&policy, RoleType::Engineer, 2)); // under limit
402        assert!(!check_wip_limit(&policy, RoleType::Engineer, 3)); // at limit
403        assert!(!check_wip_limit(&policy, RoleType::Engineer, 4)); // over limit
404    }
405
406    #[test]
407    fn default_policy_stall_and_health_fields() {
408        let policy = WorkflowPolicy::default();
409        assert_eq!(policy.stall_threshold_secs, 300);
410        assert_eq!(policy.max_stall_restarts, 2);
411        assert_eq!(policy.health_check_interval_secs, 60);
412        assert_eq!(policy.uncommitted_warn_threshold, 200);
413    }
414
415    #[test]
416    fn escalation_with_large_values() {
417        let policy = WorkflowPolicy {
418            escalation_threshold_secs: u64::MAX,
419            ..WorkflowPolicy::default()
420        };
421
422        // Should never escalate with MAX threshold (unless age is also MAX)
423        assert!(!should_escalate(&policy, 0));
424        assert!(!should_escalate(&policy, u64::MAX - 1));
425        assert!(should_escalate(&policy, u64::MAX));
426    }
427}