1use std::fmt::Write as _;
11use std::path::Path;
12
13use speccoder_core::{CommandData, CommandResult, CommandStatus, PrReport, StatusReport};
14
15use crate::git::Git;
16
17#[derive(Debug, Clone)]
19pub struct PrOptions<'a> {
20 pub remote: &'a str,
22 pub base: Option<&'a str>,
24 pub open: bool,
26 pub force: bool,
28 pub github: &'a crate::github::Ctx,
30}
31
32fn 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
55fn attestation(state: &str) -> String {
58 match state.strip_prefix("signed ") {
59 Some(signer) => format!("`{signer}`"),
60 None => state.to_owned(),
61 }
62}
63
64#[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#[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#[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 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 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(§ion);
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 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 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 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 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}