Skip to main content

preview/
preview.rs

1//! Render every comment spar can post, so you can see what lands on GitHub
2//! before you spend a token.
3//!
4//!     cargo run --example preview
5//!     cargo run --example preview -- --loose    # with the concision gate off
6//!
7//! The model output below is deliberately as verbose as a real model gets. What
8//! prints is what a reviewer would actually read.
9
10use spar::checkin::{checkin_comment, thread_reply, Settled};
11use spar::comments::{CommentKind, Pending};
12use spar::model::Ask;
13use spar::model::{
14    Dispute, Finding, Implementation, IssueRun, Judged, NextAction, ResponseDoc, Review, Severity,
15    SkippedItem, Standing, Verdict,
16};
17use spar::review::{
18    disposition_comment, outcome_comment, pr_body, review_comment, skip_comment, Ending,
19};
20use spar::review_only::verdict_comment;
21use spar::style::Style;
22
23fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
24    Finding {
25        severity: Severity::parse_lenient(severity).expect("severity"),
26        title: title.into(),
27        detail: detail.into(),
28        file: file.into(),
29        in_scope,
30        ..Default::default()
31    }
32}
33
34fn rule(label: &str) {
35    println!("\n\x1b[1m{label}\x1b[0m\n{}", "-".repeat(72));
36}
37
38fn main() {
39    let loose = std::env::args().any(|a| a == "--loose");
40    let style = if loose {
41        Style {
42            terse: false,
43            ..Style::default()
44        }
45    } else {
46        Style::default()
47    };
48    if loose {
49        println!("(concision gate OFF: this is what a model would post unedited)");
50    }
51
52    rule("A clean review");
53    println!(
54        "{}",
55        review_comment(
56            "codex",
57            1,
58            &Review {
59                verdict: Verdict::Approve,
60                next_action: NextAction::Merge,
61                summary: "I reviewed the changes on this branch carefully and I am happy to \
62                          report that the retry path is correct, the backoff calculation is \
63                          sound, and the new test covers the 429 case that the issue described. \
64                          I have no objections to this change landing as it stands."
65                    .into(),
66                findings: vec![],
67            },
68            &style
69        )
70    );
71
72    rule("A review with real work in it");
73    println!(
74        "{}",
75        review_comment(
76            "codex",
77            2,
78            &Review {
79                verdict: Verdict::ChangesRequested,
80                next_action: NextAction::HandBack,
81                summary: "There is one genuine defect here that should block, along with a \
82                          couple of improvements that I do not think need to gate this \
83                          particular pull request, and one pre-existing problem I noticed \
84                          while reading the surrounding code."
85                    .into(),
86                findings: vec![
87                    finding(
88                        "blocking",
89                        "Retry loop never terminates when max_attempts is unset",
90                        "I confirmed this by running the 429 test with max_attempts left at its \
91                         default of None: the loop spins forever because the guard on line 91 \
92                         compares against Some(0) rather than checking for None first. This is \
93                         not a theoretical concern, the test hangs and I had to kill it.",
94                        "src/net.rs:88",
95                        true,
96                    ),
97                    finding(
98                        "non-blocking",
99                        "The request timeout is hard coded to thirty seconds",
100                        "It would be better if this were configurable, since a slow upstream \
101                         will now fail rather than wait, but the previous code had the same \
102                         limitation so this is not a regression introduced by the change.",
103                        "src/net.rs:44",
104                        true,
105                    ),
106                    finding(
107                        "nit",
108                        "Log line says \"retrying\" without saying how many attempts remain",
109                        "Purely a readability point for whoever is reading the logs at 3am.",
110                        "src/net.rs:102",
111                        true,
112                    ),
113                    finding(
114                        "blocking",
115                        "Config loader swallows a parse error",
116                        "Unrelated to this PR, but load_config discards the error from serde and \
117                         returns Default::default(), so a typo in the config file is silently \
118                         ignored.",
119                        "src/config.rs:210",
120                        false,
121                    ),
122                ],
123            },
124            &style
125        )
126    );
127
128    rule("Answering that review");
129    println!(
130        "{}",
131        disposition_comment(
132            "claude",
133            &ResponseDoc {
134                summary: "One of the two blocking points was right and I have fixed it. I do not \
135                          agree with the other and have explained why below rather than changing \
136                          working code to make the review go away."
137                    .into(),
138                dispositions: vec![],
139            },
140            &["Retry loop never terminates when max_attempts is unset".to_string()],
141            &[
142                "Config loader swallows a parse error. The caller already validates the file \
143               against the schema before load_config is reached, so the discarded error is \
144               unreachable in practice."
145                    .to_string()
146            ],
147            &["https://github.com/you/thing/issues/512".to_string()],
148            &style
149        )
150        .unwrap_or_default()
151    );
152
153    rule("The pull request body");
154    println!(
155        "{}",
156        pr_body(
157            478,
158            &Implementation {
159                summary: "Retry a 429 with exponential backoff instead of failing the request."
160                    .into(),
161                problem: "A rate limited response was treated as fatal, so a single throttled \
162                          call ended a run that had hours of work left in it. The retry path \
163                          existed but only covered connection errors, and nothing in the logs \
164                          said which of the two had happened."
165                    .into(),
166                changes: vec![
167                    "`send` now retries a 429, honouring `Retry-After` when the server sets it \
168                     and backing off exponentially when it does not"
169                        .into(),
170                    "the retry budget is bounded at five attempts, so a permanent 429 still \
171                     ends the call rather than spinning"
172                        .into(),
173                    "a retry logs the status it is retrying, which is what made the original \
174                     failure impossible to tell apart from a dropped connection"
175                        .into(),
176                ],
177                testing: vec![
178                    "`cargo test retries_a_rate_limited_request`, which fakes a 429 with a \
179                     `Retry-After` of 2 and asserts the wait"
180                        .into(),
181                    "point it at a throttled endpoint and watch a run finish rather than stop \
182                     on the first 429"
183                        .into(),
184                ],
185                notes: Some(
186                    "Streaming calls do not go through `send` and are unchanged, which is worth \
187                     a follow-up but not this one."
188                        .into()
189                ),
190                ..Implementation::default()
191            },
192            Some("cargo test"),
193            &style
194        )
195    );
196
197    rule("What a whole run leaves on the PR (the default, one comment)");
198    let mut ended = IssueRun::new(482, "t");
199    ended.disputes = vec![Dispute {
200        title: "Config loader swallows a parse error".into(),
201        file: "src/config.rs".into(),
202        reasoning: "the caller validates against the schema before load_config is reached".into(),
203    }];
204    ended.filed = vec![
205        "https://github.com/you/thing/issues/485".into(),
206        "https://github.com/you/thing/issues/486".into(),
207    ];
208    println!(
209        "{}",
210        outcome_comment(
211            &ended,
212            &spar::model::Ledger::new(),
213            &Ending::OutOfRounds,
214            &style
215        )
216        .unwrap_or_default()
217    );
218    println!("\n  (and a clean run that filed nothing posts no comment at all)");
219
220    rule("What a run leaves when the closing pass did not sign it off");
221    let mut left = IssueRun::new(482, "t");
222    left.noted = vec![finding(
223        "non-blocking",
224        "Timeout is not configurable",
225        "The retry budget is fixed at three.",
226        "src/net.rs",
227        true,
228    )];
229    let unresolved = vec![finding(
230        "blocking",
231        "The retry fix never reaches the 429 path",
232        "The guard added in round 2 sits after the early return on line 88, so a rate limited \
233         response still takes the old path. Reproduced with the 429 test.",
234        "src/net.rs:88",
235        true,
236    )];
237    println!(
238        "{}",
239        outcome_comment(
240            &left,
241            &spar::model::Ledger::new(),
242            &Ending::Unresolved(&unresolved),
243            &style
244        )
245        .unwrap_or_default()
246    );
247
248    rule("A review of somebody else's pull request (spar review)");
249    let judged = |standing, severity, title: &str, detail: &str, file: &str, by: &str| Judged {
250        finding: finding(severity, title, detail, file, true),
251        raised_by: by.to_string(),
252        standing,
253        counterpoint: None,
254        defence: None,
255    };
256    let mut disputed = judged(
257        Standing::Disputed,
258        "blocking",
259        "Config loader swallows a parse error",
260        "load_config discards the error from serde and returns a default.",
261        "src/config.rs:210",
262        "claude",
263    );
264    disputed.counterpoint =
265        Some("the caller validates against the schema before load_config is reached".into());
266    println!(
267        "{}",
268        verdict_comment(
269            &[
270                judged(
271                    Standing::Corroborated,
272                    "blocking",
273                    "Retry loop never terminates when max_attempts is unset",
274                    "Both reviewers reproduced this: the guard on line 91 compares against \
275                     Some(0) rather than checking for None, so the 429 test hangs.",
276                    "src/net.rs:88",
277                    "claude and codex",
278                ),
279                judged(
280                    Standing::Confirmed,
281                    "non-blocking",
282                    "The request timeout is hard coded",
283                    "Not a regression, the previous code had the same limitation.",
284                    "src/net.rs:44",
285                    "codex",
286                ),
287                judged(
288                    Standing::Unverified,
289                    "nit",
290                    "Log line does not say how many attempts remain",
291                    "Readability for whoever reads the logs at 3am.",
292                    "src/net.rs:102",
293                    "claude",
294                ),
295                disputed,
296                judged(
297                    Standing::Withdrawn,
298                    "blocking",
299                    "Off by one in the backoff",
300                    "Withdrawn after the other reviewer pointed at the test that covers it.",
301                    "src/net.rs:70",
302                    "codex",
303                ),
304            ],
305            &style
306        )
307    );
308
309    rule("An issue both reviewers declined");
310    println!(
311        "{}",
312        skip_comment(
313            &SkippedItem {
314                issue: 91,
315                title: "Add a dark mode".into(),
316                tracker: false,
317                reasons: [
318                    (
319                        "claude".to_string(),
320                        "This was already implemented in 1.4 and shipped behind the theme \
321                         setting, so there is nothing left to do here."
322                            .to_string()
323                    ),
324                    (
325                        "codex".to_string(),
326                        "Duplicate of #62, which is still open and has the full discussion."
327                            .to_string()
328                    ),
329                ]
330                .into_iter()
331                .collect(),
332            },
333            &style
334        )
335    );
336    rule("Answering a comment in its own thread");
337    for item in [
338        settled(
339            Ask::Implement,
340            "alice",
341            "src/retry.rs:91",
342            "Added the guard on the retry path and a test that reproduces the original failure.",
343            "",
344            true,
345        ),
346        settled(
347            Ask::Decline,
348            "bob",
349            "src/pool.rs:14",
350            "",
351            "Every caller of this function already holds the pool lock, so the check on line 14 \
352             cannot be reached with a null connection. I ran the suite with an assertion in its \
353             place and nothing tripped it.",
354            false,
355        ),
356        settled(
357            Ask::Defer,
358            "carol",
359            "src/electrum.rs:203",
360            "",
361            "This is real and it predates the branch: the reconnect monitor puts a stopped wallet \
362             back into the header router. It is not caused by anything here, so fixing it in this \
363             pull request would put an unrelated change in front of whoever reviews it.",
364            false,
365        ),
366    ] {
367        println!("{}\n", thread_reply(&item, &style));
368    }
369
370    rule("What a check-in leaves on the pull request");
371    let mut fixed = settled(
372        Ask::Implement,
373        "alice",
374        "the pull request",
375        "Retried the 429 with the backoff the issue asked for.",
376        "",
377        true,
378    );
379    fixed.pending.file = None;
380    let mut parked = settled(
381        Ask::Decline,
382        "dave",
383        "the pull request",
384        "",
385        "The two reviewers read the same code and did not agree about whether the guard is \
386         reachable.",
387        false,
388    );
389    parked.pending.file = None;
390    parked.parked = true;
391    println!(
392        "{}",
393        checkin_comment(&[fixed, parked], &style)
394            .unwrap_or_else(|| "(nothing to say, so no comment is posted)".into())
395    );
396    println!("\n  (and a check-in with nothing outstanding posts no comment at all)");
397
398    println!();
399}
400
401/// One settled comment, for the preview. Deliberately verbose model output, so
402/// what prints is what somebody would actually read.
403fn settled(
404    ask: Ask,
405    author: &str,
406    at: &str,
407    summary: &str,
408    reasoning: &str,
409    pushed: bool,
410) -> Settled {
411    let (file, line) = match at.split_once(':') {
412        Some((f, l)) => (Some(f.to_string()), l.parse().ok()),
413        None => (Some(at.to_string()), None),
414    };
415    Settled {
416        pending: Pending {
417            ref_id: "c1".into(),
418            kind: CommentKind::Thread {
419                thread_id: "PRRT_kwABC".into(),
420                reply_to: 1,
421                can_resolve: true,
422            },
423            key: "thread:PRRT_kwABC".into(),
424            newest: "PRRC_kw1".into(),
425            author: author.into(),
426            association: "COLLABORATOR".into(),
427            gate_author: author.into(),
428            gate_association: "COLLABORATOR".into(),
429            body: String::new(),
430            file,
431            line,
432            hunk: String::new(),
433            url: String::new(),
434            at: "2026-01-02T03:04:05Z".into(),
435        },
436        ask,
437        request: "add a guard on the retry path".into(),
438        reasoning: reasoning.into(),
439        summary: summary.into(),
440        files: Vec::new(),
441        changed: pushed,
442        pushed,
443        blocked: None,
444        filed: (ask == Ask::Defer).then(|| "https://github.com/owner/repo/issues/512".to_string()),
445        parked: false,
446        counterpoint: None,
447    }
448}