Skip to main content

voro_core/
review.rs

1//! What a review is given against (DESIGN.md §8): the completion summary of
2//! the cycle in hand, with the feedback it answers when there is one, and how
3//! much of a task's diff the operator should be shown. Pure of I/O — the `gh`
4//! and `git` calls that supply the revisions live in the `voro` crate — so
5//! every decision here is testable against canned strings.
6
7use serde::Deserialize;
8
9use crate::model::Event;
10
11/// The event kind recording the branch revision the operator reviewed. The
12/// `events` table carries it, so nothing about delta re-review needs a column.
13pub const REVIEWED_EVENT: &str = "reviewed";
14
15/// How much of a task's work to put in front of the operator.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ReviewDiff {
18    /// Everything on the branch. A first review carries no notice; a fallback
19    /// from a delta that could not be built explains itself in one.
20    Full { notice: Option<String> },
21    /// Only what the rework added: the revision reviewed last, and the head it
22    /// is compared against.
23    Since { since: String, head: String },
24}
25
26impl ReviewDiff {
27    /// The explanation to show beside the opened diff, if any.
28    pub fn notice(&self) -> Option<&str> {
29        match self {
30            ReviewDiff::Full { notice } => notice.as_deref(),
31            ReviewDiff::Since { .. } => None,
32        }
33    }
34}
35
36/// Decide what a review should show (DESIGN.md §8). `last_reviewed` is the
37/// revision recorded when the operator rejected, `head` the branch's current
38/// tip, and `still_on_branch` whether that recorded revision is still reachable
39/// from the head — false after a force-push or a rebase rewrote it away.
40///
41/// Every gap degrades to the full diff rather than erroring: a task nobody has
42/// rejected yet reviews exactly as it always did (no notice, so nothing about a
43/// first review changes), and a delta that cannot be built says why.
44pub 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
88/// Two revisions naming the same commit. Compared prefix-tolerantly and
89/// case-insensitively, since an abbreviated SHA from one source (a viewer
90/// template, a hand-set value) has to match a full one from another.
91fn 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
99/// Abbreviate a revision for a human-facing notice. Counted in characters
100/// rather than bytes, so a value that is not a SHA cannot split one.
101fn short(sha: &str) -> String {
102    sha.chars().take(7).collect()
103}
104
105/// A tracked PR's revisions, read from `gh pr view --json headRefOid,commits`:
106/// the current head, and the commits the PR contains. The latter is what
107/// answers "is the revision I reviewed still on this branch?" — a rework that
108/// appends commits keeps it, and a force-push or rebase drops it.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct PrRevisions {
111    pub head: Option<String>,
112    pub commits: Vec<String>,
113}
114
115impl PrRevisions {
116    /// Whether `sha` is one of the PR's commits.
117    pub fn contains(&self, sha: &str) -> bool {
118        self.commits.iter().any(|c| same_revision(c, sha))
119    }
120}
121
122/// Read a PR's head and commit list out of `gh pr view --json
123/// headRefOid,commits` output. Malformed or missing fields degrade to empty —
124/// no head and no commits — which [`plan_review_diff`] turns into the full
125/// diff, so an unreachable `gh` never blocks a review.
126pub 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/// What a reviewer reads a task against (DESIGN.md §8): the completion summary
157/// the current cycle came back with, and — on a task that has been sent back —
158/// the rejection feedback that summary answers.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CompletionReport {
161    pub summary: String,
162    pub feedback: Option<String>,
163}
164
165/// Read a task's current completion report off its event log. `None` when the
166/// cycle in hand has reported nothing: a task that never completed, and a
167/// rework still in flight, whose newest summary belongs to the round that was
168/// rejected and so answers neither the feedback nor the operator's question of
169/// what changed this time.
170pub 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
187/// Whether a task has been reviewed and sent back at some point — what makes a
188/// redispatch a rework rather than a first attempt (DESIGN.md §8).
189pub 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        // an abbreviated head naming the same commit reads the same way
240        let plan = plan_review_diff(Some(A), Some(&A[..8]), true);
241        assert!(plan.notice().unwrap().contains("nothing new"), "{plan:?}");
242    }
243
244    /// The force-push case the acceptance names: the recorded revision is gone,
245    /// so the range cannot be built — degrade to the whole diff, never error.
246    #[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    /// An unreachable `gh`/`git` leaves no head at all, which is a fallback
257    /// rather than a failure.
258    #[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        // prefix-tolerant, so a recorded full SHA matches an abbreviated one
275        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    /// The first review's case, and the one the card exists for: a summary with
300    /// no rejection behind it is still the report the operator reads.
301    #[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        // the newest summary wins, as `set --summary` amending one intends
308        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    /// A rework in flight reports nothing rather than reporting the summary of
335    /// the round that was rejected, which describes work already judged.
336    #[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    /// A second rejection supersedes the first: the summary that answered the
346    /// previous round is not an answer to the newest feedback.
347    #[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}