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