1#![cfg_attr(not(test), allow(dead_code))]
2
3use 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}