1use std::time::Duration;
2use std::{fmt, process::Command};
3
4use anyhow::{Context, Result, anyhow, bail};
5
6use crate::git;
7use crate::settings;
8
9pub(super) const CHECK_GRACE_POLLS: u32 = 6;
14
15pub(super) fn check_poll_interval() -> Duration {
17 Duration::from_secs(5)
18}
19
20mod demo;
21mod github;
22mod gitlab;
23mod json;
24
25use demo::DemoProvider;
26use github::GitHubProvider;
27use gitlab::GitLabProvider;
28
29#[derive(Debug, Clone, Copy, Eq, PartialEq)]
30pub enum ProviderKind {
31 GitHub,
32 GitLab,
33 Demo,
36}
37
38impl ProviderKind {
39 fn parse(value: &str) -> Option<Self> {
40 match value.to_ascii_lowercase().as_str() {
41 "github" | "gh" => Some(Self::GitHub),
42 "gitlab" | "glab" => Some(Self::GitLab),
43 "demo" => Some(Self::Demo),
44 _ => None,
45 }
46 }
47}
48
49impl fmt::Display for ProviderKind {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::GitHub => write!(formatter, "github"),
53 Self::GitLab => write!(formatter, "gitlab"),
54 Self::Demo => write!(formatter, "demo"),
55 }
56 }
57}
58
59#[derive(Debug, Eq, PartialEq)]
60pub struct DetectedProvider {
61 pub kind: ProviderKind,
62 pub source: ProviderSource,
63}
64
65#[derive(Debug, Eq, PartialEq)]
66pub enum ProviderSource {
67 Config,
68 Remote { remote: String, url: String },
69}
70
71impl fmt::Display for ProviderSource {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::Config => write!(formatter, "config"),
75 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
76 }
77 }
78}
79
80#[derive(Debug, Eq, PartialEq)]
81pub enum ReviewState {
82 Open,
83 Merged,
84 Closed,
85 Unknown(String),
86}
87
88#[derive(Debug, Eq, PartialEq)]
89pub struct ReviewRequest {
90 pub id: String,
91 pub branch: String,
92 pub base: String,
93 pub state: ReviewState,
94 pub url: String,
95 pub title: String,
96 pub draft: bool,
97}
98
99pub trait ReviewProvider {
100 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
101
102 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
107
108 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
110
111 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
112
113 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
114
115 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
116
117 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
121
122 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
125
126 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
129
130 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
132
133 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
136
137 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
139}
140
141pub fn detect_provider() -> Result<DetectedProvider> {
142 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
143 let Some(kind) = ProviderKind::parse(&value) else {
144 bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
145 };
146
147 return Ok(DetectedProvider {
148 kind,
149 source: ProviderSource::Config,
150 });
151 }
152
153 let remote = settings::remote()?;
154 let Some(url) = git::remote_url(&remote)? else {
155 bail!("could not detect provider: remote {remote:?} does not exist");
156 };
157
158 let gitlab_host = settings::gitlab_host()?;
159 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
160 bail!("could not detect provider from remote {remote} ({url})");
161 };
162
163 Ok(DetectedProvider {
164 kind,
165 source: ProviderSource::Remote { remote, url },
166 })
167}
168
169fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
172 let normalized = url.to_ascii_lowercase();
173 let host = host_of(&normalized);
174 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
177
178 let gitlab_self_hosted = || {
181 gitlab_host.is_some_and(|configured| {
182 let configured = configured.to_ascii_lowercase();
183 is(host_of(&configured))
184 })
185 };
186
187 if is("github.com") {
188 Some(ProviderKind::GitHub)
189 } else if is("gitlab.com") || gitlab_self_hosted() {
190 Some(ProviderKind::GitLab)
191 } else {
192 None
193 }
194}
195
196fn host_of(url: &str) -> &str {
201 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
202 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
206 let host_port = authority
207 .rsplit_once('@')
208 .map_or(authority, |(_, rest)| rest);
209 if let Some(after_bracket) = host_port.strip_prefix('[') {
211 return after_bracket
212 .split_once(']')
213 .map_or(host_port, |(addr, _)| addr);
214 }
215 host_port.split(':').next().unwrap_or(host_port)
217}
218
219pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
220 match kind {
221 ProviderKind::GitHub => Box::new(GitHubProvider),
222 ProviderKind::GitLab => Box::new(GitLabProvider),
223 ProviderKind::Demo => Box::new(DemoProvider),
224 }
225}
226
227fn command_output(program: &str, args: &[&str]) -> Result<String> {
228 let output = Command::new(program)
229 .args(args)
230 .output()
231 .with_context(|| format!("failed to run {program}"))?;
232
233 if output.status.success() {
234 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
235 } else {
236 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
237 if stderr.is_empty() {
238 Err(anyhow!("{program} exited with status {}", output.status))
239 } else {
240 Err(anyhow!("{program} failed: {stderr}"))
241 }
242 }
243}
244
245const MERGE_ATTEMPTS: u32 = 3;
249const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
250
251fn is_transient_merge_error(error: &anyhow::Error) -> bool {
255 let text = error.to_string().to_lowercase();
256 [
257 "base branch was modified",
258 "head branch was modified",
259 "try the merge again",
260 ]
261 .iter()
262 .any(|signature| text.contains(signature))
263}
264
265fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
268 retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
269}
270
271fn retry_transient_merge(
272 attempts: u32,
273 backoff: Duration,
274 mut attempt: impl FnMut() -> Result<String>,
275) -> Result<String> {
276 for remaining in (0..attempts).rev() {
277 match attempt() {
278 Ok(output) => return Ok(output),
279 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
280 std::thread::sleep(backoff);
281 }
282 Err(error) => return Err(error),
283 }
284 }
285 Err(anyhow!("merge retried with no attempts left"))
287}
288
289impl fmt::Display for ReviewState {
290 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::Open => write!(formatter, "open"),
293 Self::Merged => write!(formatter, "merged"),
294 Self::Closed => write!(formatter, "closed"),
295 Self::Unknown(state) => write!(formatter, "{state}"),
296 }
297 }
298}
299
300impl ReviewRequest {
301 pub(crate) fn id_value(&self) -> &str {
302 self.id
303 .strip_prefix('#')
304 .or_else(|| self.id.strip_prefix('!'))
305 .unwrap_or(&self.id)
306 }
307
308 pub fn label(&self) -> String {
310 label(&self.title, &self.id)
311 }
312}
313
314pub(crate) fn label(title: &str, id: &str) -> String {
316 if title.is_empty() {
317 id.to_owned()
318 } else {
319 format!("{title} ({id})")
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn transient_error_is_retried_then_succeeds() {
329 let mut calls = 0;
330 let result = retry_transient_merge(3, Duration::ZERO, || {
331 calls += 1;
332 if calls < 2 {
333 Err(anyhow!(
334 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
335 ))
336 } else {
337 Ok("merged".to_owned())
338 }
339 });
340 assert_eq!(result.unwrap(), "merged");
341 assert_eq!(calls, 2, "should retry once then succeed");
342 }
343
344 #[test]
345 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
346 let mut calls = 0;
347 let result = retry_transient_merge(3, Duration::ZERO, || {
348 calls += 1;
349 Err(anyhow!("gh failed: Base branch was modified"))
350 });
351 assert!(result.is_err());
352 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
353 }
354
355 #[test]
356 fn a_real_failure_is_not_retried() {
357 let mut calls = 0;
358 let result = retry_transient_merge(3, Duration::ZERO, || {
359 calls += 1;
360 Err(anyhow!(
361 "gh failed: Pull request is not mergeable: conflicts"
362 ))
363 });
364 assert!(result.is_err());
365 assert_eq!(calls, 1, "a non-transient error must surface immediately");
366 }
367
368 #[test]
369 fn host_of_extracts_the_host_across_url_shapes() {
370 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
371 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
372 assert_eq!(
373 host_of("ssh://git@gitlab.example.com:22/g/r"),
374 "gitlab.example.com"
375 );
376 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
377 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
378 assert_eq!(
379 host_of("https://[2001:db8::1]:443/owner/repo"),
380 "2001:db8::1"
381 );
382 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
383 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
385 }
386
387 #[test]
388 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
389 let remote = "git@gitlab.example.com:team/repo.git";
390 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
391 assert_eq!(
392 detect_provider_from_url(remote, Some(configured)),
393 Some(ProviderKind::GitLab),
394 "configured {configured:?} should detect the self-hosted host"
395 );
396 }
397 assert_eq!(
399 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
400 None
401 );
402 }
403}