1use std::fs::{self, OpenOptions};
8use std::io::{self, Write};
9use std::path::PathBuf;
10
11use crate::commands::record::RecordProfile;
12use crate::provider::Repo;
13use crate::{CommandError, state};
14
15const LOCK_DIR: &str = "lifecycle-post";
16const LOCK_BUSY_CODE: &str = "plan-issue-lifecycle-lock-busy";
17
18#[derive(Debug)]
19pub struct LifecycleMutationLock {
20 path: PathBuf,
21}
22
23impl Drop for LifecycleMutationLock {
24 fn drop(&mut self) {
25 match fs::remove_file(&self.path) {
26 Ok(()) => {}
27 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
28 Err(_) => {}
29 }
30 }
31}
32
33pub fn acquire(
35 repo: &Repo,
36 issue: u64,
37 profile: RecordProfile,
38) -> Result<LifecycleMutationLock, CommandError> {
39 acquire_for_identity(
40 repo.provider.as_str(),
41 repo.host.as_deref(),
42 &repo.slug,
43 issue,
44 profile,
45 )
46}
47
48pub fn acquire_for_identity(
53 provider: &str,
54 host: Option<&str>,
55 repo_slug: &str,
56 issue: u64,
57 profile: RecordProfile,
58) -> Result<LifecycleMutationLock, CommandError> {
59 let dir = state::state_dir().join("locks").join(LOCK_DIR);
60 fs::create_dir_all(&dir).map_err(|err| {
61 CommandError::runtime(
62 "plan-issue-lifecycle-lock-dir-failed",
63 format!(
64 "failed to create lifecycle lock directory {}: {err}",
65 dir.display()
66 ),
67 )
68 })?;
69
70 let key = key_for(provider, host, repo_slug, issue, profile);
71 let path = dir.join(format!("{key}.lock"));
72 let payload = payload_for(provider, host, repo_slug, issue, profile);
73 match OpenOptions::new().write(true).create_new(true).open(&path) {
74 Ok(mut file) => {
75 if let Err(err) = file.write_all(payload.as_bytes()) {
76 let _ = fs::remove_file(&path);
77 return Err(CommandError::runtime(
78 "plan-issue-lifecycle-lock-write-failed",
79 format!("failed to write lifecycle lock {}: {err}", path.display()),
80 ));
81 }
82 if let Err(err) = file.flush() {
83 let _ = fs::remove_file(&path);
84 return Err(CommandError::runtime(
85 "plan-issue-lifecycle-lock-write-failed",
86 format!("failed to flush lifecycle lock {}: {err}", path.display()),
87 ));
88 }
89 Ok(LifecycleMutationLock { path })
90 }
91 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Err(CommandError::runtime(
92 LOCK_BUSY_CODE,
93 format!(
94 "another plan-issue lifecycle mutation is already in progress for provider={} repo={} issue={} profile={}; retry after it finishes or remove stale lock {} if the owning process exited",
95 provider,
96 repo_slug,
97 issue,
98 profile.as_str(),
99 path.display(),
100 ),
101 )),
102 Err(err) => Err(CommandError::runtime(
103 "plan-issue-lifecycle-lock-acquire-failed",
104 format!("failed to acquire lifecycle lock {}: {err}", path.display()),
105 )),
106 }
107}
108
109fn key_for(
110 provider: &str,
111 host: Option<&str>,
112 repo_slug: &str,
113 issue: u64,
114 profile: RecordProfile,
115) -> String {
116 let host = host.unwrap_or("default");
117 sanitize_key(&format!(
118 "{}__{}__{}__issue-{}__{}",
119 provider,
120 host,
121 repo_slug,
122 issue,
123 profile.as_str()
124 ))
125}
126
127fn payload_for(
128 provider: &str,
129 host: Option<&str>,
130 repo_slug: &str,
131 issue: u64,
132 profile: RecordProfile,
133) -> String {
134 format!(
135 "provider={}\nhost={}\nrepo={}\nissue={}\nprofile={}\n",
136 provider,
137 host.unwrap_or("default"),
138 repo_slug,
139 issue,
140 profile.as_str(),
141 )
142}
143
144fn sanitize_key(raw: &str) -> String {
145 raw.chars()
146 .map(|ch| {
147 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
148 ch
149 } else {
150 '_'
151 }
152 })
153 .collect()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::provider::{Provider, Repo};
160 use nils_test_support::{EnvGuard, GlobalStateLock};
161 use tempfile::TempDir;
162
163 fn repo() -> Repo {
164 Repo {
165 provider: Provider::GitHub,
166 slug: "owner/repo".to_string(),
167 host: Some("github.com".to_string()),
168 }
169 }
170
171 fn isolate_state(lock: &GlobalStateLock) -> (TempDir, EnvGuard) {
172 crate::state::set_state_dir_override(None);
173 let tmp = TempDir::new().expect("tmp");
174 let path = tmp.path().to_string_lossy().to_string();
175 let guard = EnvGuard::set(lock, "PLAN_ISSUE_HOME", &path);
176 (tmp, guard)
177 }
178
179 #[test]
180 fn lifecycle_lock_is_issue_scoped_and_released_on_drop() {
181 let lock = GlobalStateLock::new();
182 let (_state, _env) = isolate_state(&lock);
183 let repo = repo();
184
185 let first = acquire(&repo, 42, RecordProfile::Tracking).expect("first lock");
186 let busy = acquire(&repo, 42, RecordProfile::Tracking).expect_err("second lock busy");
187 assert_eq!(busy.code, LOCK_BUSY_CODE);
188
189 acquire(&repo, 43, RecordProfile::Tracking).expect("different issue");
190 acquire(&repo, 42, RecordProfile::Dispatch).expect("different profile");
191
192 drop(first);
193 acquire(&repo, 42, RecordProfile::Tracking).expect("lock released");
194 }
195
196 #[test]
197 fn lifecycle_lock_key_is_path_safe() {
198 let repo = Repo {
199 provider: Provider::GitLab,
200 slug: "group/sub/project".to_string(),
201 host: Some("gitlab.example.com".to_string()),
202 };
203 let key = key_for(
204 repo.provider.as_str(),
205 repo.host.as_deref(),
206 &repo.slug,
207 7,
208 RecordProfile::Dispatch,
209 );
210 assert!(!key.contains('/'));
211 assert!(!key.contains('\\'));
212 assert!(key.contains("gitlab"));
213 assert!(key.contains("issue-7"));
214 }
215}