speccoder_runtime/pipeline/
commit.rs1use std::path::Path;
4
5use crate::{approvals, git, tools};
6
7pub(crate) fn monitor_summary(monitor: Option<&serde_json::Value>) -> String {
9 monitor
10 .and_then(|m| m["findings"].as_array())
11 .map(|f| {
12 f.iter()
13 .map(|x| {
14 format!(
15 "{}: {}",
16 x["kind"].as_str().unwrap_or("other"),
17 x["evidence"]
18 .as_str()
19 .unwrap_or_default()
20 .split_whitespace()
21 .collect::<Vec<_>>()
22 .join(" ")
23 )
24 })
25 .collect::<Vec<_>>()
26 .join("; ")
27 })
28 .filter(|s| !s.is_empty())
29 .unwrap_or_else(|| "no details given".to_owned())
30}
31
32pub(crate) fn record_inline_approvals(
41 dir: &Path,
42 feature: &str,
43 task: &str,
44 calls: &[serde_json::Value],
45 run_grants: &std::sync::Mutex<Vec<tools::policy::Object>>,
46 run: &str,
47) -> Result<(), String> {
48 let decided: Vec<approvals::Decided> = calls
52 .iter()
53 .filter(|c| c["approved_by"] == "person" || c["rejected_by"] == "person")
54 .filter(|c| !c["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
55 .filter_map(|c| {
56 let object: tools::policy::Object = serde_json::from_value(c["object"].clone()).ok()?;
57 let by = serde_json::from_value::<approvals::Who>(c["approver"].clone())
58 .unwrap_or_else(|_| approvals::Who {
59 name: "unknown".to_owned(),
60 via: "inline".to_owned(),
61 client: None,
62 basis: "asserted".to_owned(),
63 });
64 let approved = c["approved_by"] == "person";
65 Some(approvals::Decided {
66 verb: if approved { "approve" } else { "reject" }.to_owned(),
67 object: Some(object),
68 run: Some(run.to_owned()),
69 reason: c["rejection"].as_str().map(str::to_owned),
70 scope: c["scope"].as_str().unwrap_or("once").to_owned(),
71 note: c["note"].as_str().map(str::to_owned),
72 by,
73 at: std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .map_or(0, |d| d.as_secs()),
76 })
77 })
78 .collect();
79 if decided.is_empty() {
80 return Ok(());
81 }
82 let mut d = approvals::Decisions::load(dir)?;
83 for decision in decided {
84 match (decision.verb.as_str(), decision.scope.as_str()) {
85 ("approve", "task") => {
87 let Some(object) = decision.object.clone() else {
88 continue;
89 };
90 d.approve(feature, task, object, Some(run), &decision.by);
91 if let Some(last) = d
92 .tasks
93 .get_mut(&format!("{feature}/{task}"))
94 .and_then(|t| t.decided.last_mut())
95 {
96 last.note.clone_from(&decision.note);
97 }
98 }
99 ("approve", "run") => {
101 if let (Ok(mut g), Some(object)) = (run_grants.lock(), &decision.object)
102 && !g.contains(object)
103 {
104 g.push(object.clone());
105 }
106 d.note(feature, task, decision);
107 }
108 _ => d.note(feature, task, decision),
109 }
110 }
111 d.save(dir)
112}
113
114pub(crate) fn add_usage(
116 a: Option<&serde_json::Value>,
117 b: Option<&serde_json::Value>,
118) -> Option<serde_json::Value> {
119 match (a, b) {
120 (None, b) => b.cloned(),
121 (a, None) => a.cloned(),
122 (Some(a), Some(b)) => {
123 let mut sum = serde_json::Map::new();
124 for key in [
125 "input_tokens",
126 "output_tokens",
127 "cache_read_tokens",
128 "cache_write_tokens",
129 ] {
130 let n = a[key].as_u64().unwrap_or(0) + b[key].as_u64().unwrap_or(0);
131 sum.insert(key.to_owned(), serde_json::json!(n));
132 }
133 Some(serde_json::Value::Object(sum))
134 }
135 }
136}
137
138pub(crate) fn commit_attempt(worktree: &Path, message: &str) -> Result<String, String> {
141 commit_attempt_as(worktree, message, false)
142}
143
144pub(crate) fn commit_attempt_as(
147 worktree: &Path,
148 message: &str,
149 allow_empty: bool,
150) -> Result<String, String> {
151 let g = git::Git::at(worktree);
152 let base = [
153 "-c",
154 "user.name=speccoder",
155 "-c",
156 "user.email=speccoder@users.noreply.invalid",
157 "-c",
158 "commit.gpgsign=false",
159 ];
160 let excluded = crate::include::pathspecs(worktree);
163 let mut add = base.to_vec();
164 add.extend(["add", "-A", "--", ":/"]);
165 add.extend(excluded.iter().map(String::as_str));
166 g.run(&add).map_err(|e| e.0)?;
167 crate::include::check_staged(worktree)?;
168 let mut commit = base.to_vec();
169 commit.extend(["commit", "-q", "--no-verify", "-m", message]);
170 if allow_empty {
171 commit.push("--allow-empty");
172 }
173 g.run(&commit).map_err(|e| e.0)?;
174 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
175}
176
177pub(crate) fn commit_subject(task: &str, title: &str) -> String {
179 let full = format!("speccoder: {task} {title}");
180 if full.chars().count() <= 72 {
181 return full;
182 }
183 let cut: String = full.chars().take(71).collect();
184 format!("{}…", cut.trim_end())
185}
186
187pub(crate) struct TickFacts<'a> {
189 pub(crate) feature: &'a str,
190 pub(crate) task: &'a str,
191 pub(crate) title: &'a str,
192 pub(crate) report: &'a speccoder_core::RunReport,
193 pub(crate) red: Option<speccoder_core::verify::RedClass>,
195 pub(crate) checklists_overridden: bool,
196 pub(crate) extra: String,
198}
199
200pub(crate) fn tick_message(f: &TickFacts<'_>) -> String {
205 let report = f.report;
206 let verdict_word = report
207 .verdict
208 .as_ref()
209 .map(super::outcome::verdict_word)
210 .unwrap_or_default();
211 let usage = add_usage(
213 add_usage(
214 report.usage.as_ref(),
215 report.acceptance.as_ref().and_then(|a| a.get("usage")),
216 )
217 .as_ref(),
218 report
219 .acceptance
220 .as_ref()
221 .and_then(|a| a.get("held_out"))
222 .and_then(|h| h.get("usage")),
223 );
224 let mut evidence = match (&report.regression, verdict_word.as_str()) {
225 (Some(r), "regression_only") => format!(
226 "{}: {}\n",
227 speccoder_core::regression::TRAILER,
228 speccoder_core::regression::trailer(&r.reason)
229 ),
230 _ => String::new(),
231 };
232 evidence.push_str(
233 &f.red
234 .map(|c| format!("Red: {}\n", super::acceptance::class_word(c)))
235 .unwrap_or_default(),
236 );
237 evidence.push_str(&held_out_trailer(report.acceptance.as_ref()));
238 if let Some(story) = report
239 .acceptance
240 .as_ref()
241 .and_then(|a| a["story_file"].as_str())
242 {
243 evidence.push_str(&format!("Story-File: {story}\n"));
244 }
245 evidence.push_str(&flaky_trailer(report.verification.as_ref()));
246 evidence.push_str(&scope_trailer(report.scope.as_ref()));
247 if let Some(strength) = &report.strength {
248 evidence.push_str(&speccoder_core::strength::trailers(strength));
249 }
250 if f.checklists_overridden {
251 evidence.push_str("Checklists: overridden\n");
252 }
253 evidence.push_str(&f.extra);
254 format!(
255 "{}\n\n{}\n\nSpec-Task: {}/{}\nVerdict: {verdict_word}\n{evidence}Run: {}\n{}{}",
256 commit_subject(f.task, f.title),
257 f.title,
258 f.feature,
259 f.task,
260 report.run_id,
261 crate::attempts::usage_trailer(usage.as_ref()),
262 report
263 .mutation
264 .as_ref()
265 .and_then(crate::mutation::trailer)
266 .unwrap_or_default()
267 )
268}
269
270pub(crate) fn story_files(repo: &std::path::Path, feature: &str) -> Vec<String> {
273 let log = crate::git::Git::at(repo)
274 .run(&[
275 "log",
276 "--first-parent",
277 "--format=%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Story-File,valueonly)%x1e",
278 "HEAD",
279 ])
280 .map(|b| String::from_utf8_lossy(&b).into_owned())
281 .unwrap_or_default();
282 let mut out: Vec<String> = log
283 .split('\u{1e}')
284 .filter_map(|r| {
285 let (task, file) = r.trim().split_once('\u{1f}')?;
286 task.trim()
287 .starts_with(&format!("{feature}/"))
288 .then(|| file.trim().to_owned())
289 })
290 .filter(|f| !f.is_empty())
291 .collect();
292 out.sort();
293 out.dedup();
294 out
295}
296
297fn held_out_trailer(acceptance: Option<&serde_json::Value>) -> String {
300 let Some(h) = acceptance.and_then(|a| a.get("held_out")) else {
301 return String::new();
302 };
303 match h["state"].as_str() {
304 Some("on") => {
305 let kept = h["kept"].as_u64().unwrap_or(0);
306 format!(
307 "Held-Out: passed={kept}/{kept} manifest={}\n",
308 h["manifest"].as_str().unwrap_or_default()
309 )
310 }
311 Some("off") => "Held-Out: off\n".to_owned(),
312 Some("absent") => format!(
313 "Held-Out: absent={}\n",
314 h["reason"].as_str().unwrap_or("none_generated")
315 ),
316 _ => String::new(),
317 }
318}
319
320fn flaky_trailer(verification: Option<&speccoder_core::verify::VerifyReport>) -> String {
322 let names: Vec<&str> = verification
323 .into_iter()
324 .flat_map(|v| &v.checks)
325 .filter(|c| c.flaky)
326 .map(|c| c.name.as_str())
327 .collect();
328 if names.is_empty() {
329 String::new()
330 } else {
331 format!("Flaky: {}\n", names.join(", "))
332 }
333}
334
335fn scope_trailer(scope: Option<&speccoder_core::scope::ScopeFinding>) -> String {
337 let Some(outside) = scope.map(|s| &s.outside).filter(|o| !o.is_empty()) else {
338 return String::new();
339 };
340 let mut shown: Vec<&str> = outside.iter().take(10).map(String::as_str).collect();
341 let more = outside.len().saturating_sub(10);
342 let more = format!("… (+{more})");
343 if outside.len() > 10 {
344 shown.push(&more);
345 }
346 format!("Scope-Outside: {}\n", shown.join(", "))
347}
348
349pub(crate) fn set_aside_leftovers(
352 wt_root: &Path,
353 tmp: &Path,
354 feature: &str,
355 task: &str,
356) -> Result<String, String> {
357 crate::attempts::save(
358 wt_root,
359 tmp,
360 feature,
361 task,
362 &crate::attempts::Record {
363 run: None,
364 outcome: crate::controls::SET_ASIDE,
365 reason: "the worktree had uncommitted changes when a run started (an interrupted run, or edits by hand); saved and set aside — `speccoder task restore` keeps them, `task drop` discards them",
366 pending: None,
367 usage: None,
368 },
369 )
370 .and_then(|saved| crate::attempts::restore_head(wt_root, tmp).map(|()| saved.reference))
371 .map_err(|e| e.0)
372}
373
374#[cfg(test)]
375mod tests {
376 fn trailer<'m>(message: &'m str, key: &str) -> Option<&'m str> {
379 message.lines().find_map(|l| {
380 let (k, v) = l.split_once(':')?;
381 (k == key).then(|| v.trim())
382 })
383 }
384
385 #[test]
386 fn trailers_are_read_by_their_exact_key() {
387 let m = "speccoder: T001 fix\n\nSpec-Task: 007-bugfixes/T001\nVerdict: verified\nAcceptance-Strength: 1.00 near-miss=1/1\nAcceptance: bug_confirmation manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada\nBug: fixed (standing_speckit) triad=sha256:t\nFixes: #42\n";
388 assert_eq!(
389 trailer(m, "Acceptance"),
390 Some("bug_confirmation manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada")
391 );
392 assert_eq!(
393 trailer(m, "Acceptance-Strength"),
394 Some("1.00 near-miss=1/1")
395 );
396 assert_eq!(
397 trailer(m, "Bug"),
398 Some("fixed (standing_speckit) triad=sha256:t")
399 );
400 assert_eq!(trailer(m, "Spec-Task"), Some("007-bugfixes/T001"));
401 assert_eq!(trailer(m, "Fixes"), Some("#42"));
402 assert_eq!(trailer(m, "Acceptance-Str"), None);
403 let review = "speccoder: T005 x\n\nAcceptance-Strength: 0.80 near-miss=0/1\nAcceptance: review manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada Lovelace\n";
405 assert_eq!(
406 trailer(review, "Acceptance"),
407 Some("review manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada Lovelace")
408 );
409 assert_eq!(
410 trailer(review, "Acceptance-Strength"),
411 Some("0.80 near-miss=0/1")
412 );
413 }
414}