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 } => {
98                write!(formatter, "remote {remote} ({})", redact_url(url))
99            }
100        }
101    }
102}
103
104#[derive(Debug, Eq, PartialEq)]
105pub enum ReviewState {
106    Open,
107    Merged,
108    Closed,
109    Unknown(String),
110}
111
112/// A structural reason the platform won't merge a review, read from its API
113/// rather than its error text - so a wording change can't silently reclassify
114/// a real failure. `None` means nothing structural blocks the merge, or the
115/// platform did not say (the caller falls back to matching the error text).
116#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117pub enum MergeBlocker {
118    /// Required checks or reviews have not passed yet.
119    ChecksPending,
120    /// The review conflicts with its base branch.
121    Conflicts,
122    /// Nothing structural blocks the merge, or the platform did not say.
123    None,
124}
125
126#[derive(Debug, Eq, PartialEq)]
127pub struct ReviewRequest {
128    pub id: String,
129    pub branch: String,
130    pub base: String,
131    pub state: ReviewState,
132    pub url: String,
133    pub title: String,
134    pub draft: bool,
135}
136
137/// The result of waiting on a review's checks before merging it.
138pub enum WaitOutcome {
139    /// Checks passed, or there are none - go ahead and merge.
140    Passed,
141    /// A required check failed - stop the run.
142    Failed,
143    /// The review merged out-of-band while we waited (an admin merge on the
144    /// web, say). Skip the redundant merge and let `sync` reconcile it.
145    Landed,
146}
147
148pub trait ReviewProvider {
149    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
150
151    /// Like review_for_branch, but also finds closed reviews. Kept separate
152    /// so flows that act on a review (submit, sync, cleanup) never mistake a
153    /// dead review for a live one; only the stack-notes ledger wants closed
154    /// state, to restyle the entry rather than drop it.
155    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
156
157    /// Open a review for the branch; with `draft`, as a draft.
158    fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
159
160    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
161
162    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
163
164    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
165
166    /// Merge the review with the given strategy: squash, rebase, or merge.
167    /// With `auto`, schedule the merge for when required checks pass
168    /// instead of merging now.
169    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
170
171    /// Why the platform won't merge the review right now, read from its
172    /// structured status. Consulted after a merge is rejected to explain it
173    /// without parsing the CLI's error text.
174    fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
175
176    /// Block until the review's checks settle, returning how the wait ended:
177    /// checks passed (or there are none), one failed, or the review merged
178    /// out-of-band while we waited.
179    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
180
181    /// Every open review, in one call - for annotating the stack with review
182    /// numbers without a lookup per branch.
183    fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
184
185    /// Mark a draft review as ready for review.
186    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
187
188    /// Close the review without merging, deleting its source branch when
189    /// `delete_branch`. Used to retire a review superseded by a branch rename.
190    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
191
192    /// Open the review in the user's browser.
193    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
194}
195
196/// Detect the provider and build its review client together - the pair nearly
197/// every provider-backed command opens with. The returned [`DetectedProvider`]
198/// still carries the kind and detection source for messages.
199pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
200    let provider = detect_provider()?;
201    let client = review_provider(provider.kind);
202    Ok((provider, client))
203}
204
205/// The branch's review only when it actually heads that branch. A provider can
206/// return a review for a different head (a stale or look-alike match); a flow
207/// acting on "this branch's review" wants None there, not someone else's.
208pub fn owned_review_for_branch(
209    provider: &dyn ReviewProvider,
210    branch: &str,
211) -> Result<Option<ReviewRequest>> {
212    Ok(provider
213        .review_for_branch(branch)?
214        .filter(|review| review.branch == branch))
215}
216
217/// Whether the review has merged out-of-band since a `wait_for_checks` loop
218/// began. Only a definite Merged stops the wait; anything else (still open, or
219/// no longer listed) keeps polling, leaving stk.checkTimeout as the backstop.
220pub(super) fn review_merged_out_of_band(
221    provider: &dyn ReviewProvider,
222    review: &ReviewRequest,
223) -> Result<bool> {
224    Ok(matches!(
225        provider.review_for_branch(&review.branch)?,
226        Some(current) if current.state == ReviewState::Merged
227    ))
228}
229
230pub fn detect_provider() -> Result<DetectedProvider> {
231    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
232        let Some(kind) = ProviderKind::parse(&value) else {
233            bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
234        };
235
236        return Ok(DetectedProvider {
237            kind,
238            source: ProviderSource::Config,
239        });
240    }
241
242    let remote = settings::remote()?;
243    let Some(url) = git::remote_url(&remote)? else {
244        bail!("could not detect provider: remote {remote:?} does not exist");
245    };
246
247    let gitlab_host = settings::gitlab_host()?;
248    let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
249        bail!(
250            "could not detect provider from remote {remote} ({})",
251            redact_url(&url)
252        );
253    };
254
255    Ok(DetectedProvider {
256        kind,
257        source: ProviderSource::Remote { remote, url },
258    })
259}
260
261/// Detect the provider from a remote URL by its host. A configured
262/// `stk.gitlabHost` widens GitLab detection to a self-hosted instance.
263fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
264    let normalized = url.to_ascii_lowercase();
265    let host = host_of(&normalized);
266    // Match the host itself or a subdomain of it, never a look-alike that
267    // merely embeds the name (mygithub.com, evil.com/github.com/...).
268    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
269
270    // The configured host goes through host_of too, so a full URL
271    // (https://gitlab.example.com) works as well as a bare host.
272    let gitlab_self_hosted = || {
273        gitlab_host.is_some_and(|configured| {
274            let configured = configured.to_ascii_lowercase();
275            is(host_of(&configured))
276        })
277    };
278
279    if is("github.com") {
280        Some(ProviderKind::GitHub)
281    } else if is("gitlab.com") || gitlab_self_hosted() {
282        Some(ProviderKind::GitLab)
283    } else {
284        None
285    }
286}
287
288/// The host of a git remote URL: the part after any `scheme://` and `user@`,
289/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
290/// `ssh://git@host:port/owner/repo`, scp-like `git@host:owner/repo`, and
291/// `[ipv6]` literals.
292fn host_of(url: &str) -> &str {
293    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
294    // Userinfo and the port live in the authority, before the path's first
295    // '/'. (The scp form `git@host:owner/repo` keeps the host before that '/'
296    // too.) Strip userinfo at the last '@' so an '@' inside it is tolerated.
297    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
298    let host_port = authority
299        .rsplit_once('@')
300        .map_or(authority, |(_, rest)| rest);
301    // An IPv6 literal keeps its colons inside `[..]`; any port follows it.
302    if let Some(after_bracket) = host_port.strip_prefix('[') {
303        return after_bracket
304            .split_once(']')
305            .map_or(host_port, |(addr, _)| addr);
306    }
307    // Otherwise the host ends at a ':' - a port, or the scp path separator.
308    host_port.split(':').next().unwrap_or(host_port)
309}
310
311/// A remote URL with any embedded userinfo (`user:token@`) dropped, for safe
312/// display - an HTTPS remote can carry an auth token in the URL. scp-style
313/// `git@host:path` (no `scheme://`) carries no password, so it is left as is.
314fn redact_url(url: &str) -> String {
315    let Some((scheme, rest)) = url.split_once("://") else {
316        return url.to_owned();
317    };
318    let (authority, path) = match rest.split_once('/') {
319        Some((authority, path)) => (authority, Some(path)),
320        None => (rest, None),
321    };
322    // Drop everything up to the last '@' in the authority (covers `token@`,
323    // `user:token@`, and an '@' inside the userinfo).
324    let Some((_, host)) = authority.rsplit_once('@') else {
325        return url.to_owned();
326    };
327    match path {
328        Some(path) => format!("{scheme}://{host}/{path}"),
329        None => format!("{scheme}://{host}"),
330    }
331}
332
333pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
334    match kind {
335        ProviderKind::GitHub => Box::new(GitHubProvider),
336        ProviderKind::GitLab => Box::new(GitLabProvider),
337        ProviderKind::Demo => Box::new(DemoProvider),
338    }
339}
340
341/// A provider CLI's (full name, install URL, auth command), or None for a
342/// program that isn't one (e.g. `git`).
343fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
344    match program {
345        "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
346        "glab" => Some((
347            "GitLab CLI",
348            "https://gitlab.com/gitlab-org/cli",
349            "glab auth login",
350        )),
351        _ => None,
352    }
353}
354
355/// Whether a provider CLI's stderr reads like a not-signed-in failure, so we
356/// can point the user at `... auth login` rather than just echoing it.
357fn looks_unauthenticated(stderr: &str) -> bool {
358    let stderr = stderr.to_ascii_lowercase();
359    [
360        "auth login",
361        "not logged",
362        "401",
363        "unauthorized",
364        "authentication required",
365    ]
366    .iter()
367    .any(|needle| stderr.contains(needle))
368}
369
370fn command_output(program: &str, args: &[&str]) -> Result<String> {
371    let output = match Command::new(program).args(args).output() {
372        Ok(output) => output,
373        // The most common newcomer failure: the provider CLI isn't installed.
374        // Turn the raw "No such file or directory (os error 2)" into guidance.
375        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
376            if let Some((name, url, auth)) = provider_cli(program) {
377                bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
378            }
379            return Err(error).with_context(|| format!("failed to run {program}"));
380        }
381        Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
382    };
383
384    if output.status.success() {
385        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
386    }
387
388    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
389    // Installed but (probably) not signed in: keep the CLI's own message and
390    // add the actionable hint.
391    if let Some((_, _, auth)) = provider_cli(program)
392        && looks_unauthenticated(&stderr)
393    {
394        bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
395    }
396    if stderr.is_empty() {
397        Err(anyhow!("{program} exited with status {}", output.status))
398    } else {
399        Err(anyhow!("{program} failed: {stderr}"))
400    }
401}
402
403/// Attempts and the pause between them for a merge the platform briefly
404/// rejects because it has not finished recomputing the moved base. Landing a
405/// tall stack moves the trunk on every merge, so this race is common.
406const MERGE_ATTEMPTS: u32 = 3;
407const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
408
409/// Whether a failed merge is the platform transiently rejecting against a base
410/// it has not settled - worth retrying - rather than a real failure (conflict,
411/// failed check, closed review), which must surface immediately. GitHub says
412/// the "base/head branch was modified"; GitLab returns a 405 Method Not Allowed
413/// while the MR's merge status is still recomputing after a push (which
414/// `merge --all` triggers by force-pushing each branch just before merging it).
415fn is_transient_merge_error(error: &anyhow::Error) -> bool {
416    let text = error.to_string().to_lowercase();
417    [
418        "base branch was modified",
419        "head branch was modified",
420        "try the merge again",
421        "method not allowed",
422    ]
423    .iter()
424    .any(|signature| text.contains(signature))
425}
426
427/// Run a merge, retrying while it fails transiently so the "base branch was
428/// modified" race does not stop a `merge --all` loop.
429fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
430    retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
431}
432
433fn retry_transient_merge(
434    attempts: u32,
435    backoff: Duration,
436    mut attempt: impl FnMut() -> Result<String>,
437) -> Result<String> {
438    for remaining in (0..attempts).rev() {
439        match attempt() {
440            Ok(output) => return Ok(output),
441            Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
442                std::thread::sleep(backoff);
443            }
444            Err(error) => return Err(error),
445        }
446    }
447    // attempts is always nonzero, so the final iteration returns above.
448    Err(anyhow!("merge retried with no attempts left"))
449}
450
451impl fmt::Display for ReviewState {
452    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
453        match self {
454            Self::Open => write!(formatter, "open"),
455            Self::Merged => write!(formatter, "merged"),
456            Self::Closed => write!(formatter, "closed"),
457            Self::Unknown(state) => write!(formatter, "{state}"),
458        }
459    }
460}
461
462impl ReviewRequest {
463    pub(crate) fn id_value(&self) -> &str {
464        self.id
465            .strip_prefix('#')
466            .or_else(|| self.id.strip_prefix('!'))
467            .unwrap_or(&self.id)
468    }
469
470    /// "Title (#12)", or just the id when there is no title.
471    pub fn label(&self) -> String {
472        label(&self.title, &self.id)
473    }
474}
475
476/// The display label for a review: "Title (#12)", or the bare id.
477pub(crate) fn label(title: &str, id: &str) -> String {
478    if title.is_empty() {
479        id.to_owned()
480    } else {
481        format!("{title} ({id})")
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn provider_cli_maps_only_the_provider_clis() {
491        assert!(provider_cli("gh").is_some());
492        assert!(provider_cli("glab").is_some());
493        assert!(provider_cli("git").is_none());
494    }
495
496    #[test]
497    fn looks_unauthenticated_matches_signin_failures_only() {
498        assert!(looks_unauthenticated(
499            "error: not logged into any GitHub hosts"
500        ));
501        assert!(looks_unauthenticated(
502            "To get started, please run: gh auth login"
503        ));
504        assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
505        // A normal failure must not be misread as an auth problem.
506        assert!(!looks_unauthenticated("pull request not found"));
507        assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
508    }
509
510    #[test]
511    fn transient_error_is_retried_then_succeeds() {
512        let mut calls = 0;
513        let result = retry_transient_merge(3, Duration::ZERO, || {
514            calls += 1;
515            if calls < 2 {
516                Err(anyhow!(
517                    "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
518                ))
519            } else {
520                Ok("merged".to_owned())
521            }
522        });
523        assert_eq!(result.unwrap(), "merged");
524        assert_eq!(calls, 2, "should retry once then succeed");
525    }
526
527    #[test]
528    fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
529        let mut calls = 0;
530        let result = retry_transient_merge(3, Duration::ZERO, || {
531            calls += 1;
532            if calls < 2 {
533                Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
534            } else {
535                Ok("merged".to_owned())
536            }
537        });
538        assert_eq!(result.unwrap(), "merged");
539        assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
540    }
541
542    #[test]
543    fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
544        let mut calls = 0;
545        let result = retry_transient_merge(3, Duration::ZERO, || {
546            calls += 1;
547            Err(anyhow!("gh failed: Base branch was modified"))
548        });
549        assert!(result.is_err());
550        assert_eq!(calls, 3, "should try exactly the budgeted number of times");
551    }
552
553    #[test]
554    fn a_real_failure_is_not_retried() {
555        let mut calls = 0;
556        let result = retry_transient_merge(3, Duration::ZERO, || {
557            calls += 1;
558            Err(anyhow!(
559                "gh failed: Pull request is not mergeable: conflicts"
560            ))
561        });
562        assert!(result.is_err());
563        assert_eq!(calls, 1, "a non-transient error must surface immediately");
564    }
565
566    #[test]
567    fn host_of_extracts_the_host_across_url_shapes() {
568        assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
569        assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
570        assert_eq!(
571            host_of("ssh://git@gitlab.example.com:22/g/r"),
572            "gitlab.example.com"
573        );
574        assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
575        assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
576        assert_eq!(
577            host_of("https://[2001:db8::1]:443/owner/repo"),
578            "2001:db8::1"
579        );
580        assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
581        // Userinfo with an embedded '@' is stripped at the last one.
582        assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
583    }
584
585    #[test]
586    fn redact_url_strips_embedded_credentials() {
587        // An HTTPS remote can carry a token; it must never be displayed.
588        assert_eq!(
589            redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
590            "https://github.com/owner/repo.git"
591        );
592        assert_eq!(
593            redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
594            "https://gitlab.com/owner/repo"
595        );
596        // ssh userinfo (no secret) is dropped too; port and path stay.
597        assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
598    }
599
600    #[test]
601    fn redact_url_leaves_credential_free_urls_unchanged() {
602        assert_eq!(
603            redact_url("https://github.com/owner/repo.git"),
604            "https://github.com/owner/repo.git"
605        );
606        // scp form has no scheme and carries no password - left as is.
607        assert_eq!(
608            redact_url("git@github.com:owner/repo.git"),
609            "git@github.com:owner/repo.git"
610        );
611    }
612
613    #[test]
614    fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
615        let remote = "git@gitlab.example.com:team/repo.git";
616        for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
617            assert_eq!(
618                detect_provider_from_url(remote, Some(configured)),
619                Some(ProviderKind::GitLab),
620                "configured {configured:?} should detect the self-hosted host"
621            );
622        }
623        // A look-alike host is still not matched.
624        assert_eq!(
625            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
626            None
627        );
628    }
629}