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