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/// One review in a platform-recorded stack.
133#[derive(Debug, Clone, Eq, PartialEq)]
134pub struct NativeStackLayer {
135    pub id: String,
136    pub branch: String,
137    /// Whether this layer's review is still open. A landed layer keeps its
138    /// place in the listing (verified live), so the ordering alone cannot say
139    /// what has already happened - and a landed layer is one the platform has
140    /// already retargeted away from.
141    pub open: bool,
142}
143
144/// A stack as the platform records it: its layers bottom first - the order it
145/// lands in - and the branch the bottom one targets. Read-only here;
146/// registering one is a separate step, gated behind `stk.githubStacks`.
147#[derive(Debug, Clone, Eq, PartialEq)]
148pub struct NativeStack {
149    /// The platform's own number for the stack, for messages.
150    pub number: u64,
151    /// The branch the bottom review targets.
152    pub base: String,
153    /// Layers bottom first.
154    ///
155    /// Verified live rather than assumed: registering three chained pull
156    /// requests (`main <- p <- q <- r`) lists them in exactly that order, and
157    /// `POST /stacks/{n}/add` appends - a fourth review arrives last. That
158    /// append-only behaviour is why [`plan_stack_registration`] will only
159    /// extend a stack the submitted line grew on top of; `/add` carries no
160    /// position, so it cannot express anything else.
161    pub layers: Vec<NativeStackLayer>,
162}
163
164impl NativeStack {
165    /// Whether this stack's own answer for `branch`'s parent is still current.
166    ///
167    /// It is not, once that parent's own review has landed: the platform keeps
168    /// a landed layer listed, so the ordering keeps naming it forever, while
169    /// the platform has already retargeted `branch` away from it. The stack's
170    /// base is always current - it is a branch, not a layer, and nothing
171    /// lands it out from under the stack.
172    pub fn parent_is_current(&self, branch: &str) -> bool {
173        let Some(parent) = self.parent_of(branch) else {
174            return false;
175        };
176        self.layers
177            .iter()
178            .find(|layer| layer.branch == parent)
179            .is_none_or(|layer| layer.open)
180    }
181
182    /// Whether `parent` is `branch`'s own recorded predecessor *and* has
183    /// landed - so the platform has already moved this base off it and will
184    /// never put it back.
185    ///
186    /// The exact case [`NativeStack::can_base_on`] stops accepting, which is
187    /// what makes the two complementary: some other landed layer says nothing
188    /// about where this review's base went.
189    pub fn parent_landed(&self, branch: &str, parent: &str) -> bool {
190        self.parent_of(branch) == Some(parent) && !self.parent_is_current(branch)
191    }
192
193    /// Whether this stack can still bring `branch`'s base to `parent` on its
194    /// own - `parent` is one of the two places the platform puts a base: the
195    /// layer recorded directly below `branch`, or the stack's own base, which
196    /// is where every layer ends up once the ones beneath it land.
197    ///
198    /// This is the question every caller has, and it needs the local parent
199    /// to answer. A base and a parent that disagree while the stack can still
200    /// close the gap is a chain part-way through unwinding: the platform
201    /// retargets each layer onto the stack's base as the one below it lands,
202    /// and `cleanup` walks the local parents the same way. A parent the stack
203    /// cannot reach - a line re-rooted onto a release branch, say - is a
204    /// disagreement nothing will resolve, and callers say so instead of
205    /// waiting for it.
206    pub fn can_base_on(&self, branch: &str, parent: &str) -> bool {
207        // Two destinations, not "any layer": the platform sets a layer's base
208        // to the one recorded below it, and moves it to the stack's own base
209        // once that lands. A layer *above* this one is neither - accepting it
210        // would put a reordered stack's bottom back inside the exemption, and
211        // the bottom is the one layer the platform never retargets.
212        //
213        // The predecessor only counts while it is still current: once it has
214        // landed the platform has already moved this base away from it, and
215        // will never put it back.
216        self.layers.iter().any(|layer| layer.branch == branch)
217            && (self.base == parent
218                || (self.parent_of(branch) == Some(parent) && self.parent_is_current(branch)))
219    }
220
221    /// What `branch` stacks on according to the platform: the branch below it,
222    /// or the stack's base when it is the bottom. `None` when the stack does
223    /// not hold `branch` at all.
224    pub fn parent_of(&self, branch: &str) -> Option<&str> {
225        let index = self
226            .layers
227            .iter()
228            .position(|layer| layer.branch == branch)?;
229        Some(match index.checked_sub(1) {
230            Some(below) => &self.layers[below].branch,
231            None => &self.base,
232        })
233    }
234
235    /// Where `branch` sits in the stack, 1-based and bottom first - the same
236    /// number GraphQL reports as `stackEntry.position`. The one derivation
237    /// from this side, so the two sources cannot drift into disagreeing.
238    pub fn position_of(&self, branch: &str) -> Option<u32> {
239        self.layers
240            .iter()
241            .position(|layer| layer.branch == branch)
242            .map(|index| index as u32 + 1)
243    }
244
245    /// The review id recorded for `branch`, for messages.
246    pub fn review_id_for(&self, branch: &str) -> Option<&str> {
247        self.layers
248            .iter()
249            .find(|layer| layer.branch == branch)
250            .map(|layer| layer.id.as_str())
251    }
252}
253
254#[derive(Debug, Eq, PartialEq)]
255pub struct ReviewRequest {
256    pub id: String,
257    pub branch: String,
258    pub base: String,
259    pub state: ReviewState,
260    pub url: String,
261    pub title: String,
262    pub draft: bool,
263}
264
265/// A review's CI check rollup, reduced to one at-a-glance dot for `list` and
266/// `status`. `None` means no checks ran, or the provider could not report
267/// them - either way, no dot is shown.
268#[derive(Debug, Clone, Copy, Eq, PartialEq)]
269pub enum CheckStatus {
270    Passing,
271    Failing,
272    /// Finished without a verdict - a run that was cancelled, or one waiting
273    /// on a human. Nothing is wrong, but not everything is green, so folding
274    /// it into either would say something untrue.
275    Inconclusive,
276    Pending,
277    None,
278}
279
280impl CheckStatus {
281    /// The status dot, with a trailing space so it sits before the review id -
282    /// or empty when there is nothing to show.
283    pub fn dot(self) -> &'static str {
284        match self {
285            Self::Passing => "🟢 ",
286            Self::Failing => "🔴 ",
287            Self::Inconclusive => "⚪ ",
288            Self::Pending => "🟡 ",
289            Self::None => "",
290        }
291    }
292}
293
294/// Tallies of a review's latest reviews, for `list --reviews`.
295#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
296pub struct ReviewSummary {
297    pub approvals: u32,
298    pub comments: u32,
299    pub changes_requested: u32,
300}
301
302impl ReviewSummary {
303    /// One line per non-zero category (`"2 approvals"`, `"1 requested change"`),
304    /// mirroring the `--commits` list. Empty when nothing has been reviewed, so
305    /// the caller can show a `(no reviews)` placeholder instead.
306    pub fn lines(&self) -> Vec<String> {
307        let count =
308            |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
309        let mut lines = Vec::new();
310        if self.approvals > 0 {
311            lines.push(count(self.approvals, "approval", "approvals"));
312        }
313        if self.comments > 0 {
314            lines.push(count(self.comments, "comment", "comments"));
315        }
316        if self.changes_requested > 0 {
317            lines.push(count(
318                self.changes_requested,
319                "requested change",
320                "requested changes",
321            ));
322        }
323        lines
324    }
325}
326
327/// The marker shown before a review that sits in a merge queue (GitHub) or
328/// merge train (GitLab) - it is waiting its turn to land. Includes a trailing
329/// space so it sits before the CI dot / id.
330pub const QUEUED_MARK: &str = "🕑 ";
331
332/// Shown against a review the platform holds in a stack of its own, with the
333/// layer's position - `⛁2/3`. Distinct from git-stk's own stack, which the
334/// tree already draws.
335pub const STACKED_MARK: &str = "⛁";
336
337/// Per-branch review data threaded into the `list` tree: the id (e.g. `#12`),
338/// its CI dot, whether it sits in a merge queue/train, and - only with
339/// `--reviews` - the review tallies.
340pub struct ReviewAnnotation {
341    pub id: String,
342    pub checks: CheckStatus,
343    pub queued: bool,
344    pub summary: Option<ReviewSummary>,
345    /// Where this review sits in the platform's own stack, when it keeps one.
346    pub stack: Option<StackPosition>,
347}
348
349/// A review's place in a platform-recorded stack.
350#[derive(Debug, Clone, Copy, Eq, PartialEq)]
351pub struct StackPosition {
352    pub number: u64,
353    /// 1-based, bottom first - the order the stack lands in. Verified against
354    /// GitHub: `stackEntry.position` answers 1, 2, 3 for a three-layer stack,
355    /// matching [`NativeStack::position_of`], which is the other place this
356    /// number comes from.
357    pub position: u32,
358    pub size: u32,
359}
360
361/// The result of waiting on a review's checks before merging it.
362pub enum WaitOutcome {
363    /// Checks passed, or there are none - go ahead and merge.
364    Passed,
365    /// A required check failed - stop the run.
366    Failed,
367    /// Checks stopped without a verdict - a cancelled run, or one waiting on a
368    /// person. Nothing failed, but nothing passed either, and the platform
369    /// still holds the merge, so the run stops and says which it was.
370    ///
371    /// The same distinction [`CheckStatus::Inconclusive`] draws for the dot,
372    /// and deliberately so: a provider decides both, and the two must agree
373    /// about one commit or the gate contradicts the dot the user just read.
374    /// Where a provider's gate cannot see the difference on its own - GitHub's
375    /// reads `gh pr checks` exit codes, which have no code for it - it asks
376    /// the same rollup the dot came from.
377    Inconclusive,
378    /// The review merged out-of-band while we waited (an admin merge on the
379    /// web, say). Skip the redundant merge and let `sync` reconcile it.
380    Landed,
381}
382
383pub trait ReviewProvider {
384    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
385
386    /// Like review_for_branch, but also finds closed reviews. Kept separate
387    /// so flows that act on a review (submit, sync, cleanup) never mistake a
388    /// dead review for a live one; only the stack-notes ledger wants closed
389    /// state, to restyle the entry rather than drop it.
390    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
391
392    /// Open a review for the branch; with `draft`, as a draft. `title` sets the
393    /// review's title, defaulting to the branch tip's commit subject.
394    fn create_review(
395        &self,
396        branch: &str,
397        base: &str,
398        draft: bool,
399        title: Option<&str>,
400    ) -> Result<String>;
401
402    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
403    /// What will close a gap between this review's base and `parent`, the
404    /// local parent git-stk records - or `None` when the review is in no
405    /// platform stack, in which case an ordinary retarget closes it.
406    ///
407    /// One question rather than two, because every caller needs all three
408    /// answers: those that would retarget stand down for
409    /// [`BaseGap::Platform`], and those that report a disagreement need to
410    /// name a different remedy for each of the other two.
411    ///
412    /// Defaults to `None`, and errs that way, because the two mistakes are
413    /// not equally bad. Answering `None` wrongly means attempting a retarget
414    /// the platform refuses: a loud, recoverable error, and
415    /// `update_review_base` checks again itself. Answering
416    /// [`BaseGap::Platform`] wrongly means skipping a retarget that was
417    /// needed - in `cleanup` the layer then still points at a branch about to
418    /// be deleted, and a platform that auto-closes a review whose base
419    /// disappears takes the review with it, comments and approvals included,
420    /// silently.
421    fn base_gap(&self, review: &ReviewRequest, parent: &str) -> Result<Option<BaseGap>> {
422        let _ = (review, parent);
423        Ok(None)
424    }
425
426    /// Retitle an existing review. Platforms that encode draft state in the
427    /// title (Gitea's `WIP:`, GitLab's `Draft:`) re-apply their prefix, so a
428    /// retitle never readies a draft.
429    fn update_review_title(&self, review: &ReviewRequest, title: &str) -> Result<String>;
430
431    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
432
433    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
434
435    /// A carried-forward ledger row's current state, re-fetched by id after its
436    /// branch has left the local stack. Nothing else re-queries such a row, so
437    /// one that merged or closed since it was last recorded keeps rendering as
438    /// open in the overview without this. Default None: a provider that cannot
439    /// resolve a review by id alone leaves the recorded state untouched, and
440    /// the caller treats any error as "leave it as-is" (best-effort refresh).
441    fn review_state(&self, review: &ReviewRequest) -> Result<Option<ReviewState>> {
442        let _ = review;
443        Ok(None)
444    }
445
446    /// The platform's own record of the stack `branch` belongs to, when it
447    /// keeps one. An authoritative ordering that outlives local metadata, so
448    /// `repair` can prefer it to guessing from ancestry.
449    ///
450    /// Default `None`: no platform but GitHub records stacks. Not gated on
451    /// `stk.githubStacks` - that setting says whether git-stk *registers* one,
452    /// and a stack can exist without it having done so. An error here is the
453    /// caller's to treat as "no stack" - for the callers that use it as a
454    /// hint. A caller for which the answer *is* the command must ask
455    /// differently, or it will report "no stack" for a failed lookup.
456    fn native_stack_for(&self, branch: &str) -> Result<Option<NativeStack>> {
457        let _ = branch;
458        Ok(None)
459    }
460
461    /// Record `reviews` (bottom first) as a stack on the platform, extending
462    /// `existing` when the stack is already there and the new reviews sit on
463    /// top of it. Returns a line describing what happened, or `None` when
464    /// there was nothing to do.
465    ///
466    /// Default `None`: only GitHub keeps stacks, and only when
467    /// `stk.githubStacks` is on. Registering is presentation - the stack map
468    /// and parallel review - so a failure here is reported, never fatal to a
469    /// submit whose reviews already exist.
470    fn register_stack(
471        &self,
472        reviews: &[String],
473        existing: Option<&NativeStack>,
474    ) -> Result<Option<String>> {
475        let _ = (reviews, existing);
476        Ok(None)
477    }
478
479    /// Whether this provider would register a stack at all - the provider
480    /// keeps stacks, and the user has asked for it.
481    ///
482    /// Asked before anything is fetched, and by the dry run and the real run
483    /// alike, so the two decline for the same reason rather than one
484    /// promising a stack the other declines - and so a provider that keeps no
485    /// stacks spends no lookups discovering that.
486    ///
487    /// Default `false`: only GitHub keeps stacks.
488    fn registers_stacks(&self) -> bool {
489        false
490    }
491
492    /// Every platform stack covering any of `branches`, for a caller where the
493    /// answer *is* the command rather than a hint: a lookup failure is
494    /// returned instead of read as "no stack".
495    ///
496    /// All of them, not the first - two stacks can partition one local line,
497    /// and dissolving only the one you happened to find would report success
498    /// while leaving the rest of the line blocked.
499    fn native_stacks_covering(&self, branches: &[String]) -> Result<Vec<NativeStack>> {
500        let _ = branches;
501        Ok(Vec::new())
502    }
503
504    /// Dissolve `stack` on the platform, leaving its reviews open and
505    /// standalone. Merged reviews stay in it - the platform keeps that
506    /// history. Returns a line describing what happened.
507    ///
508    /// Default `None`: only GitHub keeps stacks. Unlike registering, this is
509    /// not gated on `stk.githubStacks` - undoing something must not require
510    /// the setting that created it to still be on.
511    fn unstack_reviews(&self, stack: &NativeStack) -> Result<Option<String>> {
512        let _ = stack;
513        Ok(None)
514    }
515
516    /// Merge the review with the given strategy: squash, rebase, or merge.
517    /// With `auto`, schedule the merge for when required checks pass
518    /// instead of merging now.
519    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
520
521    /// Why the platform won't merge the review right now, read from its
522    /// structured status. Consulted after a merge is rejected to explain it
523    /// without parsing the CLI's error text.
524    fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
525
526    /// Block until the review's checks settle, returning how the wait ended:
527    /// checks passed (or there are none), one failed, or the review merged
528    /// out-of-band while we waited.
529    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
530
531    /// Every open review, in one call - for annotating the stack with review
532    /// numbers (and CI status) without a lookup per branch.
533    fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
534
535    /// Review annotations (id, CI dot, queue state, and - with `detail` -
536    /// review tallies) for the given branches, in as few calls as the provider
537    /// allows. The default is the generic per-branch path; a provider can
538    /// override to batch (GitHub folds it into a single GraphQL query). Only
539    /// branches with an open review appear in the result.
540    fn annotate_branches(
541        &self,
542        branches: &[String],
543        detail: bool,
544    ) -> Result<BTreeMap<String, ReviewAnnotation>> {
545        generic_annotate(self, branches, detail)
546    }
547
548    /// The same annotation for a single review the caller already holds -
549    /// `status`, which asks about one branch rather than a stack.
550    ///
551    /// Separate from [`ReviewProvider::annotate_branches`] because the two
552    /// want opposite things. Listing every open review amortizes across a
553    /// whole stack but is pure overhead for one branch, and the generic
554    /// listing is what most providers do. The default therefore asks per
555    /// review; GitHub overrides it with the one batched query it already
556    /// makes for `list`, which is also the only source of a stack position.
557    fn annotate_review(&self, review: &ReviewRequest, detail: bool) -> Result<ReviewAnnotation> {
558        generic_annotate_review(self, review, detail)
559    }
560
561    /// The CI check rollup for the review's head, for the `list`/`status` dot.
562    /// Best-effort display data: the default is [`CheckStatus::None`] (no dot),
563    /// which is also the right answer for a provider that cannot report it.
564    fn check_status(&self, _review: &ReviewRequest) -> Result<CheckStatus> {
565        Ok(CheckStatus::None)
566    }
567
568    /// The review's latest-review tallies, for `list --reviews`. Fetched per
569    /// branch only when the flag is set; the default is an empty summary.
570    fn review_summary(&self, _review: &ReviewRequest) -> Result<ReviewSummary> {
571        Ok(ReviewSummary::default())
572    }
573
574    /// Mark a draft review as ready for review.
575    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
576
577    /// Request reviews from the given users or teams on the review, additively
578    /// (anyone already requested stays). Team reviewers use the provider's own
579    /// form (GitHub/Gitea `org/team`). The default errors, so a provider
580    /// without reviewer support surfaces that rather than dropping the request.
581    fn request_reviewers(&self, _review: &ReviewRequest, _reviewers: &[String]) -> Result<String> {
582        bail!("requesting reviewers is not supported by this provider")
583    }
584
585    /// Close the review without merging, deleting its source branch when
586    /// `delete_branch`. Used to retire a review superseded by a branch rename.
587    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
588
589    /// Open the review in the user's browser.
590    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
591
592    /// Of `branches`, those whose review is locked by a merge queue (GitHub)
593    /// or merge train (GitLab): they must be neither rebased nor force-pushed.
594    /// Rebasing would diverge from the frozen remote tip; a push is rejected
595    /// outright (GitHub locks the branch) or silently drops the review from the
596    /// queue (GitLab does not lock it). The default is empty - for providers
597    /// without a queue, and as the safe degradation when the lookup itself
598    /// fails (the reactive push-rejection net in `git` is the backstop).
599    fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
600        Ok(BTreeSet::new())
601    }
602}
603
604/// Detect the provider and build its review client together - the pair nearly
605/// every provider-backed command opens with. The returned [`DetectedProvider`]
606/// still carries the kind and detection source for messages.
607pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
608    let provider = detect_provider()?;
609    let client = review_provider(provider.kind);
610    Ok((provider, client))
611}
612
613/// One review's annotation, asked per call - the default for every provider
614/// but GitHub, and GitHub's own fallback for a review its batched query cannot
615/// see (it reads open reviews only).
616pub fn generic_annotate_review<P: ReviewProvider + ?Sized>(
617    provider: &P,
618    review: &ReviewRequest,
619    detail: bool,
620) -> Result<ReviewAnnotation> {
621    // Only an open review can be waiting in a merge queue, so asking for a
622    // merged or closed one spends a call to learn `false`.
623    let queued = matches!(review.state, ReviewState::Open)
624        && provider
625            .enqueued_branches(std::slice::from_ref(&review.branch))
626            .map(|set| set.contains(&review.branch))
627            .unwrap_or(false);
628    Ok(ReviewAnnotation {
629        id: review.id.clone(),
630        // A queued review shows the clock rather than a CI dot, so the rollup
631        // is not worth a call.
632        checks: if queued {
633            CheckStatus::None
634        } else {
635            provider.check_status(review).unwrap_or(CheckStatus::None)
636        },
637        queued,
638        summary: if detail {
639            provider.review_summary(review).ok()
640        } else {
641            None
642        },
643        stack: provider
644            .native_stack_for(&review.branch)?
645            .and_then(|found| {
646                let size = u32::try_from(found.layers.len()).ok()?;
647                Some(StackPosition {
648                    number: found.number,
649                    position: found.position_of(&review.branch)?,
650                    size,
651                })
652            }),
653    })
654}
655
656/// The generic per-branch annotation path behind
657/// [`ReviewProvider::annotate_branches`]: list the open reviews, keep the
658/// wanted branches, then look up CI status, queue membership, and (with
659/// `detail`) review tallies. Every lookup is best-effort - a failure drops
660/// that branch's dot/tallies, not the whole map. A provider with a cheaper
661/// bulk API overrides the trait method instead of using this.
662fn generic_annotate<P: ReviewProvider + ?Sized>(
663    provider: &P,
664    branches: &[String],
665    detail: bool,
666) -> Result<BTreeMap<String, ReviewAnnotation>> {
667    let wanted: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
668    let reviewed: Vec<ReviewRequest> = provider
669        .open_reviews()?
670        .into_iter()
671        .filter(|review| wanted.contains(review.branch.as_str()))
672        .collect();
673    let names: Vec<String> = reviewed
674        .iter()
675        .map(|review| review.branch.clone())
676        .collect();
677    let queued = provider.enqueued_branches(&names).unwrap_or_default();
678    let mut annotations = BTreeMap::new();
679    for review in reviewed {
680        let checks = provider.check_status(&review).unwrap_or(CheckStatus::None);
681        let summary = if detail {
682            provider.review_summary(&review).ok()
683        } else {
684            None
685        };
686        let is_queued = queued.contains(&review.branch);
687        annotations.insert(
688            review.branch.clone(),
689            ReviewAnnotation {
690                id: review.id,
691                checks,
692                queued: is_queued,
693                summary,
694                // The generic path makes one call per branch and has no cheap
695                // way to ask; the GitHub batch fills this in.
696                stack: None,
697            },
698        );
699    }
700    Ok(annotations)
701}
702
703/// The branch's review only when it actually heads that branch. A provider can
704/// return a review for a different head (a stale or look-alike match); a flow
705/// acting on "this branch's review" wants None there, not someone else's.
706pub fn owned_review_for_branch(
707    provider: &dyn ReviewProvider,
708    branch: &str,
709) -> Result<Option<ReviewRequest>> {
710    Ok(provider
711        .review_for_branch(branch)?
712        .filter(|review| review.branch == branch))
713}
714
715/// Whether the review has merged out-of-band since a `wait_for_checks` loop
716/// began. Only a definite Merged stops the wait; anything else (still open, or
717/// no longer listed) keeps polling, leaving stk.checkTimeout as the backstop.
718pub(super) fn review_merged_out_of_band(
719    provider: &dyn ReviewProvider,
720    review: &ReviewRequest,
721) -> Result<bool> {
722    Ok(matches!(
723        provider.review_for_branch(&review.branch)?,
724        Some(current) if current.state == ReviewState::Merged
725    ))
726}
727
728pub fn detect_provider() -> Result<DetectedProvider> {
729    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
730        let Some(kind) = ProviderKind::parse(&value) else {
731            bail!(
732                "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
733            );
734        };
735
736        return Ok(DetectedProvider {
737            kind,
738            source: ProviderSource::Config,
739        });
740    }
741
742    let remote = settings::remote()?;
743    let Some(url) = git::remote_url(&remote)? else {
744        bail!("could not detect provider: remote {remote:?} does not exist");
745    };
746
747    let gitlab_host = settings::gitlab_host()?;
748    let gitea_host = settings::gitea_host()?;
749    let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
750    else {
751        bail!(
752            "could not detect provider from remote {remote} ({})",
753            redact_url(&url)
754        );
755    };
756
757    Ok(DetectedProvider {
758        kind,
759        source: ProviderSource::Remote { remote, url },
760    })
761}
762
763/// Detect the provider from a remote URL by its host. A configured
764/// `stk.gitlabHost`/`stk.giteaHost` widens GitLab/Gitea detection to a
765/// self-hosted instance.
766fn detect_provider_from_url(
767    url: &str,
768    gitlab_host: Option<&str>,
769    gitea_host: Option<&str>,
770) -> Option<ProviderKind> {
771    let normalized = url.to_ascii_lowercase();
772    let host = host_of(&normalized);
773    // Match the host itself or a subdomain of it, never a look-alike that
774    // merely embeds the name (mygithub.com, evil.com/github.com/...).
775    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
776
777    // The configured host goes through host_of too, so a full URL
778    // (https://gitlab.example.com) works as well as a bare host.
779    let self_hosted = |configured: Option<&str>| {
780        configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
781    };
782
783    if is("github.com") {
784        Some(ProviderKind::GitHub)
785    } else if is("gitlab.com") || self_hosted(gitlab_host) {
786        Some(ProviderKind::GitLab)
787    } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
788        Some(ProviderKind::Gitea)
789    } else {
790        None
791    }
792}
793
794/// The host of a git remote URL: the part after any `scheme://` and `user@`,
795/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
796/// `ssh://git@host:port/owner/repo`, scp-like `git@host:owner/repo`, and
797/// `[ipv6]` literals.
798fn host_of(url: &str) -> &str {
799    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
800    // Userinfo and the port live in the authority, before the path's first
801    // '/'. (The scp form `git@host:owner/repo` keeps the host before that '/'
802    // too.) Strip userinfo at the last '@' so an '@' inside it is tolerated.
803    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
804    let host_port = authority
805        .rsplit_once('@')
806        .map_or(authority, |(_, rest)| rest);
807    // An IPv6 literal keeps its colons inside `[..]`; any port follows it.
808    if let Some(after_bracket) = host_port.strip_prefix('[') {
809        return after_bracket
810            .split_once(']')
811            .map_or(host_port, |(addr, _)| addr);
812    }
813    // Otherwise the host ends at a ':' - a port, or the scp path separator.
814    host_port.split(':').next().unwrap_or(host_port)
815}
816
817/// A remote URL with any embedded userinfo (`user:token@`) dropped, for safe
818/// display - an HTTPS remote can carry an auth token in the URL. scp-style
819/// `git@host:path` (no `scheme://`) carries no password, so it is left as is.
820fn redact_url(url: &str) -> String {
821    let Some((scheme, rest)) = url.split_once("://") else {
822        return url.to_owned();
823    };
824    let (authority, path) = match rest.split_once('/') {
825        Some((authority, path)) => (authority, Some(path)),
826        None => (rest, None),
827    };
828    // Drop everything up to the last '@' in the authority (covers `token@`,
829    // `user:token@`, and an '@' inside the userinfo).
830    let Some((_, host)) = authority.rsplit_once('@') else {
831        return url.to_owned();
832    };
833    match path {
834        Some(path) => format!("{scheme}://{host}/{path}"),
835        None => format!("{scheme}://{host}"),
836    }
837}
838
839pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
840    match kind {
841        ProviderKind::GitHub => Box::new(GitHubProvider),
842        ProviderKind::GitLab => Box::new(GitLabProvider),
843        ProviderKind::Gitea => Box::new(GiteaProvider),
844        ProviderKind::Demo => Box::new(DemoProvider),
845    }
846}
847
848/// A provider CLI's (full name, install URL, auth command), or None for a
849/// program that isn't one (e.g. `git`).
850fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
851    match program {
852        "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
853        "glab" => Some((
854            "GitLab CLI",
855            "https://gitlab.com/gitlab-org/cli",
856            "glab auth login",
857        )),
858        "tea" => Some((
859            "Gitea CLI (tea)",
860            "https://gitea.com/gitea/tea",
861            "tea login add",
862        )),
863        _ => None,
864    }
865}
866
867/// Whether a provider CLI's stderr reads like a not-signed-in failure, so we
868/// can point the user at `... auth login` rather than just echoing it.
869fn looks_unauthenticated(stderr: &str) -> bool {
870    let stderr = stderr.to_ascii_lowercase();
871    [
872        "auth login",
873        "not logged",
874        "401",
875        "unauthorized",
876        "authentication required",
877    ]
878    .iter()
879    .any(|needle| stderr.contains(needle))
880}
881
882fn command_output(program: &str, args: &[&str]) -> Result<String> {
883    let output = match Command::new(program).args(args).output() {
884        Ok(output) => output,
885        // The most common newcomer failure: the provider CLI isn't installed.
886        // Turn the raw "No such file or directory (os error 2)" into guidance.
887        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
888            if let Some((name, url, auth)) = provider_cli(program) {
889                bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
890            }
891            return Err(error).with_context(|| format!("failed to run {program}"));
892        }
893        Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
894    };
895
896    if output.status.success() {
897        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
898    }
899
900    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
901    // Installed but (probably) not signed in: keep the CLI's own message and
902    // add the actionable hint.
903    if let Some((_, _, auth)) = provider_cli(program)
904        && looks_unauthenticated(&stderr)
905    {
906        bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
907    }
908    if stderr.is_empty() {
909        Err(anyhow!("{program} exited with status {}", output.status))
910    } else {
911        Err(anyhow!("{program} failed: {stderr}"))
912    }
913}
914
915/// Attempts and the pause between them for a merge the platform briefly
916/// rejects because it has not finished recomputing the moved base. Landing a
917/// tall stack moves the trunk on every merge, so this race is common.
918const MERGE_ATTEMPTS: u32 = 3;
919const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
920
921/// Whether a failed merge is the platform transiently rejecting against a base
922/// it has not settled - worth retrying - rather than a real failure (conflict,
923/// failed check, closed review), which must surface immediately. GitHub says
924/// the "base/head branch was modified"; GitLab returns a 405 Method Not Allowed
925/// while the MR's merge status is still recomputing after a push (which
926/// `merge --all` triggers by force-pushing each branch just before merging it);
927/// Gitea rejects with "failed to merge PR, is it still open?" in the same window.
928fn is_transient_merge_error(error: &anyhow::Error) -> bool {
929    let text = error.to_string().to_lowercase();
930    [
931        "base branch was modified",
932        "head branch was modified",
933        "try the merge again",
934        "method not allowed",
935        "is it still open",
936        // Transient API 5xx (the server hiccupped - not a verdict on the
937        // merge): 502/503/504/500. Worth retrying rather than failing the run.
938        "bad gateway",
939        "service unavailable",
940        "gateway time",
941        "internal server error",
942    ]
943    .iter()
944    .any(|signature| text.contains(signature))
945}
946
947/// Run a merge, retrying while it fails transiently so the "base branch was
948/// modified" race does not stop a `merge --all` loop. Between transient
949/// retries it only waits a fixed backoff - the right default when there is no
950/// per-provider signal to poll.
951fn merge_with_retry<T>(attempt: impl FnMut() -> Result<T>) -> Result<T> {
952    retry_transient_merge(
953        MERGE_ATTEMPTS,
954        || std::thread::sleep(MERGE_RETRY_BACKOFF),
955        attempt,
956    )
957}
958
959/// What registering a submitted line as a platform stack would do.
960///
961/// Shared by the real run and the dry run so the two cannot disagree - the
962/// decision lives here, and each caller only performs or renders it.
963#[derive(Debug, Clone, Eq, PartialEq)]
964pub enum StackPlan {
965    /// No stack recorded yet: create one holding all of these reviews.
966    Register(Vec<String>),
967    /// The submitted line continues the recorded stack: some tail of the
968    /// stack is where the submitted reviews begin, and `fresh` is everything
969    /// past that overlap. Growth on top is the one shape `/add`, which
970    /// carries no position, can express.
971    Extend { number: u64, fresh: Vec<String> },
972    /// The submitted line does not continue the recorded stack - nothing in
973    /// common at the join, or something past it the stack already holds: a
974    /// branch rooted below, a reorder, a layer removed. Appending would
975    /// record an order that is not this stack's.
976    Mismatch { number: u64 },
977}
978
979/// Plan the registration, or `None` when there is nothing to do - the stack is
980/// already exactly this, or already reaches further (a `--downstack` from the
981/// middle submits less than is recorded, which is not a divergence).
982pub fn plan_stack_registration(
983    reviews: &[String],
984    existing: Option<&NativeStack>,
985) -> Option<StackPlan> {
986    let Some(stack) = existing else {
987        // GitHub answers 422 for a one-layer stack, and a single review is not
988        // a stack in any case.
989        return (reviews.len() >= 2).then(|| StackPlan::Register(reviews.to_vec()));
990    };
991    let recorded: Vec<String> = stack.layers.iter().map(|layer| layer.id.clone()).collect();
992    // Submitting part of a stack that is already right is not a divergence,
993    // and it need not be the bottom part. A `--downstack` from the middle
994    // submits a prefix; resubmitting after the bottom layer lands submits a
995    // suffix, because GitHub keeps a landed layer listed in an open stack
996    // (verified live). Either way there is nothing to add, so say nothing.
997    if recorded
998        .windows(reviews.len().max(1))
999        .any(|run| run == reviews)
1000    {
1001        return None;
1002    }
1003
1004    // Otherwise this can only be growth on top, because `/add` carries no
1005    // position. The submitted line has to *continue* the recorded one: some
1006    // non-empty tail of the stack must be where the submitted reviews begin,
1007    // and everything past that overlap must be new. That covers the plain
1008    // case (the whole stack, then more) and the one after a layer lands and
1009    // another is stacked on - where the submitted line starts mid-stack and
1010    // still grows from the top.
1011    let overlap = (1..=recorded.len().min(reviews.len()))
1012        .rev()
1013        .find(|size| recorded[recorded.len() - size..] == reviews[..*size]);
1014    let Some(overlap) = overlap else {
1015        // Nothing in common at the join: a review rooted below the stack, a
1016        // reorder, a different stack entirely. Appending would record an
1017        // order that is not this stack's, and `repair` reads that order back
1018        // as a parent `restack` rebases against.
1019        return Some(StackPlan::Mismatch {
1020            number: stack.number,
1021        });
1022    };
1023    let fresh = &reviews[overlap..];
1024    // A "new" review the stack already holds means the submitted order
1025    // disagrees with the recorded one somewhere behind the join.
1026    if fresh.iter().any(|id| recorded.contains(id)) {
1027        return Some(StackPlan::Mismatch {
1028            number: stack.number,
1029        });
1030    }
1031    (!fresh.is_empty()).then_some(StackPlan::Extend {
1032        number: stack.number,
1033        fresh: fresh.to_vec(),
1034    })
1035}
1036
1037/// What, if anything, will close a gap between a review's base and the local
1038/// parent git-stk records for it.
1039///
1040/// The four commands that meet this gap all need the same answer, and it is
1041/// three-valued rather than two: asking "will the platform move it?" and "is
1042/// it in a stack?" separately is what let a call site wait for a move that was
1043/// never coming, or send someone to dissolve a stack that only needed a sync.
1044#[derive(Debug, Clone, Copy, Eq, PartialEq)]
1045pub enum BaseGap {
1046    /// The platform will close it: `parent` is where its stack puts this base
1047    /// next, once the layer below lands. Wait rather than retarget - a base
1048    /// change by hand is refused anyway.
1049    Platform,
1050    /// `git stk sync` will close it: the platform has already moved the base
1051    /// where it keeps it, and the local parent is a layer that has since
1052    /// landed. Local metadata is behind, not the review.
1053    Sync,
1054    /// Nothing will: the stack cannot reach this parent and refuses a change
1055    /// by hand - a re-rooted or reordered line, or the stack's own bottom.
1056    /// Dissolving the stack is what unblocks it.
1057    Neither,
1058}
1059
1060/// A merge git-stk itself refused, rather than one the platform rejected.
1061///
1062/// The distinction matters at the point of reporting: a platform failure is
1063/// worth re-diagnosing against the review's merge blocker, and a refusal is
1064/// not - its reason is already exact, and re-diagnosing one can answer it with
1065/// advice that contradicts it.
1066#[derive(Debug)]
1067pub struct MergeRefused(pub String);
1068
1069impl fmt::Display for MergeRefused {
1070    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1071        write!(formatter, "{}", self.0)
1072    }
1073}
1074
1075impl std::error::Error for MergeRefused {}
1076
1077/// Like [`merge_with_retry`], but instead of a blind backoff it runs `resettle`
1078/// between transient retries - re-polling the provider until the review is
1079/// actually mergeable again. GitLab's 405-while-recomputing race needs this:
1080/// the recompute can outlast a fixed sleep, but tracking the real status waits
1081/// exactly as long as it takes.
1082pub(super) fn merge_with_resettle(
1083    mut resettle: impl FnMut(),
1084    attempt: impl FnMut() -> Result<String>,
1085) -> Result<String> {
1086    retry_transient_merge(
1087        MERGE_ATTEMPTS,
1088        move || {
1089            // A short floor delay first, so a provider that reports "mergeable"
1090            // yet still 405s for a beat isn't hammered in a tight loop.
1091            std::thread::sleep(MERGE_RETRY_BACKOFF);
1092            resettle();
1093        },
1094        attempt,
1095    )
1096}
1097
1098fn retry_transient_merge<T>(
1099    attempts: u32,
1100    mut on_transient: impl FnMut(),
1101    mut attempt: impl FnMut() -> Result<T>,
1102) -> Result<T> {
1103    for remaining in (0..attempts).rev() {
1104        match attempt() {
1105            Ok(output) => return Ok(output),
1106            Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
1107                on_transient();
1108            }
1109            Err(error) => return Err(error),
1110        }
1111    }
1112    // attempts is always nonzero, so the final iteration returns above.
1113    Err(anyhow!("merge retried with no attempts left"))
1114}
1115
1116impl fmt::Display for ReviewState {
1117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1118        match self {
1119            Self::Open => write!(formatter, "open"),
1120            Self::Merged => write!(formatter, "merged"),
1121            Self::Closed => write!(formatter, "closed"),
1122            Self::Unknown(state) => write!(formatter, "{state}"),
1123        }
1124    }
1125}
1126
1127impl ReviewRequest {
1128    pub(crate) fn id_value(&self) -> &str {
1129        self.id
1130            .strip_prefix('#')
1131            .or_else(|| self.id.strip_prefix('!'))
1132            .unwrap_or(&self.id)
1133    }
1134
1135    /// "Title (#12)", or just the id when there is no title.
1136    pub fn label(&self) -> String {
1137        label(&self.title, &self.id)
1138    }
1139}
1140
1141/// The display label for a review: "Title (#12)", or the bare id.
1142pub(crate) fn label(title: &str, id: &str) -> String {
1143    if title.is_empty() {
1144        id.to_owned()
1145    } else {
1146        format!("{title} ({id})")
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152
1153    fn stack_of(number: u64, ids: &[&str]) -> NativeStack {
1154        stack_with(
1155            number,
1156            &ids.iter().map(|id| (*id, true)).collect::<Vec<_>>(),
1157        )
1158    }
1159
1160    /// A stack whose layers carry their own landed/open state - what GitHub
1161    /// sends, and what tells "the platform will move this base" apart from
1162    /// "it already did".
1163    fn stack_with(number: u64, layers: &[(&str, bool)]) -> NativeStack {
1164        NativeStack {
1165            number,
1166            base: "main".to_owned(),
1167            layers: layers
1168                .iter()
1169                .map(|(id, open)| NativeStackLayer {
1170                    id: (*id).to_owned(),
1171                    branch: id.trim_start_matches('#').to_owned(),
1172                    open: *open,
1173                })
1174                .collect(),
1175        }
1176    }
1177
1178    /// The two destinations the platform can bring a layer's base to, and
1179    /// nothing else. A layer above this one is the case that matters: after a
1180    /// local reorder it is what the stack's *bottom* would name as its
1181    /// parent, and the bottom is the one layer never retargeted.
1182    #[test]
1183    fn can_base_on_accepts_only_the_predecessor_and_the_stack_base() {
1184        let stack = stack_of(7, &["#12", "#13", "#14"]);
1185
1186        // The recorded predecessor, and the stack's own base.
1187        assert!(stack.can_base_on("13", "12"));
1188        assert!(stack.can_base_on("13", "main"));
1189        assert!(stack.can_base_on("12", "main"));
1190
1191        // A layer above, which a reorder makes the bottom's local parent.
1192        assert!(!stack.can_base_on("12", "13"));
1193        // A layer below, but not the one recorded directly beneath.
1194        assert!(!stack.can_base_on("14", "12"));
1195        // Somewhere the stack has never heard of.
1196        assert!(!stack.can_base_on("13", "rc-20260817"));
1197        // And a branch the stack does not hold at all.
1198        assert!(!stack.can_base_on("other", "main"));
1199
1200        // The predecessor only counts while it is still open. Once it lands
1201        // the platform has moved this base onto the stack's base and will not
1202        // put it back - and `parent_landed` is exactly that case, so the two
1203        // are complements rather than overlapping.
1204        let landed = stack_with(7, &[("#12", false), ("#13", true), ("#14", true)]);
1205        assert!(!landed.can_base_on("13", "12"));
1206        assert!(landed.parent_landed("13", "12"));
1207        assert!(landed.can_base_on("13", "main"));
1208
1209        // Some *other* landed layer says nothing about where this review's
1210        // base went, so it is neither.
1211        assert!(!landed.can_base_on("14", "12"));
1212        assert!(!landed.parent_landed("14", "12"));
1213
1214        // And `parent_is_current` is what both read.
1215        assert!(!landed.parent_is_current("13"));
1216        assert!(landed.parent_is_current("14"));
1217        // The bottom's parent is the stack's base, which no landing stales.
1218        assert!(landed.parent_is_current("12"));
1219    }
1220
1221    /// The whole decision table for registering, in one place - this is what
1222    /// the dry run renders and the real run performs, so a disagreement
1223    /// between them is impossible by construction.
1224    #[test]
1225    fn plan_stack_registration_covers_every_shape() {
1226        let ids = |s: &[&str]| s.iter().map(|id| (*id).to_owned()).collect::<Vec<_>>();
1227
1228        // Nothing recorded: register, but only for a real stack. GitHub
1229        // answers 422 for one review, and one review is not a stack.
1230        assert_eq!(
1231            plan_stack_registration(&ids(&["#12", "#13"]), None),
1232            Some(StackPlan::Register(ids(&["#12", "#13"])))
1233        );
1234        assert_eq!(plan_stack_registration(&ids(&["#12"]), None), None);
1235
1236        let recorded = stack_of(7, &["#12", "#13"]);
1237
1238        // Already exactly this, and growth on top - the one shape `/add` can
1239        // express, since it carries no position.
1240        assert_eq!(
1241            plan_stack_registration(&ids(&["#12", "#13"]), Some(&recorded)),
1242            None
1243        );
1244        assert_eq!(
1245            plan_stack_registration(&ids(&["#12", "#13", "#14"]), Some(&recorded)),
1246            Some(StackPlan::Extend {
1247                number: 7,
1248                fresh: ids(&["#14"])
1249            })
1250        );
1251
1252        // Part of a stack that is already right, from either end. A prefix is
1253        // `--downstack` from the middle; a suffix is what remains after the
1254        // bottom layer lands, which GitHub keeps listed in the open stack.
1255        let three = stack_of(7, &["#12", "#13", "#14"]);
1256        assert_eq!(
1257            plan_stack_registration(&ids(&["#12", "#13"]), Some(&three)),
1258            None
1259        );
1260        assert_eq!(
1261            plan_stack_registration(&ids(&["#13", "#14"]), Some(&three)),
1262            None
1263        );
1264        assert_eq!(plan_stack_registration(&ids(&["#13"]), Some(&three)), None);
1265
1266        // A suffix that then grew on top: merge the bottom, stack another
1267        // branch, resubmit. The overlap is a tail of the stack rather than
1268        // the whole of it, and #15 is still the only thing to append.
1269        assert_eq!(
1270            plan_stack_registration(&ids(&["#13", "#14", "#15"]), Some(&three)),
1271            Some(StackPlan::Extend {
1272                number: 7,
1273                fresh: ids(&["#15"])
1274            })
1275        );
1276
1277        // And the shapes `/add` cannot express: a review that belongs below,
1278        // and a reorder. Appending either would record an order that is not
1279        // this stack's, which `repair` reads back as a parent.
1280        assert_eq!(
1281            plan_stack_registration(&ids(&["#11", "#12", "#13"]), Some(&recorded)),
1282            Some(StackPlan::Mismatch { number: 7 })
1283        );
1284        assert_eq!(
1285            plan_stack_registration(&ids(&["#13", "#12"]), Some(&recorded)),
1286            Some(StackPlan::Mismatch { number: 7 })
1287        );
1288        // A tail that lines up but re-adds a layer behind the join: the
1289        // overlap is #14, and #12 is already in the stack.
1290        assert_eq!(
1291            plan_stack_registration(&ids(&["#14", "#12"]), Some(&three)),
1292            Some(StackPlan::Mismatch { number: 7 })
1293        );
1294        // And an unrelated stack entirely.
1295        assert_eq!(
1296            plan_stack_registration(&ids(&["#20", "#21"]), Some(&recorded)),
1297            Some(StackPlan::Mismatch { number: 7 })
1298        );
1299    }
1300    use super::*;
1301
1302    #[test]
1303    fn provider_cli_maps_only_the_provider_clis() {
1304        assert!(provider_cli("gh").is_some());
1305        assert!(provider_cli("glab").is_some());
1306        assert!(provider_cli("git").is_none());
1307    }
1308
1309    #[test]
1310    fn looks_unauthenticated_matches_signin_failures_only() {
1311        assert!(looks_unauthenticated(
1312            "error: not logged into any GitHub hosts"
1313        ));
1314        assert!(looks_unauthenticated(
1315            "To get started, please run: gh auth login"
1316        ));
1317        assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
1318        // A normal failure must not be misread as an auth problem.
1319        assert!(!looks_unauthenticated("pull request not found"));
1320        assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
1321    }
1322
1323    #[test]
1324    fn transient_error_is_retried_then_succeeds() {
1325        let mut calls = 0;
1326        let result: Result<String> = retry_transient_merge(
1327            3,
1328            || {},
1329            || {
1330                calls += 1;
1331                if calls < 2 {
1332                    Err(anyhow!(
1333                        "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
1334                    ))
1335                } else {
1336                    Ok("merged".to_owned())
1337                }
1338            },
1339        );
1340        assert_eq!(result.unwrap(), "merged");
1341        assert_eq!(calls, 2, "should retry once then succeed");
1342    }
1343
1344    #[test]
1345    fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
1346        let mut calls = 0;
1347        let result: Result<String> = retry_transient_merge(
1348            3,
1349            || {},
1350            || {
1351                calls += 1;
1352                if calls < 2 {
1353                    Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
1354                } else {
1355                    Ok("merged".to_owned())
1356                }
1357            },
1358        );
1359        assert_eq!(result.unwrap(), "merged");
1360        assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
1361    }
1362
1363    #[test]
1364    fn the_between_retry_action_runs_once_per_transient_retry() {
1365        // `merge_with_resettle` re-polls via this hook instead of a blind
1366        // sleep; the hook runs once per transient retry, never after success.
1367        let mut resettles = 0;
1368        let mut calls = 0;
1369        let result: Result<String> = retry_transient_merge(
1370            3,
1371            || resettles += 1,
1372            || {
1373                calls += 1;
1374                // 405 twice (recompute still in flight), then mergeable.
1375                if calls < 3 {
1376                    Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
1377                } else {
1378                    Ok("merged".to_owned())
1379                }
1380            },
1381        );
1382        assert_eq!(result.unwrap(), "merged");
1383        assert_eq!(calls, 3, "should retry until the merge lands");
1384        assert_eq!(
1385            resettles, 2,
1386            "re-poll once per transient retry, not after the final success"
1387        );
1388    }
1389
1390    #[test]
1391    fn the_between_retry_action_does_not_run_on_a_real_failure() {
1392        let mut resettles = 0;
1393        let result: Result<String> = retry_transient_merge(
1394            3,
1395            || resettles += 1,
1396            || {
1397                Err(anyhow!(
1398                    "glab failed: Merge request is not mergeable: conflict"
1399                ))
1400            },
1401        );
1402        assert!(result.is_err());
1403        assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
1404    }
1405
1406    #[test]
1407    fn a_transient_5xx_from_the_api_is_retried() {
1408        let mut calls = 0;
1409        let result: Result<String> = retry_transient_merge(
1410            3,
1411            || {},
1412            || {
1413                calls += 1;
1414                if calls < 2 {
1415                    Err(anyhow!(
1416                        "gh failed: non-200 OK status code: 502 Bad Gateway"
1417                    ))
1418                } else {
1419                    Ok("merged".to_owned())
1420                }
1421            },
1422        );
1423        assert_eq!(result.unwrap(), "merged");
1424        assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
1425    }
1426
1427    #[test]
1428    fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
1429        let mut calls = 0;
1430        let result: Result<String> = retry_transient_merge(
1431            3,
1432            || {},
1433            || {
1434                calls += 1;
1435                Err(anyhow!("gh failed: Base branch was modified"))
1436            },
1437        );
1438        assert!(result.is_err());
1439        assert_eq!(calls, 3, "should try exactly the budgeted number of times");
1440    }
1441
1442    #[test]
1443    fn a_real_failure_is_not_retried() {
1444        let mut calls = 0;
1445        let result: Result<String> = retry_transient_merge(
1446            3,
1447            || {},
1448            || {
1449                calls += 1;
1450                Err(anyhow!(
1451                    "gh failed: Pull request is not mergeable: conflicts"
1452                ))
1453            },
1454        );
1455        assert!(result.is_err());
1456        assert_eq!(calls, 1, "a non-transient error must surface immediately");
1457    }
1458
1459    #[test]
1460    fn host_of_extracts_the_host_across_url_shapes() {
1461        assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
1462        assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
1463        assert_eq!(
1464            host_of("ssh://git@gitlab.example.com:22/g/r"),
1465            "gitlab.example.com"
1466        );
1467        assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
1468        assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
1469        assert_eq!(
1470            host_of("https://[2001:db8::1]:443/owner/repo"),
1471            "2001:db8::1"
1472        );
1473        assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
1474        // Userinfo with an embedded '@' is stripped at the last one.
1475        assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
1476    }
1477
1478    #[test]
1479    fn redact_url_strips_embedded_credentials() {
1480        // An HTTPS remote can carry a token; it must never be displayed.
1481        assert_eq!(
1482            redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
1483            "https://github.com/owner/repo.git"
1484        );
1485        assert_eq!(
1486            redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
1487            "https://gitlab.com/owner/repo"
1488        );
1489        // ssh userinfo (no secret) is dropped too; port and path stay.
1490        assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
1491    }
1492
1493    #[test]
1494    fn redact_url_leaves_credential_free_urls_unchanged() {
1495        assert_eq!(
1496            redact_url("https://github.com/owner/repo.git"),
1497            "https://github.com/owner/repo.git"
1498        );
1499        // scp form has no scheme and carries no password - left as is.
1500        assert_eq!(
1501            redact_url("git@github.com:owner/repo.git"),
1502            "git@github.com:owner/repo.git"
1503        );
1504    }
1505
1506    #[test]
1507    fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
1508        let remote = "git@gitlab.example.com:team/repo.git";
1509        for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
1510            assert_eq!(
1511                detect_provider_from_url(remote, Some(configured), None),
1512                Some(ProviderKind::GitLab),
1513                "configured {configured:?} should detect the self-hosted host"
1514            );
1515        }
1516        // A look-alike host is still not matched.
1517        assert_eq!(
1518            detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
1519            None
1520        );
1521    }
1522
1523    #[test]
1524    fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
1525        assert_eq!(
1526            detect_provider_from_url("git@gitea.com:o/r.git", None, None),
1527            Some(ProviderKind::Gitea)
1528        );
1529        assert_eq!(
1530            detect_provider_from_url("https://codeberg.org/o/r", None, None),
1531            Some(ProviderKind::Gitea)
1532        );
1533        for configured in ["gitea.example.com", "https://gitea.example.com"] {
1534            assert_eq!(
1535                detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
1536                Some(ProviderKind::Gitea),
1537                "configured {configured:?} should detect the self-hosted Gitea host"
1538            );
1539        }
1540        // A look-alike host is not matched.
1541        assert_eq!(
1542            detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
1543            None
1544        );
1545    }
1546}