Skip to main content

batty_cli/team/
review.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3//! Review and merge transitions for Batty-managed workflow metadata.
4
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8
9use super::workflow::{ReviewDisposition, TaskState, WorkflowMeta, can_transition};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MergeDisposition {
14    MergeReady,
15    ReworkRequired,
16    Discarded,
17    Escalated,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ReviewState {
22    pub reviewer: String,
23    #[serde(default)]
24    pub packet_ref: Option<String>,
25    pub disposition: MergeDisposition,
26    #[serde(default)]
27    pub notes: Option<String>,
28    #[serde(default)]
29    pub reviewed_at: Option<u64>,
30    #[serde(default)]
31    pub nudge_sent: bool,
32}
33
34pub fn apply_review(
35    meta: &mut WorkflowMeta,
36    disposition: MergeDisposition,
37    reviewer: &str,
38) -> Result<(), String> {
39    validate_review_readiness(meta)?;
40
41    let packet_ref = meta
42        .review
43        .as_ref()
44        .and_then(|review| review.packet_ref.clone());
45    let notes = meta.review.as_ref().and_then(|review| review.notes.clone());
46
47    let (next_state, review_disposition, blocked_on) = match disposition {
48        MergeDisposition::MergeReady => (TaskState::Done, Some(ReviewDisposition::Approved), None),
49        MergeDisposition::ReworkRequired => (
50            TaskState::InProgress,
51            Some(ReviewDisposition::ChangesRequested),
52            None,
53        ),
54        MergeDisposition::Discarded => {
55            (TaskState::Archived, Some(ReviewDisposition::Rejected), None)
56        }
57        MergeDisposition::Escalated => (
58            TaskState::Blocked,
59            None,
60            Some(format!("escalated by {reviewer}")),
61        ),
62    };
63
64    can_transition(meta.state, next_state)?;
65    meta.state = next_state;
66    meta.review_owner = Some(reviewer.to_string());
67    meta.review_disposition = review_disposition;
68    let now = SystemTime::now()
69        .duration_since(UNIX_EPOCH)
70        .unwrap_or_default()
71        .as_secs();
72    meta.review = Some(ReviewState {
73        reviewer: reviewer.to_string(),
74        packet_ref,
75        disposition,
76        notes,
77        reviewed_at: Some(now),
78        nudge_sent: false,
79    });
80    meta.blocked_on = blocked_on;
81
82    Ok(())
83}
84
85pub fn validate_review_readiness(meta: &WorkflowMeta) -> Result<(), String> {
86    if meta.state == TaskState::Review {
87        Ok(())
88    } else {
89        Err(format!(
90            "task must be in Review state before applying review, found {:?}",
91            meta.state
92        ))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn review_meta() -> WorkflowMeta {
101        WorkflowMeta {
102            state: TaskState::Review,
103            review: Some(ReviewState {
104                reviewer: "manager-0".to_string(),
105                packet_ref: Some("review/packet-1.json".to_string()),
106                disposition: MergeDisposition::MergeReady,
107                notes: Some("initial packet".to_string()),
108                reviewed_at: None,
109                nudge_sent: false,
110            }),
111            ..WorkflowMeta::default()
112        }
113    }
114
115    #[test]
116    fn merge_ready_moves_review_to_done() {
117        let mut meta = review_meta();
118
119        apply_review(&mut meta, MergeDisposition::MergeReady, "manager-1").unwrap();
120
121        assert_eq!(meta.state, TaskState::Done);
122        assert_eq!(meta.review_owner.as_deref(), Some("manager-1"));
123        assert_eq!(meta.review_disposition, Some(ReviewDisposition::Approved));
124        assert_eq!(meta.blocked_on, None);
125        let review = meta.review.unwrap();
126        assert_eq!(review.disposition, MergeDisposition::MergeReady);
127        assert_eq!(review.packet_ref.as_deref(), Some("review/packet-1.json"));
128    }
129
130    #[test]
131    fn rework_required_moves_review_to_in_progress() {
132        let mut meta = review_meta();
133
134        apply_review(&mut meta, MergeDisposition::ReworkRequired, "manager-1").unwrap();
135
136        assert_eq!(meta.state, TaskState::InProgress);
137        assert_eq!(
138            meta.review_disposition,
139            Some(ReviewDisposition::ChangesRequested)
140        );
141        assert_eq!(meta.blocked_on, None);
142        assert_eq!(
143            meta.review.as_ref().map(|review| review.disposition),
144            Some(MergeDisposition::ReworkRequired)
145        );
146    }
147
148    #[test]
149    fn discarded_moves_review_to_archived() {
150        let mut meta = review_meta();
151
152        apply_review(&mut meta, MergeDisposition::Discarded, "manager-1").unwrap();
153
154        assert_eq!(meta.state, TaskState::Archived);
155        assert_eq!(meta.review_disposition, Some(ReviewDisposition::Rejected));
156        assert_eq!(meta.blocked_on, None);
157        assert_eq!(
158            meta.review.as_ref().map(|review| review.disposition),
159            Some(MergeDisposition::Discarded)
160        );
161    }
162
163    #[test]
164    fn escalated_moves_review_to_blocked() {
165        let mut meta = review_meta();
166
167        apply_review(&mut meta, MergeDisposition::Escalated, "manager-1").unwrap();
168
169        assert_eq!(meta.state, TaskState::Blocked);
170        assert_eq!(meta.review_disposition, None);
171        assert_eq!(meta.blocked_on.as_deref(), Some("escalated by manager-1"));
172        assert_eq!(
173            meta.review.as_ref().map(|review| review.disposition),
174            Some(MergeDisposition::Escalated)
175        );
176    }
177
178    #[test]
179    fn apply_review_rejects_non_review_tasks() {
180        let mut meta = WorkflowMeta {
181            state: TaskState::InProgress,
182            ..WorkflowMeta::default()
183        };
184
185        let err = apply_review(&mut meta, MergeDisposition::MergeReady, "manager-1")
186            .expect_err("non-review tasks should be rejected");
187
188        assert!(err.contains("Review state"));
189        assert_eq!(meta.state, TaskState::InProgress);
190        assert_eq!(meta.review_disposition, None);
191        assert_eq!(meta.blocked_on, None);
192        assert!(meta.review.is_none());
193    }
194
195    #[test]
196    fn validate_review_readiness_rejects_non_review_state() {
197        let meta = WorkflowMeta {
198            state: TaskState::Todo,
199            ..WorkflowMeta::default()
200        };
201
202        let err = validate_review_readiness(&meta).expect_err("todo should not be review-ready");
203        assert!(err.contains("Review state"));
204    }
205
206    #[test]
207    fn review_state_uses_merge_disposition() {
208        let state = ReviewState {
209            reviewer: "manager-1".to_string(),
210            packet_ref: Some("packet-42".to_string()),
211            disposition: MergeDisposition::MergeReady,
212            notes: Some("ready to merge".to_string()),
213            reviewed_at: Some(1700000000),
214            nudge_sent: false,
215        };
216
217        let json = serde_json::to_string(&state).unwrap();
218        assert!(json.contains("\"disposition\":\"merge_ready\""));
219        assert!(json.contains("\"reviewed_at\":1700000000"));
220    }
221}