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
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
307/// Attempts and the pause between them for a merge the platform briefly
308/// rejects because it has not finished recomputing the moved base. Landing a
309/// tall stack moves the trunk on every merge, so this race is common.
310const MERGE_ATTEMPTS: u32 = 3;
311const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
312
313/// Whether a failed merge is the platform transiently rejecting against a base
314/// it has not settled - worth retrying - rather than a real failure (conflict,
315/// failed check, closed review), which must surface immediately.
316fn 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
327/// Run a merge, retrying while it fails transiently so the "base branch was
328/// modified" race does not stop a `merge --all` loop.
329fn 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    // attempts is always nonzero, so the final iteration returns above.
348    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    /// "Title (#12)", or just the id when there is no title.
371    pub fn label(&self) -> String {
372        label(&self.title, &self.id)
373    }
374}
375
376/// The display label for a review: "Title (#12)", or the bare id.
377pub(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        // Userinfo with an embedded '@' is stripped at the last one.
446        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        // A look-alike host is still not matched.
460        assert_eq!(
461            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
462            None
463        );
464    }
465}