Skip to main content

git_stk/providers/
mod.rs

1use std::time::Duration;
2use std::{fmt, process::Command};
3
4use anyhow::{Context, Result, anyhow, bail};
5
6use crate::git;
7use crate::settings;
8
9/// How long to keep polling a "no checks / no pipeline yet" result before
10/// concluding there genuinely are none. A just-pushed branch's checks take a
11/// moment to register, so concluding too early would either merge without
12/// waiting or report a false failure.
13pub(super) const CHECK_GRACE_POLLS: u32 = 6;
14
15/// Delay between `wait_for_checks` polls.
16pub(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    /// Offline stand-in: reviews in `.git`, merges as local squashes. Only
34    /// ever selected explicitly via `stk.provider = demo`.
35    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    /// Like review_for_branch, but also finds closed reviews. Kept separate
103    /// so flows that act on a review (submit, sync, cleanup) never mistake a
104    /// dead review for a live one; only the stack-notes ledger wants closed
105    /// state, to restyle the entry rather than drop it.
106    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
107
108    /// Open a review for the branch; with `draft`, as a draft.
109    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    /// Merge the review with the given strategy: squash, rebase, or merge.
118    /// With `auto`, schedule the merge for when required checks pass
119    /// instead of merging now.
120    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
121
122    /// Block until the review's checks settle. Ok(true) when they pass (or
123    /// there are none), Ok(false) when something failed.
124    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
125
126    /// Every open review, in one call - for annotating the stack with review
127    /// numbers without a lookup per branch.
128    fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
129
130    /// Mark a draft review as ready for review.
131    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
132
133    /// Close the review without merging, deleting its source branch when
134    /// `delete_branch`. Used to retire a review superseded by a branch rename.
135    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
136
137    /// Open the review in the user's browser.
138    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
169/// Detect the provider from a remote URL by its host. A configured
170/// `stk.gitlabHost` widens GitLab detection to a self-hosted instance.
171fn 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    // Match the host itself or a subdomain of it, never a look-alike that
175    // merely embeds the name (mygithub.com, evil.com/github.com/...).
176    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
177
178    // The configured host goes through host_of too, so a full URL
179    // (https://gitlab.example.com) works as well as a bare host.
180    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
196/// The host of a git remote URL: the part after any `scheme://` and `user@`,
197/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
198/// `ssh://git@host:port/owner/repo`, scp-like `git@host:owner/repo`, and
199/// `[ipv6]` literals.
200fn host_of(url: &str) -> &str {
201    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
202    // Userinfo and the port live in the authority, before the path's first
203    // '/'. (The scp form `git@host:owner/repo` keeps the host before that '/'
204    // too.) Strip userinfo at the last '@' so an '@' inside it is tolerated.
205    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    // An IPv6 literal keeps its colons inside `[..]`; any port follows it.
210    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    // Otherwise the host ends at a ':' - a port, or the scp path separator.
216    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
245/// Attempts and the pause between them for a merge the platform briefly
246/// rejects because it has not finished recomputing the moved base. Landing a
247/// tall stack moves the trunk on every merge, so this race is common.
248const MERGE_ATTEMPTS: u32 = 3;
249const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
250
251/// Whether a failed merge is the platform transiently rejecting against a base
252/// it has not settled - worth retrying - rather than a real failure (conflict,
253/// failed check, closed review), which must surface immediately.
254fn 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
265/// Run a merge, retrying while it fails transiently so the "base branch was
266/// modified" race does not stop a `merge --all` loop.
267fn 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    // attempts is always nonzero, so the final iteration returns above.
286    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    /// "Title (#12)", or just the id when there is no title.
309    pub fn label(&self) -> String {
310        label(&self.title, &self.id)
311    }
312}
313
314/// The display label for a review: "Title (#12)", or the bare id.
315pub(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        // Userinfo with an embedded '@' is stripped at the last one.
384        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        // A look-alike host is still not matched.
398        assert_eq!(
399            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
400            None
401        );
402    }
403}