Skip to main content

spar/
model.rs

1//! The shapes that cross the boundary between spar and a model, and the shapes
2//! spar keeps for itself.
3//!
4//! Everything a model produces is parsed leniently: an LLM that answers
5//! "medium" where the schema said "med" is not a reason to abandon a run that
6//! has already spent real money. Anything genuinely unrecognisable is still an
7//! error, because silently downgrading a blocking finding is worse than
8//! stopping.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::de::{self, Deserializer, Visitor};
14use serde::{Deserialize, Serialize, Serializer};
15
16fn norm_token(text: &str) -> String {
17    text.trim()
18        .to_lowercase()
19        .chars()
20        .filter(|c| c.is_ascii_alphanumeric())
21        .collect()
22}
23
24macro_rules! string_enum {
25    (
26        $(#[$meta:meta])*
27        pub enum $name:ident {
28            $( $(#[$vmeta:meta])* $variant:ident = $canonical:literal $( | $alias:literal )* ),+ $(,)?
29        }
30    ) => {
31        $(#[$meta])*
32        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33        pub enum $name { $( $(#[$vmeta])* $variant ),+ }
34
35        impl $name {
36            pub fn as_str(self) -> &'static str {
37                match self { $( $name::$variant => $canonical ),+ }
38            }
39
40            /// Accept the canonical spelling, any listed alias, and any
41            /// difference in case, spacing, hyphens, or underscores.
42            pub fn parse_lenient(text: &str) -> Option<Self> {
43                let got = norm_token(text);
44                $(
45                    if got == norm_token($canonical) $( || got == norm_token($alias) )* {
46                        return Some($name::$variant);
47                    }
48                )+
49                None
50            }
51
52            pub fn valid_values() -> String {
53                [$( $canonical ),+].join(", ")
54            }
55        }
56
57        impl fmt::Display for $name {
58            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59                f.write_str(self.as_str())
60            }
61        }
62
63        impl Serialize for $name {
64            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65                s.serialize_str(self.as_str())
66            }
67        }
68
69        impl<'de> Deserialize<'de> for $name {
70            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71                let raw = String::deserialize(d)?;
72                $name::parse_lenient(&raw).ok_or_else(|| {
73                    de::Error::custom(format!(
74                        "{} is not one of: {}",
75                        raw,
76                        $name::valid_values()
77                    ))
78                })
79            }
80        }
81    };
82}
83
84string_enum! {
85    /// How much work an issue is. Drives ordering: cheap unblocking work first.
86    pub enum Complexity {
87        S = "s" | "small" | "sm" | "xs" | "trivial",
88        M = "m" | "medium" | "med" | "moderate",
89        L = "l" | "large" | "lg" | "xl" | "big" | "huge",
90    }
91}
92
93string_enum! {
94    /// How likely a change here is to break something.
95    pub enum Risk {
96        Low = "low" | "l" | "minimal" | "none",
97        Med = "med" | "medium" | "m" | "moderate",
98        High = "high" | "h" | "severe" | "critical",
99    }
100}
101
102string_enum! {
103    /// Only `Blocking` gates a merge. This is the whole defence against the
104    /// nitpick spiral: a competent reviewer can always find something, so
105    /// "no objections remaining" is not a stopping condition but "no blocking
106    /// objections" is.
107    pub enum Severity {
108        Blocking = "blocking" | "block" | "major" | "critical",
109        NonBlocking = "non-blocking" | "nonblocking" | "non_blocking" | "minor" | "suggestion",
110        Nit = "nit" | "nitpick" | "style" | "trivial",
111    }
112}
113
114string_enum! {
115    pub enum Verdict {
116        Approve = "approve" | "approved" | "lgtm",
117        ChangesRequested = "changes_requested" | "changes-requested" | "request_changes" | "reject",
118    }
119}
120
121string_enum! {
122    /// What a formal pull request review says. `Verdict` is what a model
123    /// answers; this is what spar submits, and spar never asks a model for it.
124    /// It is derived from the findings: approve when nothing blocks, request
125    /// changes when something both reviewers count does.
126    pub enum ReviewVerdict {
127        Approve = "approve" | "approved",
128        RequestChanges = "request_changes" | "changes_requested" | "request-changes",
129    }
130}
131
132impl ReviewVerdict {
133    /// The `event` the reviews API submits it as.
134    pub fn api_event(self) -> &'static str {
135        match self {
136            ReviewVerdict::Approve => "APPROVE",
137            ReviewVerdict::RequestChanges => "REQUEST_CHANGES",
138        }
139    }
140
141    /// The words that stand in for the badge when GitHub will not take the
142    /// verdict itself, on the viewer's own pull request.
143    pub fn words(self) -> &'static str {
144        match self {
145            ReviewVerdict::Approve => "approve",
146            ReviewVerdict::RequestChanges => "changes requested",
147        }
148    }
149}
150
151string_enum! {
152    pub enum NextAction {
153        Merge = "merge" | "approve" | "ship",
154        FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
155        HandBack = "hand_back" | "hand-back" | "handback" | "return",
156    }
157}
158
159string_enum! {
160    /// A reviewer's point gets exactly one of these. Refutation is a first
161    /// class outcome, not friction: an agent that accepts every comment to get
162    /// approved produces worse code, not better.
163    pub enum Action {
164        Fixed = "fixed" | "fix" | "accepted" | "done",
165        Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
166        FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
167    }
168}
169
170string_enum! {
171    /// What spar should do about one comment somebody left.
172    ///
173    /// Every value maps to exactly one action, which is the point: a verdict
174    /// that needs a second field to decide what it means is one that gets
175    /// decided differently in two places.
176    pub enum Ask {
177        /// A change is asked for, it is right, and it belongs on this branch.
178        /// Make it, push it, say so, and resolve the thread.
179        Implement = "implement" | "do" | "accept" | "fix",
180        /// Right, but really its own piece of work. File it, say where it went.
181        Defer = "defer" | "file_issue" | "filed_issue" | "out_of_scope" | "followup",
182        /// Should not be made. Reply with the reason, leave the thread open.
183        Decline = "decline" | "refute" | "reject" | "disagree" | "wontfix",
184        /// A question rather than a request. Answer it in words.
185        Answer = "answer" | "question" | "reply" | "clarify",
186        /// Nothing is being asked. Praise, or a thread they settled themselves.
187        Nothing = "nothing" | "none" | "no_request" | "noop" | "skip",
188    }
189}
190
191string_enum! {
192    /// What one screening pass decided about one recorded follow-up.
193    ///
194    /// Only `StillRelevant` files anything. The other three all take the entry
195    /// out of the queue, which is why the prompt asks for a reason and the log
196    /// prints it: they are the verdicts nobody sees the working for.
197    pub enum Screened {
198        StillRelevant = "still_relevant" | "still-relevant" | "relevant" | "keep" | "file",
199        AlreadyFixed = "already_fixed" | "already-fixed" | "fixed" | "done" | "resolved",
200        NotWorthIt = "not_worth_it" | "not-worth-it" | "not_worth_doing" | "skip" | "drop" | "wontfix",
201        Duplicate = "duplicate" | "dupe" | "dup",
202    }
203}
204
205string_enum! {
206    /// Terminal state of one issue or one resumed PR.
207    pub enum Status {
208        Pending = "pending",
209        Abandoned = "abandoned",
210        Approved = "approved",
211        Merged = "merged",
212        Escalated = "escalated",
213        Error = "error",
214        /// Review only: findings were produced and posted, nothing was changed.
215        Reviewed = "reviewed",
216        /// Review only: both reviewers found nothing that blocks a merge.
217        Clean = "clean",
218        /// Check-in: comments were read and answered.
219        Answered = "answered",
220        /// Split: the item was broken into parts, which were filed or opened.
221        Split = "split",
222        /// Split: read, and left as one piece.
223        Whole = "whole",
224        /// Review only: the head moved while it was read, or GitHub had not
225        /// caught up with a push yet, so nothing was posted. It runs again on
226        /// the new head.
227        Moved = "moved",
228    }
229}
230
231impl Complexity {
232    pub fn rank(self) -> u8 {
233        match self {
234            Complexity::S => 0,
235            Complexity::M => 1,
236            Complexity::L => 2,
237        }
238    }
239}
240
241impl Severity {
242    /// How badly it matters, independent of the order the variants happen to
243    /// be declared in. Relying on derived `Ord` here would silently invert the
244    /// moment somebody reorders the enum.
245    pub fn rank(self) -> u8 {
246        match self {
247            Severity::Nit => 0,
248            Severity::NonBlocking => 1,
249            Severity::Blocking => 2,
250        }
251    }
252
253    /// The graver of two judgements.
254    ///
255    /// Two reviewers disagreeing about severity is resolved upward on purpose.
256    /// Nothing here gates a merge, it is all advice to a person, and advice
257    /// that under-reports a real defect is worse than advice that over-reports
258    /// a small one.
259    pub fn graver(self, other: Self) -> Self {
260        if self.rank() >= other.rank() {
261            self
262        } else {
263            other
264        }
265    }
266}
267
268impl Risk {
269    pub fn rank(self) -> u8 {
270        match self {
271            Risk::Low => 0,
272            Risk::Med => 1,
273            Risk::High => 2,
274        }
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Lenient scalar helpers
280// ---------------------------------------------------------------------------
281
282/// An integer that may arrive as a number, a float, or a quoted string, with or
283/// without a leading `#`.
284pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
285    struct V;
286    impl<'de> Visitor<'de> for V {
287        type Value = i64;
288        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289            f.write_str("an issue number")
290        }
291        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
292            Ok(v)
293        }
294        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
295            Ok(v as i64)
296        }
297        fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
298            Ok(v as i64)
299        }
300        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
301            v.trim()
302                .trim_start_matches('#')
303                .parse()
304                .map_err(|_| E::custom(format!("{v} is not a number")))
305        }
306    }
307    d.deserialize_any(V)
308}
309
310fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
311    #[derive(Deserialize)]
312    struct One(#[serde(deserialize_with = "de_i64")] i64);
313    let raw = Option::<Vec<One>>::deserialize(d)?;
314    Ok(raw
315        .unwrap_or_default()
316        .into_iter()
317        .map(|One(n)| n)
318        .collect())
319}
320
321/// A boolean that may arrive as `true`, `"true"`, `"yes"`, or `1`.
322pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
323    struct V;
324    impl<'de> Visitor<'de> for V {
325        type Value = bool;
326        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327            f.write_str("a boolean")
328        }
329        fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
330            Ok(v)
331        }
332        fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
333            Ok(v != 0)
334        }
335        fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
336            Ok(v != 0)
337        }
338        fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
339            match norm_token(v).as_str() {
340                "true" | "yes" | "y" | "1" => Ok(true),
341                "false" | "no" | "n" | "0" => Ok(false),
342                other => Err(E::custom(format!("{other} is not a boolean"))),
343            }
344        }
345    }
346    d.deserialize_any(V)
347}
348
349/// An optional number that may arrive as 412, "412", "#412", null, or "none".
350///
351/// Anything unparseable yields None rather than failing. A duplicate pointer is
352/// decoration on a verdict that stands without it, and taking a whole batch down
353/// over one stray string costs a second full repo pass to learn nothing.
354pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
355    Ok(match Option::<serde_json::Value>::deserialize(d)? {
356        Some(serde_json::Value::Number(n)) => n.as_i64(),
357        Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
358        _ => None,
359    })
360}
361
362/// A list of strings that may arrive as null or be missing entirely.
363fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
364    Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
365}
366
367fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
368    #[derive(Deserialize)]
369    struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
370    Ok(Option::<Wrap>::deserialize(d)?
371        .map(|Wrap(b)| b)
372        .unwrap_or(true))
373}
374
375fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
376    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
377}
378
379// ---------------------------------------------------------------------------
380// What the models return
381// ---------------------------------------------------------------------------
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct TriageVerdict {
385    #[serde(deserialize_with = "de_i64")]
386    pub issue: i64,
387    #[serde(deserialize_with = "de_bool")]
388    pub worth_doing: bool,
389    /// The issue holds context for work filed elsewhere. Never opened, and
390    /// never closed either.
391    #[serde(default, deserialize_with = "de_bool")]
392    pub tracker: bool,
393    #[serde(default, deserialize_with = "de_string")]
394    pub reason: String,
395    pub complexity: Complexity,
396    #[serde(default, deserialize_with = "de_i64_vec")]
397    pub depends_on: Vec<i64>,
398    pub risk: Risk,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct TriageResponse {
403    #[serde(default)]
404    pub issues: Vec<TriageVerdict>,
405}
406
407/// One agent's ruling on one entry in the local follow-up queue.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ScreenVerdict {
410    /// The entry's position in the list it was given, from 1.
411    ///
412    /// Matched back by index rather than by title, because the entries are
413    /// spar's own data and so can carry a handle a model cannot paraphrase.
414    #[serde(deserialize_with = "de_i64")]
415    pub entry: i64,
416    pub verdict: Screened,
417    #[serde(default, deserialize_with = "de_string")]
418    pub title: String,
419    #[serde(default, deserialize_with = "de_string")]
420    pub reason: String,
421    /// The open issue a duplicate points at.
422    ///
423    /// Kept apart from `duplicate_of_entry`, because one number cannot say
424    /// which it is and the two are acted on differently: an issue already
425    /// exists, while a sibling entry may itself be dropped or fail to file.
426    #[serde(default, deserialize_with = "de_opt_i64")]
427    pub duplicate_of_issue: Option<i64>,
428    /// The number of another entry in the same list a duplicate points at.
429    #[serde(default, deserialize_with = "de_opt_i64")]
430    pub duplicate_of_entry: Option<i64>,
431}
432
433impl ScreenVerdict {
434    /// Whether this verdict names anything at all as the thing that covers it.
435    pub fn names_a_duplicate(&self) -> bool {
436        self.duplicate_of_issue.is_some() || self.duplicate_of_entry.is_some()
437    }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct ScreenResponse {
442    #[serde(default)]
443    pub entries: Vec<ScreenVerdict>,
444}
445
446/// One agent's ruling on whether one open item is worth splitting at all.
447///
448/// The cheap screen the bare `spar split` runs over a whole queue, so that two
449/// agent calls per item are only spent on the ones where the answer might be
450/// yes.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct SplitScreen {
453    /// The issue or pull request number, which is spar's own data and so cannot
454    /// be paraphrased back wrong.
455    #[serde(deserialize_with = "de_i64")]
456    pub item: i64,
457    #[serde(deserialize_with = "de_bool")]
458    pub split: bool,
459    #[serde(default, deserialize_with = "de_string")]
460    pub reason: String,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct SplitScreenDoc {
465    #[serde(default)]
466    pub items: Vec<SplitScreen>,
467}
468
469/// One piece of a proposed decomposition.
470#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct SplitPart {
472    #[serde(default, deserialize_with = "de_string")]
473    pub title: String,
474    /// The issue body, or the rationale for a pull request slice.
475    #[serde(default, deserialize_with = "de_string")]
476    pub body: String,
477    /// The paths this slice carries. Empty for an issue split, where there is
478    /// no diff to partition.
479    #[serde(default, deserialize_with = "de_string_vec")]
480    pub files: Vec<String>,
481}
482
483/// One agent's decomposition of an issue or a pull request.
484///
485/// Deliberately one proposal rather than two. Two agents asked to decompose the
486/// same thing return two decompositions that cannot be reconciled mechanically,
487/// and reconciling them is a third judgement nobody asked for.
488#[derive(Debug, Clone, Default, Serialize, Deserialize)]
489pub struct SplitProposal {
490    #[serde(default, deserialize_with = "de_bool")]
491    pub should_split: bool,
492    #[serde(default, deserialize_with = "de_string")]
493    pub reason: String,
494    /// Whether the parts are sequential, so each is based on its predecessor
495    /// rather than on the base branch. A property of the change, not of the
496    /// repository, which is why it rides on the proposal.
497    #[serde(default, deserialize_with = "de_bool")]
498    pub stacked: bool,
499    #[serde(default)]
500    pub parts: Vec<SplitPart>,
501}
502
503/// The second agent's ruling on the first one's decomposition.
504#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505pub struct SplitCheck {
506    #[serde(default, deserialize_with = "de_bool")]
507    pub accept: bool,
508    #[serde(default, deserialize_with = "de_bool")]
509    pub stacked: bool,
510    /// Part numbers, from 1, that should not be split out.
511    #[serde(default, deserialize_with = "de_i64_vec")]
512    pub strike: Vec<i64>,
513    #[serde(default, deserialize_with = "de_string")]
514    pub reasoning: String,
515}
516
517/// One agent's judgement of one comment somebody left on a pull request.
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CommentVerdict {
520    /// The handle spar printed beside the comment, copied back so the answer
521    /// can be matched to it. Not a title: a finding's title is the only handle
522    /// there is because findings are model authored, but a comment is spar's
523    /// own data, so it gets one that cannot be paraphrased or collided.
524    #[serde(default, deserialize_with = "de_string")]
525    pub ref_id: String,
526    pub ask: Ask,
527    /// What is being asked for, in one sentence, in the agent's own words.
528    /// How spar checks the comment was understood before acting on it.
529    #[serde(default, deserialize_with = "de_string")]
530    pub request: String,
531    /// The whole argument. For a decline this is posted in the thread, so it is
532    /// written for the person who raised the point.
533    #[serde(default, deserialize_with = "de_string")]
534    pub reasoning: String,
535    /// False when the comment could be read more than one way. spar answers an
536    /// ambiguous comment in words and never guesses at a commit.
537    #[serde(deserialize_with = "de_bool")]
538    pub unambiguous: bool,
539    #[serde(default)]
540    pub new_issue_title: Option<String>,
541    #[serde(default)]
542    pub new_issue_body: Option<String>,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct CheckinDoc {
547    #[serde(default)]
548    pub verdicts: Vec<CommentVerdict>,
549}
550
551/// The second agent's ruling on the first one's call.
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct CommentCheck {
554    #[serde(default, deserialize_with = "de_string")]
555    pub ref_id: String,
556    #[serde(deserialize_with = "de_bool")]
557    pub agrees: bool,
558    /// What this agent would do. It has to say implement alongside
559    /// `agrees` before anything is implemented: the two coming apart is the
560    /// model contradicting itself, and that resolves toward saying rather than
561    /// doing.
562    pub ask: Ask,
563    #[serde(deserialize_with = "de_bool")]
564    pub unambiguous: bool,
565    #[serde(default, deserialize_with = "de_string")]
566    pub reasoning: String,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct CheckDoc {
571    #[serde(default)]
572    pub checks: Vec<CommentCheck>,
573}
574
575/// What the fix pass did about one comment.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct FixOutcome {
578    #[serde(default, deserialize_with = "de_string")]
579    pub ref_id: String,
580    /// False when the change turned out to be wrong once the code was open.
581    /// Declining here is a better answer than making a change you now believe
582    /// is a mistake.
583    #[serde(deserialize_with = "de_bool")]
584    pub changed: bool,
585    /// One sentence naming what changed, or why it was left alone. Posted in
586    /// the thread, so it is written for the person who asked.
587    #[serde(default, deserialize_with = "de_string")]
588    pub summary: String,
589    /// The paths this comment's change touched, checked against the diff so a
590    /// reply never claims a fix that is not in it.
591    #[serde(default)]
592    pub files: Vec<String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct FixReport {
597    #[serde(default)]
598    pub done: Vec<FixOutcome>,
599}
600
601/// What spar has already answered on one pull request or issue.
602///
603/// Keyed by thread or comment, valued by the newest message in it that spar did
604/// not write. A thread that has moved since spar answered carries a different
605/// value and is read again, which is what makes "they replied to my reply"
606/// work. GitHub's own resolved flag covers the threads spar fixed; this covers
607/// the ones it argued with, which stay open on purpose and would otherwise be
608/// re-argued once per run, forever.
609///
610/// Written only after a reply has posted. A run that could not post is a run
611/// that has not answered, and recording it as answered would lose the comment.
612#[derive(Debug, Clone, Default, Serialize, Deserialize)]
613pub struct Answered {
614    #[serde(default)]
615    pub version: u32,
616    #[serde(default)]
617    pub seen: BTreeMap<String, String>,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct Finding {
622    pub severity: Severity,
623    #[serde(default, deserialize_with = "de_string")]
624    pub title: String,
625    #[serde(default, deserialize_with = "de_string")]
626    pub detail: String,
627    #[serde(default, deserialize_with = "de_string")]
628    pub file: String,
629    /// A real problem that this PR did not cause. Those become follow-ups
630    /// rather than review comments, so they cannot gate an unrelated merge.
631    #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
632    pub in_scope: bool,
633
634    // -- the parts of a bug report ---------------------------------------
635    //
636    // Filled when a finding is going to become an issue somebody picks up
637    // cold. `detail` is the one line the pull request thread shows; these are
638    // what a person needs when the thread is not in front of them. All
639    // optional: a finding that stays in the thread has no use for them.
640    /// What is wrong, with the specifics.
641    #[serde(default)]
642    pub problem: Option<String>,
643    /// Steps to reproduce it, and what actually happens.
644    #[serde(default)]
645    pub reproduction: Option<String>,
646    /// What it costs somebody.
647    #[serde(default)]
648    pub impact: Option<String>,
649    /// What it should do instead.
650    #[serde(default)]
651    pub expected: Option<String>,
652}
653
654impl Default for Finding {
655    /// A blank finding, for building one field at a time.
656    ///
657    /// Severity is spelled out here rather than derived, because a severity
658    /// arriving by default is exactly the mistake this codebase refuses
659    /// elsewhere: it is the field that decides whether a merge is gated, and
660    /// the least severe value is the only safe thing to assume.
661    fn default() -> Self {
662        Self {
663            severity: Severity::Nit,
664            title: String::new(),
665            detail: String::new(),
666            file: String::new(),
667            in_scope: true,
668            problem: None,
669            reproduction: None,
670            impact: None,
671            expected: None,
672        }
673    }
674}
675
676impl Finding {
677    /// The parts of a bug report this finding carries, in the order they are
678    /// written, skipping the ones it does not.
679    pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
680        [
681            ("Problem", self.problem.as_deref()),
682            ("Reproduction", self.reproduction.as_deref()),
683            ("Impact", self.impact.as_deref()),
684            ("Expected behavior", self.expected.as_deref()),
685        ]
686        .into_iter()
687        .filter_map(|(heading, text)| {
688            text.map(str::trim)
689                .filter(|t| !t.is_empty())
690                .map(|t| (heading, t))
691        })
692        .collect()
693    }
694}
695
696fn yes() -> bool {
697    true
698}
699
700impl Finding {
701    pub fn blocks(&self) -> bool {
702        self.severity == Severity::Blocking && self.in_scope
703    }
704
705    pub fn where_at(&self) -> &str {
706        if self.file.trim().is_empty() {
707            "general"
708        } else {
709            self.file.trim()
710        }
711    }
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct Review {
716    pub verdict: Verdict,
717    pub next_action: NextAction,
718    #[serde(default, deserialize_with = "de_string")]
719    pub summary: String,
720    #[serde(default)]
721    pub findings: Vec<Finding>,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
725pub struct Disposition {
726    #[serde(default, deserialize_with = "de_string")]
727    pub title: String,
728    /// Carried so a refutation lands on the same ledger key the reviewer's
729    /// finding will hash to next round. Without it the re-litigation guard
730    /// silently never fires for any finding that names a file.
731    #[serde(default, deserialize_with = "de_string")]
732    pub file: String,
733    pub action: Action,
734    #[serde(default, deserialize_with = "de_string")]
735    pub reasoning: String,
736    #[serde(default)]
737    pub new_issue_title: Option<String>,
738    #[serde(default)]
739    pub new_issue_body: Option<String>,
740}
741
742/// What a reviewer did about one of its own findings when it chose to fix them.
743///
744/// The other three editing calls return a structured report. This one returned
745/// free text, and three things were inferred from it: that every finding was
746/// fixed, what the commit subject should be, and that the author had nothing to
747/// answer. A reviewer that fixed two of three points and said so in prose had
748/// all three recorded as fixed.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct OwnFix {
751    #[serde(default, deserialize_with = "de_string")]
752    pub title: String,
753    /// Carried for the same reason a disposition carries it: the ledger key has
754    /// to be the one the finding hashes to.
755    #[serde(default, deserialize_with = "de_string")]
756    pub file: String,
757    #[serde(deserialize_with = "de_bool")]
758    pub fixed: bool,
759    #[serde(default, deserialize_with = "de_string")]
760    pub reason: String,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct OwnFixDoc {
765    #[serde(default, deserialize_with = "de_string")]
766    pub summary: String,
767    #[serde(default)]
768    pub fixes: Vec<OwnFix>,
769}
770
771/// One reviewer's judgement of a finding the *other* reviewer raised.
772///
773/// This is the whole point of review only mode. A finding both models raise
774/// independently is worth a maintainer's attention; a finding one raised and
775/// the other examined and rejected is usually not, and saying so is more useful
776/// than forwarding both.
777#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct Adjudication {
779    #[serde(default, deserialize_with = "de_string")]
780    pub title: String,
781    #[serde(default, deserialize_with = "de_string")]
782    pub file: String,
783    /// Whether the defect is real, judged by reading the code rather than by
784    /// deferring to the other reviewer.
785    #[serde(deserialize_with = "de_bool")]
786    pub agrees: bool,
787    /// This reviewer's own view of how badly it matters.
788    pub severity: Severity,
789    #[serde(default, deserialize_with = "de_string")]
790    pub reasoning: String,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize)]
794pub struct AdjudicationDoc {
795    #[serde(default)]
796    pub verdicts: Vec<Adjudication>,
797}
798
799string_enum! {
800    /// What a second look found about one earlier finding.
801    pub enum Ruling {
802        /// The code now answers the point.
803        Fixed = "fixed" | "resolved" | "addressed" | "done",
804        /// The code did not change for it, and a response showed the point
805        /// was wrong or does not apply.
806        Rebutted = "rebutted" | "refuted" | "answered" | "withdrawn" | "not_a_bug",
807        /// Neither. The point stands, or the change made for it does not
808        /// settle it.
809        Open = "open" | "still_open" | "unresolved" | "stands" | "unfixed",
810    }
811}
812
813/// One reviewer's ruling on one earlier finding.
814#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct RecheckRuling {
816    /// The 1-based number the prompt gave the earlier finding. A number rather
817    /// than a title, for the reason the follow-up screen uses one: a model
818    /// rewrites titles, and a number it copies.
819    #[serde(deserialize_with = "de_i64")]
820    pub index: i64,
821    pub outcome: Ruling,
822    #[serde(default, deserialize_with = "de_string")]
823    pub reasoning: String,
824}
825
826/// What one reviewer answers on a second look: a ruling per earlier finding,
827/// and ordinary findings for anything new.
828#[derive(Debug, Clone, Default, Serialize, Deserialize)]
829pub struct RecheckDoc {
830    #[serde(default, deserialize_with = "de_string")]
831    pub summary: String,
832    #[serde(default)]
833    pub rulings: Vec<RecheckRuling>,
834    #[serde(default)]
835    pub findings: Vec<Finding>,
836}
837
838// ---------------------------------------------------------------------------
839// Brainstorming
840// ---------------------------------------------------------------------------
841
842/// One idea from a brainstorm: existing pieces, paired in a way that has not
843/// been done.
844///
845/// `combines` is the load bearing field. The premise of `spar brainstorm` is
846/// that the parts of an answer already exist and have not been put together, so
847/// an idea that cannot name its parts is a wish rather than a proposal, and
848/// `why_new` has to say what the nearest existing thing is rather than assert
849/// novelty.
850#[derive(Debug, Clone, Default, Serialize, Deserialize)]
851pub struct Idea {
852    #[serde(default, deserialize_with = "de_string")]
853    pub title: String,
854    /// The existing techniques, primitives, results, or patterns this pairs.
855    #[serde(default, deserialize_with = "de_string_vec")]
856    pub combines: Vec<String>,
857    #[serde(default, deserialize_with = "de_string")]
858    pub how_it_works: String,
859    /// The nearest thing that already exists, and how this differs from it.
860    #[serde(default, deserialize_with = "de_string")]
861    pub why_new: String,
862    #[serde(default, deserialize_with = "de_string")]
863    pub enables: String,
864    #[serde(default, deserialize_with = "de_string")]
865    pub risks: String,
866    /// The cheapest thing that would show whether it works.
867    #[serde(default, deserialize_with = "de_string")]
868    pub first_experiment: String,
869}
870
871#[derive(Debug, Clone, Default, Serialize, Deserialize)]
872pub struct IdeasDoc {
873    #[serde(default)]
874    pub ideas: Vec<Idea>,
875}
876
877string_enum! {
878    /// What one agent does with an idea the other proposed.
879    pub enum CrossKind {
880        Build = "build" | "extend" | "improve" | "combine",
881        Challenge = "challenge" | "reject" | "exists" | "object",
882        Keep = "keep" | "agree" | "accept" | "fine",
883    }
884}
885
886/// One agent's ruling on an idea the *other* agent proposed.
887#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct CrossVerdict {
889    /// The number the idea was given in the list, so the ruling can be matched
890    /// back to it. Titles are not used for this: a model rewrites them.
891    #[serde(default, deserialize_with = "de_i64")]
892    pub idea: i64,
893    pub verdict: CrossKind,
894    #[serde(default, deserialize_with = "de_string")]
895    pub reason: String,
896    /// The revised idea, when the verdict is build.
897    #[serde(default)]
898    pub build: Option<Idea>,
899}
900
901#[derive(Debug, Clone, Default, Serialize, Deserialize)]
902pub struct CrossDoc {
903    #[serde(default)]
904    pub verdicts: Vec<CrossVerdict>,
905}
906
907/// What the agent that proposed a challenged idea says to the objection.
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct Defence {
910    #[serde(default, deserialize_with = "de_i64")]
911    pub idea: i64,
912    /// True to stand by it with specifics, false to withdraw it.
913    #[serde(deserialize_with = "de_bool")]
914    pub stands: bool,
915    #[serde(default, deserialize_with = "de_string")]
916    pub reply: String,
917}
918
919/// The last round's answer: defences for the agent's own challenged ideas, and
920/// its ranking of everything still standing.
921#[derive(Debug, Clone, Default, Serialize, Deserialize)]
922pub struct ConvergeDoc {
923    #[serde(default)]
924    pub defences: Vec<Defence>,
925    /// Idea numbers, best first.
926    #[serde(default, deserialize_with = "de_i64_vec")]
927    pub ranking: Vec<i64>,
928}
929
930/// A finding after both reviewers have had their say.
931#[derive(Debug, Clone)]
932pub struct Judged {
933    pub finding: Finding,
934    /// Who first raised it.
935    pub raised_by: String,
936    /// How it ended up.
937    pub standing: Standing,
938    /// The other reviewer's reasoning, when they had something to say.
939    pub counterpoint: Option<String>,
940    /// What the reviewer who raised it said when the objection came back.
941    /// Kept apart from the objection: running both together behind a single
942    /// "the other says" turns the most valuable content in the comment into
943    /// one unreadable sentence.
944    pub defence: Option<String>,
945}
946
947string_enum! {
948    /// How a finding stands after both reviewers have had their say. A word
949    /// rather than a bare enum because the review record keeps it on disk.
950    pub enum Standing {
951        /// Both reviewers raised it independently. The strongest signal there is.
952        Corroborated = "corroborated" | "both",
953        /// One raised it, the other read the code and agreed.
954        Confirmed = "confirmed",
955        /// One raised it, the other read the code and rejected it, and it survived
956        /// a rebuttal. A person decides.
957        Disputed = "disputed",
958        /// Raised, rejected, and withdrawn by the reviewer who raised it.
959        Withdrawn = "withdrawn",
960        /// Raised with nobody left to check it, because the round budget ran out.
961        Unverified = "unverified" | "one_reviewer",
962    }
963}
964
965/// What the implementor did, and what the pull request body is built from.
966///
967/// Structured for the reason every other exchange here is structured: a model
968/// asked for a description writes a paragraph about having written one, while a
969/// model asked for a problem, a change list, and a way to check them answers
970/// each of those. The body's substance comes from the fields being asked for
971/// separately, and its brevity from spar composing them rather than the model
972/// narrating.
973#[derive(Debug, Clone, Default, Serialize, Deserialize)]
974pub struct Implementation {
975    /// The issue should not be implemented. No changes, and `reason` says why.
976    #[serde(default, deserialize_with = "de_bool")]
977    pub not_worth_doing: bool,
978    /// Only when declining. Posted on the issue, so it is written for whoever
979    /// opened it rather than for the harness.
980    #[serde(default, deserialize_with = "de_string")]
981    pub reason: String,
982    /// One sentence saying what changed. Leads the body.
983    #[serde(default, deserialize_with = "de_string")]
984    pub summary: String,
985    /// What was actually wrong, as understood after reading the code. Not a
986    /// restatement of the issue: the reviewer can follow the link.
987    #[serde(default, deserialize_with = "de_string")]
988    pub problem: String,
989    /// One line per change that alters behaviour.
990    #[serde(default)]
991    pub changes: Vec<String>,
992    /// How a reviewer confirms the change works.
993    #[serde(default)]
994    pub testing: Vec<String>,
995    /// Anything the reviewer would otherwise have to ask about: a deliberate
996    /// omission, a decision worth defending, a risk. Usually nothing.
997    #[serde(default)]
998    pub notes: Option<String>,
999}
1000
1001#[derive(Debug, Clone, Serialize, Deserialize)]
1002pub struct ResponseDoc {
1003    #[serde(default, deserialize_with = "de_string")]
1004    pub summary: String,
1005    #[serde(default)]
1006    pub dispositions: Vec<Disposition>,
1007}
1008
1009// ---------------------------------------------------------------------------
1010// What spar keeps
1011// ---------------------------------------------------------------------------
1012
1013#[derive(Debug, Clone, Serialize, Deserialize)]
1014pub struct PlanItem {
1015    pub issue: i64,
1016    pub title: String,
1017    pub complexity: Complexity,
1018    pub risk: Risk,
1019    pub depends_on: Vec<i64>,
1020    pub reason: String,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct SkippedItem {
1025    pub issue: i64,
1026    pub title: String,
1027    /// Keyed by agent name, so the plan file says who said what.
1028    pub reasons: BTreeMap<String, String>,
1029    /// An umbrella or epic, which spar comments on and leaves open.
1030    ///
1031    /// Set when *either* agent said so, unlike everything else here, which
1032    /// needs both. Closing already requires agreement, on the principle that
1033    /// one agent's opinion is not enough to close somebody's report; one agent
1034    /// saying the issue is not finished is the same principle from the other
1035    /// side.
1036    #[serde(default)]
1037    pub tracker: bool,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1041pub struct ContestedItem {
1042    pub issue: i64,
1043    pub title: String,
1044    /// Agent name to "do" or "skip".
1045    pub positions: BTreeMap<String, String>,
1046    pub reasons: BTreeMap<String, String>,
1047    #[serde(default, skip_serializing_if = "Option::is_none")]
1048    pub note: Option<String>,
1049}
1050
1051/// An issue triage disagreed about, remembered between runs.
1052///
1053/// A disagreement is the one triage outcome that needs a person, and it was
1054/// also the one that reached them least reliably: a log line suppressed by
1055/// `--quiet`, an entry in `plan.json`, and two calls spent on it again on every
1056/// later run.
1057#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1058pub struct Contested {
1059    #[serde(default)]
1060    pub title: String,
1061    /// Agent name to "do" or "skip".
1062    #[serde(default)]
1063    pub positions: BTreeMap<String, String>,
1064    #[serde(default)]
1065    pub reasons: BTreeMap<String, String>,
1066}
1067
1068#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1069pub struct Plan {
1070    #[serde(default)]
1071    pub order: Vec<PlanItem>,
1072    #[serde(default)]
1073    pub skipped: Vec<SkippedItem>,
1074    #[serde(default)]
1075    pub contested: Vec<ContestedItem>,
1076}
1077
1078string_enum! {
1079    /// How a point stands, one round to the next.
1080    ///
1081    /// Three of these are endings: the code will not change for the point here,
1082    /// so raising it again only spends a round. They do not mean the same thing
1083    /// to a person, which is why `Dropped` is not folded into `Filed`: only
1084    /// `Filed` promises that somewhere holds the point.
1085    ///
1086    /// `Fixed` is not an ending. The code changed, the change is the author's
1087    /// claim about the point, and nothing has checked it. It is here because the
1088    /// ledger is the only thing a later round reads, and recording nothing for a
1089    /// fix left it holding only the points the reviewer lost. Six fix rounds
1090    /// across two pull requests produced no entry at all, and the guard that
1091    /// ends an argument had nothing to match.
1092    pub enum Settled {
1093        Refuted = "refuted" | "refute" | "rejected",
1094        Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1095        Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1096        Fixed = "fixed" | "fix" | "changed",
1097    }
1098}
1099
1100impl Default for Settled {
1101    /// State written before the ledger held anything but refutations.
1102    fn default() -> Self {
1103        Settled::Refuted
1104    }
1105}
1106
1107/// What one attempt to record a follow-up actually did.
1108///
1109/// The distinction the ledger needs. A point written down somewhere is settled
1110/// and tracked; a point deliberately not written down is settled and untracked;
1111/// a point that failed to be written down is not settled at all, and saying it
1112/// was loses it. A single `Option<String>` collapsed all three into "no URL".
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub enum Followup {
1115    /// A tracker issue or a local note now carries it. The string is what a
1116    /// reader is shown: an issue URL, or a note line.
1117    Recorded(String),
1118    /// Something already covers it and nothing was written: a closed issue, or
1119    /// a note the local queue still holds. There is a reference to point at,
1120    /// but no new work to hand anyone.
1121    Covered(String),
1122    /// Deliberately not recorded, with the reason. Follow-ups are off, or the
1123    /// run has spent its cap.
1124    Dropped(&'static str),
1125    /// Nothing was written and nothing covers it. The point is still open, so
1126    /// the next round is free to try again.
1127    Failed,
1128}
1129
1130impl Followup {
1131    /// The reference to add to the run's filed list, when there is a live one.
1132    ///
1133    /// `Covered` is deliberately excluded: a closed issue reported as filed
1134    /// goes back into a wave to be implemented again.
1135    pub fn url(&self) -> Option<&str> {
1136        match self {
1137            Followup::Recorded(url) => Some(url),
1138            _ => None,
1139        }
1140    }
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize)]
1144pub struct LedgerEntry {
1145    pub title: String,
1146    pub file: String,
1147    pub reasoning: String,
1148    pub round: u32,
1149    #[serde(default)]
1150    pub reraised: u32,
1151    #[serde(default)]
1152    pub outcome: Settled,
1153}
1154
1155/// Settled points, keyed by the review loop's finding identity. Ordered so the settled block in a
1156/// prompt is stable between rounds, which keeps prompt caches warm and diffs
1157/// readable.
1158pub type Ledger = BTreeMap<String, LedgerEntry>;
1159
1160#[derive(Debug, Clone, Serialize, Deserialize)]
1161pub struct Dispute {
1162    pub title: String,
1163    #[serde(default, deserialize_with = "de_string")]
1164    pub file: String,
1165    pub reasoning: String,
1166}
1167
1168/// The outcome of working one issue, or resuming one PR.
1169#[derive(Debug, Clone, Serialize, Deserialize)]
1170pub struct IssueRun {
1171    pub issue: i64,
1172    pub title: String,
1173    pub status: Status,
1174    #[serde(default, skip_serializing_if = "Option::is_none")]
1175    pub pr: Option<String>,
1176    #[serde(default)]
1177    pub rounds: u32,
1178    #[serde(default)]
1179    pub disputes: Vec<Dispute>,
1180    #[serde(default)]
1181    pub filed: Vec<String>,
1182    /// Real points a reviewer judged smaller than another round.
1183    ///
1184    /// Nothing else carries them. The round comment is off under the default
1185    /// `pr_comments = "outcome"` and a non-blocking finding is not filed under
1186    /// the default `file_non_blocking = false`, so without this the severity
1187    /// ladder is a way to make a finding disappear rather than a way to stop it
1188    /// costing a round. Silence on a pull request should mean nothing was
1189    /// found, not that nothing was gated.
1190    #[serde(default)]
1191    pub noted: Vec<Finding>,
1192    #[serde(default)]
1193    pub notes: Vec<String>,
1194    /// Runtime circuit breaker after an external follow-up write could not be
1195    /// verified. A later invocation rechecks GitHub before any new write.
1196    #[serde(skip)]
1197    pub followup_writes_uncertain: bool,
1198}
1199
1200impl IssueRun {
1201    pub fn new(issue: i64, title: impl Into<String>) -> Self {
1202        Self {
1203            issue,
1204            title: title.into(),
1205            status: Status::Pending,
1206            pr: None,
1207            rounds: 0,
1208            disputes: Vec::new(),
1209            filed: Vec::new(),
1210            noted: Vec::new(),
1211            notes: Vec::new(),
1212            followup_writes_uncertain: false,
1213        }
1214    }
1215
1216    /// Whether this outcome counts as the run having done its job.
1217    ///
1218    /// A review that produced findings did its job: the findings are the
1219    /// product, and a PR needing work is not a failure of the reviewer.
1220    pub fn succeeded(&self) -> bool {
1221        matches!(
1222            self.status,
1223            Status::Merged
1224                | Status::Approved
1225                | Status::Abandoned
1226                | Status::Reviewed
1227                | Status::Clean
1228                | Status::Answered
1229                // A split that correctly decided nothing needed splitting did
1230                // its job. Without Whole here, `spar split` over a tidy queue
1231                // exits non-zero, which in a script reads as a failure.
1232                | Status::Split
1233                | Status::Whole
1234        )
1235    }
1236}
1237
1238/// Everything needed to pick a review back up, including on another machine.
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct PersistedState {
1241    pub version: u32,
1242    /// Monotonic checkpoint order for writes within this repository.
1243    #[serde(default)]
1244    pub checkpoint: u64,
1245    pub round: u32,
1246    pub next_actor: String,
1247    pub status: Status,
1248    /// Published pull request head to which this checkpoint applies.
1249    #[serde(default, skip_serializing_if = "String::is_empty")]
1250    pub pr_head: String,
1251    #[serde(default)]
1252    pub ledger: Ledger,
1253    #[serde(default)]
1254    pub filed: Vec<String>,
1255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1256    pub open_findings: Vec<Finding>,
1257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1258    pub disputes: Vec<Dispute>,
1259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1260    pub noted: Vec<Finding>,
1261}
1262
1263pub const STATE_VERSION: u32 = 2;
1264
1265/// One finding as the review record keeps it: enough to list it again, rule on
1266/// it, and post it, and nothing a model is not asked for. The parts of a bug
1267/// report a `Finding` may carry are for an issue filed from it, which a record
1268/// never does.
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270pub struct RecordedFinding {
1271    pub title: String,
1272    #[serde(default)]
1273    pub file: String,
1274    pub severity: Severity,
1275    #[serde(default)]
1276    pub detail: String,
1277    #[serde(default = "yes")]
1278    pub in_scope: bool,
1279    #[serde(default)]
1280    pub raised_by: String,
1281    pub standing: Standing,
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub counterpoint: Option<String>,
1284    #[serde(default, skip_serializing_if = "Option::is_none")]
1285    pub defence: Option<String>,
1286}
1287
1288impl RecordedFinding {
1289    pub fn blocks(&self) -> bool {
1290        self.severity == Severity::Blocking && self.in_scope
1291    }
1292
1293    pub fn where_at(&self) -> &str {
1294        if self.file.trim().is_empty() {
1295            "general"
1296        } else {
1297            self.file.trim()
1298        }
1299    }
1300
1301    /// Back to the in-memory shape, for the renderer and the record that
1302    /// follows a second look.
1303    pub fn to_judged(&self) -> Judged {
1304        Judged {
1305            finding: Finding {
1306                severity: self.severity,
1307                title: self.title.clone(),
1308                detail: self.detail.clone(),
1309                file: self.file.clone(),
1310                in_scope: self.in_scope,
1311                ..Finding::default()
1312            },
1313            raised_by: self.raised_by.clone(),
1314            standing: self.standing,
1315            counterpoint: self.counterpoint.clone(),
1316            defence: self.defence.clone(),
1317        }
1318    }
1319}
1320
1321impl From<&Judged> for RecordedFinding {
1322    fn from(j: &Judged) -> Self {
1323        Self {
1324            title: j.finding.title.clone(),
1325            file: j.finding.file.clone(),
1326            severity: j.finding.severity,
1327            detail: j.finding.detail.clone(),
1328            in_scope: j.finding.in_scope,
1329            raised_by: j.raised_by.clone(),
1330            standing: j.standing,
1331            counterpoint: j.counterpoint.clone(),
1332            defence: j.defence.clone(),
1333        }
1334    }
1335}
1336
1337/// What `spar review` leaves behind, and what a second look reads and rewrites.
1338///
1339/// `spar review` used to leave nothing machine readable: not the commit it
1340/// read, not the points it raised. There was therefore nothing to check a later
1341/// push against, and the only second look possible was a first look again.
1342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1343pub struct ReviewRecord {
1344    pub version: u32,
1345    /// The commit the review worktree had checked out when the reviewers read
1346    /// it. From `git rev-parse HEAD` there, never from a second `gh` call.
1347    pub head: String,
1348    /// When the review began, in the one shape GitHub returns,
1349    /// `2026-10-20T18:04:05Z`, so it compares with a comment's `created_at`
1350    /// as a string. The start rather than the end, so a reply made while a
1351    /// long review ran still counts as new on the next look.
1352    pub reviewed_at: String,
1353    #[serde(default)]
1354    pub findings: Vec<RecordedFinding>,
1355    /// Second looks taken so far, which `[listen] max_rechecks` caps.
1356    #[serde(default)]
1357    pub rechecks: u32,
1358    /// The formal verdict posted, when one was. None for a plain comment.
1359    #[serde(default, skip_serializing_if = "Option::is_none")]
1360    pub verdict: Option<ReviewVerdict>,
1361    /// Whether the review reached the pull request. A dry run's record is kept
1362    /// so `spar post` can complete it, but nobody has seen its findings, so it
1363    /// does not count as a review to take a second look after.
1364    #[serde(default)]
1365    pub posted: bool,
1366}
1367
1368pub const REVIEW_RECORD_VERSION: u32 = 1;
1369
1370impl ReviewRecord {
1371    pub fn short_head(&self) -> &str {
1372        let end = self.head.len().min(7);
1373        &self.head[..end]
1374    }
1375
1376    /// The points a second look rules on: everything but what was withdrawn.
1377    pub fn open_points(&self) -> impl Iterator<Item = &RecordedFinding> {
1378        self.findings
1379            .iter()
1380            .filter(|f| f.standing != Standing::Withdrawn)
1381    }
1382}
1383
1384// ---------------------------------------------------------------------------
1385// What gh returns
1386// ---------------------------------------------------------------------------
1387
1388#[derive(Debug, Clone, Deserialize)]
1389pub struct Label {
1390    #[serde(default)]
1391    pub name: String,
1392}
1393
1394#[derive(Debug, Clone, Deserialize, Default)]
1395pub struct Author {
1396    #[serde(default)]
1397    pub login: String,
1398}
1399
1400/// A login, never empty. A deleted account is null on GitHub and becomes
1401/// `ghost`, which no trust setting but `anyone` will act on.
1402pub fn login_of(author: Option<&Author>) -> &str {
1403    match author.map(|a| a.login.trim()) {
1404        Some(login) if !login.is_empty() => login,
1405        _ => "ghost",
1406    }
1407}
1408
1409/// One top level comment on an issue, as `gh issue view --json comments`
1410/// returns it, oldest first.
1411#[derive(Debug, Clone, Deserialize, Default)]
1412#[serde(rename_all = "camelCase")]
1413pub struct IssueComment {
1414    /// Null when the account was deleted.
1415    #[serde(default)]
1416    pub author: Option<Author>,
1417    #[serde(default)]
1418    pub author_association: String,
1419    #[serde(default)]
1420    pub body: String,
1421    #[serde(default)]
1422    pub created_at: String,
1423    #[serde(default)]
1424    pub is_minimized: bool,
1425}
1426
1427impl IssueComment {
1428    pub fn login(&self) -> &str {
1429        login_of(self.author.as_ref())
1430    }
1431}
1432
1433#[derive(Debug, Clone, Deserialize)]
1434pub struct Issue {
1435    pub number: i64,
1436    #[serde(default)]
1437    pub title: String,
1438    #[serde(default)]
1439    pub body: Option<String>,
1440    #[serde(default)]
1441    pub state: String,
1442    /// Why it was closed: `completed` or `not_planned`, empty when open or
1443    /// when the field was not asked for.
1444    ///
1445    /// The tracker's tick means the work landed, and a closed issue on its own
1446    /// does not say that. With `close_skipped = true`, the default, an item
1447    /// both agents declined is closed as not planned, and ticking it writes
1448    /// "this was done" into somebody's checklist.
1449    #[serde(default, rename = "stateReason")]
1450    pub state_reason: Option<String>,
1451    #[serde(default)]
1452    pub url: String,
1453    #[serde(default)]
1454    pub labels: Vec<Label>,
1455    /// Who filed it. None when the field was not asked for.
1456    #[serde(default)]
1457    pub author: Option<Author>,
1458    /// What was said on it since it was filed. Empty when the field was not
1459    /// asked for, which a listing of the whole queue never does.
1460    #[serde(default)]
1461    pub comments: Vec<IssueComment>,
1462}
1463
1464impl Issue {
1465    pub fn body_text(&self) -> &str {
1466        self.body.as_deref().unwrap_or("")
1467    }
1468
1469    /// The body as a prompt carries it, and whether anything was left off.
1470    ///
1471    /// Shortened only past `max`, which is sized so that nothing a person
1472    /// wrote ever reaches it. When it does fire the cut is announced in the
1473    /// text itself: an agent handed a fragment with no marker has no way to
1474    /// tell it from an issue that simply ended there, so it judges the part it
1475    /// saw and reports the confidence of having seen all of it.
1476    ///
1477    /// The cut lands on a line boundary, and an unbalanced code fence is closed
1478    /// rather than left hanging. Broken markdown reads as a defect in the issue
1479    /// and costs the model attention to rule out.
1480    pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1481        let body = self.body_text().trim();
1482        if body.chars().count() <= max {
1483            return (body.to_string(), false);
1484        }
1485        let clipped: String = body.chars().take(max).collect();
1486        let mut kept = match clipped.rfind('\n') {
1487            Some(at) => clipped[..at].to_string(),
1488            None => clipped,
1489        };
1490        if kept.matches("```").count() % 2 == 1 {
1491            kept.push_str("\n```");
1492        }
1493        kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1494        (kept, true)
1495    }
1496
1497    pub fn is_closed(&self) -> bool {
1498        self.state.eq_ignore_ascii_case("closed")
1499    }
1500
1501    /// Closed because the work was done, rather than because somebody decided
1502    /// not to do it.
1503    ///
1504    /// An unknown reason counts as completed: GitHub leaves `stateReason`
1505    /// empty on issues closed before it existed, and reading those as declined
1506    /// would leave every old checklist unticked.
1507    pub fn closed_as_completed(&self) -> bool {
1508        self.is_closed() && !self.closed_as_not_planned()
1509    }
1510
1511    pub fn closed_as_not_planned(&self) -> bool {
1512        self.is_closed()
1513            && self
1514                .state_reason
1515                .as_deref()
1516                .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1517    }
1518}
1519
1520#[derive(Debug, Clone, Deserialize)]
1521pub struct PrRef {
1522    pub number: i64,
1523    #[serde(default)]
1524    pub url: String,
1525    #[serde(default)]
1526    pub title: String,
1527}
1528
1529#[derive(Debug, Clone, Deserialize)]
1530pub struct IssueRef {
1531    pub number: i64,
1532}
1533
1534/// An open pull request's size, as one listing call reports it.
1535///
1536/// Enough for the screen to say whether a change is worth splitting without
1537/// fetching its head, which would be one network round trip per pull request
1538/// for a question whose answer is usually no.
1539#[derive(Debug, Clone, Default, Deserialize)]
1540#[serde(rename_all = "camelCase")]
1541pub struct PrRow {
1542    pub number: i64,
1543    #[serde(default)]
1544    pub title: String,
1545    #[serde(default)]
1546    pub changed_files: i64,
1547    #[serde(default)]
1548    pub additions: i64,
1549    #[serde(default)]
1550    pub deletions: i64,
1551}
1552
1553impl PrRow {
1554    /// The size, as the screen prompt carries it.
1555    pub fn size(&self) -> String {
1556        format!(
1557            "{} file(s), +{} -{}",
1558            self.changed_files, self.additions, self.deletions
1559        )
1560    }
1561}
1562
1563#[derive(Debug, Clone, Deserialize)]
1564#[serde(rename_all = "camelCase")]
1565pub struct PrView {
1566    pub number: i64,
1567    #[serde(default)]
1568    pub url: String,
1569    #[serde(default)]
1570    pub title: String,
1571    /// The description, which is the author's own account of the change. Read
1572    /// by every pass that judges whether the change does what it claims.
1573    #[serde(default)]
1574    pub body: String,
1575    #[serde(default)]
1576    pub head_ref_name: String,
1577    #[serde(default)]
1578    pub base_ref_name: String,
1579    #[serde(default)]
1580    pub state: String,
1581    #[serde(default)]
1582    pub closing_issues_references: Vec<IssueRef>,
1583    /// True when the PR's head branch lives on a fork rather than this
1584    /// repository.
1585    #[serde(default)]
1586    pub is_cross_repository: bool,
1587}
1588
1589/// The fields a sweep reads from `gh pr list` and `gh pr view`: enough to
1590/// notice a head that moved, a review request, an assignment, and a close.
1591pub const PR_SWEEP_FIELDS: &str =
1592    "number,title,headRefOid,updatedAt,author,isDraft,isCrossRepository,reviewRequests,assignees,state";
1593
1594/// One row of `gh pr list --json PR_SWEEP_FIELDS`, or of `gh pr view` with
1595/// the same fields.
1596#[derive(Debug, Clone, Default, Deserialize)]
1597#[serde(rename_all = "camelCase")]
1598pub struct PrSweepRow {
1599    pub number: i64,
1600    #[serde(default)]
1601    pub title: String,
1602    #[serde(default)]
1603    pub head_ref_oid: String,
1604    #[serde(default)]
1605    pub updated_at: String,
1606    #[serde(default)]
1607    pub author: Option<Author>,
1608    #[serde(default)]
1609    pub is_draft: bool,
1610    #[serde(default)]
1611    pub is_cross_repository: bool,
1612    #[serde(default)]
1613    pub review_requests: Vec<Login>,
1614    #[serde(default)]
1615    pub assignees: Vec<Login>,
1616    /// OPEN, CLOSED, or MERGED.
1617    #[serde(default)]
1618    pub state: String,
1619}
1620
1621/// A user in a `reviewRequests` or `assignees` array. A team entry has `name`
1622/// and `slug` instead, which reads as an empty login and never matches the
1623/// viewer.
1624#[derive(Debug, Clone, Default, Deserialize)]
1625pub struct Login {
1626    #[serde(default)]
1627    pub login: String,
1628    /// A team's `org/slug`, as gh gives it. Empty for a user.
1629    #[serde(default)]
1630    pub slug: String,
1631}
1632
1633/// Issues and pull requests share one number sequence per repository, so a
1634/// number names exactly one of them and spar can work out which.
1635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1636#[serde(rename_all = "lowercase")]
1637pub enum ItemKind {
1638    Issue,
1639    Pr,
1640}
1641
1642impl std::fmt::Display for ItemKind {
1643    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1644        f.write_str(match self {
1645            ItemKind::Issue => "issue",
1646            ItemKind::Pr => "pull request",
1647        })
1648    }
1649}
1650
1651impl PrView {
1652    pub fn is_open(&self) -> bool {
1653        self.state.eq_ignore_ascii_case("open")
1654    }
1655}
1656
1657/// Where a pull request's head stands right now, asked two ways.
1658///
1659/// GitHub moves a pull request to a pushed commit some time after the push,
1660/// usually seconds and sometimes minutes, and until then `headRefOid` and
1661/// `refs/pull/N/head` both name the commit before it. The branch itself moves
1662/// with the push. A look that trusted the pull request alone read the commit
1663/// before a push, and by the time it posted its verdict was two pushes old.
1664#[derive(Debug, Clone, PartialEq, Eq)]
1665pub struct LiveHead {
1666    /// The head GitHub's pull request names.
1667    pub pull: String,
1668    /// Where the branch is, for a branch in this repository. None for a fork,
1669    /// whose branch `origin` does not have.
1670    pub branch: Option<String>,
1671}
1672
1673impl LiveHead {
1674    /// The head, once the pull request and its branch agree on it.
1675    pub fn settled(&self) -> Option<&str> {
1676        match &self.branch {
1677            Some(branch) if !branch.eq_ignore_ascii_case(&self.pull) => None,
1678            _ => Some(&self.pull),
1679        }
1680    }
1681
1682    /// The newer head when either differs from the one a look read. The
1683    /// branch is asked first, since it moves before the pull request does.
1684    pub fn moved_from(&self, reviewed: &str) -> Option<&str> {
1685        self.branch
1686            .as_deref()
1687            .filter(|branch| !branch.eq_ignore_ascii_case(reviewed))
1688            .or_else(|| (!self.pull.eq_ignore_ascii_case(reviewed)).then_some(self.pull.as_str()))
1689    }
1690}
1691
1692#[cfg(test)]
1693mod tests {
1694    use super::*;
1695
1696    #[test]
1697    fn severity_accepts_the_canonical_spelling() {
1698        assert_eq!(
1699            Some(Severity::NonBlocking),
1700            Severity::parse_lenient("non-blocking")
1701        );
1702    }
1703
1704    #[test]
1705    fn severity_accepts_near_misses() {
1706        for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1707            assert_eq!(
1708                Some(Severity::NonBlocking),
1709                Severity::parse_lenient(text),
1710                "{text}"
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn severity_rejects_nonsense() {
1717        assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1718    }
1719
1720    #[test]
1721    fn complexity_ordering_is_cheapest_first() {
1722        assert!(Complexity::S.rank() < Complexity::M.rank());
1723        assert!(Complexity::M.rank() < Complexity::L.rank());
1724    }
1725
1726    #[test]
1727    fn finding_defaults_to_in_scope() {
1728        let f: Finding = serde_json::from_value(serde_json::json!({
1729            "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1730        }))
1731        .unwrap();
1732        assert!(f.in_scope);
1733        assert!(f.blocks());
1734    }
1735
1736    #[test]
1737    fn out_of_scope_blocking_does_not_block() {
1738        let f: Finding = serde_json::from_value(serde_json::json!({
1739            "severity": "blocking", "title": "t", "detail": "d",
1740            "file": "a.rs", "in_scope": false
1741        }))
1742        .unwrap();
1743        assert!(!f.blocks());
1744    }
1745
1746    #[test]
1747    fn triage_tolerates_a_quoted_issue_number() {
1748        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1749            "issue": "#42", "worth_doing": "yes", "reason": "r",
1750            "complexity": "medium", "depends_on": ["39"], "risk": "low"
1751        }))
1752        .unwrap();
1753        assert_eq!(42, v.issue);
1754        assert!(v.worth_doing);
1755        assert_eq!(Complexity::M, v.complexity);
1756        assert_eq!(vec![39], v.depends_on);
1757    }
1758
1759    #[test]
1760    fn triage_tolerates_a_missing_depends_on() {
1761        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1762            "issue": 1, "worth_doing": false, "reason": "r",
1763            "complexity": "s", "risk": "high"
1764        }))
1765        .unwrap();
1766        assert!(v.depends_on.is_empty());
1767    }
1768
1769    #[test]
1770    fn review_tolerates_a_missing_findings_array() {
1771        let r: Review = serde_json::from_value(serde_json::json!({
1772            "verdict": "approve", "next_action": "merge", "summary": "fine"
1773        }))
1774        .unwrap();
1775        assert!(r.findings.is_empty());
1776    }
1777
1778    #[test]
1779    fn a_null_reason_is_an_empty_string_not_a_failure() {
1780        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1781            "issue": 1, "worth_doing": true, "reason": null,
1782            "complexity": "s", "depends_on": [], "risk": "low"
1783        }))
1784        .unwrap();
1785        assert_eq!("", v.reason);
1786    }
1787
1788    #[test]
1789    fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1790        let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1791            "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1792        }));
1793        assert!(out.is_err());
1794    }
1795
1796    /// A `spar split` that correctly decided nothing needed splitting did its
1797    /// job. Without both of these in `succeeded`, it exits non-zero over a tidy
1798    /// queue, which in a script reads as a failure.
1799    #[test]
1800    fn deciding_not_to_split_is_not_a_failure() {
1801        for status in [Status::Split, Status::Whole] {
1802            let mut run = IssueRun::new(1, "t");
1803            run.status = status;
1804            assert!(run.succeeded(), "{status}");
1805        }
1806    }
1807
1808    #[test]
1809    fn status_round_trips_through_json() {
1810        let run = IssueRun::new(4, "t");
1811        let text = serde_json::to_string(&run).unwrap();
1812        let back: IssueRun = serde_json::from_str(&text).unwrap();
1813        assert_eq!(Status::Pending, back.status);
1814    }
1815
1816    /// Nothing was posted, so a script that runs `spar review` has something
1817    /// to try again.
1818    #[test]
1819    fn a_look_whose_head_moved_did_not_do_its_job() {
1820        let mut run = IssueRun::new(4, "t");
1821        run.status = Status::Moved;
1822        assert!(!run.succeeded());
1823        let text = serde_json::to_string(&run).unwrap();
1824        assert!(text.contains("\"moved\""), "{text}");
1825        let back: IssueRun = serde_json::from_str(&text).unwrap();
1826        assert_eq!(Status::Moved, back.status);
1827    }
1828
1829    fn live(pull: &str, branch: Option<&str>) -> LiveHead {
1830        LiveHead {
1831            pull: pull.into(),
1832            branch: branch.map(String::from),
1833        }
1834    }
1835
1836    /// A branch can be at the pushed commit for minutes while its pull request
1837    /// still names the one before.
1838    #[test]
1839    fn a_head_is_settled_only_once_the_pull_request_has_caught_up_with_its_branch() {
1840        assert_eq!(None, live("fb3a7ab", Some("2d5e2b5")).settled());
1841        assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2d5e2b5")).settled());
1842        assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2D5E2B5")).settled());
1843        // A fork's branch is not on origin, so the pull request is all there is.
1844        assert_eq!(Some("fb3a7ab"), live("fb3a7ab", None).settled());
1845    }
1846
1847    #[test]
1848    fn a_head_moved_from_the_reviewed_one_names_the_newer_commit() {
1849        assert_eq!(None, live("fb3a7ab", Some("fb3a7ab")).moved_from("fb3a7ab"));
1850        assert_eq!(None, live("FB3A7AB", None).moved_from("fb3a7ab"));
1851        // The branch moves first, so it is the newer of the two.
1852        assert_eq!(
1853            Some("c493635"),
1854            live("2d5e2b5", Some("c493635")).moved_from("fb3a7ab")
1855        );
1856        assert_eq!(
1857            Some("c493635"),
1858            live("fb3a7ab", Some("c493635")).moved_from("fb3a7ab")
1859        );
1860        assert_eq!(Some("2d5e2b5"), live("2d5e2b5", None).moved_from("fb3a7ab"));
1861        assert_eq!(
1862            Some("2d5e2b5"),
1863            live("2d5e2b5", Some("fb3a7ab")).moved_from("fb3a7ab")
1864        );
1865    }
1866}
1867
1868#[cfg(test)]
1869mod body_for_prompt_tests {
1870    use super::*;
1871
1872    fn issue(body: &str) -> Issue {
1873        let mut i: Issue = serde_json::from_value(serde_json::json!({
1874            "number": 1, "title": "t", "state": "open", "url": "u"
1875        }))
1876        .expect("an issue");
1877        i.body = Some(body.to_string());
1878        i
1879    }
1880
1881    /// The case that is every real issue: nothing is touched and nothing is
1882    /// claimed to be.
1883    #[test]
1884    fn an_issue_that_fits_is_handed_over_whole() {
1885        let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1886        assert_eq!("The guard is inverted.", body);
1887        assert!(!cut);
1888    }
1889
1890    /// A fragment with no marker is indistinguishable from an issue that ended
1891    /// there, so the agent judges what it saw with the confidence of having
1892    /// seen everything. That is what the silent caps did.
1893    #[test]
1894    fn a_shortened_body_says_so_in_the_text() {
1895        let long = "line of text\n".repeat(500);
1896        let (body, cut) = issue(&long).body_for_prompt(200);
1897        assert!(cut);
1898        assert!(body.contains("Shortened to fit"), "{body}");
1899        assert!(body.len() < long.len());
1900    }
1901
1902    /// Broken markdown reads as a defect in the issue, and costs the model
1903    /// attention to rule out.
1904    #[test]
1905    fn a_cut_never_leaves_a_code_fence_open() {
1906        let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1907        let (out, cut) = issue(&body).body_for_prompt(120);
1908        assert!(cut);
1909        assert_eq!(0, out.matches("```").count() % 2, "{out}");
1910    }
1911
1912    /// Cutting mid-word turns the last thing the agent reads into nonsense.
1913    #[test]
1914    fn a_cut_lands_on_a_line_boundary() {
1915        let body = "aaaa bbbb cccc\n".repeat(100);
1916        let (out, _) = issue(&body).body_for_prompt(100);
1917        let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1918        assert!(kept.ends_with("cccc"), "{kept:?}");
1919    }
1920}
1921
1922#[cfg(test)]
1923mod review_record_tests {
1924    use super::*;
1925
1926    fn judged() -> Judged {
1927        Judged {
1928            finding: Finding {
1929                severity: Severity::Blocking,
1930                title: "Retry loop never terminates".into(),
1931                detail: "the guard compares against Some(0)".into(),
1932                file: "src/net.rs:88".into(),
1933                in_scope: true,
1934                problem: Some("dropped on the way to the record".into()),
1935                ..Finding::default()
1936            },
1937            raised_by: "claude and codex".into(),
1938            standing: Standing::Corroborated,
1939            counterpoint: None,
1940            defence: Some("stands".into()),
1941        }
1942    }
1943
1944    #[test]
1945    fn a_review_record_round_trips_through_json_and_keeps_its_standing_as_a_word() {
1946        let record = ReviewRecord {
1947            version: REVIEW_RECORD_VERSION,
1948            head: "0123abcdef".into(),
1949            reviewed_at: "2026-10-20T18:04:05Z".into(),
1950            findings: vec![RecordedFinding::from(&judged())],
1951            rechecks: 2,
1952            verdict: Some(ReviewVerdict::RequestChanges),
1953            posted: true,
1954        };
1955        let text = serde_json::to_string_pretty(&record).unwrap();
1956        assert!(text.contains("\"standing\": \"corroborated\""), "{text}");
1957        assert!(text.contains("\"verdict\": \"request_changes\""), "{text}");
1958        assert!(text.contains("\"severity\": \"blocking\""), "{text}");
1959        assert!(!text.contains("problem"), "{text}");
1960        let back: ReviewRecord = serde_json::from_str(&text).unwrap();
1961        assert_eq!(record, back);
1962        assert_eq!("0123abc", back.short_head());
1963    }
1964
1965    #[test]
1966    fn a_record_missing_the_newer_fields_still_reads() {
1967        let text = r#"{"version":1,"head":"abc","reviewed_at":"2026-10-20T18:04:05Z",
1968            "findings":[{"title":"T","severity":"nit","standing":"one_reviewer"}]}"#;
1969        let back: ReviewRecord = serde_json::from_str(text).unwrap();
1970        assert_eq!(0, back.rechecks);
1971        assert!(!back.posted);
1972        assert_eq!(None, back.verdict);
1973        assert!(back.findings[0].in_scope);
1974        assert_eq!(Standing::Unverified, back.findings[0].standing);
1975        assert_eq!("general", back.findings[0].where_at());
1976    }
1977
1978    #[test]
1979    fn a_recorded_finding_turns_back_into_a_judged_finding() {
1980        let original = judged();
1981        let back = RecordedFinding::from(&original).to_judged();
1982        assert_eq!(original.finding.title, back.finding.title);
1983        assert_eq!(original.finding.file, back.finding.file);
1984        assert_eq!(original.finding.severity, back.finding.severity);
1985        assert_eq!(original.raised_by, back.raised_by);
1986        assert_eq!(original.standing, back.standing);
1987        assert_eq!(original.defence, back.defence);
1988        assert_eq!(None, back.finding.problem);
1989        assert!(back.finding.blocks());
1990    }
1991
1992    #[test]
1993    fn a_ruling_tolerates_a_quoted_index_and_a_synonym_outcome() {
1994        let doc: RecheckDoc = serde_json::from_str(
1995            r#"{"summary":"holds","rulings":[{"index":"2","outcome":"Resolved","reasoning":"line 4"}],"findings":[]}"#,
1996        )
1997        .unwrap();
1998        assert_eq!(2, doc.rulings[0].index);
1999        assert_eq!(Ruling::Fixed, doc.rulings[0].outcome);
2000        let doc: RecheckDoc =
2001            serde_json::from_str(r#"{"summary":"","rulings":[],"findings":[]}"#).unwrap();
2002        assert!(doc.rulings.is_empty());
2003        assert_eq!("APPROVE", ReviewVerdict::Approve.api_event());
2004        assert_eq!("changes requested", ReviewVerdict::RequestChanges.words());
2005    }
2006}