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 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
401fn 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}