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    }
191}
192
193impl Complexity {
194    pub fn rank(self) -> u8 {
195        match self {
196            Complexity::S => 0,
197            Complexity::M => 1,
198            Complexity::L => 2,
199        }
200    }
201}
202
203impl Severity {
204    /// How badly it matters, independent of the order the variants happen to
205    /// be declared in. Relying on derived `Ord` here would silently invert the
206    /// moment somebody reorders the enum.
207    pub fn rank(self) -> u8 {
208        match self {
209            Severity::Nit => 0,
210            Severity::NonBlocking => 1,
211            Severity::Blocking => 2,
212        }
213    }
214
215    /// The graver of two judgements.
216    ///
217    /// Two reviewers disagreeing about severity is resolved upward on purpose.
218    /// Nothing here gates a merge, it is all advice to a person, and advice
219    /// that under-reports a real defect is worse than advice that over-reports
220    /// a small one.
221    pub fn graver(self, other: Self) -> Self {
222        if self.rank() >= other.rank() {
223            self
224        } else {
225            other
226        }
227    }
228}
229
230impl Risk {
231    pub fn rank(self) -> u8 {
232        match self {
233            Risk::Low => 0,
234            Risk::Med => 1,
235            Risk::High => 2,
236        }
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Lenient scalar helpers
242// ---------------------------------------------------------------------------
243
244/// An integer that may arrive as a number, a float, or a quoted string, with or
245/// without a leading `#`.
246pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
247    struct V;
248    impl<'de> Visitor<'de> for V {
249        type Value = i64;
250        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251            f.write_str("an issue number")
252        }
253        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
254            Ok(v)
255        }
256        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
257            Ok(v as i64)
258        }
259        fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
260            Ok(v as i64)
261        }
262        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
263            v.trim()
264                .trim_start_matches('#')
265                .parse()
266                .map_err(|_| E::custom(format!("{v} is not a number")))
267        }
268    }
269    d.deserialize_any(V)
270}
271
272fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
273    #[derive(Deserialize)]
274    struct One(#[serde(deserialize_with = "de_i64")] i64);
275    let raw = Option::<Vec<One>>::deserialize(d)?;
276    Ok(raw
277        .unwrap_or_default()
278        .into_iter()
279        .map(|One(n)| n)
280        .collect())
281}
282
283/// A boolean that may arrive as `true`, `"true"`, `"yes"`, or `1`.
284pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
285    struct V;
286    impl<'de> Visitor<'de> for V {
287        type Value = bool;
288        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289            f.write_str("a boolean")
290        }
291        fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
292            Ok(v)
293        }
294        fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
295            Ok(v != 0)
296        }
297        fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
298            Ok(v != 0)
299        }
300        fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
301            match norm_token(v).as_str() {
302                "true" | "yes" | "y" | "1" => Ok(true),
303                "false" | "no" | "n" | "0" => Ok(false),
304                other => Err(E::custom(format!("{other} is not a boolean"))),
305            }
306        }
307    }
308    d.deserialize_any(V)
309}
310
311/// An optional number that may arrive as 412, "412", "#412", null, or "none".
312///
313/// Anything unparseable yields None rather than failing. A duplicate pointer is
314/// decoration on a verdict that stands without it, and taking a whole batch down
315/// over one stray string costs a second full repo pass to learn nothing.
316pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
317    Ok(match Option::<serde_json::Value>::deserialize(d)? {
318        Some(serde_json::Value::Number(n)) => n.as_i64(),
319        Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
320        _ => None,
321    })
322}
323
324fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
325    #[derive(Deserialize)]
326    struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
327    Ok(Option::<Wrap>::deserialize(d)?
328        .map(|Wrap(b)| b)
329        .unwrap_or(true))
330}
331
332fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
333    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
334}
335
336// ---------------------------------------------------------------------------
337// What the models return
338// ---------------------------------------------------------------------------
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TriageVerdict {
342    #[serde(deserialize_with = "de_i64")]
343    pub issue: i64,
344    #[serde(deserialize_with = "de_bool")]
345    pub worth_doing: bool,
346    /// The issue holds context for work filed elsewhere. Never opened, and
347    /// never closed either.
348    #[serde(default, deserialize_with = "de_bool")]
349    pub tracker: bool,
350    #[serde(default, deserialize_with = "de_string")]
351    pub reason: String,
352    pub complexity: Complexity,
353    #[serde(default, deserialize_with = "de_i64_vec")]
354    pub depends_on: Vec<i64>,
355    pub risk: Risk,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct TriageResponse {
360    #[serde(default)]
361    pub issues: Vec<TriageVerdict>,
362}
363
364/// One agent's ruling on one entry in the local follow-up queue.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ScreenVerdict {
367    /// The entry's position in the list it was given, from 1.
368    ///
369    /// Matched back by index rather than by title, because the entries are
370    /// spar's own data and so can carry a handle a model cannot paraphrase.
371    #[serde(deserialize_with = "de_i64")]
372    pub entry: i64,
373    pub verdict: Screened,
374    #[serde(default, deserialize_with = "de_string")]
375    pub title: String,
376    #[serde(default, deserialize_with = "de_string")]
377    pub reason: String,
378    /// The issue or sibling entry a duplicate points at.
379    #[serde(default, deserialize_with = "de_opt_i64")]
380    pub duplicate_of: Option<i64>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ScreenResponse {
385    #[serde(default)]
386    pub entries: Vec<ScreenVerdict>,
387}
388
389/// One agent's judgement of one comment somebody left on a pull request.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct CommentVerdict {
392    /// The handle spar printed beside the comment, copied back so the answer
393    /// can be matched to it. Not a title: a finding's title is the only handle
394    /// there is because findings are model authored, but a comment is spar's
395    /// own data, so it gets one that cannot be paraphrased or collided.
396    #[serde(default, deserialize_with = "de_string")]
397    pub ref_id: String,
398    pub ask: Ask,
399    /// What is being asked for, in one sentence, in the agent's own words.
400    /// How spar checks the comment was understood before acting on it.
401    #[serde(default, deserialize_with = "de_string")]
402    pub request: String,
403    /// The whole argument. For a decline this is posted in the thread, so it is
404    /// written for the person who raised the point.
405    #[serde(default, deserialize_with = "de_string")]
406    pub reasoning: String,
407    /// False when the comment could be read more than one way. spar answers an
408    /// ambiguous comment in words and never guesses at a commit.
409    #[serde(deserialize_with = "de_bool")]
410    pub unambiguous: bool,
411    #[serde(default)]
412    pub new_issue_title: Option<String>,
413    #[serde(default)]
414    pub new_issue_body: Option<String>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct CheckinDoc {
419    #[serde(default)]
420    pub verdicts: Vec<CommentVerdict>,
421}
422
423/// The second agent's ruling on the first one's call.
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct CommentCheck {
426    #[serde(default, deserialize_with = "de_string")]
427    pub ref_id: String,
428    #[serde(deserialize_with = "de_bool")]
429    pub agrees: bool,
430    /// What this agent would do instead. Read only when `agrees` is false.
431    pub ask: Ask,
432    #[serde(deserialize_with = "de_bool")]
433    pub unambiguous: bool,
434    #[serde(default, deserialize_with = "de_string")]
435    pub reasoning: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct CheckDoc {
440    #[serde(default)]
441    pub checks: Vec<CommentCheck>,
442}
443
444/// What the fix pass did about one comment.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct FixOutcome {
447    #[serde(default, deserialize_with = "de_string")]
448    pub ref_id: String,
449    /// False when the change turned out to be wrong once the code was open.
450    /// Declining here is a better answer than making a change you now believe
451    /// is a mistake.
452    #[serde(deserialize_with = "de_bool")]
453    pub changed: bool,
454    /// One sentence naming what changed, or why it was left alone. Posted in
455    /// the thread, so it is written for the person who asked.
456    #[serde(default, deserialize_with = "de_string")]
457    pub summary: String,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct FixReport {
462    #[serde(default)]
463    pub done: Vec<FixOutcome>,
464}
465
466/// What spar has already answered on one pull request or issue.
467///
468/// Keyed by thread or comment, valued by the newest message in it that spar did
469/// not write. A thread that has moved since spar answered carries a different
470/// value and is read again, which is what makes "they replied to my reply"
471/// work. GitHub's own resolved flag covers the threads spar fixed; this covers
472/// the ones it argued with, which stay open on purpose and would otherwise be
473/// re-argued once per run, forever.
474///
475/// Written only after a reply has posted. A run that could not post is a run
476/// that has not answered, and recording it as answered would lose the comment.
477#[derive(Debug, Clone, Default, Serialize, Deserialize)]
478pub struct Answered {
479    #[serde(default)]
480    pub version: u32,
481    #[serde(default)]
482    pub seen: BTreeMap<String, String>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct Finding {
487    pub severity: Severity,
488    #[serde(default, deserialize_with = "de_string")]
489    pub title: String,
490    #[serde(default, deserialize_with = "de_string")]
491    pub detail: String,
492    #[serde(default, deserialize_with = "de_string")]
493    pub file: String,
494    /// A real problem that this PR did not cause. Those become follow-ups
495    /// rather than review comments, so they cannot gate an unrelated merge.
496    #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
497    pub in_scope: bool,
498
499    // -- the parts of a bug report ---------------------------------------
500    //
501    // Filled when a finding is going to become an issue somebody picks up
502    // cold. `detail` is the one line the pull request thread shows; these are
503    // what a person needs when the thread is not in front of them. All
504    // optional: a finding that stays in the thread has no use for them.
505    /// What is wrong, with the specifics.
506    #[serde(default)]
507    pub problem: Option<String>,
508    /// Steps to reproduce it, and what actually happens.
509    #[serde(default)]
510    pub reproduction: Option<String>,
511    /// What it costs somebody.
512    #[serde(default)]
513    pub impact: Option<String>,
514    /// What it should do instead.
515    #[serde(default)]
516    pub expected: Option<String>,
517}
518
519impl Default for Finding {
520    /// A blank finding, for building one field at a time.
521    ///
522    /// Severity is spelled out here rather than derived, because a severity
523    /// arriving by default is exactly the mistake this codebase refuses
524    /// elsewhere: it is the field that decides whether a merge is gated, and
525    /// the least severe value is the only safe thing to assume.
526    fn default() -> Self {
527        Self {
528            severity: Severity::Nit,
529            title: String::new(),
530            detail: String::new(),
531            file: String::new(),
532            in_scope: true,
533            problem: None,
534            reproduction: None,
535            impact: None,
536            expected: None,
537        }
538    }
539}
540
541impl Finding {
542    /// The parts of a bug report this finding carries, in the order they are
543    /// written, skipping the ones it does not.
544    pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
545        [
546            ("Problem", self.problem.as_deref()),
547            ("Reproduction", self.reproduction.as_deref()),
548            ("Impact", self.impact.as_deref()),
549            ("Expected behavior", self.expected.as_deref()),
550        ]
551        .into_iter()
552        .filter_map(|(heading, text)| {
553            text.map(str::trim)
554                .filter(|t| !t.is_empty())
555                .map(|t| (heading, t))
556        })
557        .collect()
558    }
559}
560
561fn yes() -> bool {
562    true
563}
564
565impl Finding {
566    pub fn blocks(&self) -> bool {
567        self.severity == Severity::Blocking && self.in_scope
568    }
569
570    pub fn where_at(&self) -> &str {
571        if self.file.trim().is_empty() {
572            "general"
573        } else {
574            self.file.trim()
575        }
576    }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct Review {
581    pub verdict: Verdict,
582    pub next_action: NextAction,
583    #[serde(default, deserialize_with = "de_string")]
584    pub summary: String,
585    #[serde(default)]
586    pub findings: Vec<Finding>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct Disposition {
591    #[serde(default, deserialize_with = "de_string")]
592    pub title: String,
593    /// Carried so a refutation lands on the same ledger key the reviewer's
594    /// finding will hash to next round. Without it the re-litigation guard
595    /// silently never fires for any finding that names a file.
596    #[serde(default, deserialize_with = "de_string")]
597    pub file: String,
598    pub action: Action,
599    #[serde(default, deserialize_with = "de_string")]
600    pub reasoning: String,
601    #[serde(default)]
602    pub new_issue_title: Option<String>,
603    #[serde(default)]
604    pub new_issue_body: Option<String>,
605}
606
607/// One reviewer's judgement of a finding the *other* reviewer raised.
608///
609/// This is the whole point of review only mode. A finding both models raise
610/// independently is worth a maintainer's attention; a finding one raised and
611/// the other examined and rejected is usually not, and saying so is more useful
612/// than forwarding both.
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct Adjudication {
615    #[serde(default, deserialize_with = "de_string")]
616    pub title: String,
617    #[serde(default, deserialize_with = "de_string")]
618    pub file: String,
619    /// Whether the defect is real, judged by reading the code rather than by
620    /// deferring to the other reviewer.
621    #[serde(deserialize_with = "de_bool")]
622    pub agrees: bool,
623    /// This reviewer's own view of how badly it matters.
624    pub severity: Severity,
625    #[serde(default, deserialize_with = "de_string")]
626    pub reasoning: String,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct AdjudicationDoc {
631    #[serde(default)]
632    pub verdicts: Vec<Adjudication>,
633}
634
635/// A finding after both reviewers have had their say.
636#[derive(Debug, Clone)]
637pub struct Judged {
638    pub finding: Finding,
639    /// Who first raised it.
640    pub raised_by: String,
641    /// How it ended up.
642    pub standing: Standing,
643    /// The other reviewer's reasoning, when they had something to say.
644    pub counterpoint: Option<String>,
645    /// What the reviewer who raised it said when the objection came back.
646    /// Kept apart from the objection: running both together behind a single
647    /// "the other says" turns the most valuable content in the comment into
648    /// one unreadable sentence.
649    pub defence: Option<String>,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub enum Standing {
654    /// Both reviewers raised it independently. The strongest signal there is.
655    Corroborated,
656    /// One raised it, the other read the code and agreed.
657    Confirmed,
658    /// One raised it, the other read the code and rejected it, and it survived
659    /// a rebuttal. A person decides.
660    Disputed,
661    /// Raised, rejected, and withdrawn by the reviewer who raised it.
662    Withdrawn,
663    /// Raised with nobody left to check it, because the round budget ran out.
664    Unverified,
665}
666
667/// What the implementor did, and what the pull request body is built from.
668///
669/// Structured for the reason every other exchange here is structured: a model
670/// asked for a description writes a paragraph about having written one, while a
671/// model asked for a problem, a change list, and a way to check them answers
672/// each of those. The body's substance comes from the fields being asked for
673/// separately, and its brevity from spar composing them rather than the model
674/// narrating.
675#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676pub struct Implementation {
677    /// The issue should not be implemented. No commits, and `reason` says why.
678    #[serde(default, deserialize_with = "de_bool")]
679    pub not_worth_doing: bool,
680    /// Only when declining. Posted on the issue, so it is written for whoever
681    /// opened it rather than for the harness.
682    #[serde(default, deserialize_with = "de_string")]
683    pub reason: String,
684    /// One sentence saying what changed. Leads the body.
685    #[serde(default, deserialize_with = "de_string")]
686    pub summary: String,
687    /// What was actually wrong, as understood after reading the code. Not a
688    /// restatement of the issue: the reviewer can follow the link.
689    #[serde(default, deserialize_with = "de_string")]
690    pub problem: String,
691    /// One line per change that alters behaviour.
692    #[serde(default)]
693    pub changes: Vec<String>,
694    /// How a reviewer confirms the change works.
695    #[serde(default)]
696    pub testing: Vec<String>,
697    /// Anything the reviewer would otherwise have to ask about: a deliberate
698    /// omission, a decision worth defending, a risk. Usually nothing.
699    #[serde(default)]
700    pub notes: Option<String>,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct ResponseDoc {
705    #[serde(default, deserialize_with = "de_string")]
706    pub summary: String,
707    #[serde(default)]
708    pub dispositions: Vec<Disposition>,
709}
710
711// ---------------------------------------------------------------------------
712// What spar keeps
713// ---------------------------------------------------------------------------
714
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct PlanItem {
717    pub issue: i64,
718    pub title: String,
719    pub complexity: Complexity,
720    pub risk: Risk,
721    pub depends_on: Vec<i64>,
722    pub reason: String,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct SkippedItem {
727    pub issue: i64,
728    pub title: String,
729    /// Keyed by agent name, so the plan file says who said what.
730    pub reasons: BTreeMap<String, String>,
731    /// An umbrella or epic, which spar comments on and leaves open.
732    ///
733    /// Set when *either* agent said so, unlike everything else here, which
734    /// needs both. Closing already requires agreement, on the principle that
735    /// one agent's opinion is not enough to close somebody's report; one agent
736    /// saying the issue is not finished is the same principle from the other
737    /// side.
738    #[serde(default)]
739    pub tracker: bool,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct ContestedItem {
744    pub issue: i64,
745    pub title: String,
746    /// Agent name to "do" or "skip".
747    pub positions: BTreeMap<String, String>,
748    pub reasons: BTreeMap<String, String>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub note: Option<String>,
751}
752
753#[derive(Debug, Clone, Default, Serialize, Deserialize)]
754pub struct Plan {
755    #[serde(default)]
756    pub order: Vec<PlanItem>,
757    #[serde(default)]
758    pub skipped: Vec<SkippedItem>,
759    #[serde(default)]
760    pub contested: Vec<ContestedItem>,
761}
762
763string_enum! {
764    /// How a point stopped being open. Every ending means the same thing to the
765    /// next round: the code will not change for it here, so raising it again
766    /// only spends a round. They do not mean the same thing to a person, which
767    /// is why `Dropped` is not folded into `Filed`: only `Filed` promises that
768    /// somewhere holds the point.
769    pub enum Settled {
770        Refuted = "refuted" | "refute" | "rejected",
771        Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
772        Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
773    }
774}
775
776impl Default for Settled {
777    /// State written before the ledger held anything but refutations.
778    fn default() -> Self {
779        Settled::Refuted
780    }
781}
782
783/// What one attempt to record a follow-up actually did.
784///
785/// The distinction the ledger needs. A point written down somewhere is settled
786/// and tracked; a point deliberately not written down is settled and untracked;
787/// a point that failed to be written down is not settled at all, and saying it
788/// was loses it. A single `Option<String>` collapsed all three into "no URL".
789#[derive(Debug, Clone, PartialEq, Eq)]
790pub enum Followup {
791    /// A tracker issue or a local note now carries it. The string is what a
792    /// reader is shown: an issue URL, or a note line.
793    Recorded(String),
794    /// Something already covers it and nothing was written: a closed issue, or
795    /// a note the local queue still holds. There is a reference to point at,
796    /// but no new work to hand anyone.
797    Covered(String),
798    /// Deliberately not recorded, with the reason. Follow-ups are off, or the
799    /// run has spent its cap.
800    Dropped(&'static str),
801    /// Nothing was written and nothing covers it. The point is still open, so
802    /// the next round is free to try again.
803    Failed,
804}
805
806impl Followup {
807    /// The reference to add to the run's filed list, when there is a live one.
808    ///
809    /// `Covered` is deliberately excluded: a closed issue reported as filed
810    /// goes back into a wave to be implemented again.
811    pub fn url(&self) -> Option<&str> {
812        match self {
813            Followup::Recorded(url) => Some(url),
814            _ => None,
815        }
816    }
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize)]
820pub struct LedgerEntry {
821    pub title: String,
822    pub file: String,
823    pub reasoning: String,
824    pub round: u32,
825    #[serde(default)]
826    pub reraised: u32,
827    #[serde(default)]
828    pub outcome: Settled,
829}
830
831/// Settled points, keyed by `finding_key`. Ordered so the settled block in a
832/// prompt is stable between rounds, which keeps prompt caches warm and diffs
833/// readable.
834pub type Ledger = BTreeMap<String, LedgerEntry>;
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
837pub struct Dispute {
838    pub title: String,
839    pub reasoning: String,
840}
841
842/// The outcome of working one issue, or resuming one PR.
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct IssueRun {
845    pub issue: i64,
846    pub title: String,
847    pub status: Status,
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub pr: Option<String>,
850    #[serde(default)]
851    pub rounds: u32,
852    #[serde(default)]
853    pub disputes: Vec<Dispute>,
854    #[serde(default)]
855    pub filed: Vec<String>,
856    #[serde(default)]
857    pub notes: Vec<String>,
858}
859
860impl IssueRun {
861    pub fn new(issue: i64, title: impl Into<String>) -> Self {
862        Self {
863            issue,
864            title: title.into(),
865            status: Status::Pending,
866            pr: None,
867            rounds: 0,
868            disputes: Vec::new(),
869            filed: Vec::new(),
870            notes: Vec::new(),
871        }
872    }
873
874    /// Whether this outcome counts as the run having done its job.
875    ///
876    /// A review that produced findings did its job: the findings are the
877    /// product, and a PR needing work is not a failure of the reviewer.
878    pub fn succeeded(&self) -> bool {
879        matches!(
880            self.status,
881            Status::Merged
882                | Status::Approved
883                | Status::Abandoned
884                | Status::Reviewed
885                | Status::Clean
886                | Status::Answered
887        )
888    }
889}
890
891/// Everything needed to pick a review back up, including on another machine.
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct PersistedState {
894    pub version: u32,
895    pub round: u32,
896    pub next_actor: String,
897    pub status: Status,
898    #[serde(default)]
899    pub ledger: Ledger,
900    #[serde(default)]
901    pub filed: Vec<String>,
902}
903
904pub const STATE_VERSION: u32 = 1;
905
906// ---------------------------------------------------------------------------
907// What gh returns
908// ---------------------------------------------------------------------------
909
910#[derive(Debug, Clone, Deserialize)]
911pub struct Label {
912    #[serde(default)]
913    pub name: String,
914}
915
916#[derive(Debug, Clone, Deserialize)]
917pub struct Issue {
918    pub number: i64,
919    #[serde(default)]
920    pub title: String,
921    #[serde(default)]
922    pub body: Option<String>,
923    #[serde(default)]
924    pub state: String,
925    #[serde(default)]
926    pub url: String,
927    #[serde(default)]
928    pub labels: Vec<Label>,
929}
930
931impl Issue {
932    pub fn body_text(&self) -> &str {
933        self.body.as_deref().unwrap_or("")
934    }
935
936    /// The body as a prompt carries it, and whether anything was left off.
937    ///
938    /// Shortened only past `max`, which is sized so that nothing a person
939    /// wrote ever reaches it. When it does fire the cut is announced in the
940    /// text itself: an agent handed a fragment with no marker has no way to
941    /// tell it from an issue that simply ended there, so it judges the part it
942    /// saw and reports the confidence of having seen all of it.
943    ///
944    /// The cut lands on a line boundary, and an unbalanced code fence is closed
945    /// rather than left hanging. Broken markdown reads as a defect in the issue
946    /// and costs the model attention to rule out.
947    pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
948        let body = self.body_text().trim();
949        if body.chars().count() <= max {
950            return (body.to_string(), false);
951        }
952        let clipped: String = body.chars().take(max).collect();
953        let mut kept = match clipped.rfind('\n') {
954            Some(at) => clipped[..at].to_string(),
955            None => clipped,
956        };
957        if kept.matches("```").count() % 2 == 1 {
958            kept.push_str("\n```");
959        }
960        kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
961        (kept, true)
962    }
963
964    pub fn is_closed(&self) -> bool {
965        self.state.eq_ignore_ascii_case("closed")
966    }
967}
968
969#[derive(Debug, Clone, Deserialize)]
970pub struct PrRef {
971    pub number: i64,
972    #[serde(default)]
973    pub url: String,
974    #[serde(default)]
975    pub title: String,
976}
977
978#[derive(Debug, Clone, Deserialize)]
979pub struct IssueRef {
980    pub number: i64,
981}
982
983#[derive(Debug, Clone, Deserialize)]
984#[serde(rename_all = "camelCase")]
985pub struct PrView {
986    pub number: i64,
987    #[serde(default)]
988    pub url: String,
989    #[serde(default)]
990    pub title: String,
991    #[serde(default)]
992    pub head_ref_name: String,
993    #[serde(default)]
994    pub base_ref_name: String,
995    #[serde(default)]
996    pub state: String,
997    #[serde(default)]
998    pub closing_issues_references: Vec<IssueRef>,
999    /// True when the PR's head branch lives on a fork rather than this
1000    /// repository.
1001    #[serde(default)]
1002    pub is_cross_repository: bool,
1003}
1004
1005/// Issues and pull requests share one number sequence per repository, so a
1006/// number names exactly one of them and spar can work out which.
1007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1008pub enum ItemKind {
1009    Issue,
1010    Pr,
1011}
1012
1013impl std::fmt::Display for ItemKind {
1014    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1015        f.write_str(match self {
1016            ItemKind::Issue => "issue",
1017            ItemKind::Pr => "pull request",
1018        })
1019    }
1020}
1021
1022impl PrView {
1023    pub fn is_open(&self) -> bool {
1024        self.state.eq_ignore_ascii_case("open")
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    #[test]
1033    fn severity_accepts_the_canonical_spelling() {
1034        assert_eq!(
1035            Some(Severity::NonBlocking),
1036            Severity::parse_lenient("non-blocking")
1037        );
1038    }
1039
1040    #[test]
1041    fn severity_accepts_near_misses() {
1042        for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1043            assert_eq!(
1044                Some(Severity::NonBlocking),
1045                Severity::parse_lenient(text),
1046                "{text}"
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn severity_rejects_nonsense() {
1053        assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1054    }
1055
1056    #[test]
1057    fn complexity_ordering_is_cheapest_first() {
1058        assert!(Complexity::S.rank() < Complexity::M.rank());
1059        assert!(Complexity::M.rank() < Complexity::L.rank());
1060    }
1061
1062    #[test]
1063    fn finding_defaults_to_in_scope() {
1064        let f: Finding = serde_json::from_value(serde_json::json!({
1065            "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1066        }))
1067        .unwrap();
1068        assert!(f.in_scope);
1069        assert!(f.blocks());
1070    }
1071
1072    #[test]
1073    fn out_of_scope_blocking_does_not_block() {
1074        let f: Finding = serde_json::from_value(serde_json::json!({
1075            "severity": "blocking", "title": "t", "detail": "d",
1076            "file": "a.rs", "in_scope": false
1077        }))
1078        .unwrap();
1079        assert!(!f.blocks());
1080    }
1081
1082    #[test]
1083    fn triage_tolerates_a_quoted_issue_number() {
1084        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1085            "issue": "#42", "worth_doing": "yes", "reason": "r",
1086            "complexity": "medium", "depends_on": ["39"], "risk": "low"
1087        }))
1088        .unwrap();
1089        assert_eq!(42, v.issue);
1090        assert!(v.worth_doing);
1091        assert_eq!(Complexity::M, v.complexity);
1092        assert_eq!(vec![39], v.depends_on);
1093    }
1094
1095    #[test]
1096    fn triage_tolerates_a_missing_depends_on() {
1097        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1098            "issue": 1, "worth_doing": false, "reason": "r",
1099            "complexity": "s", "risk": "high"
1100        }))
1101        .unwrap();
1102        assert!(v.depends_on.is_empty());
1103    }
1104
1105    #[test]
1106    fn review_tolerates_a_missing_findings_array() {
1107        let r: Review = serde_json::from_value(serde_json::json!({
1108            "verdict": "approve", "next_action": "merge", "summary": "fine"
1109        }))
1110        .unwrap();
1111        assert!(r.findings.is_empty());
1112    }
1113
1114    #[test]
1115    fn a_null_reason_is_an_empty_string_not_a_failure() {
1116        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1117            "issue": 1, "worth_doing": true, "reason": null,
1118            "complexity": "s", "depends_on": [], "risk": "low"
1119        }))
1120        .unwrap();
1121        assert_eq!("", v.reason);
1122    }
1123
1124    #[test]
1125    fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1126        let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1127            "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1128        }));
1129        assert!(out.is_err());
1130    }
1131
1132    #[test]
1133    fn status_round_trips_through_json() {
1134        let run = IssueRun::new(4, "t");
1135        let text = serde_json::to_string(&run).unwrap();
1136        let back: IssueRun = serde_json::from_str(&text).unwrap();
1137        assert_eq!(Status::Pending, back.status);
1138    }
1139}
1140
1141#[cfg(test)]
1142mod body_for_prompt_tests {
1143    use super::*;
1144
1145    fn issue(body: &str) -> Issue {
1146        let mut i: Issue = serde_json::from_value(serde_json::json!({
1147            "number": 1, "title": "t", "state": "open", "url": "u"
1148        }))
1149        .expect("an issue");
1150        i.body = Some(body.to_string());
1151        i
1152    }
1153
1154    /// The case that is every real issue: nothing is touched and nothing is
1155    /// claimed to be.
1156    #[test]
1157    fn an_issue_that_fits_is_handed_over_whole() {
1158        let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1159        assert_eq!("The guard is inverted.", body);
1160        assert!(!cut);
1161    }
1162
1163    /// A fragment with no marker is indistinguishable from an issue that ended
1164    /// there, so the agent judges what it saw with the confidence of having
1165    /// seen everything. That is what the silent caps did.
1166    #[test]
1167    fn a_shortened_body_says_so_in_the_text() {
1168        let long = "line of text\n".repeat(500);
1169        let (body, cut) = issue(&long).body_for_prompt(200);
1170        assert!(cut);
1171        assert!(body.contains("Shortened to fit"), "{body}");
1172        assert!(body.len() < long.len());
1173    }
1174
1175    /// Broken markdown reads as a defect in the issue, and costs the model
1176    /// attention to rule out.
1177    #[test]
1178    fn a_cut_never_leaves_a_code_fence_open() {
1179        let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1180        let (out, cut) = issue(&body).body_for_prompt(120);
1181        assert!(cut);
1182        assert_eq!(0, out.matches("```").count() % 2, "{out}");
1183    }
1184
1185    /// Cutting mid-word turns the last thing the agent reads into nonsense.
1186    #[test]
1187    fn a_cut_lands_on_a_line_boundary() {
1188        let body = "aaaa bbbb cccc\n".repeat(100);
1189        let (out, _) = issue(&body).body_for_prompt(100);
1190        let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1191        assert!(kept.ends_with("cccc"), "{kept:?}");
1192    }
1193}