1use crate::gate::{self, MergeToken, Refusal};
20use crate::{Error, Result};
21use ostraka_core::config::Config;
22use ostraka_core::record::{Outcome, RunRecord};
23use std::path::Path;
24use std::process::Command;
25
26#[derive(Debug)]
28pub struct Promotion {
29 pub branch: String,
30 pub commit: String,
31 pub run_branch: String,
32 pub token: MergeToken,
33}
34
35#[derive(Debug)]
37pub enum NotPromoted {
38 Refused(Refusal),
40 Unverifiable(String),
42}
43
44impl std::fmt::Display for NotPromoted {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::Refused(r) => write!(f, "the gate refuses to promote this run: {r:?}"),
48 Self::Unverifiable(why) => write!(f, "{why}"),
49 }
50 }
51}
52
53pub fn default_branch(run_id: &str) -> String {
55 format!("promoted/{run_id}")
56}
57
58pub fn promote(
66 repo: &Path,
67 records_root: &Path,
68 run_id: &str,
69 config: &Config,
70 branch: Option<&str>,
71) -> Result<std::result::Result<Promotion, NotPromoted>> {
72 let record = read_record(records_root, run_id)?;
73
74 if record.outcome != Some(Outcome::Approved) {
77 return Ok(Err(NotPromoted::Unverifiable(format!(
78 "run {run_id:?} ended as {:?}, not approved",
79 record.outcome
80 ))));
81 }
82
83 let token = match gate::reaffirm(
84 &config.gate,
85 &record,
86 config.gate.review.must_differ_from_author,
87 ) {
88 Ok(token) => token,
89 Err(refusal) => return Ok(Err(NotPromoted::Refused(refusal))),
90 };
91
92 let run_branch = format!("ostraka/{run_id}");
93 let commit = match rev_parse(repo, &run_branch)? {
94 Some(sha) => sha,
95 None => {
96 return Ok(Err(NotPromoted::Unverifiable(format!(
97 "no branch {run_branch:?} in this repository; the run's commit is not here"
98 ))));
99 }
100 };
101
102 let message = commit_message(repo, &commit)?;
103 if let Err(why) = trailers_agree(&message, run_id, &record, &token) {
104 return Ok(Err(NotPromoted::Unverifiable(why)));
105 }
106
107 let target = branch
108 .map(str::to_string)
109 .unwrap_or_else(|| default_branch(run_id));
110 if rev_parse(repo, &target)?.is_some() {
111 return Ok(Err(NotPromoted::Unverifiable(format!(
112 "branch {target:?} already exists; name another with --branch"
113 ))));
114 }
115
116 let out = Command::new("git")
117 .args(["branch", &target, &commit])
118 .current_dir(repo)
119 .output()?;
120 if !out.status.success() {
121 return Err(Error::Other(format!(
122 "git branch failed: {}",
123 String::from_utf8_lossy(&out.stderr).trim()
124 )));
125 }
126
127 Ok(Ok(Promotion {
128 branch: target,
129 commit,
130 run_branch,
131 token,
132 }))
133}
134
135fn read_record(records_root: &Path, run_id: &str) -> Result<RunRecord> {
136 let path = records_root.join("runs").join(run_id).join("record.json");
137 let text = std::fs::read_to_string(&path)
138 .map_err(|e| Error::Other(format!("no run {run_id:?}: {e}")))?;
139 serde_json::from_str(&text).map_err(|e| Error::Other(format!("run record is unreadable: {e}")))
140}
141
142fn rev_parse(repo: &Path, rev: &str) -> Result<Option<String>> {
143 let out = Command::new("git")
144 .args(["rev-parse", "--verify", "--quiet", rev])
145 .current_dir(repo)
146 .output()?;
147 if !out.status.success() {
148 return Ok(None);
149 }
150 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
151 Ok((!sha.is_empty()).then_some(sha))
152}
153
154fn commit_message(repo: &Path, commit: &str) -> Result<String> {
155 let out = Command::new("git")
156 .args(["log", "-1", "--format=%B", commit])
157 .current_dir(repo)
158 .output()?;
159 if !out.status.success() {
160 return Err(Error::Other(format!(
161 "git log failed: {}",
162 String::from_utf8_lossy(&out.stderr).trim()
163 )));
164 }
165 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
166}
167
168fn trailers_agree(
174 message: &str,
175 run_id: &str,
176 record: &RunRecord,
177 token: &MergeToken,
178) -> std::result::Result<(), String> {
179 let has = |prefix: &str, value: &str| {
180 message.lines().any(|line| {
181 line.trim().strip_prefix(prefix).is_some_and(|rest| {
182 rest.trim() == value || rest.trim().starts_with(&format!("{value} ("))
183 })
184 })
185 };
186
187 if !has("Run:", run_id) {
188 return Err(format!(
189 "the commit on this run's branch does not carry `Run: {run_id}`; it is not the commit \
190 this run produced"
191 ));
192 }
193 if !has("Authored-by:", record.author.as_str()) {
194 return Err(format!(
195 "the commit says it was written by someone other than {}, which the run record names",
196 record.author
197 ));
198 }
199 if !has("Reviewed-by:", token.reviewer().as_str()) {
200 return Err(format!(
201 "the commit says it was reviewed by someone other than {}, which the approval names",
202 token.reviewer()
203 ));
204 }
205 Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use ostraka_core::gate::{Approval, CheckRecord, Verdict};
212 use ostraka_core::identity::ActorId;
213
214 fn record() -> RunRecord {
215 RunRecord {
216 run_id: "r1".into(),
217 task_id: "t1".into(),
218 prompt: "do a thing".into(),
219 author: ActorId::new("archon"),
220 adapter: "a".into(),
221 repository: "only".into(),
222 started_at: "now".into(),
223 finished_at: None,
224 checks: vec![CheckRecord {
225 name: "test".into(),
226 cmd: "true".into(),
227 exit_code: Some(0),
228 stdout: String::new(),
229 stderr: String::new(),
230 duration_ms: 1,
231 }],
232 approval: Some(Approval {
233 reviewer: ActorId::new("ephor"),
234 verdict: Verdict::Approve,
235 }),
236 usage: Vec::new(),
237 outcome: Some(Outcome::Approved),
238 }
239 }
240
241 fn token() -> MergeToken {
242 let spec = ostraka_core::gate::GateSpec {
243 timeout_secs: None,
244 checks: vec![ostraka_core::gate::Check {
245 name: "test".into(),
246 cmd: "true".into(),
247 required: true,
248 }],
249 review: ostraka_core::gate::ReviewPolicy::default(),
250 };
251 gate::reaffirm(&spec, &record(), true).expect("the record earns a token")
252 }
253
254 #[test]
255 fn agreeing_trailers_are_accepted() {
256 let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (claude-code)\nReviewed-by: \
257 ephor (codex)\n";
258 assert!(trailers_agree(message, "r1", &record(), &token()).is_ok());
259 }
260
261 #[test]
262 fn a_commit_from_a_different_run_is_not_this_runs_commit() {
263 let message = "Do a thing\n\nRun: r2\nAuthored-by: archon (c)\nReviewed-by: ephor (d)\n";
264 let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
265 assert!(err.contains("Run: r1"), "{err}");
266 }
267
268 #[test]
269 fn a_commit_naming_a_different_reviewer_than_the_approval_is_refused() {
270 let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (c)\nReviewed-by: archon (c)\n";
273 let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
274 assert!(err.contains("reviewed by"), "{err}");
275 }
276
277 #[test]
278 fn a_run_whose_required_check_never_passed_earns_no_token() {
279 let mut r = record();
280 r.checks[0].exit_code = Some(1);
281 let spec = ostraka_core::gate::GateSpec {
282 timeout_secs: None,
283 checks: vec![ostraka_core::gate::Check {
284 name: "test".into(),
285 cmd: "true".into(),
286 required: true,
287 }],
288 review: ostraka_core::gate::ReviewPolicy::default(),
289 };
290 assert!(gate::reaffirm(&spec, &r, true).is_err());
291 }
292
293 #[test]
294 fn a_check_added_since_the_run_blocks_promotion() {
295 let spec = ostraka_core::gate::GateSpec {
298 timeout_secs: None,
299 checks: vec![
300 ostraka_core::gate::Check {
301 name: "test".into(),
302 cmd: "true".into(),
303 required: true,
304 },
305 ostraka_core::gate::Check {
306 name: "lint".into(),
307 cmd: "true".into(),
308 required: true,
309 },
310 ],
311 review: ostraka_core::gate::ReviewPolicy::default(),
312 };
313 match gate::reaffirm(&spec, &record(), true) {
314 Err(Refusal::ChecksFailed { failed, .. }) => assert_eq!(failed, ["lint"]),
315 other => panic!("expected the missing check to block: {other:?}"),
316 }
317 }
318
319 #[test]
320 fn a_self_approved_run_cannot_be_promoted() {
321 let mut r = record();
322 r.approval = Some(Approval {
323 reviewer: ActorId::new("archon"),
324 verdict: Verdict::Approve,
325 });
326 let spec = ostraka_core::gate::GateSpec {
327 timeout_secs: None,
328 checks: vec![ostraka_core::gate::Check {
329 name: "test".into(),
330 cmd: "true".into(),
331 required: true,
332 }],
333 review: ostraka_core::gate::ReviewPolicy::default(),
334 };
335 assert!(matches!(
336 gate::reaffirm(&spec, &r, true),
337 Err(Refusal::SelfApproval { .. })
338 ));
339 }
340}