Skip to main content

git_stk/providers/
mod.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::time::Duration;
3use std::{fmt, process::Command};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7use crate::git;
8use crate::settings;
9
10/// How long to keep polling a "no checks / no pipeline yet" result before
11/// concluding there genuinely are none. A just-pushed branch's checks take a
12/// moment to register, so concluding too early would either merge without
13/// waiting or report a false failure.
14pub(super) const CHECK_GRACE_POLLS: u32 = 6;
15
16/// Delay between `wait_for_checks` polls.
17pub(super) fn check_poll_interval() -> Duration {
18    Duration::from_secs(5)
19}
20
21/// The error a `wait_for_checks` loop returns when its `stk.checkTimeout`
22/// ceiling elapses with the checks still unsettled - so a pipeline that never
23/// reports does not block `merge --wait` forever.
24pub(super) fn checks_timed_out(review: &ReviewRequest, timeout: Duration) -> anyhow::Error {
25    anyhow!(
26        "{}'s checks have not settled within {}; rerun `git stk merge` once they pass, \
27         or raise stk.checkTimeout",
28        review.id,
29        humanize(timeout),
30    )
31}
32
33/// A whole-minute duration as "30m"; otherwise plain seconds.
34fn humanize(duration: Duration) -> String {
35    let seconds = duration.as_secs();
36    if seconds >= 60 && seconds.is_multiple_of(60) {
37        format!("{}m", seconds / 60)
38    } else {
39        format!("{seconds}s")
40    }
41}
42
43mod demo;
44mod gitea;
45mod github;
46mod gitlab;
47mod json;
48
49use demo::DemoProvider;
50use gitea::GiteaProvider;
51use github::GitHubProvider;
52use gitlab::GitLabProvider;
53
54#[derive(Debug, Clone, Copy, Eq, PartialEq)]
55pub enum ProviderKind {
56    GitHub,
57    GitLab,
58    Gitea,
59    /// Offline stand-in: reviews in `.git`, merges as local squashes. Only
60    /// ever selected explicitly via `stk.provider = demo`.
61    Demo,
62}
63
64impl ProviderKind {
65    fn parse(value: &str) -> Option<Self> {
66        match value.to_ascii_lowercase().as_str() {
67            "github" | "gh" => Some(Self::GitHub),
68            "gitlab" | "glab" => Some(Self::GitLab),
69            "gitea" | "tea" => Some(Self::Gitea),
70            "demo" => Some(Self::Demo),
71            _ => None,
72        }
73    }
74}
75
76impl fmt::Display for ProviderKind {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::GitHub => write!(formatter, "github"),
80            Self::GitLab => write!(formatter, "gitlab"),
81            Self::Gitea => write!(formatter, "gitea"),
82            Self::Demo => write!(formatter, "demo"),
83        }
84    }
85}
86
87#[derive(Debug, Eq, PartialEq)]
88pub struct DetectedProvider {
89    pub kind: ProviderKind,
90    pub source: ProviderSource,
91}
92
93#[derive(Debug, Eq, PartialEq)]
94pub enum ProviderSource {
95    Config,
96    Remote { remote: String, url: String },
97}
98
99impl fmt::Display for ProviderSource {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::Config => write!(formatter, "config"),
103            Self::Remote { remote, url } => {
104                write!(formatter, "remote {remote} ({})", redact_url(url))
105            }
106        }
107    }
108}
109
110#[derive(Debug, Eq, PartialEq)]
111pub enum ReviewState {
112    Open,
113    Merged,
114    Closed,
115    Unknown(String),
116}
117
118/// A structural reason the platform won't merge a review, read from its API
119/// rather than its error text - so a wording change can't silently reclassify
120/// a real failure. `None` means nothing structural blocks the merge, or the
121/// platform did not say (the caller falls back to matching the error text).
122#[derive(Debug, Clone, Copy, Eq, PartialEq)]
123pub enum MergeBlocker {
124    /// Required checks or reviews have not passed yet.
125    ChecksPending,
126    /// The review conflicts with its base branch.
127    Conflicts,
128    /// Nothing structural blocks the merge, or the platform did not say.
129    None,
130}
131
132#[derive(Debug, Eq, PartialEq)]
133pub struct ReviewRequest {
134    pub id: String,
135    pub branch: String,
136    pub base: String,
137    pub state: ReviewState,
138    pub url: String,
139    pub title: String,
140    pub draft: bool,
141}
142
143/// A review's CI check rollup, reduced to one at-a-glance dot for `list` and
144/// `status`. `None` means no checks ran, or the provider could not report
145/// them - either way, no dot is shown.
146#[derive(Debug, Clone, Copy, Eq, PartialEq)]
147pub enum CheckStatus {
148    Passing,
149    Failing,
150    Pending,
151    None,
152}
153
154impl CheckStatus {
155    /// The status dot, with a trailing space so it sits before the review id -
156    /// or empty when there is nothing to show.
157    pub fn dot(self) -> &'static str {
158        match self {
159            Self::Passing => "🟢 ",
160            Self::Failing => "🔴 ",
161            Self::Pending => "🟡 ",
162            Self::None => "",
163        }
164    }
165}
166
167/// Tallies of a review's latest reviews, for `list --reviews`.
168#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
169pub struct ReviewSummary {
170    pub approvals: u32,
171    pub comments: u32,
172    pub changes_requested: u32,
173}
174
175impl ReviewSummary {
176    /// One line per non-zero category (`"2 approvals"`, `"1 requested change"`),
177    /// mirroring the `--commits` list. Empty when nothing has been reviewed, so
178    /// the caller can show a `(no reviews)` placeholder instead.
179    pub fn lines(&self) -> Vec<String> {
180        let count =
181            |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
182        let mut lines = Vec::new();
183        if self.approvals > 0 {
184            lines.push(count(self.approvals, "approval", "approvals"));
185        }
186        if self.comments > 0 {
187            lines.push(count(self.comments, "comment", "comments"));
188        }
189        if self.changes_requested > 0 {
190            lines.push(count(
191                self.changes_requested,
192                "requested change",
193                "requested changes",
194            ));
195        }
196        lines
197    }
198}
199
200/// The marker shown before a review that sits in a merge queue (GitHub) or
201/// merge train (GitLab) - it is waiting its turn to land. Includes a trailing
202/// space so it sits before the CI dot / id.
203pub const QUEUED_MARK: &str = "🕑 ";
204
205/// Per-branch review data threaded into the `list` tree: the id (e.g. `#12`),
206/// its CI dot, whether it sits in a merge queue/train, and - only with
207/// `--reviews` - the review tallies.
208pub struct ReviewAnnotation {
209    pub id: String,
210    pub checks: CheckStatus,
211    pub queued: bool,
212    pub summary: Option<ReviewSummary>,
213}
214
215/// The result of waiting on a review's checks before merging it.
216pub enum WaitOutcome {
217    /// Checks passed, or there are none - go ahead and merge.
218    Passed,
219    /// A required check failed - stop the run.
220    Failed,
221    /// The review merged out-of-band while we waited (an admin merge on the
222    /// web, say). Skip the redundant merge and let `sync` reconcile it.
223    Landed,
224}
225
226pub trait ReviewProvider {
227    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
228
229    /// Like review_for_branch, but also finds closed reviews. Kept separate
230    /// so flows that act on a review (submit, sync, cleanup) never mistake a
231    /// dead review for a live one; only the stack-notes ledger wants closed
232    /// state, to restyle the entry rather than drop it.
233    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
234
235    /// Open a review for the branch; with `draft`, as a draft. `title` sets the
236    /// review's title, defaulting to the branch tip's commit subject.
237    fn create_review(
238        &self,
239        branch: &str,
240        base: &str,
241        draft: bool,
242        title: Option<&str>,
243    ) -> Result<String>;
244
245    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
246
247    /// Retitle an existing review. Platforms that encode draft state in the
248    /// title (Gitea's `WIP:`, GitLab's `Draft:`) re-apply their prefix, so a
249    /// retitle never readies a draft.
250    fn update_review_title(&self, review: &ReviewRequest, title: &str) -> Result<String>;
251
252    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
253
254    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
255
256    /// A carried-forward ledger row's current state, re-fetched by id after its
257    /// branch has left the local stack. Nothing else re-queries such a row, so
258    /// one that merged or closed since it was last recorded keeps rendering as
259    /// open in the overview without this. Default None: a provider that cannot
260    /// resolve a review by id alone leaves the recorded state untouched, and
261    /// the caller treats any error as "leave it as-is" (best-effort refresh).
262    fn review_state(&self, review: &ReviewRequest) -> Result<Option<ReviewState>> {
263        let _ = review;
264        Ok(None)
265    }
266
267    /// Merge the review with the given strategy: squash, rebase, or merge.
268    /// With `auto`, schedule the merge for when required checks pass
269    /// instead of merging now.
270    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
271
272    /// Why the platform won't merge the review right now, read from its
273    /// structured status. Consulted after a merge is rejected to explain it
274    /// without parsing the CLI's error text.
275    fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
276
277    /// Block until the review's checks settle, returning how the wait ended:
278    /// checks passed (or there are none), one failed, or the review merged
279    /// out-of-band while we waited.
280    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
281
282    /// Every open review, in one call - for annotating the stack with review
283    /// numbers (and CI status) without a lookup per branch.
284    fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
285
286    /// Review annotations (id, CI dot, queue state, and - with `detail` -
287    /// review tallies) for the given branches, in as few calls as the provider
288    /// allows. The default is the generic per-branch path; a provider can
289    /// override to batch (GitHub folds it into a single GraphQL query). Only
290    /// branches with an open review appear in the result.
291    fn annotate_branches(
292        &self,
293        branches: &[String],
294        detail: bool,
295    ) -> Result<BTreeMap<String, ReviewAnnotation>> {
296        generic_annotate(self, branches, detail)
297    }
298
299    /// The CI check rollup for the review's head, for the `list`/`status` dot.
300    /// Best-effort display data: the default is [`CheckStatus::None`] (no dot),
301    /// which is also the right answer for a provider that cannot report it.
302    fn check_status(&self, _review: &ReviewRequest) -> Result<CheckStatus> {
303        Ok(CheckStatus::None)
304    }
305
306    /// The review's latest-review tallies, for `list --reviews`. Fetched per
307    /// branch only when the flag is set; the default is an empty summary.
308    fn review_summary(&self, _review: &ReviewRequest) -> Result<ReviewSummary> {
309        Ok(ReviewSummary::default())
310    }
311
312    /// Mark a draft review as ready for review.
313    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
314
315    /// Request reviews from the given users or teams on the review, additively
316    /// (anyone already requested stays). Team reviewers use the provider's own
317    /// form (GitHub/Gitea `org/team`). The default errors, so a provider
318    /// without reviewer support surfaces that rather than dropping the request.
319    fn request_reviewers(&self, _review: &ReviewRequest, _reviewers: &[String]) -> Result<String> {
320        bail!("requesting reviewers is not supported by this provider")
321    }
322
323    /// Close the review without merging, deleting its source branch when
324    /// `delete_branch`. Used to retire a review superseded by a branch rename.
325    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
326
327    /// Open the review in the user's browser.
328    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
329
330    /// Of `branches`, those whose review is locked by a merge queue (GitHub)
331    /// or merge train (GitLab): they must be neither rebased nor force-pushed.
332    /// Rebasing would diverge from the frozen remote tip; a push is rejected
333    /// outright (GitHub locks the branch) or silently drops the review from the
334    /// queue (GitLab does not lock it). The default is empty - for providers
335    /// without a queue, and as the safe degradation when the lookup itself
336    /// fails (the reactive push-rejection net in `git` is the backstop).
337    fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
338        Ok(BTreeSet::new())
339    }
340}
341
342/// Detect the provider and build its review client together - the pair nearly
343/// every provider-backed command opens with. The returned [`DetectedProvider`]
344/// still carries the kind and detection source for messages.
345pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
346    let provider = detect_provider()?;
347    let client = review_provider(provider.kind);
348    Ok((provider, client))
349}
350
351/// The generic per-branch annotation path behind [`ReviewProvider::
352/// annotate_branches`]: list the open reviews, keep the wanted branches, then
353/// look up CI status, queue membership, and (with `detail`) review tallies.
354/// Every lookup is best-effort - a failure drops that branch's dot/tallies,
355/// not the whole map. A provider with a cheaper bulk API overrides the trait
356/// method instead of using this.
357fn generic_annotate<P: ReviewProvider + ?Sized>(
358    provider: &P,
359    branches: &[String],
360    detail: bool,
361) -> Result<BTreeMap<String, ReviewAnnotation>> {
362    let wanted: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
363    let reviewed: Vec<ReviewRequest> = provider
364        .open_reviews()?
365        .into_iter()
366        .filter(|review| wanted.contains(review.branch.as_str()))
367        .collect();
368    let names: Vec<String> = reviewed
369        .iter()
370        .map(|review| review.branch.clone())
371        .collect();
372    let queued = provider.enqueued_branches(&names).unwrap_or_default();
373    let mut annotations = BTreeMap::new();
374    for review in reviewed {
375        let checks = provider.check_status(&review).unwrap_or(CheckStatus::None);
376        let summary = if detail {
377            provider.review_summary(&review).ok()
378        } else {
379            None
380        };
381        let is_queued = queued.contains(&review.branch);
382        annotations.insert(
383            review.branch.clone(),
384            ReviewAnnotation {
385                id: review.id,
386                checks,
387                queued: is_queued,
388                summary,
389            },
390        );
391    }
392    Ok(annotations)
393}
394
395/// The branch's review only when it actually heads that branch. A provider can
396/// return a review for a different head (a stale or look-alike match); a flow
397/// acting on "this branch's review" wants None there, not someone else's.
398pub fn owned_review_for_branch(
399    provider: &dyn ReviewProvider,
400    branch: &str,
401) -> Result<Option<ReviewRequest>> {
402    Ok(provider
403        .review_for_branch(branch)?
404        .filter(|review| review.branch == branch))
405}
406
407/// Whether the review has merged out-of-band since a `wait_for_checks` loop
408/// began. Only a definite Merged stops the wait; anything else (still open, or
409/// no longer listed) keeps polling, leaving stk.checkTimeout as the backstop.
410pub(super) fn review_merged_out_of_band(
411    provider: &dyn ReviewProvider,
412    review: &ReviewRequest,
413) -> Result<bool> {
414    Ok(matches!(
415        provider.review_for_branch(&review.branch)?,
416        Some(current) if current.state == ReviewState::Merged
417    ))
418}
419
420pub fn detect_provider() -> Result<DetectedProvider> {
421    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
422        let Some(kind) = ProviderKind::parse(&value) else {
423            bail!(
424                "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
425            );
426        };
427
428        return Ok(DetectedProvider {
429            kind,
430            source: ProviderSource::Config,
431        });
432    }
433
434    let remote = settings::remote()?;
435    let Some(url) = git::remote_url(&remote)? else {
436        bail!("could not detect provider: remote {remote:?} does not exist");
437    };
438
439    let gitlab_host = settings::gitlab_host()?;
440    let gitea_host = settings::gitea_host()?;
441    let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
442    else {
443        bail!(
444            "could not detect provider from remote {remote} ({})",
445            redact_url(&url)
446        );
447    };
448
449    Ok(DetectedProvider {
450        kind,
451        source: ProviderSource::Remote { remote, url },
452    })
453}
454
455/// Detect the provider from a remote URL by its host. A configured
456/// `stk.gitlabHost`/`stk.giteaHost` widens GitLab/Gitea detection to a
457/// self-hosted instance.
458fn detect_provider_from_url(
459    url: &str,
460    gitlab_host: Option<&str>,
461    gitea_host: Option<&str>,
462) -> Option<ProviderKind> {
463    let normalized = url.to_ascii_lowercase();
464    let host = host_of(&normalized);
465    // Match the host itself or a subdomain of it, never a look-alike that
466    // merely embeds the name (mygithub.com, evil.com/github.com/...).
467    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
468
469    // The configured host goes through host_of too, so a full URL
470    // (https://gitlab.example.com) works as well as a bare host.
471    let self_hosted = |configured: Option<&str>| {
472        configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
473    };
474
475    if is("github.com") {
476        Some(ProviderKind::GitHub)
477    } else if is("gitlab.com") || self_hosted(gitlab_host) {
478        Some(ProviderKind::GitLab)
479    } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
480        Some(ProviderKind::Gitea)
481    } else {
482        None
483    }
484}
485
486/// The host of a git remote URL: the part after any `scheme://` and `user@`,
487/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
488/// `ssh://git@host:port/owner/repo`, scp-like `git@host:owner/repo`, and
489/// `[ipv6]` literals.
490fn host_of(url: &str) -> &str {
491    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
492    // Userinfo and the port live in the authority, before the path's first
493    // '/'. (The scp form `git@host:owner/repo` keeps the host before that '/'
494    // too.) Strip userinfo at the last '@' so an '@' inside it is tolerated.
495    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
496    let host_port = authority
497        .rsplit_once('@')
498        .map_or(authority, |(_, rest)| rest);
499    // An IPv6 literal keeps its colons inside `[..]`; any port follows it.
500    if let Some(after_bracket) = host_port.strip_prefix('[') {
501        return after_bracket
502            .split_once(']')
503            .map_or(host_port, |(addr, _)| addr);
504    }
505    // Otherwise the host ends at a ':' - a port, or the scp path separator.
506    host_port.split(':').next().unwrap_or(host_port)
507}
508
509/// A remote URL with any embedded userinfo (`user:token@`) dropped, for safe
510/// display - an HTTPS remote can carry an auth token in the URL. scp-style
511/// `git@host:path` (no `scheme://`) carries no password, so it is left as is.
512fn redact_url(url: &str) -> String {
513    let Some((scheme, rest)) = url.split_once("://") else {
514        return url.to_owned();
515    };
516    let (authority, path) = match rest.split_once('/') {
517        Some((authority, path)) => (authority, Some(path)),
518        None => (rest, None),
519    };
520    // Drop everything up to the last '@' in the authority (covers `token@`,
521    // `user:token@`, and an '@' inside the userinfo).
522    let Some((_, host)) = authority.rsplit_once('@') else {
523        return url.to_owned();
524    };
525    match path {
526        Some(path) => format!("{scheme}://{host}/{path}"),
527        None => format!("{scheme}://{host}"),
528    }
529}
530
531pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
532    match kind {
533        ProviderKind::GitHub => Box::new(GitHubProvider),
534        ProviderKind::GitLab => Box::new(GitLabProvider),
535        ProviderKind::Gitea => Box::new(GiteaProvider),
536        ProviderKind::Demo => Box::new(DemoProvider),
537    }
538}
539
540/// A provider CLI's (full name, install URL, auth command), or None for a
541/// program that isn't one (e.g. `git`).
542fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
543    match program {
544        "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
545        "glab" => Some((
546            "GitLab CLI",
547            "https://gitlab.com/gitlab-org/cli",
548            "glab auth login",
549        )),
550        "tea" => Some((
551            "Gitea CLI (tea)",
552            "https://gitea.com/gitea/tea",
553            "tea login add",
554        )),
555        _ => None,
556    }
557}
558
559/// Whether a provider CLI's stderr reads like a not-signed-in failure, so we
560/// can point the user at `... auth login` rather than just echoing it.
561fn looks_unauthenticated(stderr: &str) -> bool {
562    let stderr = stderr.to_ascii_lowercase();
563    [
564        "auth login",
565        "not logged",
566        "401",
567        "unauthorized",
568        "authentication required",
569    ]
570    .iter()
571    .any(|needle| stderr.contains(needle))
572}
573
574fn command_output(program: &str, args: &[&str]) -> Result<String> {
575    let output = match Command::new(program).args(args).output() {
576        Ok(output) => output,
577        // The most common newcomer failure: the provider CLI isn't installed.
578        // Turn the raw "No such file or directory (os error 2)" into guidance.
579        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
580            if let Some((name, url, auth)) = provider_cli(program) {
581                bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
582            }
583            return Err(error).with_context(|| format!("failed to run {program}"));
584        }
585        Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
586    };
587
588    if output.status.success() {
589        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
590    }
591
592    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
593    // Installed but (probably) not signed in: keep the CLI's own message and
594    // add the actionable hint.
595    if let Some((_, _, auth)) = provider_cli(program)
596        && looks_unauthenticated(&stderr)
597    {
598        bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
599    }
600    if stderr.is_empty() {
601        Err(anyhow!("{program} exited with status {}", output.status))
602    } else {
603        Err(anyhow!("{program} failed: {stderr}"))
604    }
605}
606
607/// Attempts and the pause between them for a merge the platform briefly
608/// rejects because it has not finished recomputing the moved base. Landing a
609/// tall stack moves the trunk on every merge, so this race is common.
610const MERGE_ATTEMPTS: u32 = 3;
611const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
612
613/// Whether a failed merge is the platform transiently rejecting against a base
614/// it has not settled - worth retrying - rather than a real failure (conflict,
615/// failed check, closed review), which must surface immediately. GitHub says
616/// the "base/head branch was modified"; GitLab returns a 405 Method Not Allowed
617/// while the MR's merge status is still recomputing after a push (which
618/// `merge --all` triggers by force-pushing each branch just before merging it);
619/// Gitea rejects with "failed to merge PR, is it still open?" in the same window.
620fn is_transient_merge_error(error: &anyhow::Error) -> bool {
621    let text = error.to_string().to_lowercase();
622    [
623        "base branch was modified",
624        "head branch was modified",
625        "try the merge again",
626        "method not allowed",
627        "is it still open",
628        // Transient API 5xx (the server hiccupped - not a verdict on the
629        // merge): 502/503/504/500. Worth retrying rather than failing the run.
630        "bad gateway",
631        "service unavailable",
632        "gateway time",
633        "internal server error",
634    ]
635    .iter()
636    .any(|signature| text.contains(signature))
637}
638
639/// Run a merge, retrying while it fails transiently so the "base branch was
640/// modified" race does not stop a `merge --all` loop. Between transient
641/// retries it only waits a fixed backoff - the right default when there is no
642/// per-provider signal to poll.
643fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
644    retry_transient_merge(
645        MERGE_ATTEMPTS,
646        || std::thread::sleep(MERGE_RETRY_BACKOFF),
647        attempt,
648    )
649}
650
651/// Like [`merge_with_retry`], but instead of a blind backoff it runs `resettle`
652/// between transient retries - re-polling the provider until the review is
653/// actually mergeable again. GitLab's 405-while-recomputing race needs this:
654/// the recompute can outlast a fixed sleep, but tracking the real status waits
655/// exactly as long as it takes.
656pub(super) fn merge_with_resettle(
657    mut resettle: impl FnMut(),
658    attempt: impl FnMut() -> Result<String>,
659) -> Result<String> {
660    retry_transient_merge(
661        MERGE_ATTEMPTS,
662        move || {
663            // A short floor delay first, so a provider that reports "mergeable"
664            // yet still 405s for a beat isn't hammered in a tight loop.
665            std::thread::sleep(MERGE_RETRY_BACKOFF);
666            resettle();
667        },
668        attempt,
669    )
670}
671
672fn retry_transient_merge(
673    attempts: u32,
674    mut on_transient: impl FnMut(),
675    mut attempt: impl FnMut() -> Result<String>,
676) -> Result<String> {
677    for remaining in (0..attempts).rev() {
678        match attempt() {
679            Ok(output) => return Ok(output),
680            Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
681                on_transient();
682            }
683            Err(error) => return Err(error),
684        }
685    }
686    // attempts is always nonzero, so the final iteration returns above.
687    Err(anyhow!("merge retried with no attempts left"))
688}
689
690impl fmt::Display for ReviewState {
691    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
692        match self {
693            Self::Open => write!(formatter, "open"),
694            Self::Merged => write!(formatter, "merged"),
695            Self::Closed => write!(formatter, "closed"),
696            Self::Unknown(state) => write!(formatter, "{state}"),
697        }
698    }
699}
700
701impl ReviewRequest {
702    pub(crate) fn id_value(&self) -> &str {
703        self.id
704            .strip_prefix('#')
705            .or_else(|| self.id.strip_prefix('!'))
706            .unwrap_or(&self.id)
707    }
708
709    /// "Title (#12)", or just the id when there is no title.
710    pub fn label(&self) -> String {
711        label(&self.title, &self.id)
712    }
713}
714
715/// The display label for a review: "Title (#12)", or the bare id.
716pub(crate) fn label(title: &str, id: &str) -> String {
717    if title.is_empty() {
718        id.to_owned()
719    } else {
720        format!("{title} ({id})")
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn provider_cli_maps_only_the_provider_clis() {
730        assert!(provider_cli("gh").is_some());
731        assert!(provider_cli("glab").is_some());
732        assert!(provider_cli("git").is_none());
733    }
734
735    #[test]
736    fn looks_unauthenticated_matches_signin_failures_only() {
737        assert!(looks_unauthenticated(
738            "error: not logged into any GitHub hosts"
739        ));
740        assert!(looks_unauthenticated(
741            "To get started, please run: gh auth login"
742        ));
743        assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
744        // A normal failure must not be misread as an auth problem.
745        assert!(!looks_unauthenticated("pull request not found"));
746        assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
747    }
748
749    #[test]
750    fn transient_error_is_retried_then_succeeds() {
751        let mut calls = 0;
752        let result = retry_transient_merge(
753            3,
754            || {},
755            || {
756                calls += 1;
757                if calls < 2 {
758                    Err(anyhow!(
759                        "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
760                    ))
761                } else {
762                    Ok("merged".to_owned())
763                }
764            },
765        );
766        assert_eq!(result.unwrap(), "merged");
767        assert_eq!(calls, 2, "should retry once then succeed");
768    }
769
770    #[test]
771    fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
772        let mut calls = 0;
773        let result = retry_transient_merge(
774            3,
775            || {},
776            || {
777                calls += 1;
778                if calls < 2 {
779                    Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
780                } else {
781                    Ok("merged".to_owned())
782                }
783            },
784        );
785        assert_eq!(result.unwrap(), "merged");
786        assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
787    }
788
789    #[test]
790    fn the_between_retry_action_runs_once_per_transient_retry() {
791        // `merge_with_resettle` re-polls via this hook instead of a blind
792        // sleep; the hook runs once per transient retry, never after success.
793        let mut resettles = 0;
794        let mut calls = 0;
795        let result = retry_transient_merge(
796            3,
797            || resettles += 1,
798            || {
799                calls += 1;
800                // 405 twice (recompute still in flight), then mergeable.
801                if calls < 3 {
802                    Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
803                } else {
804                    Ok("merged".to_owned())
805                }
806            },
807        );
808        assert_eq!(result.unwrap(), "merged");
809        assert_eq!(calls, 3, "should retry until the merge lands");
810        assert_eq!(
811            resettles, 2,
812            "re-poll once per transient retry, not after the final success"
813        );
814    }
815
816    #[test]
817    fn the_between_retry_action_does_not_run_on_a_real_failure() {
818        let mut resettles = 0;
819        let result = retry_transient_merge(
820            3,
821            || resettles += 1,
822            || {
823                Err(anyhow!(
824                    "glab failed: Merge request is not mergeable: conflict"
825                ))
826            },
827        );
828        assert!(result.is_err());
829        assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
830    }
831
832    #[test]
833    fn a_transient_5xx_from_the_api_is_retried() {
834        let mut calls = 0;
835        let result = retry_transient_merge(
836            3,
837            || {},
838            || {
839                calls += 1;
840                if calls < 2 {
841                    Err(anyhow!(
842                        "gh failed: non-200 OK status code: 502 Bad Gateway"
843                    ))
844                } else {
845                    Ok("merged".to_owned())
846                }
847            },
848        );
849        assert_eq!(result.unwrap(), "merged");
850        assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
851    }
852
853    #[test]
854    fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
855        let mut calls = 0;
856        let result = retry_transient_merge(
857            3,
858            || {},
859            || {
860                calls += 1;
861                Err(anyhow!("gh failed: Base branch was modified"))
862            },
863        );
864        assert!(result.is_err());
865        assert_eq!(calls, 3, "should try exactly the budgeted number of times");
866    }
867
868    #[test]
869    fn a_real_failure_is_not_retried() {
870        let mut calls = 0;
871        let result = retry_transient_merge(
872            3,
873            || {},
874            || {
875                calls += 1;
876                Err(anyhow!(
877                    "gh failed: Pull request is not mergeable: conflicts"
878                ))
879            },
880        );
881        assert!(result.is_err());
882        assert_eq!(calls, 1, "a non-transient error must surface immediately");
883    }
884
885    #[test]
886    fn host_of_extracts_the_host_across_url_shapes() {
887        assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
888        assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
889        assert_eq!(
890            host_of("ssh://git@gitlab.example.com:22/g/r"),
891            "gitlab.example.com"
892        );
893        assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
894        assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
895        assert_eq!(
896            host_of("https://[2001:db8::1]:443/owner/repo"),
897            "2001:db8::1"
898        );
899        assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
900        // Userinfo with an embedded '@' is stripped at the last one.
901        assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
902    }
903
904    #[test]
905    fn redact_url_strips_embedded_credentials() {
906        // An HTTPS remote can carry a token; it must never be displayed.
907        assert_eq!(
908            redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
909            "https://github.com/owner/repo.git"
910        );
911        assert_eq!(
912            redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
913            "https://gitlab.com/owner/repo"
914        );
915        // ssh userinfo (no secret) is dropped too; port and path stay.
916        assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
917    }
918
919    #[test]
920    fn redact_url_leaves_credential_free_urls_unchanged() {
921        assert_eq!(
922            redact_url("https://github.com/owner/repo.git"),
923            "https://github.com/owner/repo.git"
924        );
925        // scp form has no scheme and carries no password - left as is.
926        assert_eq!(
927            redact_url("git@github.com:owner/repo.git"),
928            "git@github.com:owner/repo.git"
929        );
930    }
931
932    #[test]
933    fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
934        let remote = "git@gitlab.example.com:team/repo.git";
935        for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
936            assert_eq!(
937                detect_provider_from_url(remote, Some(configured), None),
938                Some(ProviderKind::GitLab),
939                "configured {configured:?} should detect the self-hosted host"
940            );
941        }
942        // A look-alike host is still not matched.
943        assert_eq!(
944            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
945            None
946        );
947    }
948
949    #[test]
950    fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
951        assert_eq!(
952            detect_provider_from_url("git@gitea.com:o/r.git", None, None),
953            Some(ProviderKind::Gitea)
954        );
955        assert_eq!(
956            detect_provider_from_url("https://codeberg.org/o/r", None, None),
957            Some(ProviderKind::Gitea)
958        );
959        for configured in ["gitea.example.com", "https://gitea.example.com"] {
960            assert_eq!(
961                detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
962                Some(ProviderKind::Gitea),
963                "configured {configured:?} should detect the self-hosted Gitea host"
964            );
965        }
966        // A look-alike host is not matched.
967        assert_eq!(
968            detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
969            None
970        );
971    }
972}