1use std::path::Path;
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 config::{Config, MergeMode},
10 git, land,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "snake_case")]
16pub enum Disposition {
17 Land,
19 Review,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConfigEvidence {
26 pub layers: Vec<std::path::PathBuf>,
28 pub overrides: Option<crate::queue::RunOverrides>,
30 pub daemon_merge: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Selection {
37 pub url: String,
39 pub branch: String,
41 pub head: String,
43 #[serde(default)]
45 pub source_head: Option<String>,
46 #[serde(default)]
48 pub disposition: Option<Disposition>,
49 pub instruction: String,
51 pub approved_at: jiff::Timestamp,
53}
54
55const RETAIN_SOURCES: &str = "retain-recovery-sources";
56
57pub fn retain_sources(task: &crate::queue::Task, home: &Path) -> Result<()> {
60 for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
61 ensure!(
62 crate::run::is_run_id(id),
63 "invalid historical run id; refusing source retention"
64 );
65 let dir = home.join("runs").join(id);
66 ensure!(
67 dir.is_dir(),
68 "historical run {id} is missing; cannot retain its sources"
69 );
70 std::fs::write(dir.join(RETAIN_SOURCES), &task.id)
71 .with_context(|| format!("retaining sources of historical run {id}"))?;
72 }
73 Ok(())
74}
75
76pub(crate) fn sources_retained(home: &Path, id: &str) -> bool {
77 home.join("runs")
78 .join(id)
79 .join(RETAIN_SOURCES)
80 .try_exists()
81 .unwrap_or(true)
82}
83
84#[derive(Debug)]
86pub(crate) struct StartupFailed {
87 pub run: String,
88 pub cause: String,
89}
90
91impl std::fmt::Display for StartupFailed {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(
94 f,
95 "recovery run {} could not start: {}",
96 self.run, self.cause
97 )
98 }
99}
100impl std::error::Error for StartupFailed {}
101
102impl Selection {
103 pub fn source(&self) -> &str {
105 self.source_head.as_deref().unwrap_or(&self.head)
106 }
107
108 pub fn configure(&self, config: &mut Config) -> Result<()> {
110 match self.disposition {
111 Some(Disposition::Land) => {
112 ensure!(
113 config.graph.reviewers > 0 && config.graph.review_rounds > 0,
114 "delivery requires at least one reviewer and review round"
115 );
116 ensure!(
117 !config.verify.gate.is_empty()
118 && config.verify.gate.iter().all(|s| !s.trim().is_empty()),
119 "delivery requires nonempty verify.gate commands; configure the repository's documented gate in a machine/repository layer or recover-pr --config <complete-config>"
120 );
121 ensure!(
122 !config.verify.e2e.is_empty()
123 && config.verify.e2e.iter().all(|s| !s.trim().is_empty()),
124 "delivery requires nonempty verify.e2e commands; configure the repository's required real-machine checks before recover-pr --disposition land"
125 );
126 config.merge.mode = MergeMode::Pr;
127 config.graph.land = true;
128 config.graph.land_approval = true;
129 }
130 Some(Disposition::Review) => {
131 config.merge.mode = MergeMode::None;
132 config.graph.land = false;
133 }
134 None => anyhow::bail!(
135 "recovery disposition is missing; select the public head again with recover-pr --disposition land or --disposition review (and --source-head <full-SHA> to preserve a reviewed fix)"
136 ),
137 }
138 Ok(())
139 }
140
141 pub async fn verify_source(&self, repo: &Path) -> Result<()> {
143 let source = git::rev_parse(repo, &format!("{}^{{commit}}", self.source())).await?;
144 ensure!(
145 source == self.source(),
146 "source-head must resolve to its full commit SHA"
147 );
148 ensure!(
149 git::is_ancestor(repo, &self.head, &source).await,
150 "source-head does not preserve the selected public implementation; select a descendant commit"
151 );
152 Ok(())
153 }
154
155 pub fn check_task(&self, task: &str, instruction: &str) -> Result<()> {
157 ensure!(
158 self.branch == format!("magi/task-{task}"),
159 "recovery head belongs to another task"
160 );
161 ensure!(
162 self.instruction == instruction,
163 "recovery criteria changed; select the public head again"
164 );
165 ensure!(
166 [self.head.as_str(), self.source()]
167 .iter()
168 .all(|sha| sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit())),
169 "recovery requires a full commit SHA"
170 );
171 Ok(())
172 }
173
174 pub async fn verify(&self, repo: &Path, remote: &str, base: &str, lease: &str) -> Result<()> {
176 let pr_repo = land::slug_of_pr_url(&self.url).context("invalid recovery PR URL")?;
177 for args in [
178 vec!["remote", "get-url", "--all", remote],
179 vec!["remote", "get-url", "--push", "--all", remote],
180 ] {
181 let urls = git::git(repo, &args).await?;
182 ensure!(
183 !urls.trim().is_empty(),
184 "recovery remote has no repository URL"
185 );
186 for url in urls.lines() {
187 let remote_repo =
188 remote_slug(url).context("cannot prove recovery remote repository identity")?;
189 land::ensure_same_repo(&remote_repo, &pr_repo)?;
190 }
191 }
192 let found = land::find_open_pr(repo, &self.branch, base).await?;
193 ensure!(
194 matches!(found, land::OpenPr::One { ref url, .. } if url == &self.url),
195 "selected PR is not the unique open task PR in this repository"
196 );
197 let (ok, raw) = land::gh(
198 repo,
199 &[
200 "pr".into(),
201 "view".into(),
202 self.url.clone(),
203 "--json".into(),
204 "headRefName,headRefOid,baseRefName,state,isCrossRepository".into(),
205 ],
206 )
207 .await?;
208 ensure!(ok, "could not verify selected PR: {raw}");
209 validate_view(&raw, &self.branch, base, lease)?;
210 let fetched = git::fetch(repo, remote, &self.branch).await?;
211 ensure!(
212 fetched.ok(),
213 "fetch selected public head failed: {}",
214 fetched.stderr
215 );
216 let fetched_head =
217 git::rev_parse(repo, &format!("refs/remotes/{remote}/{}", self.branch)).await?;
218 ensure!(
219 fetched_head == lease,
220 "public head moved; select and approve its new head before recovery"
221 );
222 ensure!(
223 git::remote_head(repo, remote, &self.branch)
224 .await?
225 .as_deref()
226 == Some(lease),
227 "public head lease changed during recovery"
228 );
229 Ok(())
230 }
231}
232
233fn remote_slug(url: &str) -> Option<String> {
235 let url = url
236 .trim()
237 .trim_end_matches('/')
238 .strip_suffix(".git")
239 .unwrap_or(url.trim().trim_end_matches('/'));
240 let (host, path) = if let Some(rest) = url
241 .strip_prefix("https://")
242 .or_else(|| url.strip_prefix("http://"))
243 .or_else(|| url.strip_prefix("ssh://"))
244 {
245 let (authority, path) = rest.split_once('/')?;
246 (authority.rsplit('@').next()?, path)
247 } else {
248 let (authority, path) = url.split_once(':')?;
249 let (_, host) = authority.split_once('@')?;
250 (host, path)
251 };
252 let mut parts = path.split('/');
253 let owner = parts.next()?;
254 let repo = parts.next()?;
255 if host.is_empty() || owner.is_empty() || repo.is_empty() || parts.next().is_some() {
256 return None;
257 }
258 Some(format!("{host}/{owner}/{repo}"))
259}
260
261fn validate_view(raw: &str, branch: &str, base: &str, lease: &str) -> Result<()> {
262 #[derive(Deserialize)]
263 #[serde(rename_all = "camelCase")]
264 struct View {
265 head_ref_name: String,
266 head_ref_oid: String,
267 base_ref_name: String,
268 state: String,
269 is_cross_repository: bool,
270 }
271 let view: View = serde_json::from_str(raw).context("selected public head metadata")?;
272 ensure!(
273 view.state == "OPEN"
274 && !view.is_cross_repository
275 && view.head_ref_name == branch
276 && view.base_ref_name == base,
277 "selected PR ownership or base changed"
278 );
279 ensure!(
280 view.head_ref_oid == lease,
281 "selected public head moved; refusing recovery"
282 );
283 Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn recovery_uses_resolved_layers_and_preserves_unrelated_overrides() {
292 let dir = tempfile::tempdir().unwrap();
293 let machine = dir.path().join("machine.toml");
294 let repo = dir.path().join("repository.toml");
295 std::fs::write(&machine, "[verify]\ngate = ['documented gate']\ne2e = ['real checks']\n[graph]\nland = false\nland_approval = false\n").unwrap();
296 std::fs::write(&repo, "[merge]\nmode = 'none'\nbase = 'trunk'\n").unwrap();
297 let mut config = Config::load_layers(&[machine, repo]).unwrap();
298 let overrides = crate::queue::RunOverrides {
299 merge: Some("none".into()),
300 reviewers: Some(2),
301 ..Default::default()
302 };
303 overrides.apply(&mut config);
304 let mut selection = Selection {
305 url: "url".into(),
306 branch: "magi/task-t".into(),
307 head: "a".repeat(40),
308 source_head: None,
309 disposition: Some(Disposition::Land),
310 instruction: "criteria".into(),
311 approved_at: jiff::Timestamp::now(),
312 };
313 selection.configure(&mut config).unwrap();
314 assert_eq!(config.merge.mode, MergeMode::Pr);
315 assert_eq!(config.graph.reviewers, 2);
316 assert_eq!(config.merge.base.as_deref(), Some("trunk"));
317 assert_eq!(config.verify.gate, ["documented gate"]);
318 assert_eq!(config.verify.e2e, ["real checks"]);
319 assert!(config.graph.land && config.graph.land_approval);
320 let explicit = dir.path().join("explicit.toml");
321 std::fs::write(&explicit, "[merge]\nbase = 'other'\n").unwrap();
322 let (mut replacement, paths) = Config::discover(dir.path(), Some(&explicit)).unwrap();
323 assert_eq!(paths, [explicit]);
324 assert!(
325 selection
326 .configure(&mut replacement)
327 .unwrap_err()
328 .to_string()
329 .contains("verify.gate")
330 );
331 replacement.verify.gate = vec![" ".into()];
332 assert!(selection.configure(&mut replacement).is_err());
333 selection.disposition = Some(Disposition::Review);
334 selection.configure(&mut replacement).unwrap();
335 assert_eq!(replacement.merge.mode, MergeMode::None);
336 assert!(!replacement.graph.land);
337 }
338
339 #[test]
340 fn selected_source_requires_the_same_fetch_and_push_repository() {
341 for remote in [
342 "https://example.test/team/project.git",
343 "git@example.test:team/project.git",
344 "ssh://git@example.test/team/project.git",
345 ] {
346 assert_eq!(
347 remote_slug(remote).as_deref(),
348 Some("example.test/team/project")
349 );
350 }
351 assert!(remote_slug("/tmp/another.git").is_none());
352 assert!(
353 land::ensure_same_repo(
354 &remote_slug("git@example.test:team/other.git").unwrap(),
355 "example.test/team/project"
356 )
357 .is_err()
358 );
359 assert!(
360 land::ensure_same_repo(
361 &remote_slug("git@other.test:team/project.git").unwrap(),
362 "example.test/team/project"
363 )
364 .is_err()
365 );
366 }
367
368 #[test]
369 fn selected_head_refuses_moved_or_foreign_public_heads() {
370 let raw = r#"{"headRefName":"magi/task-t","headRefOid":"aaa","baseRefName":"main","state":"OPEN","isCrossRepository":false}"#;
371 assert!(validate_view(raw, "magi/task-t", "main", "aaa").is_ok());
372 assert!(validate_view(raw, "magi/task-t", "main", "bbb").is_err());
373 assert!(validate_view(raw, "magi/task-other", "main", "aaa").is_err());
374 assert!(validate_view(raw, "magi/task-t", "other", "aaa").is_err());
375 assert!(
376 validate_view(&raw.replace("false", "true"), "magi/task-t", "main", "aaa").is_err()
377 );
378 assert!(
379 validate_view(&raw.replace("OPEN", "CLOSED"), "magi/task-t", "main", "aaa").is_err()
380 );
381 }
382
383 #[test]
384 fn selected_head_is_bound_to_authored_criteria_and_task() {
385 let selected = Selection {
386 url: "url".into(),
387 branch: "magi/task-t".into(),
388 head: "a".repeat(40),
389 source_head: None,
390 disposition: Some(Disposition::Review),
391 instruction: "approved".into(),
392 approved_at: jiff::Timestamp::now(),
393 };
394 assert!(selected.check_task("t", "approved").is_ok());
395 assert!(
396 selected
397 .check_task("t", "prose mentioning another candidate")
398 .is_err()
399 );
400 assert!(selected.check_task("other", "approved").is_err());
401 }
402}