1use std::path::Path;
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use crate::{git, land};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Selection {
13 pub url: String,
15 pub branch: String,
17 pub head: String,
19 pub instruction: String,
21 pub approved_at: jiff::Timestamp,
23}
24
25const RETAIN_SOURCES: &str = "retain-recovery-sources";
26
27pub fn retain_sources(task: &crate::queue::Task, home: &Path) -> Result<()> {
30 for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
31 ensure!(
32 crate::run::is_run_id(id),
33 "invalid historical run id; refusing source retention"
34 );
35 let dir = home.join("runs").join(id);
36 ensure!(
37 dir.is_dir(),
38 "historical run {id} is missing; cannot retain its sources"
39 );
40 std::fs::write(dir.join(RETAIN_SOURCES), &task.id)
41 .with_context(|| format!("retaining sources of historical run {id}"))?;
42 }
43 Ok(())
44}
45
46pub(crate) fn sources_retained(home: &Path, id: &str) -> bool {
47 home.join("runs")
48 .join(id)
49 .join(RETAIN_SOURCES)
50 .try_exists()
51 .unwrap_or(true)
52}
53
54#[derive(Debug)]
56pub(crate) struct StartupFailed {
57 pub run: String,
58 pub cause: String,
59}
60
61impl std::fmt::Display for StartupFailed {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(
64 f,
65 "recovery run {} could not start: {}",
66 self.run, self.cause
67 )
68 }
69}
70impl std::error::Error for StartupFailed {}
71
72impl Selection {
73 pub fn check_task(&self, task: &str, instruction: &str) -> Result<()> {
75 ensure!(
76 self.branch == format!("magi/task-{task}"),
77 "recovery head belongs to another task"
78 );
79 ensure!(
80 self.instruction == instruction,
81 "recovery criteria changed; select the public head again"
82 );
83 ensure!(
84 self.head.len() == 40 && self.head.bytes().all(|b| b.is_ascii_hexdigit()),
85 "recovery requires a full commit SHA"
86 );
87 Ok(())
88 }
89
90 pub async fn verify(&self, repo: &Path, remote: &str, base: &str, lease: &str) -> Result<()> {
92 let pr_repo = land::slug_of_pr_url(&self.url).context("invalid recovery PR URL")?;
93 for args in [
94 vec!["remote", "get-url", "--all", remote],
95 vec!["remote", "get-url", "--push", "--all", remote],
96 ] {
97 let urls = git::git(repo, &args).await?;
98 ensure!(
99 !urls.trim().is_empty(),
100 "recovery remote has no repository URL"
101 );
102 for url in urls.lines() {
103 let remote_repo =
104 remote_slug(url).context("cannot prove recovery remote repository identity")?;
105 land::ensure_same_repo(&remote_repo, &pr_repo)?;
106 }
107 }
108 let found = land::find_open_pr(repo, &self.branch, base).await?;
109 ensure!(
110 matches!(found, land::OpenPr::One { ref url, .. } if url == &self.url),
111 "selected PR is not the unique open task PR in this repository"
112 );
113 let (ok, raw) = land::gh(
114 repo,
115 &[
116 "pr".into(),
117 "view".into(),
118 self.url.clone(),
119 "--json".into(),
120 "headRefName,headRefOid,baseRefName,state,isCrossRepository".into(),
121 ],
122 )
123 .await?;
124 ensure!(ok, "could not verify selected PR: {raw}");
125 validate_view(&raw, &self.branch, base, lease)?;
126 let fetched = git::fetch(repo, remote, &self.branch).await?;
127 ensure!(
128 fetched.ok(),
129 "fetch selected public head failed: {}",
130 fetched.stderr
131 );
132 let fetched_head =
133 git::rev_parse(repo, &format!("refs/remotes/{remote}/{}", self.branch)).await?;
134 ensure!(
135 fetched_head == lease,
136 "public head moved; select and approve its new head before recovery"
137 );
138 ensure!(
139 git::remote_head(repo, remote, &self.branch)
140 .await?
141 .as_deref()
142 == Some(lease),
143 "public head lease changed during recovery"
144 );
145 Ok(())
146 }
147}
148
149fn remote_slug(url: &str) -> Option<String> {
151 let url = url
152 .trim()
153 .trim_end_matches('/')
154 .strip_suffix(".git")
155 .unwrap_or(url.trim().trim_end_matches('/'));
156 let (host, path) = if let Some(rest) = url
157 .strip_prefix("https://")
158 .or_else(|| url.strip_prefix("http://"))
159 .or_else(|| url.strip_prefix("ssh://"))
160 {
161 let (authority, path) = rest.split_once('/')?;
162 (authority.rsplit('@').next()?, path)
163 } else {
164 let (authority, path) = url.split_once(':')?;
165 let (_, host) = authority.split_once('@')?;
166 (host, path)
167 };
168 let mut parts = path.split('/');
169 let owner = parts.next()?;
170 let repo = parts.next()?;
171 if host.is_empty() || owner.is_empty() || repo.is_empty() || parts.next().is_some() {
172 return None;
173 }
174 Some(format!("{host}/{owner}/{repo}"))
175}
176
177fn validate_view(raw: &str, branch: &str, base: &str, lease: &str) -> Result<()> {
178 #[derive(Deserialize)]
179 #[serde(rename_all = "camelCase")]
180 struct View {
181 head_ref_name: String,
182 head_ref_oid: String,
183 base_ref_name: String,
184 state: String,
185 is_cross_repository: bool,
186 }
187 let view: View = serde_json::from_str(raw).context("selected public head metadata")?;
188 ensure!(
189 view.state == "OPEN"
190 && !view.is_cross_repository
191 && view.head_ref_name == branch
192 && view.base_ref_name == base,
193 "selected PR ownership or base changed"
194 );
195 ensure!(
196 view.head_ref_oid == lease,
197 "selected public head moved; refusing recovery"
198 );
199 Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn selected_source_requires_the_same_fetch_and_push_repository() {
208 for remote in [
209 "https://example.test/team/project.git",
210 "git@example.test:team/project.git",
211 "ssh://git@example.test/team/project.git",
212 ] {
213 assert_eq!(
214 remote_slug(remote).as_deref(),
215 Some("example.test/team/project")
216 );
217 }
218 assert!(remote_slug("/tmp/another.git").is_none());
219 assert!(
220 land::ensure_same_repo(
221 &remote_slug("git@example.test:team/other.git").unwrap(),
222 "example.test/team/project"
223 )
224 .is_err()
225 );
226 assert!(
227 land::ensure_same_repo(
228 &remote_slug("git@other.test:team/project.git").unwrap(),
229 "example.test/team/project"
230 )
231 .is_err()
232 );
233 }
234
235 #[test]
236 fn selected_head_refuses_moved_or_foreign_public_heads() {
237 let raw = r#"{"headRefName":"magi/task-t","headRefOid":"aaa","baseRefName":"main","state":"OPEN","isCrossRepository":false}"#;
238 assert!(validate_view(raw, "magi/task-t", "main", "aaa").is_ok());
239 assert!(validate_view(raw, "magi/task-t", "main", "bbb").is_err());
240 assert!(validate_view(raw, "magi/task-other", "main", "aaa").is_err());
241 assert!(validate_view(raw, "magi/task-t", "other", "aaa").is_err());
242 assert!(
243 validate_view(&raw.replace("false", "true"), "magi/task-t", "main", "aaa").is_err()
244 );
245 assert!(
246 validate_view(&raw.replace("OPEN", "CLOSED"), "magi/task-t", "main", "aaa").is_err()
247 );
248 }
249
250 #[test]
251 fn selected_head_is_bound_to_authored_criteria_and_task() {
252 let selected = Selection {
253 url: "url".into(),
254 branch: "magi/task-t".into(),
255 head: "a".repeat(40),
256 instruction: "approved".into(),
257 approved_at: jiff::Timestamp::now(),
258 };
259 assert!(selected.check_task("t", "approved").is_ok());
260 assert!(
261 selected
262 .check_task("t", "prose mentioning another candidate")
263 .is_err()
264 );
265 assert!(selected.check_task("other", "approved").is_err());
266 }
267}