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