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