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>`, the
8//! attestation notes (`refs/notes/speccoder`) and the held-out tests of
9//! the tasks closed on the pushed head (`refs/speccoder/held-out/<unit>`),
10//! none of them forced.
11
12use std::fmt::Write as _;
13use std::path::Path;
14
15use speccoder_core::{CommandData, CommandResult, CommandStatus, PrReport, StatusReport};
16
17use crate::git::Git;
18
19/// How to hand off.
20#[derive(Debug, Clone)]
21pub struct PrOptions<'a> {
22    /// The git remote (`origin`).
23    pub remote: &'a str,
24    /// The target branch; default: the branch the checkout is on.
25    pub base: Option<&'a str>,
26    /// Push and open (`false`: a dry run).
27    pub open: bool,
28    /// Hand off although the branch's full-tier checks failed (said in the body).
29    pub force: bool,
30    /// How GitHub is reached ([`crate::github::Ctx::from_env`]).
31    pub github: &'a crate::github::Ctx,
32}
33
34/// Commit trailers on the branch: (task, `Mutation:` value, `Usage:` value).
35fn trailers(repo: &Git, base: &str, branch: &str) -> Vec<(String, String, String)> {
36    const FS: char = '\u{1f}';
37    const RS: char = '\u{1e}';
38    let format = format!(
39        "--format=%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Mutation,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
40    );
41    let log = repo
42        .run(&["log", &format, &format!("{base}..{branch}")])
43        .map(|b| String::from_utf8_lossy(&b).into_owned())
44        .unwrap_or_default();
45    log.split(RS)
46        .filter_map(|r| {
47            let f: Vec<&str> = r.trim_start_matches('\n').split(FS).collect();
48            let [task, mutation, usage] = f[..] else {
49                return None;
50            };
51            let task = task.trim().rsplit('/').next()?.to_owned();
52            (!task.is_empty()).then(|| (task, mutation.trim().to_owned(), usage.trim().to_owned()))
53        })
54        .collect()
55}
56
57/// The bug fixes on the branch: (task, `Bug:` value, `Fixes:` value) of
58/// every commit carrying a `Fixes` trailer.
59fn bug_trailers(repo: &Git, base: &str, branch: &str) -> Vec<(String, String, String)> {
60    const FS: char = '\u{1f}';
61    const RS: char = '\u{1e}';
62    let format = format!(
63        "--format=%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Bug,valueonly){FS}%(trailers:key=Fixes,valueonly){RS}"
64    );
65    let log = repo
66        .run(&["log", &format, &format!("{base}..{branch}")])
67        .map(|b| String::from_utf8_lossy(&b).into_owned())
68        .unwrap_or_default();
69    log.split(RS)
70        .filter_map(|r| {
71            let f: Vec<&str> = r.trim_start_matches('\n').split(FS).collect();
72            let [task, bug, fixes] = f[..] else {
73                return None;
74            };
75            // The whole `<unit>/<task>`: a bare id collides across units.
76            let task = task.trim().to_owned();
77            (task.contains('/') && !fixes.trim().is_empty())
78                .then(|| (task, bug.trim().to_owned(), fixes.trim().to_owned()))
79        })
80        .collect()
81}
82
83/// The issues the branch's bug fixes close: a `Fixes: #N` trailer on a
84/// commit whose `Bug:` outcome is `fixed`, for a task of the status's unit
85/// that is still verified or attested, or a trailer-only bug task
86/// (`bugs/<slug>/bug:<slug>`, which no task list holds). A task of another
87/// unit, or one the status does not list, closes nothing. Each issue once,
88/// in order.
89#[must_use]
90pub fn bug_fixes(status: &StatusReport, trailers: &[(String, String, String)]) -> Vec<u64> {
91    let states: Vec<(&str, &str)> = status
92        .tasks
93        .iter()
94        .map(|t| (t.id.as_str(), t.state.as_str()))
95        .collect();
96    closes(&status.feature, &states, trailers)
97}
98
99/// [`bug_fixes`] for unit `feature` whose tasks are in `states`.
100fn closes(
101    feature: &str,
102    states: &[(&str, &str)],
103    trailers: &[(String, String, String)],
104) -> Vec<u64> {
105    let mut out = Vec::new();
106    for (claim, bug, fixes) in trailers {
107        let Some((unit, task)) = claim.rsplit_once('/') else {
108            continue;
109        };
110        let trailer_only = task.strip_prefix("bug:").is_some_and(|slug| {
111            speccoder_core::bug::valid_slug(slug) && unit == format!("bugs/{slug}")
112        });
113        let holds = trailer_only
114            || (unit == feature
115                && states
116                    .iter()
117                    .find(|(id, _)| *id == task)
118                    .is_some_and(|(_, state)| matches!(*state, "verified" | "attested")));
119        if !bug.starts_with("fixed ") || !holds {
120            continue;
121        }
122        for n in fixes
123            .lines()
124            .filter_map(|l| l.trim().trim_start_matches('#').parse::<u64>().ok())
125        {
126            if !out.contains(&n) {
127                out.push(n);
128            }
129        }
130    }
131    out
132}
133
134/// A note's state for the table: `ssh SHA256:…` (signer and key; who the
135/// key belongs to is `attest verify`'s answer), `unsigned` or `missing`.
136fn attestation(state: &str) -> String {
137    match state.strip_prefix("signed ") {
138        Some(signer) => format!("`{signer}`"),
139        None => state.to_owned(),
140    }
141}
142
143/// How a reviewer checks the attestations themselves, when there are any.
144/// `sigstore` is the identity and issuer the project says sign: printed
145/// for the reviewer to confirm, never trusted on the project's word.
146#[must_use]
147pub fn verify_section(
148    status: &StatusReport,
149    remote: &str,
150    sigstore: Option<(&str, &str)>,
151) -> Option<String> {
152    let attested = status
153        .tasks
154        .iter()
155        .filter(|t| t.attestation.as_deref().is_some_and(|a| a != "missing"))
156        .count();
157    let trust = sigstore.map_or_else(
158        || "--allowed-signers <file>".to_owned(),
159        |(identity, issuer)| {
160            format!("--certificate-identity {identity} --certificate-oidc-issuer {issuer}")
161        },
162    );
163    (attested > 0).then(|| {
164        format!(
165            "\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",
166            status.feature,
167            REF = crate::attest::notes::REF,
168        )
169    })
170}
171
172/// The pull request body: totals, a table of every task's evidence, what
173/// is open, mutation results and tokens, and what `verified` does not mean.
174#[must_use]
175pub fn body(status: &StatusReport, trailers: &[(String, String, String)]) -> String {
176    let t = &status.totals;
177    let ticked = t.verified + t.regression_only + t.attested + t.ticked_without_evidence;
178    let mut out = String::new();
179    let _ = writeln!(
180        out,
181        "`{}`, 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",
182        status.feature,
183        status.tasks.len(),
184        t.verified,
185        t.regression_only,
186        t.attested,
187        t.ticked_without_evidence,
188        t.skipped,
189        t.ready + t.blocked
190    );
191    let _ = writeln!(
192        out,
193        "| Task | Evidence | Mutation | Attestation | Commit |\n|---|---|---|---|---|"
194    );
195    for task in &status.tasks {
196        let mutation = trailers
197            .iter()
198            .find(|(id, m, _)| *id == task.id && !m.is_empty())
199            .map(|(_, m, _)| m.replace(' ', ", "))
200            .unwrap_or_default();
201        let mut evidence = task.state.replace('_', " ");
202        if let Some(n) = &task.note {
203            let _ = write!(evidence, " — {n}");
204        }
205        if let Some(a) = &task.last_attempt {
206            let _ = write!(
207                evidence,
208                " (last attempt: {}, `{}`)",
209                a.outcome.replace('_', " "),
210                a.reference
211            );
212        }
213        let title: String = task.title.chars().take(70).collect();
214        let _ = writeln!(
215            out,
216            "| {} {} | {} | {} | {} | {} |",
217            task.id,
218            title.replace('|', "\\|"),
219            evidence.replace('|', "\\|"),
220            mutation,
221            task.attestation
222                .as_deref()
223                .map(attestation)
224                .unwrap_or_default(),
225            task.commit
226                .as_deref()
227                .map(|c| &c[..c.len().min(9)])
228                .unwrap_or("")
229        );
230    }
231    let (mut input, mut output) = (0u64, 0u64);
232    for (_, _, usage) in trailers {
233        if let Some(u) = crate::attempts::parse_usage(usage) {
234            input += u["input_tokens"].as_u64().unwrap_or(0);
235            output += u["output_tokens"].as_u64().unwrap_or(0);
236        }
237    }
238    if input + output > 0 {
239        let _ = writeln!(
240            out,
241            "\nTokens: {input} in, {output} out (`speccoder report --cost` has the breakdown)."
242        );
243    }
244    if !status.convergence.is_empty() {
245        let _ = writeln!(
246            out,
247            "\nConvergence appended {} round(s) of tasks after assessing the feature against its spec.",
248            status.convergence.len()
249        );
250    }
251    let _ = writeln!(
252        out,
253        "\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."
254    );
255    out
256}
257
258/// Where the guidance to add the evidence check is.
259pub const EVIDENCE_CHECK_DOCS: &str =
260    "https://hupe1980.github.io/speccoder/docs/github/#evidence-check";
261
262/// GitHub's cap on a pull request body, in characters.
263pub const BODY_LIMIT: usize = 65_536;
264
265/// The workflow under `.github/workflows/` that runs the evidence check
266/// (the speccoder-verify action, or `attest verify --branch`), if one
267/// does (repository-relative).
268#[must_use]
269pub fn evidence_workflow(repo_root: &Path) -> Option<String> {
270    let dir = repo_root.join(".github/workflows");
271    let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
272        .into_iter()
273        .flatten()
274        .flatten()
275        .map(|e| e.path())
276        .filter(|p| p.extension().is_some_and(|x| x == "yml" || x == "yaml"))
277        .collect();
278    files.sort();
279    files.into_iter().find_map(|p| {
280        let text = std::fs::read_to_string(&p).ok()?;
281        (text.contains("speccoder-verify") || text.contains("attest verify --branch")).then(|| {
282            format!(
283                ".github/workflows/{}",
284                p.file_name()
285                    .map(|n| n.to_string_lossy())
286                    .unwrap_or_default()
287            )
288        })
289    })
290}
291
292/// `### Evidence tour`: every tick's tour, collapsed, as many as fit in
293/// `room` characters (dropped last-first, counted).
294#[must_use]
295pub fn tours(tours: &[String], room: usize) -> String {
296    if tours.is_empty() {
297        return String::new();
298    }
299    let mut out = String::from("\n### Evidence tour\n\n");
300    let mut shown = 0;
301    for t in tours {
302        if out.chars().count() + t.chars().count() + 200 > room {
303            break;
304        }
305        out.push_str(t);
306        shown += 1;
307    }
308    if shown < tours.len() {
309        out.push_str(&format!(
310            "\n{} tour(s) not shown (GitHub's body limit) — `speccoder attest tour <id> --feature <unit>`\n",
311            tours.len() - shown
312        ));
313    }
314    if shown == 0 && out.chars().count() > room {
315        return String::new();
316    }
317    out
318}
319
320/// `speccoder pr`.
321#[must_use]
322pub fn pr(path: &Path, feature: Option<&str>, options: &PrOptions<'_>) -> CommandResult {
323    let refuse = |status: CommandStatus, m: &str| CommandResult::new("pr", status, m);
324    let status = crate::status::report(path, feature);
325    let Some(CommandData::Report(report)) = status.data() else {
326        return refuse(status.status(), status.message());
327    };
328    let Some(branch) = report.branch.clone() else {
329        return refuse(
330            CommandStatus::GitPrecondition,
331            "speccoder has not worked on this feature yet (no speccoder branch)",
332        )
333        .with_code("NOTHING-TO-HAND-OFF");
334    };
335    let repo_root = match Git::at(path).toplevel() {
336        Ok(Some(r)) => r,
337        _ => {
338            return refuse(CommandStatus::GitPrecondition, "not a git repository")
339                .with_code("NO-REPOSITORY");
340        }
341    };
342    let repo = Git::at(&repo_root);
343    let base = match options.base {
344        Some(b) => b.to_owned(),
345        None => match repo.line(&["rev-parse", "--abbrev-ref", "HEAD"]) {
346            Ok(b) if b != "HEAD" => b,
347            _ => {
348                return refuse(
349                    CommandStatus::GitPrecondition,
350                    "the checkout is not on a branch; pass --base",
351                );
352            }
353        },
354    };
355    if base == branch {
356        return refuse(
357            CommandStatus::GitPrecondition,
358            "the base is the speccoder branch itself",
359        );
360    }
361    let ahead = repo
362        .line(&["rev-list", "--count", &format!("{base}..{branch}")])
363        .ok()
364        .and_then(|n| n.parse::<usize>().ok())
365        .unwrap_or(0);
366    if ahead == 0 {
367        return refuse(
368            CommandStatus::GitPrecondition,
369            &format!("{branch} has nothing that {base} does not have"),
370        )
371        .with_code("NOTHING-TO-HAND-OFF");
372    }
373    if !repo
374        .succeeds(&["remote", "get-url", options.remote])
375        .unwrap_or(false)
376    {
377        return refuse(
378            CommandStatus::GitPrecondition,
379            &format!("no git remote `{}`", options.remote),
380        )
381        .with_code("NO-REMOTE");
382    }
383    let t = &report.totals;
384    let title = format!(
385        "{}: {} verified, {} regression only, {} open (speccoder)",
386        report.feature,
387        t.verified,
388        t.regression_only,
389        t.ready + t.blocked
390    );
391    // A branch whose full-tier checks failed is not handed off unless forced.
392    let full =
393        crate::pipeline::full::read(&repo_root, &report.feature, &branch).filter(|t| !t.passed);
394    if let Some(t) = full.as_ref().filter(|_| !options.force) {
395        return refuse(
396            CommandStatus::VerificationFailed,
397            &format!(
398                "the full-tier checks failed on {branch}: {}; fix them, or pass --force",
399                t.failed.join(", ")
400            ),
401        );
402    }
403    let mut body = body(report, &trailers(&repo, &base, &branch));
404    if let Some(t) = &full {
405        body.push_str(&format!(
406            "\n> **Full-tier checks failed** on this branch: {}. Handed off with `--force`.\n",
407            t.failed.join(", ")
408        ));
409    }
410    let attest = crate::verify::find_config(path)
411        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
412        .and_then(|c| crate::substrate::run_config::parse(&c).ok())
413        .map(|c| c.attest);
414    let sigstore = attest
415        .as_ref()
416        .filter(|a| a.signer == crate::substrate::run_config::Signer::Sigstore)
417        .and_then(|a| {
418            a.sigstore_identity
419                .as_deref()
420                .zip(a.sigstore_issuer.as_deref())
421        });
422    // The issues the spec names: merged, the pull request closes them.
423    if !report.issues.is_empty() {
424        body.push('\n');
425        for url in &report.issues {
426            body.push_str(&format!("Closes {url}\n"));
427        }
428    }
429    // The issues the bug fixes on the branch close (`speccoder bug --issue N`).
430    let fixes = bug_fixes(report, &bug_trailers(&repo, &base, &branch));
431    if !fixes.is_empty() {
432        body.push('\n');
433        for n in fixes {
434            body.push_str(&format!("Fixes #{n}\n"));
435        }
436    }
437    if let Some(section) = verify_section(report, options.remote, sigstore) {
438        body.push_str(&section);
439    }
440    // Each closed task's tour, from its signed statements and commit.
441    let tip = repo
442        .line(&["rev-parse", "--verify", &format!("{branch}^{{commit}}")])
443        .unwrap_or_default();
444    if let Ok(recorded) = crate::attest::branch::recorded(&repo_root, &tip, &report.feature) {
445        let room = BODY_LIMIT.saturating_sub(body.chars().count());
446        body.push_str(&tours(&crate::tour::markdown_all(&recorded), room));
447    }
448    // The held-out sets of exactly the tasks closed on the pushed head.
449    let project_state = crate::format::at(path)
450        .detect(path)
451        .zip(crate::worktree::state_dir())
452        .map(|(root, state)| crate::worktree::project_state_dir(&state, &root));
453    let selection = project_state
454        .as_ref()
455        .map(|s| {
456            let closed = crate::held_out::closed_on(&repo_root, &tip, &report.feature);
457            crate::held_out::select_closed(s, &report.feature, &closed)
458        })
459        .unwrap_or_default();
460    let held_out_ref = format!("{}/{}", crate::held_out::REF_PREFIX, report.feature);
461    let evidence = evidence_workflow(&repo_root);
462    let refspec = format!("{branch}:refs/heads/{branch}");
463    let notes = crate::attest::notes::REF;
464    let push_notes = repo
465        .succeeds(&["rev-parse", "--verify", "-q", notes])
466        .unwrap_or(false);
467    let mut commands = vec![format!("git push {} {refspec}", options.remote)];
468    if push_notes {
469        commands.push(format!("git push {} {notes}", options.remote));
470    }
471    if !selection.sets.is_empty() {
472        commands.push(format!("git push {} {held_out_ref}", options.remote));
473    }
474    // The repository the remote names on GitHub: where the draft opens.
475    let github = crate::github::repo(&repo_root, Some(options.remote));
476    commands.push(match &github {
477        Ok(r) => format!(
478            "open (or update) a draft pull request on {}: {branch} → {base} (GitHub API, as you)",
479            r.slug()
480        ),
481        Err(e) => format!("no pull request can be opened: {e}"),
482    });
483    let mut out = PrReport {
484        feature: report.feature.clone(),
485        branch: branch.clone(),
486        base: base.clone(),
487        remote: options.remote.to_owned(),
488        title: title.clone(),
489        body: body.clone(),
490        commands,
491        opened: false,
492        url: None,
493        held_out_ref: (!selection.sets.is_empty()).then(|| held_out_ref.clone()),
494        held_out_tasks: selection.tasks.clone(),
495        held_out_withheld: selection.withheld,
496        evidence_check: if evidence.is_some() {
497            "present"
498        } else {
499            "absent"
500        }
501        .to_owned(),
502        gates: Vec::new(),
503    };
504    if !options.open {
505        let mut message =
506            "dry run: nothing was pushed (confirm, or pass --yes, to push and open the draft)"
507                .to_owned();
508        message.push_str(&match &evidence {
509            Some(w) => format!("\nevidence check: {w} runs the evidence check"),
510            None => format!("\nevidence check: none — see {EVIDENCE_CHECK_DOCS}"),
511        });
512        if !selection.tasks.is_empty() {
513            message.push_str(&format!(
514                "\nheld-out: sets of {}",
515                selection.tasks.join(", ")
516            ));
517        }
518        if selection.withheld > 0 {
519            message.push_str(&format!(
520                "\nheld-out tests of {} open task(s) stay local",
521                selection.withheld
522            ));
523        }
524        if !selection.tampered.is_empty() {
525            message.push_str(&format!(
526                "\nheld-out sets changed since they judged, not published: {}",
527                selection.tampered.join(", ")
528            ));
529        }
530        return CommandResult::new("pr", CommandStatus::Ok, &message)
531            .with_data(CommandData::Pr(Box::new(out)));
532    }
533    // The person's gates, before anything leaves the machine (spec 095).
534    // The commit the gates judge is the one pushed: resolved once.
535    let tip = tip.trim().to_owned();
536    let prefix = crate::verify::find_config(path)
537        .and_then(|d| std::fs::canonicalize(d).ok())
538        .zip(std::fs::canonicalize(&repo_root).ok())
539        .and_then(|(d, r)| d.strip_prefix(&r).ok().map(Path::to_path_buf))
540        .unwrap_or_default();
541    out.gates = pr_gates(&repo, &branch, &tip, &prefix, &report.feature);
542    if let Some(g) = speccoder_core::hooks::first_block(&out.gates) {
543        let code = g.code.clone().unwrap_or_else(|| "HOOK-BLOCKED".to_owned());
544        let fix = if code == "HOOK-UNTRUSTED" {
545            vec![format!("speccoder hooks trust {}", g.name)]
546        } else {
547            vec![
548                format!(
549                    "→ fix what {} names on {branch}, then open the pull request again",
550                    g.name
551                ),
552                format!("speccoder hooks test {}", g.name),
553            ]
554        };
555        return CommandResult::new(
556            "pr",
557            CommandStatus::NeedsHuman,
558            &format!("{}; nothing was pushed", g.blocked_line()),
559        )
560        .with_code(&code)
561        .with_fix(fix)
562        .with_data(CommandData::Pr(Box::new(out)));
563    }
564    // Nothing leaves the machine unless the pull request can be opened:
565    // a GitHub repository the remote names, and someone signed in there.
566    let github = match github {
567        Ok(r) => r,
568        Err(e) => {
569            return crate::github::refused("pr", &format!("{e}; nothing was pushed"))
570                .with_data(CommandData::Pr(Box::new(out)));
571        }
572    };
573    if options.github.token(&github.host).is_none() {
574        return crate::github::refused(
575            "pr",
576            &format!(
577                "not signed in to {}: `speccoder auth login` (or set GH_TOKEN); nothing was pushed",
578                github.host
579            ),
580        )
581        .with_data(CommandData::Pr(Box::new(out)));
582    }
583    let pushed = if tip.is_empty() {
584        refspec.clone()
585    } else {
586        format!("{tip}:refs/heads/{branch}")
587    };
588    if let Err(e) = repo.run(&["push", "--quiet", options.remote, &pushed]) {
589        return CommandResult::new(
590            "pr",
591            CommandStatus::GitPrecondition,
592            &format!("push failed: {}", e.0),
593        )
594        .with_data(CommandData::Pr(Box::new(out)));
595    }
596    // The notes are pushed as they are: a remote that has diverged is told
597    // how to merge, never overwritten; the pull request opens regardless.
598    let notes_warning = push_notes
599        .then(|| repo.run(&["push", "--quiet", options.remote, &format!("{notes}:{notes}")]).err())
600        .flatten()
601        .map(|e| {
602            format!(
603                "; 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}`",
604                e.0.lines().last().unwrap_or_default().trim(),
605                r = options.remote,
606            )
607        })
608        .unwrap_or_default();
609    // The held-out ref, built on the pushed tip and its previous
610    // publication: a fast-forward, never forced.
611    let held_out_warning = if selection.sets.is_empty() {
612        String::new()
613    } else {
614        // Built in the private store and pushed from it: the project's
615        // repository, which later attempts read, never holds them.
616        let pushed = project_state
617            .as_ref()
618            .ok_or_else(|| "no state directory".to_owned())
619            .and_then(|s| {
620                let store = crate::held_out::publication_store(s, &repo_root)?;
621                let commit = crate::held_out::publish_closed(
622                    &store,
623                    &report.feature,
624                    &tip,
625                    &selection,
626                    &s.join("tmp"),
627                )?;
628                match commit {
629                    Some(c) => crate::held_out::push(
630                        &repo_root,
631                        &store,
632                        options.remote,
633                        &report.feature,
634                        &c,
635                    ),
636                    None => Ok(()),
637                }
638            });
639        match pushed {
640            Ok(()) => String::new(),
641            Err(e) => format!(
642                "; the held-out tests were not pushed ({}): git fetch {r} {held_out_ref} and compare",
643                e.lines().last().unwrap_or_default().trim(),
644                r = options.remote,
645            ),
646        }
647    };
648    let notes_warning = format!("{notes_warning}{held_out_warning}");
649    let failed = |e: &str, out: PrReport| {
650        let r = crate::github::refused("pr", e);
651        let status = r.status();
652        r.reworded(status, &format!("pushed {branch}, but {e}"))
653            .with_data(CommandData::Pr(Box::new(out)))
654    };
655    // A pull request for this branch already: the push updated its code;
656    // its title and description are brought up to date with the evidence.
657    // A second one is never opened.
658    // The pull request in the repository this remote names (not `origin`'s).
659    match crate::github::pr_in(options.github, &github, &branch) {
660        Ok(Some(existing)) if existing.state == "OPEN" => {
661            out.opened = true;
662            out.url = Some(existing.url.clone());
663            if let Err(e) =
664                crate::github::update(options.github, &github, existing.number, &title, &body)
665            {
666                return failed(&e, out);
667            }
668            return CommandResult::new(
669                "pr",
670                CommandStatus::Ok,
671                &format!(
672                    "pushed {branch}: updated pull request #{} and its evidence ({}){notes_warning}",
673                    existing.number, existing.url
674                ),
675            )
676            .with_data(CommandData::Pr(Box::new(out)));
677        }
678        Ok(_) => {}
679        Err(e) => return failed(&e, out),
680    }
681    match crate::github::open_draft(options.github, &github, &base, &branch, &title, &body) {
682        Ok((number, url)) => {
683            out.opened = true;
684            out.url = Some(url.clone());
685            CommandResult::new(
686                "pr",
687                CommandStatus::Ok,
688                &format!("draft pull request #{number} opened: {url}{notes_warning}"),
689            )
690            .with_data(CommandData::Pr(Box::new(out)))
691        }
692        Err(e) => failed(&e, out),
693    }
694}
695
696/// The `pr` gates of the project at `prefix`, run in a fresh checkout of
697/// `tip` (the commit the push sends), whatever the worktree holds besides.
698fn pr_gates(
699    repo: &Git,
700    branch: &str,
701    tip: &str,
702    prefix: &Path,
703    feature: &str,
704) -> Vec<speccoder_core::hooks::GateRecord> {
705    use speccoder_core::hooks::{GateRecord, GateState, Point};
706    let blocked = |reason: String| {
707        vec![GateRecord {
708            name: "hooks".to_owned(),
709            point: Point::Pr,
710            outcome: GateState::Blocked,
711            reason: Some(reason),
712            code: Some("HOOK-BLOCKED".to_owned()),
713            duration_ms: 0,
714        }]
715    };
716    // No `pr` gate declared on the branch: nothing to check out.
717    let declared = repo
718        .run(&[
719            "show",
720            &format!(
721                "{tip}:{}",
722                prefix
723                    .join(crate::verify::config::FILE)
724                    .to_string_lossy()
725                    .replace('\\', "/")
726            ),
727        ])
728        .ok()
729        .and_then(|b| String::from_utf8(b).ok())
730        .and_then(|t| crate::hooks::parse(&t).ok())
731        .is_none_or(|h| h.iter().any(|d| d.at() == Some(Point::Pr)));
732    if !declared {
733        return Vec::new();
734    }
735    let Some(state) = crate::worktree::state_dir() else {
736        return blocked("speccoder has no state directory to read trust from".to_owned());
737    };
738    let Ok(tmp) = tempfile::tempdir() else {
739        return blocked("no temporary directory for the gate's checkout".to_owned());
740    };
741    let checkout = tmp.path().join("checkout");
742    let checkout_s = checkout.to_string_lossy().into_owned();
743    if let Err(e) = repo.run(&["worktree", "add", "--quiet", "--detach", &checkout_s, tip]) {
744        return blocked(format!("could not check out {branch}: {}", e.0));
745    }
746    let project = checkout.join(prefix);
747    let gates = match crate::sandbox::runner_at(&project) {
748        Ok((runner, _)) => crate::hooks::gate::run(
749            &project,
750            &runner,
751            &state,
752            crate::hooks::gate::Facts {
753                point: Point::Pr,
754                feature,
755                task: None,
756                branch: Some(branch),
757            },
758        ),
759        Err(e) => blocked(e),
760    };
761    let _ = repo.run(&["worktree", "remove", "--force", &checkout_s]);
762    gates
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    #[test]
770    fn a_workflow_running_the_verifier_is_found() {
771        let d = tempfile::tempdir().unwrap();
772        assert_eq!(evidence_workflow(d.path()), None);
773        let w = d.path().join(".github/workflows");
774        std::fs::create_dir_all(&w).unwrap();
775        std::fs::write(w.join("ci.yml"), "jobs: {}\n").unwrap();
776        std::fs::write(
777            w.join("evidence.yaml"),
778            "steps:\n  - uses: hupe1980/speccoder/packaging/speccoder-verify-action@v0.2.0\n",
779        )
780        .unwrap();
781        assert_eq!(
782            evidence_workflow(d.path()).as_deref(),
783            Some(".github/workflows/evidence.yaml")
784        );
785    }
786
787    #[test]
788    fn only_the_units_own_held_bug_tasks_close_issues() {
789        let t = |claim: &str, n: u32| {
790            (
791                claim.to_owned(),
792                "fixed (standing_speckit) triad=sha256:x".to_owned(),
793                format!("#{n}"),
794            )
795        };
796        let states = [("T001", "verified"), ("T002", "ready")];
797        let trailers = [
798            t("001-bugfixes/T001", 1),
799            // Another unit's T001: the bare id collides, the unit does not.
800            t("002-other/T001", 2),
801            // Not held any more.
802            t("001-bugfixes/T002", 3),
803            // Not listed by the status at all.
804            t("001-bugfixes/T009", 4),
805            // A trailer-only bug task stands on its trailers…
806            t("bugs/empty-title/bug:empty-title", 5),
807            // …only when its unit is its slug's.
808            t("bugs/other/bug:empty-title", 6),
809            t("bugs/../x/bug:..", 7),
810        ];
811        assert_eq!(closes("001-bugfixes", &states, &trailers), [1, 5]);
812    }
813
814    #[test]
815    fn tours_fit_the_body_dropping_the_last_first() {
816        let all: Vec<String> = (0..10)
817            .map(|i| format!("<details>{i}{}</details>\n", "x".repeat(100)))
818            .collect();
819        let fitted = tours(&all, 700);
820        assert!(fitted.chars().count() <= 700, "{}", fitted.chars().count());
821        assert!(fitted.contains("<details>0"));
822        assert!(!fitted.contains("<details>9"));
823        assert!(fitted.contains("tour(s) not shown"));
824        assert_eq!(tours(&[], 100), "");
825        assert!(tours(&all, 100_000).contains("<details>9"));
826    }
827}