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::model::{
11    Dispute, Finding, Implementation, IssueRun, Judged, NextAction, ResponseDoc, Review, Severity,
12    SkippedItem, Standing, Verdict,
13};
14use spar::review::{
15    disposition_comment, outcome_comment, pr_body, review_comment, skip_comment, Ending,
16};
17use spar::review_only::verdict_comment;
18use spar::style::Style;
19
20fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
21    Finding {
22        severity: Severity::parse_lenient(severity).expect("severity"),
23        title: title.into(),
24        detail: detail.into(),
25        file: file.into(),
26        in_scope,
27        ..Default::default()
28    }
29}
30
31fn rule(label: &str) {
32    println!("\n\x1b[1m{label}\x1b[0m\n{}", "-".repeat(72));
33}
34
35fn main() {
36    let loose = std::env::args().any(|a| a == "--loose");
37    let style = if loose {
38        Style {
39            terse: false,
40            ..Style::default()
41        }
42    } else {
43        Style::default()
44    };
45    if loose {
46        println!("(concision gate OFF: this is what a model would post unedited)");
47    }
48
49    rule("A clean review");
50    println!(
51        "{}",
52        review_comment(
53            "codex",
54            1,
55            &Review {
56                verdict: Verdict::Approve,
57                next_action: NextAction::Merge,
58                summary: "I reviewed the changes on this branch carefully and I am happy to \
59                          report that the retry path is correct, the backoff calculation is \
60                          sound, and the new test covers the 429 case that the issue described. \
61                          I have no objections to this change landing as it stands."
62                    .into(),
63                findings: vec![],
64            },
65            &style
66        )
67    );
68
69    rule("A review with real work in it");
70    println!(
71        "{}",
72        review_comment(
73            "codex",
74            2,
75            &Review {
76                verdict: Verdict::ChangesRequested,
77                next_action: NextAction::HandBack,
78                summary: "There is one genuine defect here that should block, along with a \
79                          couple of improvements that I do not think need to gate this \
80                          particular pull request, and one pre-existing problem I noticed \
81                          while reading the surrounding code."
82                    .into(),
83                findings: vec![
84                    finding(
85                        "blocking",
86                        "Retry loop never terminates when max_attempts is unset",
87                        "I confirmed this by running the 429 test with max_attempts left at its \
88                         default of None: the loop spins forever because the guard on line 91 \
89                         compares against Some(0) rather than checking for None first. This is \
90                         not a theoretical concern, the test hangs and I had to kill it.",
91                        "src/net.rs:88",
92                        true,
93                    ),
94                    finding(
95                        "non-blocking",
96                        "The request timeout is hard coded to thirty seconds",
97                        "It would be better if this were configurable, since a slow upstream \
98                         will now fail rather than wait, but the previous code had the same \
99                         limitation so this is not a regression introduced by the change.",
100                        "src/net.rs:44",
101                        true,
102                    ),
103                    finding(
104                        "nit",
105                        "Log line says \"retrying\" without saying how many attempts remain",
106                        "Purely a readability point for whoever is reading the logs at 3am.",
107                        "src/net.rs:102",
108                        true,
109                    ),
110                    finding(
111                        "blocking",
112                        "Config loader swallows a parse error",
113                        "Unrelated to this PR, but load_config discards the error from serde and \
114                         returns Default::default(), so a typo in the config file is silently \
115                         ignored.",
116                        "src/config.rs:210",
117                        false,
118                    ),
119                ],
120            },
121            &style
122        )
123    );
124
125    rule("Answering that review");
126    println!(
127        "{}",
128        disposition_comment(
129            "claude",
130            &ResponseDoc {
131                summary: "One of the two blocking points was right and I have fixed it. I do not \
132                          agree with the other and have explained why below rather than changing \
133                          working code to make the review go away."
134                    .into(),
135                dispositions: vec![],
136            },
137            &["Retry loop never terminates when max_attempts is unset".to_string()],
138            &[
139                "Config loader swallows a parse error. The caller already validates the file \
140               against the schema before load_config is reached, so the discarded error is \
141               unreachable in practice."
142                    .to_string()
143            ],
144            &["https://github.com/you/thing/issues/512".to_string()],
145            &style
146        )
147        .unwrap_or_default()
148    );
149
150    rule("The pull request body");
151    println!(
152        "{}",
153        pr_body(
154            478,
155            &Implementation {
156                summary: "Retry a 429 with exponential backoff instead of failing the request."
157                    .into(),
158                problem: "A rate limited response was treated as fatal, so a single throttled \
159                          call ended a run that had hours of work left in it. The retry path \
160                          existed but only covered connection errors, and nothing in the logs \
161                          said which of the two had happened."
162                    .into(),
163                changes: vec![
164                    "`send` now retries a 429, honouring `Retry-After` when the server sets it \
165                     and backing off exponentially when it does not"
166                        .into(),
167                    "the retry budget is bounded at five attempts, so a permanent 429 still \
168                     ends the call rather than spinning"
169                        .into(),
170                    "a retry logs the status it is retrying, which is what made the original \
171                     failure impossible to tell apart from a dropped connection"
172                        .into(),
173                ],
174                testing: vec![
175                    "`cargo test retries_a_rate_limited_request`, which fakes a 429 with a \
176                     `Retry-After` of 2 and asserts the wait"
177                        .into(),
178                    "point it at a throttled endpoint and watch a run finish rather than stop \
179                     on the first 429"
180                        .into(),
181                ],
182                notes: Some(
183                    "Streaming calls do not go through `send` and are unchanged, which is worth \
184                     a follow-up but not this one."
185                        .into()
186                ),
187                ..Implementation::default()
188            },
189            &style
190        )
191    );
192
193    rule("What a whole run leaves on the PR (the default, one comment)");
194    let mut ended = IssueRun::new(482, "t");
195    ended.disputes = vec![Dispute {
196        title: "Config loader swallows a parse error".into(),
197        reasoning: "the caller validates against the schema before load_config is reached".into(),
198    }];
199    ended.filed = vec![
200        "https://github.com/you/thing/issues/485".into(),
201        "https://github.com/you/thing/issues/486".into(),
202    ];
203    println!(
204        "{}",
205        outcome_comment(
206            &ended,
207            &spar::model::Ledger::new(),
208            &Ending::OutOfRounds,
209            &style
210        )
211        .unwrap_or_default()
212    );
213    println!("\n  (and a clean run that filed nothing posts no comment at all)");
214
215    rule("A review of somebody else's pull request (spar review)");
216    let judged = |standing, severity, title: &str, detail: &str, file: &str, by: &str| Judged {
217        finding: finding(severity, title, detail, file, true),
218        raised_by: by.to_string(),
219        standing,
220        counterpoint: None,
221        defence: None,
222    };
223    let mut disputed = judged(
224        Standing::Disputed,
225        "blocking",
226        "Config loader swallows a parse error",
227        "load_config discards the error from serde and returns a default.",
228        "src/config.rs:210",
229        "claude",
230    );
231    disputed.counterpoint =
232        Some("the caller validates against the schema before load_config is reached".into());
233    println!(
234        "{}",
235        verdict_comment(
236            &[
237                judged(
238                    Standing::Corroborated,
239                    "blocking",
240                    "Retry loop never terminates when max_attempts is unset",
241                    "Both reviewers reproduced this: the guard on line 91 compares against \
242                     Some(0) rather than checking for None, so the 429 test hangs.",
243                    "src/net.rs:88",
244                    "claude and codex",
245                ),
246                judged(
247                    Standing::Confirmed,
248                    "non-blocking",
249                    "The request timeout is hard coded",
250                    "Not a regression, the previous code had the same limitation.",
251                    "src/net.rs:44",
252                    "codex",
253                ),
254                judged(
255                    Standing::Unverified,
256                    "nit",
257                    "Log line does not say how many attempts remain",
258                    "Readability for whoever reads the logs at 3am.",
259                    "src/net.rs:102",
260                    "claude",
261                ),
262                disputed,
263                judged(
264                    Standing::Withdrawn,
265                    "blocking",
266                    "Off by one in the backoff",
267                    "Withdrawn after the other reviewer pointed at the test that covers it.",
268                    "src/net.rs:70",
269                    "codex",
270                ),
271            ],
272            &style
273        )
274    );
275
276    rule("An issue both reviewers declined");
277    println!(
278        "{}",
279        skip_comment(
280            &SkippedItem {
281                issue: 91,
282                title: "Add a dark mode".into(),
283                tracker: false,
284                reasons: [
285                    (
286                        "claude".to_string(),
287                        "This was already implemented in 1.4 and shipped behind the theme \
288                         setting, so there is nothing left to do here."
289                            .to_string()
290                    ),
291                    (
292                        "codex".to_string(),
293                        "Duplicate of #62, which is still open and has the full discussion."
294                            .to_string()
295                    ),
296                ]
297                .into_iter()
298                .collect(),
299            },
300            &style
301        )
302    );
303    println!();
304}