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 block = message.trim_end().rsplit("\n\n").next().unwrap_or_default();
185 let has = |prefix: &str, value: &str| {
186 block.lines().any(|line| {
187 line.trim().strip_prefix(prefix).is_some_and(|rest| {
188 rest.trim() == value || rest.trim().starts_with(&format!("{value} ("))
189 })
190 })
191 };
192
193 if !has("Run:", run_id) {
194 return Err(format!(
195 "the commit on this run's branch does not carry `Run: {run_id}`; it is not the commit \
196 this run produced"
197 ));
198 }
199 if !has("Authored-by:", record.author.as_str()) {
200 return Err(format!(
201 "the commit says it was written by someone other than {}, which the run record names",
202 record.author
203 ));
204 }
205 if !has("Reviewed-by:", token.reviewer().as_str()) {
206 return Err(format!(
207 "the commit says it was reviewed by someone other than {}, which the approval names",
208 token.reviewer()
209 ));
210 }
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use ostraka_core::gate::{Approval, CheckRecord, Verdict};
218 use ostraka_core::identity::ActorId;
219
220 fn record() -> RunRecord {
221 RunRecord {
222 run_id: "r1".into(),
223 task_id: "t1".into(),
224 prompt: "do a thing".into(),
225 author: ActorId::new("archon"),
226 adapter: "a".into(),
227 repository: "only".into(),
228 started_at: "now".into(),
229 finished_at: None,
230 checks: vec![CheckRecord {
231 name: "test".into(),
232 cmd: "true".into(),
233 exit_code: Some(0),
234 stdout: String::new(),
235 stderr: String::new(),
236 duration_ms: 1,
237 }],
238 approval: Some(Approval {
239 reviewer: ActorId::new("ephor"),
240 verdict: Verdict::Approve,
241 }),
242 usage: Vec::new(),
243 outcome: Some(Outcome::Approved),
244 }
245 }
246
247 fn token() -> MergeToken {
248 let spec = ostraka_core::gate::GateSpec {
249 timeout_secs: None,
250 checks: vec![ostraka_core::gate::Check {
251 name: "test".into(),
252 cmd: "true".into(),
253 required: true,
254 }],
255 review: ostraka_core::gate::ReviewPolicy::default(),
256 };
257 gate::reaffirm(&spec, &record(), true).expect("the record earns a token")
258 }
259
260 #[test]
261 fn agreeing_trailers_are_accepted() {
262 let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (claude-code)\nReviewed-by: \
263 ephor (codex)\n";
264 assert!(trailers_agree(message, "r1", &record(), &token()).is_ok());
265 }
266
267 #[test]
268 fn trailers_written_into_the_prompt_do_not_stand_in_for_the_real_ones() {
269 let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (c)\nReviewed-by: ephor (d)\n\n\
273 Run: r1\nAuthored-by: archon (c)\nReviewed-by: archon (c)\n";
274 let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
275 assert!(err.contains("reviewed by"), "{err}");
276
277 let missing = "Run: r1\nAuthored-by: archon (c)\nReviewed-by: ephor (d)\n\nRun: r1\n";
279 assert!(trailers_agree(missing, "r1", &record(), &token()).is_err());
280 }
281
282 #[test]
283 fn a_commit_from_a_different_run_is_not_this_runs_commit() {
284 let message = "Do a thing\n\nRun: r2\nAuthored-by: archon (c)\nReviewed-by: ephor (d)\n";
285 let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
286 assert!(err.contains("Run: r1"), "{err}");
287 }
288
289 #[test]
290 fn a_commit_naming_a_different_reviewer_than_the_approval_is_refused() {
291 let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (c)\nReviewed-by: archon (c)\n";
294 let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
295 assert!(err.contains("reviewed by"), "{err}");
296 }
297
298 #[test]
299 fn a_run_whose_required_check_never_passed_earns_no_token() {
300 let mut r = record();
301 r.checks[0].exit_code = Some(1);
302 let spec = ostraka_core::gate::GateSpec {
303 timeout_secs: None,
304 checks: vec![ostraka_core::gate::Check {
305 name: "test".into(),
306 cmd: "true".into(),
307 required: true,
308 }],
309 review: ostraka_core::gate::ReviewPolicy::default(),
310 };
311 assert!(gate::reaffirm(&spec, &r, true).is_err());
312 }
313
314 #[test]
315 fn a_check_added_since_the_run_blocks_promotion() {
316 let spec = ostraka_core::gate::GateSpec {
319 timeout_secs: None,
320 checks: vec![
321 ostraka_core::gate::Check {
322 name: "test".into(),
323 cmd: "true".into(),
324 required: true,
325 },
326 ostraka_core::gate::Check {
327 name: "lint".into(),
328 cmd: "true".into(),
329 required: true,
330 },
331 ],
332 review: ostraka_core::gate::ReviewPolicy::default(),
333 };
334 match gate::reaffirm(&spec, &record(), true) {
335 Err(Refusal::ChecksFailed { failed, .. }) => assert_eq!(failed, ["lint"]),
336 other => panic!("expected the missing check to block: {other:?}"),
337 }
338 }
339
340 #[test]
341 fn a_self_approved_run_cannot_be_promoted() {
342 let mut r = record();
343 r.approval = Some(Approval {
344 reviewer: ActorId::new("archon"),
345 verdict: Verdict::Approve,
346 });
347 let spec = ostraka_core::gate::GateSpec {
348 timeout_secs: None,
349 checks: vec![ostraka_core::gate::Check {
350 name: "test".into(),
351 cmd: "true".into(),
352 required: true,
353 }],
354 review: ostraka_core::gate::ReviewPolicy::default(),
355 };
356 assert!(matches!(
357 gate::reaffirm(&spec, &r, true),
358 Err(Refusal::SelfApproval { .. })
359 ));
360 }
361}