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