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
20/// The error a `wait_for_checks` loop returns when its `stk.checkTimeout`
21/// ceiling elapses with the checks still unsettled - so a pipeline that never
22/// reports does not block `merge --wait` forever.
23pub(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
32/// A whole-minute duration as "30m"; otherwise plain seconds.
33fn 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    /// Offline stand-in: reviews in `.git`, merges as local squashes. Only
56    /// ever selected explicitly via `stk.provider = demo`.
57    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/// A structural reason the platform won't merge a review, read from its API
111/// rather than its error text - so a wording change can't silently reclassify
112/// a real failure. `None` means nothing structural blocks the merge, or the
113/// platform did not say (the caller falls back to matching the error text).
114#[derive(Debug, Clone, Copy, Eq, PartialEq)]
115pub enum MergeBlocker {
116    /// Required checks or reviews have not passed yet.
117    ChecksPending,
118    /// The review conflicts with its base branch.
119    Conflicts,
120    /// Nothing structural blocks the merge, or the platform did not say.
121    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    /// Like review_for_branch, but also finds closed reviews. Kept separate
139    /// so flows that act on a review (submit, sync, cleanup) never mistake a
140    /// dead review for a live one; only the stack-notes ledger wants closed
141    /// state, to restyle the entry rather than drop it.
142    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
143
144    /// Open a review for the branch; with `draft`, as a draft.
145    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    /// Merge the review with the given strategy: squash, rebase, or merge.
154    /// With `auto`, schedule the merge for when required checks pass
155    /// instead of merging now.
156    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
157
158    /// Why the platform won't merge the review right now, read from its
159    /// structured status. Consulted after a merge is rejected to explain it
160    /// without parsing the CLI's error text.
161    fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
162
163    /// Block until the review's checks settle. Ok(true) when they pass (or
164    /// there are none), Ok(false) when something failed.
165    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
166
167    /// Every open review, in one call - for annotating the stack with review
168    /// numbers without a lookup per branch.
169    fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
170
171    /// Mark a draft review as ready for review.
172    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
173
174    /// Close the review without merging, deleting its source branch when
175    /// `delete_branch`. Used to retire a review superseded by a branch rename.
176    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
177
178    /// Open the review in the user's browser.
179    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
180}
181
182/// Detect the provider and build its review client together - the pair nearly
183/// every provider-backed command opens with. The returned [`DetectedProvider`]
184/// still carries the kind and detection source for messages.
185pub 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
191/// The branch's review only when it actually heads that branch. A provider can
192/// return a review for a different head (a stale or look-alike match); a flow
193/// acting on "this branch's review" wants None there, not someone else's.
194pub 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
231/// Detect the provider from a remote URL by its host. A configured
232/// `stk.gitlabHost` widens GitLab detection to a self-hosted instance.
233fn 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    // Match the host itself or a subdomain of it, never a look-alike that
237    // merely embeds the name (mygithub.com, evil.com/github.com/...).
238    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
239
240    // The configured host goes through host_of too, so a full URL
241    // (https://gitlab.example.com) works as well as a bare host.
242    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
258/// The host of a git remote URL: the part after any `scheme://` and `user@`,
259/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
260/// `ssh://git@host:port/owner/repo`, scp-like `git@host:owner/repo`, and
261/// `[ipv6]` literals.
262fn host_of(url: &str) -> &str {
263    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
264    // Userinfo and the port live in the authority, before the path's first
265    // '/'. (The scp form `git@host:owner/repo` keeps the host before that '/'
266    // too.) Strip userinfo at the last '@' so an '@' inside it is tolerated.
267    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    // An IPv6 literal keeps its colons inside `[..]`; any port follows it.
272    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    // Otherwise the host ends at a ':' - a port, or the scp path separator.
278    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
289/// A provider CLI's (full name, install URL, auth command), or None for a
290/// program that isn't one (e.g. `git`).
291fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
292    match program {
293        "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
294        "glab" => Some((
295            "GitLab CLI",
296            "https://gitlab.com/gitlab-org/cli",
297            "glab auth login",
298        )),
299        _ => None,
300    }
301}
302
303/// Whether a provider CLI's stderr reads like a not-signed-in failure, so we
304/// can point the user at `... auth login` rather than just echoing it.
305fn looks_unauthenticated(stderr: &str) -> bool {
306    let stderr = stderr.to_ascii_lowercase();
307    [
308        "auth login",
309        "not logged",
310        "401",
311        "unauthorized",
312        "authentication required",
313    ]
314    .iter()
315    .any(|needle| stderr.contains(needle))
316}
317
318fn command_output(program: &str, args: &[&str]) -> Result<String> {
319    let output = match Command::new(program).args(args).output() {
320        Ok(output) => output,
321        // The most common newcomer failure: the provider CLI isn't installed.
322        // Turn the raw "No such file or directory (os error 2)" into guidance.
323        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
324            if let Some((name, url, auth)) = provider_cli(program) {
325                bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
326            }
327            return Err(error).with_context(|| format!("failed to run {program}"));
328        }
329        Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
330    };
331
332    if output.status.success() {
333        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
334    }
335
336    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
337    // Installed but (probably) not signed in: keep the CLI's own message and
338    // add the actionable hint.
339    if let Some((_, _, auth)) = provider_cli(program)
340        && looks_unauthenticated(&stderr)
341    {
342        bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
343    }
344    if stderr.is_empty() {
345        Err(anyhow!("{program} exited with status {}", output.status))
346    } else {
347        Err(anyhow!("{program} failed: {stderr}"))
348    }
349}
350
351/// Attempts and the pause between them for a merge the platform briefly
352/// rejects because it has not finished recomputing the moved base. Landing a
353/// tall stack moves the trunk on every merge, so this race is common.
354const MERGE_ATTEMPTS: u32 = 3;
355const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
356
357/// Whether a failed merge is the platform transiently rejecting against a base
358/// it has not settled - worth retrying - rather than a real failure (conflict,
359/// failed check, closed review), which must surface immediately.
360fn is_transient_merge_error(error: &anyhow::Error) -> bool {
361    let text = error.to_string().to_lowercase();
362    [
363        "base branch was modified",
364        "head branch was modified",
365        "try the merge again",
366    ]
367    .iter()
368    .any(|signature| text.contains(signature))
369}
370
371/// Run a merge, retrying while it fails transiently so the "base branch was
372/// modified" race does not stop a `merge --all` loop.
373fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
374    retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
375}
376
377fn retry_transient_merge(
378    attempts: u32,
379    backoff: Duration,
380    mut attempt: impl FnMut() -> Result<String>,
381) -> Result<String> {
382    for remaining in (0..attempts).rev() {
383        match attempt() {
384            Ok(output) => return Ok(output),
385            Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
386                std::thread::sleep(backoff);
387            }
388            Err(error) => return Err(error),
389        }
390    }
391    // attempts is always nonzero, so the final iteration returns above.
392    Err(anyhow!("merge retried with no attempts left"))
393}
394
395impl fmt::Display for ReviewState {
396    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
397        match self {
398            Self::Open => write!(formatter, "open"),
399            Self::Merged => write!(formatter, "merged"),
400            Self::Closed => write!(formatter, "closed"),
401            Self::Unknown(state) => write!(formatter, "{state}"),
402        }
403    }
404}
405
406impl ReviewRequest {
407    pub(crate) fn id_value(&self) -> &str {
408        self.id
409            .strip_prefix('#')
410            .or_else(|| self.id.strip_prefix('!'))
411            .unwrap_or(&self.id)
412    }
413
414    /// "Title (#12)", or just the id when there is no title.
415    pub fn label(&self) -> String {
416        label(&self.title, &self.id)
417    }
418}
419
420/// The display label for a review: "Title (#12)", or the bare id.
421pub(crate) fn label(title: &str, id: &str) -> String {
422    if title.is_empty() {
423        id.to_owned()
424    } else {
425        format!("{title} ({id})")
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn provider_cli_maps_only_the_provider_clis() {
435        assert!(provider_cli("gh").is_some());
436        assert!(provider_cli("glab").is_some());
437        assert!(provider_cli("git").is_none());
438    }
439
440    #[test]
441    fn looks_unauthenticated_matches_signin_failures_only() {
442        assert!(looks_unauthenticated(
443            "error: not logged into any GitHub hosts"
444        ));
445        assert!(looks_unauthenticated(
446            "To get started, please run: gh auth login"
447        ));
448        assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
449        // A normal failure must not be misread as an auth problem.
450        assert!(!looks_unauthenticated("pull request not found"));
451        assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
452    }
453
454    #[test]
455    fn transient_error_is_retried_then_succeeds() {
456        let mut calls = 0;
457        let result = retry_transient_merge(3, Duration::ZERO, || {
458            calls += 1;
459            if calls < 2 {
460                Err(anyhow!(
461                    "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
462                ))
463            } else {
464                Ok("merged".to_owned())
465            }
466        });
467        assert_eq!(result.unwrap(), "merged");
468        assert_eq!(calls, 2, "should retry once then succeed");
469    }
470
471    #[test]
472    fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
473        let mut calls = 0;
474        let result = retry_transient_merge(3, Duration::ZERO, || {
475            calls += 1;
476            Err(anyhow!("gh failed: Base branch was modified"))
477        });
478        assert!(result.is_err());
479        assert_eq!(calls, 3, "should try exactly the budgeted number of times");
480    }
481
482    #[test]
483    fn a_real_failure_is_not_retried() {
484        let mut calls = 0;
485        let result = retry_transient_merge(3, Duration::ZERO, || {
486            calls += 1;
487            Err(anyhow!(
488                "gh failed: Pull request is not mergeable: conflicts"
489            ))
490        });
491        assert!(result.is_err());
492        assert_eq!(calls, 1, "a non-transient error must surface immediately");
493    }
494
495    #[test]
496    fn host_of_extracts_the_host_across_url_shapes() {
497        assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
498        assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
499        assert_eq!(
500            host_of("ssh://git@gitlab.example.com:22/g/r"),
501            "gitlab.example.com"
502        );
503        assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
504        assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
505        assert_eq!(
506            host_of("https://[2001:db8::1]:443/owner/repo"),
507            "2001:db8::1"
508        );
509        assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
510        // Userinfo with an embedded '@' is stripped at the last one.
511        assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
512    }
513
514    #[test]
515    fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
516        let remote = "git@gitlab.example.com:team/repo.git";
517        for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
518            assert_eq!(
519                detect_provider_from_url(remote, Some(configured)),
520                Some(ProviderKind::GitLab),
521                "configured {configured:?} should detect the self-hosted host"
522            );
523        }
524        // A look-alike host is still not matched.
525        assert_eq!(
526            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
527            None
528        );
529    }
530}