1use std::fmt::Write as _;
13use std::path::Path;
14
15use speccoder_core::{CommandData, CommandResult, CommandStatus, PrReport, StatusReport};
16
17use crate::git::Git;
18
19#[derive(Debug, Clone)]
21pub struct PrOptions<'a> {
22 pub remote: &'a str,
24 pub base: Option<&'a str>,
26 pub open: bool,
28 pub force: bool,
30 pub github: &'a crate::github::Ctx,
32}
33
34fn 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
57fn 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 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#[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
99fn 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
134fn attestation(state: &str) -> String {
137 match state.strip_prefix("signed ") {
138 Some(signer) => format!("`{signer}`"),
139 None => state.to_owned(),
140 }
141}
142
143#[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#[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
258pub const EVIDENCE_CHECK_DOCS: &str =
260 "https://hupe1980.github.io/speccoder/docs/github/#evidence-check";
261
262pub const BODY_LIMIT: usize = 65_536;
264
265#[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#[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#[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 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 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 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(§ion);
439 }
440 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 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 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 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 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 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 let held_out_warning = if selection.sets.is_empty() {
612 String::new()
613 } else {
614 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 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
696fn 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 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 t("002-other/T001", 2),
801 t("001-bugfixes/T002", 3),
803 t("001-bugfixes/T009", 4),
805 t("bugs/empty-title/bug:empty-title", 5),
807 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}