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