speccoder_runtime/format/openspec/
archive.rs1use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12
13use serde_json::Value;
14use speccoder_core::{ArchiveReport, CommandData, CommandResult, CommandStatus};
15
16use crate::git::Git;
17use crate::verify::process::LocalRunner;
18
19pub struct Options<'a> {
21 pub change: Option<&'a str>,
23 pub yes: bool,
25 pub allow_unverified: &'a [String],
27 pub confirm: Option<&'a dyn Fn(&ArchiveReport) -> bool>,
29 pub program: Option<PathBuf>,
31}
32
33const COMMAND: &str = "spec";
34
35fn result(status: CommandStatus, message: &str, report: ArchiveReport) -> CommandResult {
36 CommandResult::new(COMMAND, status, message).with_data(CommandData::Archive(Box::new(report)))
37}
38
39fn json(text: &str) -> Option<Value> {
41 let start = text.find('{')?;
42 serde_json::from_str(&text[start..]).ok()
43}
44
45fn error(answer: &Value) -> Option<&Value> {
47 answer["status"]
48 .as_array()
49 .and_then(|s| s.iter().find(|e| e["severity"] == "error"))
50}
51
52fn unverified(root: &Path, change: &str, done: &[String]) -> Vec<String> {
55 let log = Git::at(root)
56 .run(&[
57 "log",
58 "--format=%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
59 "HEAD",
60 ])
61 .map(|b| String::from_utf8_lossy(&b).into_owned())
62 .unwrap_or_default();
63 let verdicts: BTreeSet<String> = log
64 .split('\u{1e}')
65 .filter_map(|rec| {
66 let (task, verdict) = rec.split_once('\u{1f}')?;
67 (!verdict.trim().is_empty()).then(|| task.trim().to_owned())
68 })
69 .collect();
70 done.iter()
71 .filter(|id| !verdicts.contains(&format!("{change}/{id}")))
72 .cloned()
73 .collect()
74}
75
76#[must_use]
78pub fn archive(path: &Path, opts: &Options<'_>) -> CommandResult {
79 let refuse = |status, message: &str| CommandResult::new(COMMAND, status, message);
80 let opened = match crate::format::open_with(
81 path,
82 opts.change,
83 Some(speccoder_core::spec::FormatKind::Openspec),
84 ) {
85 Ok(o) => o,
86 Err(r) => return refuse(r.status, &r.message),
87 };
88 let snapshot = opened.snapshot;
89 let Some(f) = snapshot.feature.as_ref() else {
90 let why = snapshot
91 .diagnostics
92 .iter()
93 .find(|d| d.code.starts_with("select."))
94 .map_or("no change selected", |d| d.message.as_str());
95 return refuse(
96 CommandStatus::SelectionNeeded,
97 &format!("{why}; name the change"),
98 );
99 };
100 let id = f.feature.name.clone();
101 let root = PathBuf::from(&snapshot.root);
102 let mut report = ArchiveReport {
103 change: id.clone(),
104 stage: "probe".to_owned(),
105 openspec_status: None,
106 unverified_tasks: Vec::new(),
107 diff: None,
108 archived_as: None,
109 totals: None,
110 commit: None,
111 confirmed_by: None,
112 };
113 let Some(program) = opts.program.clone().or_else(super::cli::find) else {
114 return refuse(
115 CommandStatus::NotFound,
116 "archiving needs OpenSpec's CLI: install it with `npm i -g @fission-ai/openspec` (Node 20.19 or newer)",
117 );
118 };
119 let git = Git::at(&root);
120 let dirty = git
121 .run(&["status", "--porcelain", "--", "openspec"])
122 .map(|b| !b.is_empty())
123 .unwrap_or(true);
124 if dirty {
125 return refuse(
126 CommandStatus::GitPrecondition,
127 "openspec/ has uncommitted changes; commit them first, so the archive commit holds only the archive",
128 )
129 .with_code("UNCOMMITTED-INTENT");
130 }
131
132 let done: Vec<String> = f
134 .tasks
135 .iter()
136 .flat_map(|t| &t.tasks)
137 .filter(|t| t.done)
138 .map(|t| t.id.clone())
139 .collect();
140 let unproven = unverified(&root, &id, &done);
141 let unaccepted: Vec<String> = unproven
142 .iter()
143 .filter(|t| !opts.allow_unverified.contains(t))
144 .cloned()
145 .collect();
146 report.unverified_tasks = unproven.clone();
147 if !unaccepted.is_empty() {
148 report.stage = "unverified".to_owned();
149 return result(
150 CommandStatus::NeedsHuman,
151 &format!(
152 "{} ticked without a speccoder verdict: {}; run them, accept them (`speccoder task accept`), or name each with --allow-unverified",
153 id,
154 unaccepted.join(", ")
155 ),
156 report,
157 );
158 }
159
160 let runner = LocalRunner;
162 let probe = match super::cli::run(&runner, &program, &root, &["archive", &id, "--json"]) {
163 Ok(out) => json(&out),
164 Err(e) => {
165 report.stage = "failed".to_owned();
166 return result(
167 CommandStatus::RunFailed,
168 &format!("openspec archive: {e}"),
169 report,
170 );
171 }
172 };
173 let Some(probe) = probe else {
174 report.stage = "failed".to_owned();
175 return result(
176 CommandStatus::RunFailed,
177 "openspec archive printed no JSON",
178 report,
179 );
180 };
181 let finish = |mut report: ArchiveReport, answer: &Value| -> CommandResult {
182 report.archived_as = answer["archive"]["archivedAs"].as_str().map(str::to_owned);
183 report.totals = Some(answer["archive"]["totals"].clone()).filter(|t| !t.is_null());
184 let who = crate::approvals::who();
185 let mut message = format!(
186 "archive {id}\n\nArchived-By: {who}\nSpeccoder-Archive: {}\n",
187 report.archived_as.as_deref().unwrap_or(&id)
188 );
189 if !unproven.is_empty() {
190 message.push_str(&format!("Archived-Unverified: {}\n", unproven.join(", ")));
191 }
192 let committed = git
193 .run(&["add", "-A", "--", "openspec"])
194 .and_then(|_| {
195 git.run(&[
196 "-c",
197 "commit.gpgsign=false",
198 "commit",
199 "-q",
200 "--no-verify",
201 "-m",
202 &message,
203 ])
204 })
205 .and_then(|_| git.line(&["rev-parse", "HEAD"]));
206 match committed {
207 Ok(sha) => {
208 report.commit = Some(sha);
209 report.confirmed_by = Some(who);
210 report.stage = "archived".to_owned();
211 let m = format!(
212 "{id} archived as {} and committed",
213 report.archived_as.as_deref().unwrap_or(&id)
214 );
215 result(CommandStatus::Ok, &m, report)
216 }
217 Err(e) => {
218 report.stage = "failed".to_owned();
219 result(
220 CommandStatus::GitPrecondition,
221 &format!("OpenSpec archived {id}, but committing failed: {}", e.0),
222 report,
223 )
224 }
225 }
226 };
227 if !probe["archive"].is_null() {
228 return finish(report, &probe);
230 }
231 let Some(status) = error(&probe) else {
232 report.stage = "failed".to_owned();
233 return result(
234 CommandStatus::RunFailed,
235 "openspec archive answered neither success nor an error",
236 report,
237 );
238 };
239 report.openspec_status = Some(status.clone());
240 let code = status["code"].as_str().unwrap_or_default();
241 let said = |s: &Value| {
242 let fix = s["fix"]
243 .as_str()
244 .map(|f| format!(" — {f}"))
245 .unwrap_or_default();
246 format!("{}{fix}", s["message"].as_str().unwrap_or_default())
247 };
248 match code {
249 "archive_tasks_incomplete" => {
250 report.stage = "incomplete".to_owned();
251 return result(
252 CommandStatus::NeedsHuman,
253 &format!("OpenSpec: {}", said(status)),
254 report,
255 );
256 }
257 "archive_confirmation_required" => {}
258 _ => {
259 report.stage = "failed".to_owned();
260 return result(
261 CommandStatus::RunFailed,
262 &format!("OpenSpec refused ({code}): {}", said(status)),
263 report,
264 );
265 }
266 }
267
268 report.diff = super::cli::run(&runner, &program, &root, &["show", &id, "--diff"]).ok();
270 report.stage = "awaiting_confirmation".to_owned();
271 let yes = opts.yes || opts.confirm.is_some_and(|ask| ask(&report));
272 if !yes {
273 if opts.confirm.is_some() {
274 report.stage = "declined".to_owned();
275 return result(
276 CommandStatus::Ok,
277 &format!("{id} not archived; nothing changed"),
278 report,
279 );
280 }
281 return result(
282 CommandStatus::NeedsHuman,
283 &format!("archiving {id} rewrites the main specs and needs your yes: rerun with --yes"),
284 report,
285 );
286 }
287 let answer = match super::cli::run(
288 &runner,
289 &program,
290 &root,
291 &["archive", &id, "--yes", "--json"],
292 )
293 .ok()
294 .and_then(|o| json(&o))
295 {
296 Some(a) => a,
297 None => {
298 report.stage = "failed".to_owned();
299 return result(
300 CommandStatus::RunFailed,
301 "openspec archive --yes printed no JSON",
302 report,
303 );
304 }
305 };
306 if answer["archive"].is_null() {
307 report.openspec_status = error(&answer).cloned();
308 report.stage = "failed".to_owned();
309 let why = report
310 .openspec_status
311 .as_ref()
312 .map(said)
313 .unwrap_or_default();
314 return result(
315 CommandStatus::RunFailed,
316 &format!("OpenSpec did not archive {id}: {why}"),
317 report,
318 );
319 }
320 finish(report, &answer)
321}