Skip to main content

speccoder_runtime/
handoff.rs

1//! `speccoder pr`: hands a feature's work to review as a **draft**
2//! pull request whose body is the evidence behind every tick.
3//!
4//! The one place speccoder talks to a git remote, and only when a person
5//! runs it and confirms (or passes `--yes`); without that it is a dry run
6//! that prints the body and the commands. It never merges, never marks the
7//! pull request ready, and pushes only `speccoder/<feature>` and the
8//! attestation notes (`refs/notes/speccoder`, never forced).
9
10use std::fmt::Write as _;
11use std::path::Path;
12
13use speccoder_core::{CommandData, CommandResult, CommandStatus, PrReport, StatusReport};
14
15use crate::git::Git;
16
17/// How to hand off.
18#[derive(Debug, Clone)]
19pub struct PrOptions<'a> {
20    /// The git remote (`origin`).
21    pub remote: &'a str,
22    /// The target branch; default: the branch the checkout is on.
23    pub base: Option<&'a str>,
24    /// Push and open (`false`: a dry run).
25    pub open: bool,
26    /// Hand off although the branch's full-tier checks failed (said in the body).
27    pub force: bool,
28    /// How GitHub is reached ([`crate::github::Ctx::from_env`]).
29    pub github: &'a crate::github::Ctx,
30}
31
32/// Commit trailers on the branch: (task, `Mutation:` value, `Usage:` value).
33fn trailers(repo: &Git, base: &str, branch: &str) -> Vec<(String, String, String)> {
34    const FS: char = '\u{1f}';
35    const RS: char = '\u{1e}';
36    let format = format!(
37        "--format=%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Mutation,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
38    );
39    let log = repo
40        .run(&["log", &format, &format!("{base}..{branch}")])
41        .map(|b| String::from_utf8_lossy(&b).into_owned())
42        .unwrap_or_default();
43    log.split(RS)
44        .filter_map(|r| {
45            let f: Vec<&str> = r.trim_start_matches('\n').split(FS).collect();
46            let [task, mutation, usage] = f[..] else {
47                return None;
48            };
49            let task = task.trim().rsplit('/').next()?.to_owned();
50            (!task.is_empty()).then(|| (task, mutation.trim().to_owned(), usage.trim().to_owned()))
51        })
52        .collect()
53}
54
55/// A note's state for the table: `ssh SHA256:…` (signer and key; who the
56/// key belongs to is `attest verify`'s answer), `unsigned` or `missing`.
57fn attestation(state: &str) -> String {
58    match state.strip_prefix("signed ") {
59        Some(signer) => format!("`{signer}`"),
60        None => state.to_owned(),
61    }
62}
63
64/// How a reviewer checks the attestations themselves, when there are any.
65/// `sigstore` is the identity and issuer the project says sign: printed
66/// for the reviewer to confirm, never trusted on the project's word.
67#[must_use]
68pub fn verify_section(
69    status: &StatusReport,
70    remote: &str,
71    sigstore: Option<(&str, &str)>,
72) -> Option<String> {
73    let attested = status
74        .tasks
75        .iter()
76        .filter(|t| t.attestation.as_deref().is_some_and(|a| a != "missing"))
77        .count();
78    let trust = sigstore.map_or_else(
79        || "--allowed-signers <file>".to_owned(),
80        |(identity, issuer)| {
81            format!("--certificate-identity {identity} --certificate-oidc-issuer {issuer}")
82        },
83    );
84    (attested > 0).then(|| {
85        format!(
86            "\n**Attestations.** {attested} tick(s) carry in-toto statements (DSSE, SLSA VSA) in the git note `{REF}` on their commit. To check them yourself:\n\n```sh\ngit fetch {remote} {REF}:{REF}\nspeccoder attest verify --feature {} {trust}\n```\n",
87            status.feature,
88            REF = crate::attest::notes::REF,
89        )
90    })
91}
92
93/// The pull request body: totals, a table of every task's evidence, what
94/// is open, mutation results and tokens, and what `verified` does not mean.
95#[must_use]
96pub fn body(status: &StatusReport, trailers: &[(String, String, String)]) -> String {
97    let t = &status.totals;
98    let ticked = t.verified + t.regression_only + t.attested + t.ticked_without_evidence;
99    let mut out = String::new();
100    let _ = writeln!(
101        out,
102        "`{}`, implemented by speccoder. **{ticked} of {} tasks ticked**: {} verified (spec-derived acceptance tests went from red to green), {} regression only (the checks stayed green), {} attested by a person, {} ticked without evidence; {} skipped, {} open.\n",
103        status.feature,
104        status.tasks.len(),
105        t.verified,
106        t.regression_only,
107        t.attested,
108        t.ticked_without_evidence,
109        t.skipped,
110        t.ready + t.blocked
111    );
112    let _ = writeln!(
113        out,
114        "| Task | Evidence | Mutation | Attestation | Commit |\n|---|---|---|---|---|"
115    );
116    for task in &status.tasks {
117        let mutation = trailers
118            .iter()
119            .find(|(id, m, _)| *id == task.id && !m.is_empty())
120            .map(|(_, m, _)| m.replace(' ', ", "))
121            .unwrap_or_default();
122        let mut evidence = task.state.replace('_', " ");
123        if let Some(n) = &task.note {
124            let _ = write!(evidence, " — {n}");
125        }
126        if let Some(a) = &task.last_attempt {
127            let _ = write!(
128                evidence,
129                " (last attempt: {}, `{}`)",
130                a.outcome.replace('_', " "),
131                a.reference
132            );
133        }
134        let title: String = task.title.chars().take(70).collect();
135        let _ = writeln!(
136            out,
137            "| {} {} | {} | {} | {} | {} |",
138            task.id,
139            title.replace('|', "\\|"),
140            evidence.replace('|', "\\|"),
141            mutation,
142            task.attestation
143                .as_deref()
144                .map(attestation)
145                .unwrap_or_default(),
146            task.commit
147                .as_deref()
148                .map(|c| &c[..c.len().min(9)])
149                .unwrap_or("")
150        );
151    }
152    let (mut input, mut output) = (0u64, 0u64);
153    for (_, _, usage) in trailers {
154        if let Some(u) = crate::attempts::parse_usage(usage) {
155            input += u["input_tokens"].as_u64().unwrap_or(0);
156            output += u["output_tokens"].as_u64().unwrap_or(0);
157        }
158    }
159    if input + output > 0 {
160        let _ = writeln!(
161            out,
162            "\nTokens: {input} in, {output} out (`speccoder report --cost` has the breakdown)."
163        );
164    }
165    if !status.convergence.is_empty() {
166        let _ = writeln!(
167            out,
168            "\nConvergence appended {} round(s) of tasks after assessing the feature against its spec.",
169            status.convergence.len()
170        );
171    }
172    let _ = writeln!(
173        out,
174        "\n> `verified` means the spec-derived acceptance tests went from red to green and no check regressed. It does not mean the change is correct, complete or safe: review this like any contribution. Opened as a draft by `speccoder pr`; speccoder never merges."
175    );
176    out
177}
178
179/// `speccoder pr`.
180#[must_use]
181pub fn pr(path: &Path, feature: Option<&str>, options: &PrOptions<'_>) -> CommandResult {
182    let refuse = |status: CommandStatus, m: &str| CommandResult::new("pr", status, m);
183    let status = crate::status::report(path, feature);
184    let Some(CommandData::Report(report)) = status.data() else {
185        return refuse(status.status(), status.message());
186    };
187    let Some(branch) = report.branch.clone() else {
188        return refuse(
189            CommandStatus::GitPrecondition,
190            "speccoder has not worked on this feature yet (no speccoder branch)",
191        )
192        .with_code("NOTHING-TO-HAND-OFF");
193    };
194    let repo_root = match Git::at(path).toplevel() {
195        Ok(Some(r)) => r,
196        _ => {
197            return refuse(CommandStatus::GitPrecondition, "not a git repository")
198                .with_code("NO-REPOSITORY");
199        }
200    };
201    let repo = Git::at(&repo_root);
202    let base = match options.base {
203        Some(b) => b.to_owned(),
204        None => match repo.line(&["rev-parse", "--abbrev-ref", "HEAD"]) {
205            Ok(b) if b != "HEAD" => b,
206            _ => {
207                return refuse(
208                    CommandStatus::GitPrecondition,
209                    "the checkout is not on a branch; pass --base",
210                );
211            }
212        },
213    };
214    if base == branch {
215        return refuse(
216            CommandStatus::GitPrecondition,
217            "the base is the speccoder branch itself",
218        );
219    }
220    let ahead = repo
221        .line(&["rev-list", "--count", &format!("{base}..{branch}")])
222        .ok()
223        .and_then(|n| n.parse::<usize>().ok())
224        .unwrap_or(0);
225    if ahead == 0 {
226        return refuse(
227            CommandStatus::GitPrecondition,
228            &format!("{branch} has nothing that {base} does not have"),
229        )
230        .with_code("NOTHING-TO-HAND-OFF");
231    }
232    if !repo
233        .succeeds(&["remote", "get-url", options.remote])
234        .unwrap_or(false)
235    {
236        return refuse(
237            CommandStatus::GitPrecondition,
238            &format!("no git remote `{}`", options.remote),
239        )
240        .with_code("NO-REMOTE");
241    }
242    let t = &report.totals;
243    let title = format!(
244        "{}: {} verified, {} regression only, {} open (speccoder)",
245        report.feature,
246        t.verified,
247        t.regression_only,
248        t.ready + t.blocked
249    );
250    // A branch whose full-tier checks failed is not handed off unless forced.
251    let full =
252        crate::pipeline::full::read(&repo_root, &report.feature, &branch).filter(|t| !t.passed);
253    if let Some(t) = full.as_ref().filter(|_| !options.force) {
254        return refuse(
255            CommandStatus::VerificationFailed,
256            &format!(
257                "the full-tier checks failed on {branch}: {}; fix them, or pass --force",
258                t.failed.join(", ")
259            ),
260        );
261    }
262    let mut body = body(report, &trailers(&repo, &base, &branch));
263    if let Some(t) = &full {
264        body.push_str(&format!(
265            "\n> **Full-tier checks failed** on this branch: {}. Handed off with `--force`.\n",
266            t.failed.join(", ")
267        ));
268    }
269    let attest = crate::verify::find_config(path)
270        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
271        .and_then(|c| crate::substrate::run_config::parse(&c).ok())
272        .map(|c| c.attest);
273    let sigstore = attest
274        .as_ref()
275        .filter(|a| a.signer == crate::substrate::run_config::Signer::Sigstore)
276        .and_then(|a| {
277            a.sigstore_identity
278                .as_deref()
279                .zip(a.sigstore_issuer.as_deref())
280        });
281    // The issues the spec names: merged, the pull request closes them.
282    if !report.issues.is_empty() {
283        body.push('\n');
284        for url in &report.issues {
285            body.push_str(&format!("Closes {url}\n"));
286        }
287    }
288    if let Some(section) = verify_section(report, options.remote, sigstore) {
289        body.push_str(&section);
290    }
291    let refspec = format!("{branch}:refs/heads/{branch}");
292    let notes = crate::attest::notes::REF;
293    let push_notes = repo
294        .succeeds(&["rev-parse", "--verify", "-q", notes])
295        .unwrap_or(false);
296    let mut commands = vec![format!("git push {} {refspec}", options.remote)];
297    if push_notes {
298        commands.push(format!("git push {} {notes}", options.remote));
299    }
300    // The repository the remote names on GitHub: where the draft opens.
301    let github = crate::github::repo(&repo_root, Some(options.remote));
302    commands.push(match &github {
303        Ok(r) => format!(
304            "open (or update) a draft pull request on {}: {branch} → {base} (GitHub API, as you)",
305            r.slug()
306        ),
307        Err(e) => format!("no pull request can be opened: {e}"),
308    });
309    let mut out = PrReport {
310        feature: report.feature.clone(),
311        branch: branch.clone(),
312        base: base.clone(),
313        remote: options.remote.to_owned(),
314        title: title.clone(),
315        body: body.clone(),
316        commands,
317        opened: false,
318        url: None,
319    };
320    if !options.open {
321        return CommandResult::new(
322            "pr",
323            CommandStatus::Ok,
324            "dry run: nothing was pushed (confirm, or pass --yes, to push and open the draft)",
325        )
326        .with_data(CommandData::Pr(Box::new(out)));
327    }
328    // Nothing leaves the machine unless the pull request can be opened:
329    // a GitHub repository the remote names, and someone signed in there.
330    let github = match github {
331        Ok(r) => r,
332        Err(e) => {
333            return crate::github::refused("pr", &format!("{e}; nothing was pushed"))
334                .with_data(CommandData::Pr(Box::new(out)));
335        }
336    };
337    if options.github.token(&github.host).is_none() {
338        return crate::github::refused(
339            "pr",
340            &format!(
341                "not signed in to {}: `speccoder auth login` (or set GH_TOKEN); nothing was pushed",
342                github.host
343            ),
344        )
345        .with_data(CommandData::Pr(Box::new(out)));
346    }
347    if let Err(e) = repo.run(&["push", "--quiet", options.remote, &refspec]) {
348        return CommandResult::new(
349            "pr",
350            CommandStatus::GitPrecondition,
351            &format!("push failed: {}", e.0),
352        )
353        .with_data(CommandData::Pr(Box::new(out)));
354    }
355    // The notes are pushed as they are: a remote that has diverged is told
356    // how to merge, never overwritten; the pull request opens regardless.
357    let notes_warning = push_notes
358        .then(|| repo.run(&["push", "--quiet", options.remote, &format!("{notes}:{notes}")]).err())
359        .flatten()
360        .map(|e| {
361            format!(
362                "; the attestation notes were not pushed ({}): `git fetch {r} {notes}:refs/notes/speccoder-remote && git notes --ref=speccoder merge refs/notes/speccoder-remote`, then `git push {r} {notes}`",
363                e.0.lines().last().unwrap_or_default().trim(),
364                r = options.remote,
365            )
366        })
367        .unwrap_or_default();
368    let failed = |e: &str, out: PrReport| {
369        let r = crate::github::refused("pr", e);
370        let status = r.status();
371        r.reworded(status, &format!("pushed {branch}, but {e}"))
372            .with_data(CommandData::Pr(Box::new(out)))
373    };
374    // A pull request for this branch already: the push updated its code;
375    // its title and description are brought up to date with the evidence.
376    // A second one is never opened.
377    // The pull request in the repository this remote names (not `origin`'s).
378    match crate::github::pr_in(options.github, &github, &branch) {
379        Ok(Some(existing)) if existing.state == "OPEN" => {
380            out.opened = true;
381            out.url = Some(existing.url.clone());
382            if let Err(e) =
383                crate::github::update(options.github, &github, existing.number, &title, &body)
384            {
385                return failed(&e, out);
386            }
387            return CommandResult::new(
388                "pr",
389                CommandStatus::Ok,
390                &format!(
391                    "pushed {branch}: updated pull request #{} and its evidence ({}){notes_warning}",
392                    existing.number, existing.url
393                ),
394            )
395            .with_data(CommandData::Pr(Box::new(out)));
396        }
397        Ok(_) => {}
398        Err(e) => return failed(&e, out),
399    }
400    match crate::github::open_draft(options.github, &github, &base, &branch, &title, &body) {
401        Ok((number, url)) => {
402            out.opened = true;
403            out.url = Some(url.clone());
404            CommandResult::new(
405                "pr",
406                CommandStatus::Ok,
407                &format!("draft pull request #{number} opened: {url}{notes_warning}"),
408            )
409            .with_data(CommandData::Pr(Box::new(out)))
410        }
411        Err(e) => failed(&e, out),
412    }
413}