1use 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 &style
193 )
194 );
195
196 rule("What a whole run leaves on the PR (the default, one comment)");
197 let mut ended = IssueRun::new(482, "t");
198 ended.disputes = vec![Dispute {
199 title: "Config loader swallows a parse error".into(),
200 file: "src/config.rs".into(),
201 reasoning: "the caller validates against the schema before load_config is reached".into(),
202 }];
203 ended.filed = vec![
204 "https://github.com/you/thing/issues/485".into(),
205 "https://github.com/you/thing/issues/486".into(),
206 ];
207 println!(
208 "{}",
209 outcome_comment(
210 &ended,
211 &spar::model::Ledger::new(),
212 &Ending::OutOfRounds,
213 &style
214 )
215 .unwrap_or_default()
216 );
217 println!("\n (and a clean run that filed nothing posts no comment at all)");
218
219 rule("What a run leaves when the closing pass did not sign it off");
220 let mut left = IssueRun::new(482, "t");
221 left.noted = vec![finding(
222 "non-blocking",
223 "Timeout is not configurable",
224 "The retry budget is fixed at three.",
225 "src/net.rs",
226 true,
227 )];
228 let unresolved = vec![finding(
229 "blocking",
230 "The retry fix never reaches the 429 path",
231 "The guard added in round 2 sits after the early return on line 88, so a rate limited \
232 response still takes the old path. Reproduced with the 429 test.",
233 "src/net.rs:88",
234 true,
235 )];
236 println!(
237 "{}",
238 outcome_comment(
239 &left,
240 &spar::model::Ledger::new(),
241 &Ending::Unresolved(&unresolved),
242 &style
243 )
244 .unwrap_or_default()
245 );
246
247 rule("A review of somebody else's pull request (spar review)");
248 let judged = |standing, severity, title: &str, detail: &str, file: &str, by: &str| Judged {
249 finding: finding(severity, title, detail, file, true),
250 raised_by: by.to_string(),
251 standing,
252 counterpoint: None,
253 defence: None,
254 };
255 let mut disputed = judged(
256 Standing::Disputed,
257 "blocking",
258 "Config loader swallows a parse error",
259 "load_config discards the error from serde and returns a default.",
260 "src/config.rs:210",
261 "claude",
262 );
263 disputed.counterpoint =
264 Some("the caller validates against the schema before load_config is reached".into());
265 println!(
266 "{}",
267 verdict_comment(
268 &[
269 judged(
270 Standing::Corroborated,
271 "blocking",
272 "Retry loop never terminates when max_attempts is unset",
273 "Both reviewers reproduced this: the guard on line 91 compares against \
274 Some(0) rather than checking for None, so the 429 test hangs.",
275 "src/net.rs:88",
276 "claude and codex",
277 ),
278 judged(
279 Standing::Confirmed,
280 "non-blocking",
281 "The request timeout is hard coded",
282 "Not a regression, the previous code had the same limitation.",
283 "src/net.rs:44",
284 "codex",
285 ),
286 judged(
287 Standing::Unverified,
288 "nit",
289 "Log line does not say how many attempts remain",
290 "Readability for whoever reads the logs at 3am.",
291 "src/net.rs:102",
292 "claude",
293 ),
294 disputed,
295 judged(
296 Standing::Withdrawn,
297 "blocking",
298 "Off by one in the backoff",
299 "Withdrawn after the other reviewer pointed at the test that covers it.",
300 "src/net.rs:70",
301 "codex",
302 ),
303 ],
304 &style
305 )
306 );
307
308 rule("An issue both reviewers declined");
309 println!(
310 "{}",
311 skip_comment(
312 &SkippedItem {
313 issue: 91,
314 title: "Add a dark mode".into(),
315 tracker: false,
316 reasons: [
317 (
318 "claude".to_string(),
319 "This was already implemented in 1.4 and shipped behind the theme \
320 setting, so there is nothing left to do here."
321 .to_string()
322 ),
323 (
324 "codex".to_string(),
325 "Duplicate of #62, which is still open and has the full discussion."
326 .to_string()
327 ),
328 ]
329 .into_iter()
330 .collect(),
331 },
332 &style
333 )
334 );
335 rule("Answering a comment in its own thread");
336 for item in [
337 settled(
338 Ask::Implement,
339 "alice",
340 "src/retry.rs:91",
341 "Added the guard on the retry path and a test that reproduces the original failure.",
342 "",
343 true,
344 ),
345 settled(
346 Ask::Decline,
347 "bob",
348 "src/pool.rs:14",
349 "",
350 "Every caller of this function already holds the pool lock, so the check on line 14 \
351 cannot be reached with a null connection. I ran the suite with an assertion in its \
352 place and nothing tripped it.",
353 false,
354 ),
355 settled(
356 Ask::Defer,
357 "carol",
358 "src/electrum.rs:203",
359 "",
360 "This is real and it predates the branch: the reconnect monitor puts a stopped wallet \
361 back into the header router. It is not caused by anything here, so fixing it in this \
362 pull request would put an unrelated change in front of whoever reviews it.",
363 false,
364 ),
365 ] {
366 println!("{}\n", thread_reply(&item, &style));
367 }
368
369 rule("What a check-in leaves on the pull request");
370 let mut fixed = settled(
371 Ask::Implement,
372 "alice",
373 "the pull request",
374 "Retried the 429 with the backoff the issue asked for.",
375 "",
376 true,
377 );
378 fixed.pending.file = None;
379 let mut parked = settled(
380 Ask::Decline,
381 "dave",
382 "the pull request",
383 "",
384 "The two reviewers read the same code and did not agree about whether the guard is \
385 reachable.",
386 false,
387 );
388 parked.pending.file = None;
389 parked.parked = true;
390 println!(
391 "{}",
392 checkin_comment(&[fixed, parked], &style)
393 .unwrap_or_else(|| "(nothing to say, so no comment is posted)".into())
394 );
395 println!("\n (and a check-in with nothing outstanding posts no comment at all)");
396
397 println!();
398}
399
400fn settled(
403 ask: Ask,
404 author: &str,
405 at: &str,
406 summary: &str,
407 reasoning: &str,
408 pushed: bool,
409) -> Settled {
410 let (file, line) = match at.split_once(':') {
411 Some((f, l)) => (Some(f.to_string()), l.parse().ok()),
412 None => (Some(at.to_string()), None),
413 };
414 Settled {
415 pending: Pending {
416 ref_id: "c1".into(),
417 kind: CommentKind::Thread {
418 thread_id: "PRRT_kwABC".into(),
419 reply_to: 1,
420 can_resolve: true,
421 },
422 key: "thread:PRRT_kwABC".into(),
423 newest: "PRRC_kw1".into(),
424 author: author.into(),
425 association: "COLLABORATOR".into(),
426 body: String::new(),
427 file,
428 line,
429 hunk: String::new(),
430 url: String::new(),
431 at: "2026-01-02T03:04:05Z".into(),
432 },
433 ask,
434 request: "add a guard on the retry path".into(),
435 reasoning: reasoning.into(),
436 summary: summary.into(),
437 changed: pushed,
438 pushed,
439 blocked: None,
440 filed: (ask == Ask::Defer).then(|| "https://github.com/owner/repo/issues/512".to_string()),
441 parked: false,
442 counterpoint: None,
443 }
444}