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
20pub(super) fn checks_timed_out(review: &ReviewRequest, timeout: Duration) -> anyhow::Error {
24 anyhow!(
25 "{}'s checks have not settled within {}; rerun `git stk merge` once they pass, \
26 or raise stk.checkTimeout",
27 review.id,
28 humanize(timeout),
29 )
30}
31
32fn humanize(duration: Duration) -> String {
34 let seconds = duration.as_secs();
35 if seconds >= 60 && seconds.is_multiple_of(60) {
36 format!("{}m", seconds / 60)
37 } else {
38 format!("{seconds}s")
39 }
40}
41
42mod demo;
43mod github;
44mod gitlab;
45mod json;
46
47use demo::DemoProvider;
48use github::GitHubProvider;
49use gitlab::GitLabProvider;
50
51#[derive(Debug, Clone, Copy, Eq, PartialEq)]
52pub enum ProviderKind {
53 GitHub,
54 GitLab,
55 Demo,
58}
59
60impl ProviderKind {
61 fn parse(value: &str) -> Option<Self> {
62 match value.to_ascii_lowercase().as_str() {
63 "github" | "gh" => Some(Self::GitHub),
64 "gitlab" | "glab" => Some(Self::GitLab),
65 "demo" => Some(Self::Demo),
66 _ => None,
67 }
68 }
69}
70
71impl fmt::Display for ProviderKind {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::GitHub => write!(formatter, "github"),
75 Self::GitLab => write!(formatter, "gitlab"),
76 Self::Demo => write!(formatter, "demo"),
77 }
78 }
79}
80
81#[derive(Debug, Eq, PartialEq)]
82pub struct DetectedProvider {
83 pub kind: ProviderKind,
84 pub source: ProviderSource,
85}
86
87#[derive(Debug, Eq, PartialEq)]
88pub enum ProviderSource {
89 Config,
90 Remote { remote: String, url: String },
91}
92
93impl fmt::Display for ProviderSource {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 Self::Config => write!(formatter, "config"),
97 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
98 }
99 }
100}
101
102#[derive(Debug, Eq, PartialEq)]
103pub enum ReviewState {
104 Open,
105 Merged,
106 Closed,
107 Unknown(String),
108}
109
110#[derive(Debug, Clone, Copy, Eq, PartialEq)]
115pub enum MergeBlocker {
116 ChecksPending,
118 Conflicts,
120 None,
122}
123
124#[derive(Debug, Eq, PartialEq)]
125pub struct ReviewRequest {
126 pub id: String,
127 pub branch: String,
128 pub base: String,
129 pub state: ReviewState,
130 pub url: String,
131 pub title: String,
132 pub draft: bool,
133}
134
135pub trait ReviewProvider {
136 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
137
138 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
143
144 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
146
147 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
148
149 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
150
151 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
152
153 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
157
158 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
162
163 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
166
167 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
170
171 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
173
174 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
177
178 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
180}
181
182pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
186 let provider = detect_provider()?;
187 let client = review_provider(provider.kind);
188 Ok((provider, client))
189}
190
191pub fn owned_review_for_branch(
195 provider: &dyn ReviewProvider,
196 branch: &str,
197) -> Result<Option<ReviewRequest>> {
198 Ok(provider
199 .review_for_branch(branch)?
200 .filter(|review| review.branch == branch))
201}
202
203pub fn detect_provider() -> Result<DetectedProvider> {
204 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
205 let Some(kind) = ProviderKind::parse(&value) else {
206 bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
207 };
208
209 return Ok(DetectedProvider {
210 kind,
211 source: ProviderSource::Config,
212 });
213 }
214
215 let remote = settings::remote()?;
216 let Some(url) = git::remote_url(&remote)? else {
217 bail!("could not detect provider: remote {remote:?} does not exist");
218 };
219
220 let gitlab_host = settings::gitlab_host()?;
221 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
222 bail!("could not detect provider from remote {remote} ({url})");
223 };
224
225 Ok(DetectedProvider {
226 kind,
227 source: ProviderSource::Remote { remote, url },
228 })
229}
230
231fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
234 let normalized = url.to_ascii_lowercase();
235 let host = host_of(&normalized);
236 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
239
240 let gitlab_self_hosted = || {
243 gitlab_host.is_some_and(|configured| {
244 let configured = configured.to_ascii_lowercase();
245 is(host_of(&configured))
246 })
247 };
248
249 if is("github.com") {
250 Some(ProviderKind::GitHub)
251 } else if is("gitlab.com") || gitlab_self_hosted() {
252 Some(ProviderKind::GitLab)
253 } else {
254 None
255 }
256}
257
258fn host_of(url: &str) -> &str {
263 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
264 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
268 let host_port = authority
269 .rsplit_once('@')
270 .map_or(authority, |(_, rest)| rest);
271 if let Some(after_bracket) = host_port.strip_prefix('[') {
273 return after_bracket
274 .split_once(']')
275 .map_or(host_port, |(addr, _)| addr);
276 }
277 host_port.split(':').next().unwrap_or(host_port)
279}
280
281pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
282 match kind {
283 ProviderKind::GitHub => Box::new(GitHubProvider),
284 ProviderKind::GitLab => Box::new(GitLabProvider),
285 ProviderKind::Demo => Box::new(DemoProvider),
286 }
287}
288
289fn command_output(program: &str, args: &[&str]) -> Result<String> {
290 let output = Command::new(program)
291 .args(args)
292 .output()
293 .with_context(|| format!("failed to run {program}"))?;
294
295 if output.status.success() {
296 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
297 } else {
298 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
299 if stderr.is_empty() {
300 Err(anyhow!("{program} exited with status {}", output.status))
301 } else {
302 Err(anyhow!("{program} failed: {stderr}"))
303 }
304 }
305}
306
307const MERGE_ATTEMPTS: u32 = 3;
311const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
312
313fn is_transient_merge_error(error: &anyhow::Error) -> bool {
317 let text = error.to_string().to_lowercase();
318 [
319 "base branch was modified",
320 "head branch was modified",
321 "try the merge again",
322 ]
323 .iter()
324 .any(|signature| text.contains(signature))
325}
326
327fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
330 retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
331}
332
333fn retry_transient_merge(
334 attempts: u32,
335 backoff: Duration,
336 mut attempt: impl FnMut() -> Result<String>,
337) -> Result<String> {
338 for remaining in (0..attempts).rev() {
339 match attempt() {
340 Ok(output) => return Ok(output),
341 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
342 std::thread::sleep(backoff);
343 }
344 Err(error) => return Err(error),
345 }
346 }
347 Err(anyhow!("merge retried with no attempts left"))
349}
350
351impl fmt::Display for ReviewState {
352 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353 match self {
354 Self::Open => write!(formatter, "open"),
355 Self::Merged => write!(formatter, "merged"),
356 Self::Closed => write!(formatter, "closed"),
357 Self::Unknown(state) => write!(formatter, "{state}"),
358 }
359 }
360}
361
362impl ReviewRequest {
363 pub(crate) fn id_value(&self) -> &str {
364 self.id
365 .strip_prefix('#')
366 .or_else(|| self.id.strip_prefix('!'))
367 .unwrap_or(&self.id)
368 }
369
370 pub fn label(&self) -> String {
372 label(&self.title, &self.id)
373 }
374}
375
376pub(crate) fn label(title: &str, id: &str) -> String {
378 if title.is_empty() {
379 id.to_owned()
380 } else {
381 format!("{title} ({id})")
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn transient_error_is_retried_then_succeeds() {
391 let mut calls = 0;
392 let result = retry_transient_merge(3, Duration::ZERO, || {
393 calls += 1;
394 if calls < 2 {
395 Err(anyhow!(
396 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
397 ))
398 } else {
399 Ok("merged".to_owned())
400 }
401 });
402 assert_eq!(result.unwrap(), "merged");
403 assert_eq!(calls, 2, "should retry once then succeed");
404 }
405
406 #[test]
407 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
408 let mut calls = 0;
409 let result = retry_transient_merge(3, Duration::ZERO, || {
410 calls += 1;
411 Err(anyhow!("gh failed: Base branch was modified"))
412 });
413 assert!(result.is_err());
414 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
415 }
416
417 #[test]
418 fn a_real_failure_is_not_retried() {
419 let mut calls = 0;
420 let result = retry_transient_merge(3, Duration::ZERO, || {
421 calls += 1;
422 Err(anyhow!(
423 "gh failed: Pull request is not mergeable: conflicts"
424 ))
425 });
426 assert!(result.is_err());
427 assert_eq!(calls, 1, "a non-transient error must surface immediately");
428 }
429
430 #[test]
431 fn host_of_extracts_the_host_across_url_shapes() {
432 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
433 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
434 assert_eq!(
435 host_of("ssh://git@gitlab.example.com:22/g/r"),
436 "gitlab.example.com"
437 );
438 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
439 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
440 assert_eq!(
441 host_of("https://[2001:db8::1]:443/owner/repo"),
442 "2001:db8::1"
443 );
444 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
445 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
447 }
448
449 #[test]
450 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
451 let remote = "git@gitlab.example.com:team/repo.git";
452 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
453 assert_eq!(
454 detect_provider_from_url(remote, Some(configured)),
455 Some(ProviderKind::GitLab),
456 "configured {configured:?} should detect the self-hosted host"
457 );
458 }
459 assert_eq!(
461 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
462 None
463 );
464 }
465}