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, IssueRun, Judged, NextAction, ResponseDoc, Review, Severity, SkippedItem,
12    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            "Retry a 429 with exponential backoff instead of failing the request.",
156            &style
157        )
158    );
159
160    rule("What a whole run leaves on the PR (the default, one comment)");
161    let mut ended = IssueRun::new(482, "t");
162    ended.disputes = vec![Dispute {
163        title: "Config loader swallows a parse error".into(),
164        reasoning: "the caller validates against the schema before load_config is reached".into(),
165    }];
166    ended.filed = vec![
167        "https://github.com/you/thing/issues/485".into(),
168        "https://github.com/you/thing/issues/486".into(),
169    ];
170    println!(
171        "{}",
172        outcome_comment(
173            &ended,
174            &spar::model::Ledger::new(),
175            &Ending::OutOfRounds,
176            &style
177        )
178        .unwrap_or_default()
179    );
180    println!("\n  (and a clean run that filed nothing posts no comment at all)");
181
182    rule("A review of somebody else's pull request (spar review)");
183    let judged = |standing, severity, title: &str, detail: &str, file: &str, by: &str| Judged {
184        finding: finding(severity, title, detail, file, true),
185        raised_by: by.to_string(),
186        standing,
187        counterpoint: None,
188        defence: None,
189    };
190    let mut disputed = judged(
191        Standing::Disputed,
192        "blocking",
193        "Config loader swallows a parse error",
194        "load_config discards the error from serde and returns a default.",
195        "src/config.rs:210",
196        "claude",
197    );
198    disputed.counterpoint =
199        Some("the caller validates against the schema before load_config is reached".into());
200    println!(
201        "{}",
202        verdict_comment(
203            &[
204                judged(
205                    Standing::Corroborated,
206                    "blocking",
207                    "Retry loop never terminates when max_attempts is unset",
208                    "Both reviewers reproduced this: the guard on line 91 compares against \
209                     Some(0) rather than checking for None, so the 429 test hangs.",
210                    "src/net.rs:88",
211                    "claude and codex",
212                ),
213                judged(
214                    Standing::Confirmed,
215                    "non-blocking",
216                    "The request timeout is hard coded",
217                    "Not a regression, the previous code had the same limitation.",
218                    "src/net.rs:44",
219                    "codex",
220                ),
221                judged(
222                    Standing::Unverified,
223                    "nit",
224                    "Log line does not say how many attempts remain",
225                    "Readability for whoever reads the logs at 3am.",
226                    "src/net.rs:102",
227                    "claude",
228                ),
229                disputed,
230                judged(
231                    Standing::Withdrawn,
232                    "blocking",
233                    "Off by one in the backoff",
234                    "Withdrawn after the other reviewer pointed at the test that covers it.",
235                    "src/net.rs:70",
236                    "codex",
237                ),
238            ],
239            &style
240        )
241    );
242
243    rule("An issue both reviewers declined");
244    println!(
245        "{}",
246        skip_comment(
247            &SkippedItem {
248                issue: 91,
249                title: "Add a dark mode".into(),
250                reasons: [
251                    (
252                        "claude".to_string(),
253                        "This was already implemented in 1.4 and shipped behind the theme \
254                         setting, so there is nothing left to do here."
255                            .to_string()
256                    ),
257                    (
258                        "codex".to_string(),
259                        "Duplicate of #62, which is still open and has the full discussion."
260                            .to_string()
261                    ),
262                ]
263                .into_iter()
264                .collect(),
265            },
266            &style
267        )
268    );
269    println!();
270}