1use serde::Deserialize;
8
9use crate::model::Event;
10
11pub const REVIEWED_EVENT: &str = "reviewed";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ReviewDiff {
18 Full { notice: Option<String> },
21 Since { since: String, head: String },
24}
25
26impl ReviewDiff {
27 pub fn notice(&self) -> Option<&str> {
29 match self {
30 ReviewDiff::Full { notice } => notice.as_deref(),
31 ReviewDiff::Since { .. } => None,
32 }
33 }
34}
35
36pub fn plan_review_diff(
45 last_reviewed: Option<&str>,
46 head: Option<&str>,
47 still_on_branch: bool,
48) -> ReviewDiff {
49 let Some(since) = trimmed(last_reviewed) else {
50 return ReviewDiff::Full { notice: None };
51 };
52 let Some(head) = trimmed(head) else {
53 return ReviewDiff::Full {
54 notice: Some(format!(
55 "could not read the branch head, so the diff since {} is unavailable — \
56 showing the full diff",
57 short(since)
58 )),
59 };
60 };
61 if same_revision(since, head) {
62 return ReviewDiff::Full {
63 notice: Some(format!(
64 "nothing new since the last review ({}) — showing the full diff",
65 short(since)
66 )),
67 };
68 }
69 if !still_on_branch {
70 return ReviewDiff::Full {
71 notice: Some(format!(
72 "the revision reviewed last ({}) is no longer on the branch — \
73 showing the full diff",
74 short(since)
75 )),
76 };
77 }
78 ReviewDiff::Since {
79 since: since.to_string(),
80 head: head.to_string(),
81 }
82}
83
84fn trimmed(raw: Option<&str>) -> Option<&str> {
85 raw.map(str::trim).filter(|s| !s.is_empty())
86}
87
88fn same_revision(a: &str, b: &str) -> bool {
92 let (short, long) = if a.len() <= b.len() { (a, b) } else { (b, a) };
93 short.len() >= 7
94 && long
95 .to_ascii_lowercase()
96 .starts_with(&short.to_ascii_lowercase())
97}
98
99fn short(sha: &str) -> String {
102 sha.chars().take(7).collect()
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct PrRevisions {
111 pub head: Option<String>,
112 pub commits: Vec<String>,
113}
114
115impl PrRevisions {
116 pub fn contains(&self, sha: &str) -> bool {
118 self.commits.iter().any(|c| same_revision(c, sha))
119 }
120}
121
122pub fn parse_pr_revisions(json: &str) -> PrRevisions {
127 #[derive(Deserialize)]
128 struct Commit {
129 #[serde(default)]
130 oid: String,
131 }
132 #[derive(Deserialize)]
133 struct View {
134 #[serde(default)]
135 #[serde(rename = "headRefOid")]
136 head_ref_oid: String,
137 #[serde(default)]
138 commits: Vec<Commit>,
139 }
140 match serde_json::from_str::<View>(json) {
141 Ok(view) => PrRevisions {
142 head: Some(view.head_ref_oid)
143 .map(|h| h.trim().to_string())
144 .filter(|h| !h.is_empty()),
145 commits: view
146 .commits
147 .into_iter()
148 .map(|c| c.oid.trim().to_string())
149 .filter(|o| !o.is_empty())
150 .collect(),
151 },
152 Err(_) => PrRevisions::default(),
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CompletionReport {
161 pub summary: String,
162 pub feedback: Option<String>,
163}
164
165pub fn completion_report(events: &[Event]) -> Option<CompletionReport> {
171 let last_feedback = events
172 .iter()
173 .rev()
174 .find(|e| e.kind == "feedback" && detail(e).is_some());
175 let newer_than = last_feedback.map_or(i64::MIN, |e| e.id);
176 let summary = events
177 .iter()
178 .rev()
179 .take_while(|e| e.id > newer_than)
180 .find_map(|e| if e.kind == "summary" { detail(e) } else { None })?;
181 Some(CompletionReport {
182 summary: summary.to_string(),
183 feedback: last_feedback.and_then(detail).map(str::to_string),
184 })
185}
186
187pub fn was_rejected(events: &[Event]) -> bool {
190 events
191 .iter()
192 .any(|e| e.kind == "feedback" && detail(e).is_some())
193}
194
195fn detail(event: &Event) -> Option<&str> {
196 event
197 .detail
198 .as_deref()
199 .map(str::trim)
200 .filter(|d| !d.is_empty())
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
208 const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
209
210 #[test]
211 fn a_first_review_is_the_full_diff_and_says_nothing() {
212 assert_eq!(
213 plan_review_diff(None, Some(B), true),
214 ReviewDiff::Full { notice: None }
215 );
216 assert_eq!(
217 plan_review_diff(Some(" "), Some(B), true),
218 ReviewDiff::Full { notice: None }
219 );
220 }
221
222 #[test]
223 fn a_rework_reviews_only_what_it_added() {
224 assert_eq!(
225 plan_review_diff(Some(A), Some(B), true),
226 ReviewDiff::Since {
227 since: A.into(),
228 head: B.into(),
229 }
230 );
231 assert!(plan_review_diff(Some(A), Some(B), true).notice().is_none());
232 }
233
234 #[test]
235 fn a_head_that_never_moved_falls_back_with_a_notice() {
236 let plan = plan_review_diff(Some(A), Some(A), true);
237 assert!(matches!(plan, ReviewDiff::Full { .. }));
238 assert!(plan.notice().unwrap().contains("nothing new"), "{plan:?}");
239 let plan = plan_review_diff(Some(A), Some(&A[..8]), true);
241 assert!(plan.notice().unwrap().contains("nothing new"), "{plan:?}");
242 }
243
244 #[test]
247 fn a_rewritten_history_falls_back_with_a_notice() {
248 let plan = plan_review_diff(Some(A), Some(B), false);
249 assert!(matches!(plan, ReviewDiff::Full { .. }));
250 let notice = plan.notice().unwrap();
251 assert!(notice.contains("no longer on the branch"), "{notice}");
252 assert!(notice.contains("aaaaaaa"), "{notice}");
253 assert!(!notice.contains(A), "the notice abbreviates: {notice}");
254 }
255
256 #[test]
259 fn an_unreadable_head_falls_back_with_a_notice() {
260 let plan = plan_review_diff(Some(A), None, true);
261 assert!(
262 plan.notice().unwrap().contains("could not read"),
263 "{plan:?}"
264 );
265 }
266
267 #[test]
268 fn pr_revisions_carry_the_head_and_the_commits() {
269 let json = r#"{"headRefOid":"bbbb","commits":[{"oid":"aaaaaaaaaa"},{"oid":"bbbb"}]}"#;
270 let revs = parse_pr_revisions(json);
271 assert_eq!(revs.head.as_deref(), Some("bbbb"));
272 assert_eq!(revs.commits.len(), 2);
273 assert!(revs.contains("aaaaaaaaaa"));
274 assert!(revs.contains("aaaaaaa"));
276 assert!(!revs.contains("cccccccccc"));
277 }
278
279 #[test]
280 fn unusable_gh_output_reads_as_no_revisions() {
281 for raw in ["not json", "", "{}", r#"{"headRefOid":""}"#] {
282 let revs = parse_pr_revisions(raw);
283 assert!(revs.head.is_none(), "{raw}");
284 assert!(revs.commits.is_empty(), "{raw}");
285 assert!(!revs.contains(A), "{raw}");
286 }
287 }
288
289 fn event(id: i64, kind: &str, detail: &str) -> Event {
290 Event {
291 id,
292 task_id: Some(1),
293 at: "2026-08-12 00:00:00".into(),
294 kind: kind.into(),
295 detail: Some(detail.into()),
296 }
297 }
298
299 #[test]
302 fn a_task_nobody_rejected_reports_its_summary_alone() {
303 let events = vec![event(1, "summary", "did the thing")];
304 let report = completion_report(&events).unwrap();
305 assert_eq!(report.summary, "did the thing");
306 assert!(report.feedback.is_none());
307 let events = vec![
309 event(1, "summary", "first draft"),
310 event(2, "summary", "amended"),
311 ];
312 assert_eq!(completion_report(&events).unwrap().summary, "amended");
313 }
314
315 #[test]
316 fn a_task_that_reported_nothing_has_no_report() {
317 assert!(completion_report(&[]).is_none());
318 assert!(completion_report(&[event(1, "dispatch", "claude")]).is_none());
319 assert!(completion_report(&[event(1, "summary", " ")]).is_none());
320 }
321
322 #[test]
323 fn the_report_pairs_the_newest_feedback_with_the_answer_to_it() {
324 let events = vec![
325 event(1, "summary", "first attempt"),
326 event(2, "feedback", "tests missing"),
327 event(3, "summary", "1. tests missing — added them"),
328 ];
329 let report = completion_report(&events).unwrap();
330 assert_eq!(report.feedback.as_deref(), Some("tests missing"));
331 assert_eq!(report.summary, "1. tests missing — added them");
332 }
333
334 #[test]
337 fn a_rework_still_in_flight_has_no_report() {
338 let events = vec![
339 event(1, "summary", "first attempt"),
340 event(2, "feedback", "tests missing"),
341 ];
342 assert!(completion_report(&events).is_none());
343 }
344
345 #[test]
348 fn a_second_rejection_supersedes_the_first() {
349 let events = vec![
350 event(1, "feedback", "tests missing"),
351 event(2, "summary", "added tests"),
352 event(3, "feedback", "and the docs"),
353 ];
354 assert!(completion_report(&events).is_none());
355 }
356
357 #[test]
358 fn a_rejection_anywhere_in_the_history_marks_a_rework() {
359 assert!(!was_rejected(&[event(1, "summary", "did the thing")]));
360 assert!(!was_rejected(&[event(1, "feedback", " ")]));
361 assert!(was_rejected(&[
362 event(1, "feedback", "tests missing"),
363 event(2, "summary", "added tests"),
364 ]));
365 }
366}