Skip to main content

git_stk/providers/
mod.rs

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