use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use std::{fmt, process::Command};
use anyhow::{Context, Result, anyhow, bail};
use crate::git;
use crate::settings;
pub(super) const CHECK_GRACE_POLLS: u32 = 6;
pub(super) fn check_poll_interval() -> Duration {
Duration::from_secs(5)
}
pub(super) fn checks_timed_out(review: &ReviewRequest, timeout: Duration) -> anyhow::Error {
anyhow!(
"{}'s checks have not settled within {}; rerun `git stk merge` once they pass, \
or raise stk.checkTimeout",
review.id,
humanize(timeout),
)
}
fn humanize(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds >= 60 && seconds.is_multiple_of(60) {
format!("{}m", seconds / 60)
} else {
format!("{seconds}s")
}
}
mod demo;
mod gitea;
mod github;
mod gitlab;
mod json;
use demo::DemoProvider;
use gitea::GiteaProvider;
use github::GitHubProvider;
use gitlab::GitLabProvider;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ProviderKind {
GitHub,
GitLab,
Gitea,
Demo,
}
impl ProviderKind {
fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"github" | "gh" => Some(Self::GitHub),
"gitlab" | "glab" => Some(Self::GitLab),
"gitea" | "tea" => Some(Self::Gitea),
"demo" => Some(Self::Demo),
_ => None,
}
}
}
impl fmt::Display for ProviderKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GitHub => write!(formatter, "github"),
Self::GitLab => write!(formatter, "gitlab"),
Self::Gitea => write!(formatter, "gitea"),
Self::Demo => write!(formatter, "demo"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct DetectedProvider {
pub kind: ProviderKind,
pub source: ProviderSource,
}
#[derive(Debug, Eq, PartialEq)]
pub enum ProviderSource {
Config,
Remote { remote: String, url: String },
}
impl fmt::Display for ProviderSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config => write!(formatter, "config"),
Self::Remote { remote, url } => {
write!(formatter, "remote {remote} ({})", redact_url(url))
}
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum ReviewState {
Open,
Merged,
Closed,
Unknown(String),
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum MergeBlocker {
ChecksPending,
Conflicts,
None,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct NativeStackLayer {
pub id: String,
pub branch: String,
pub open: bool,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct NativeStack {
pub number: u64,
pub base: String,
pub layers: Vec<NativeStackLayer>,
}
impl NativeStack {
pub fn parent_is_current(&self, branch: &str) -> bool {
let Some(parent) = self.parent_of(branch) else {
return false;
};
self.layers
.iter()
.find(|layer| layer.branch == parent)
.is_none_or(|layer| layer.open)
}
pub fn parent_landed(&self, branch: &str, parent: &str) -> bool {
self.parent_of(branch) == Some(parent) && !self.parent_is_current(branch)
}
pub fn can_base_on(&self, branch: &str, parent: &str) -> bool {
self.layers.iter().any(|layer| layer.branch == branch)
&& (self.base == parent
|| (self.parent_of(branch) == Some(parent) && self.parent_is_current(branch)))
}
pub fn parent_of(&self, branch: &str) -> Option<&str> {
let index = self
.layers
.iter()
.position(|layer| layer.branch == branch)?;
Some(match index.checked_sub(1) {
Some(below) => &self.layers[below].branch,
None => &self.base,
})
}
pub fn position_of(&self, branch: &str) -> Option<u32> {
self.layers
.iter()
.position(|layer| layer.branch == branch)
.map(|index| index as u32 + 1)
}
pub fn review_id_for(&self, branch: &str) -> Option<&str> {
self.layers
.iter()
.find(|layer| layer.branch == branch)
.map(|layer| layer.id.as_str())
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct ReviewRequest {
pub id: String,
pub branch: String,
pub base: String,
pub state: ReviewState,
pub url: String,
pub title: String,
pub draft: bool,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum CheckStatus {
Passing,
Failing,
Pending,
None,
}
impl CheckStatus {
pub fn dot(self) -> &'static str {
match self {
Self::Passing => "🟢 ",
Self::Failing => "🔴 ",
Self::Pending => "🟡 ",
Self::None => "",
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
pub struct ReviewSummary {
pub approvals: u32,
pub comments: u32,
pub changes_requested: u32,
}
impl ReviewSummary {
pub fn lines(&self) -> Vec<String> {
let count =
|n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
let mut lines = Vec::new();
if self.approvals > 0 {
lines.push(count(self.approvals, "approval", "approvals"));
}
if self.comments > 0 {
lines.push(count(self.comments, "comment", "comments"));
}
if self.changes_requested > 0 {
lines.push(count(
self.changes_requested,
"requested change",
"requested changes",
));
}
lines
}
}
pub const QUEUED_MARK: &str = "🕑 ";
pub const STACKED_MARK: &str = "⛁";
pub struct ReviewAnnotation {
pub id: String,
pub checks: CheckStatus,
pub queued: bool,
pub summary: Option<ReviewSummary>,
pub stack: Option<StackPosition>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct StackPosition {
pub number: u64,
pub position: u32,
pub size: u32,
}
pub enum WaitOutcome {
Passed,
Failed,
Landed,
}
pub trait ReviewProvider {
fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
fn create_review(
&self,
branch: &str,
base: &str,
draft: bool,
title: Option<&str>,
) -> Result<String>;
fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
fn base_gap(&self, review: &ReviewRequest, parent: &str) -> Result<Option<BaseGap>> {
let _ = (review, parent);
Ok(None)
}
fn update_review_title(&self, review: &ReviewRequest, title: &str) -> Result<String>;
fn review_body(&self, review: &ReviewRequest) -> Result<String>;
fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
fn review_state(&self, review: &ReviewRequest) -> Result<Option<ReviewState>> {
let _ = review;
Ok(None)
}
fn native_stack_for(&self, branch: &str) -> Result<Option<NativeStack>> {
let _ = branch;
Ok(None)
}
fn register_stack(
&self,
reviews: &[String],
existing: Option<&NativeStack>,
) -> Result<Option<String>> {
let _ = (reviews, existing);
Ok(None)
}
fn registers_stacks(&self) -> bool {
false
}
fn native_stacks_covering(&self, branches: &[String]) -> Result<Vec<NativeStack>> {
let _ = branches;
Ok(Vec::new())
}
fn unstack_reviews(&self, stack: &NativeStack) -> Result<Option<String>> {
let _ = stack;
Ok(None)
}
fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
fn annotate_branches(
&self,
branches: &[String],
detail: bool,
) -> Result<BTreeMap<String, ReviewAnnotation>> {
generic_annotate(self, branches, detail)
}
fn annotate_review(&self, review: &ReviewRequest, detail: bool) -> Result<ReviewAnnotation> {
generic_annotate_review(self, review, detail)
}
fn check_status(&self, _review: &ReviewRequest) -> Result<CheckStatus> {
Ok(CheckStatus::None)
}
fn review_summary(&self, _review: &ReviewRequest) -> Result<ReviewSummary> {
Ok(ReviewSummary::default())
}
fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
fn request_reviewers(&self, _review: &ReviewRequest, _reviewers: &[String]) -> Result<String> {
bail!("requesting reviewers is not supported by this provider")
}
fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
fn open_review(&self, review: &ReviewRequest) -> Result<String>;
fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
Ok(BTreeSet::new())
}
}
pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
let provider = detect_provider()?;
let client = review_provider(provider.kind);
Ok((provider, client))
}
pub fn generic_annotate_review<P: ReviewProvider + ?Sized>(
provider: &P,
review: &ReviewRequest,
detail: bool,
) -> Result<ReviewAnnotation> {
let queued = matches!(review.state, ReviewState::Open)
&& provider
.enqueued_branches(std::slice::from_ref(&review.branch))
.map(|set| set.contains(&review.branch))
.unwrap_or(false);
Ok(ReviewAnnotation {
id: review.id.clone(),
checks: if queued {
CheckStatus::None
} else {
provider.check_status(review).unwrap_or(CheckStatus::None)
},
queued,
summary: if detail {
provider.review_summary(review).ok()
} else {
None
},
stack: provider
.native_stack_for(&review.branch)?
.and_then(|found| {
let size = u32::try_from(found.layers.len()).ok()?;
Some(StackPosition {
number: found.number,
position: found.position_of(&review.branch)?,
size,
})
}),
})
}
fn generic_annotate<P: ReviewProvider + ?Sized>(
provider: &P,
branches: &[String],
detail: bool,
) -> Result<BTreeMap<String, ReviewAnnotation>> {
let wanted: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
let reviewed: Vec<ReviewRequest> = provider
.open_reviews()?
.into_iter()
.filter(|review| wanted.contains(review.branch.as_str()))
.collect();
let names: Vec<String> = reviewed
.iter()
.map(|review| review.branch.clone())
.collect();
let queued = provider.enqueued_branches(&names).unwrap_or_default();
let mut annotations = BTreeMap::new();
for review in reviewed {
let checks = provider.check_status(&review).unwrap_or(CheckStatus::None);
let summary = if detail {
provider.review_summary(&review).ok()
} else {
None
};
let is_queued = queued.contains(&review.branch);
annotations.insert(
review.branch.clone(),
ReviewAnnotation {
id: review.id,
checks,
queued: is_queued,
summary,
stack: None,
},
);
}
Ok(annotations)
}
pub fn owned_review_for_branch(
provider: &dyn ReviewProvider,
branch: &str,
) -> Result<Option<ReviewRequest>> {
Ok(provider
.review_for_branch(branch)?
.filter(|review| review.branch == branch))
}
pub(super) fn review_merged_out_of_band(
provider: &dyn ReviewProvider,
review: &ReviewRequest,
) -> Result<bool> {
Ok(matches!(
provider.review_for_branch(&review.branch)?,
Some(current) if current.state == ReviewState::Merged
))
}
pub fn detect_provider() -> Result<DetectedProvider> {
if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
let Some(kind) = ProviderKind::parse(&value) else {
bail!(
"unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
);
};
return Ok(DetectedProvider {
kind,
source: ProviderSource::Config,
});
}
let remote = settings::remote()?;
let Some(url) = git::remote_url(&remote)? else {
bail!("could not detect provider: remote {remote:?} does not exist");
};
let gitlab_host = settings::gitlab_host()?;
let gitea_host = settings::gitea_host()?;
let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
else {
bail!(
"could not detect provider from remote {remote} ({})",
redact_url(&url)
);
};
Ok(DetectedProvider {
kind,
source: ProviderSource::Remote { remote, url },
})
}
fn detect_provider_from_url(
url: &str,
gitlab_host: Option<&str>,
gitea_host: Option<&str>,
) -> Option<ProviderKind> {
let normalized = url.to_ascii_lowercase();
let host = host_of(&normalized);
let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
let self_hosted = |configured: Option<&str>| {
configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
};
if is("github.com") {
Some(ProviderKind::GitHub)
} else if is("gitlab.com") || self_hosted(gitlab_host) {
Some(ProviderKind::GitLab)
} else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
Some(ProviderKind::Gitea)
} else {
None
}
}
fn host_of(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, rest)| rest);
if let Some(after_bracket) = host_port.strip_prefix('[') {
return after_bracket
.split_once(']')
.map_or(host_port, |(addr, _)| addr);
}
host_port.split(':').next().unwrap_or(host_port)
}
fn redact_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let (authority, path) = match rest.split_once('/') {
Some((authority, path)) => (authority, Some(path)),
None => (rest, None),
};
let Some((_, host)) = authority.rsplit_once('@') else {
return url.to_owned();
};
match path {
Some(path) => format!("{scheme}://{host}/{path}"),
None => format!("{scheme}://{host}"),
}
}
pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
match kind {
ProviderKind::GitHub => Box::new(GitHubProvider),
ProviderKind::GitLab => Box::new(GitLabProvider),
ProviderKind::Gitea => Box::new(GiteaProvider),
ProviderKind::Demo => Box::new(DemoProvider),
}
}
fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
match program {
"gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
"glab" => Some((
"GitLab CLI",
"https://gitlab.com/gitlab-org/cli",
"glab auth login",
)),
"tea" => Some((
"Gitea CLI (tea)",
"https://gitea.com/gitea/tea",
"tea login add",
)),
_ => None,
}
}
fn looks_unauthenticated(stderr: &str) -> bool {
let stderr = stderr.to_ascii_lowercase();
[
"auth login",
"not logged",
"401",
"unauthorized",
"authentication required",
]
.iter()
.any(|needle| stderr.contains(needle))
}
fn command_output(program: &str, args: &[&str]) -> Result<String> {
let output = match Command::new(program).args(args).output() {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if let Some((name, url, auth)) = provider_cli(program) {
bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
}
return Err(error).with_context(|| format!("failed to run {program}"));
}
Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
};
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
if let Some((_, _, auth)) = provider_cli(program)
&& looks_unauthenticated(&stderr)
{
bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
}
if stderr.is_empty() {
Err(anyhow!("{program} exited with status {}", output.status))
} else {
Err(anyhow!("{program} failed: {stderr}"))
}
}
const MERGE_ATTEMPTS: u32 = 3;
const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
fn is_transient_merge_error(error: &anyhow::Error) -> bool {
let text = error.to_string().to_lowercase();
[
"base branch was modified",
"head branch was modified",
"try the merge again",
"method not allowed",
"is it still open",
"bad gateway",
"service unavailable",
"gateway time",
"internal server error",
]
.iter()
.any(|signature| text.contains(signature))
}
fn merge_with_retry<T>(attempt: impl FnMut() -> Result<T>) -> Result<T> {
retry_transient_merge(
MERGE_ATTEMPTS,
|| std::thread::sleep(MERGE_RETRY_BACKOFF),
attempt,
)
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum StackPlan {
Register(Vec<String>),
Extend { number: u64, fresh: Vec<String> },
Mismatch { number: u64 },
}
pub fn plan_stack_registration(
reviews: &[String],
existing: Option<&NativeStack>,
) -> Option<StackPlan> {
let Some(stack) = existing else {
return (reviews.len() >= 2).then(|| StackPlan::Register(reviews.to_vec()));
};
let recorded: Vec<String> = stack.layers.iter().map(|layer| layer.id.clone()).collect();
if recorded
.windows(reviews.len().max(1))
.any(|run| run == reviews)
{
return None;
}
let overlap = (1..=recorded.len().min(reviews.len()))
.rev()
.find(|size| recorded[recorded.len() - size..] == reviews[..*size]);
let Some(overlap) = overlap else {
return Some(StackPlan::Mismatch {
number: stack.number,
});
};
let fresh = &reviews[overlap..];
if fresh.iter().any(|id| recorded.contains(id)) {
return Some(StackPlan::Mismatch {
number: stack.number,
});
}
(!fresh.is_empty()).then_some(StackPlan::Extend {
number: stack.number,
fresh: fresh.to_vec(),
})
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum BaseGap {
Platform,
Sync,
Neither,
}
#[derive(Debug)]
pub struct MergeRefused(pub String);
impl fmt::Display for MergeRefused {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}
impl std::error::Error for MergeRefused {}
pub(super) fn merge_with_resettle(
mut resettle: impl FnMut(),
attempt: impl FnMut() -> Result<String>,
) -> Result<String> {
retry_transient_merge(
MERGE_ATTEMPTS,
move || {
std::thread::sleep(MERGE_RETRY_BACKOFF);
resettle();
},
attempt,
)
}
fn retry_transient_merge<T>(
attempts: u32,
mut on_transient: impl FnMut(),
mut attempt: impl FnMut() -> Result<T>,
) -> Result<T> {
for remaining in (0..attempts).rev() {
match attempt() {
Ok(output) => return Ok(output),
Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
on_transient();
}
Err(error) => return Err(error),
}
}
Err(anyhow!("merge retried with no attempts left"))
}
impl fmt::Display for ReviewState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => write!(formatter, "open"),
Self::Merged => write!(formatter, "merged"),
Self::Closed => write!(formatter, "closed"),
Self::Unknown(state) => write!(formatter, "{state}"),
}
}
}
impl ReviewRequest {
pub(crate) fn id_value(&self) -> &str {
self.id
.strip_prefix('#')
.or_else(|| self.id.strip_prefix('!'))
.unwrap_or(&self.id)
}
pub fn label(&self) -> String {
label(&self.title, &self.id)
}
}
pub(crate) fn label(title: &str, id: &str) -> String {
if title.is_empty() {
id.to_owned()
} else {
format!("{title} ({id})")
}
}
#[cfg(test)]
mod tests {
fn stack_of(number: u64, ids: &[&str]) -> NativeStack {
stack_with(
number,
&ids.iter().map(|id| (*id, true)).collect::<Vec<_>>(),
)
}
fn stack_with(number: u64, layers: &[(&str, bool)]) -> NativeStack {
NativeStack {
number,
base: "main".to_owned(),
layers: layers
.iter()
.map(|(id, open)| NativeStackLayer {
id: (*id).to_owned(),
branch: id.trim_start_matches('#').to_owned(),
open: *open,
})
.collect(),
}
}
#[test]
fn can_base_on_accepts_only_the_predecessor_and_the_stack_base() {
let stack = stack_of(7, &["#12", "#13", "#14"]);
assert!(stack.can_base_on("13", "12"));
assert!(stack.can_base_on("13", "main"));
assert!(stack.can_base_on("12", "main"));
assert!(!stack.can_base_on("12", "13"));
assert!(!stack.can_base_on("14", "12"));
assert!(!stack.can_base_on("13", "rc-20260817"));
assert!(!stack.can_base_on("other", "main"));
let landed = stack_with(7, &[("#12", false), ("#13", true), ("#14", true)]);
assert!(!landed.can_base_on("13", "12"));
assert!(landed.parent_landed("13", "12"));
assert!(landed.can_base_on("13", "main"));
assert!(!landed.can_base_on("14", "12"));
assert!(!landed.parent_landed("14", "12"));
assert!(!landed.parent_is_current("13"));
assert!(landed.parent_is_current("14"));
assert!(landed.parent_is_current("12"));
}
#[test]
fn plan_stack_registration_covers_every_shape() {
let ids = |s: &[&str]| s.iter().map(|id| (*id).to_owned()).collect::<Vec<_>>();
assert_eq!(
plan_stack_registration(&ids(&["#12", "#13"]), None),
Some(StackPlan::Register(ids(&["#12", "#13"])))
);
assert_eq!(plan_stack_registration(&ids(&["#12"]), None), None);
let recorded = stack_of(7, &["#12", "#13"]);
assert_eq!(
plan_stack_registration(&ids(&["#12", "#13"]), Some(&recorded)),
None
);
assert_eq!(
plan_stack_registration(&ids(&["#12", "#13", "#14"]), Some(&recorded)),
Some(StackPlan::Extend {
number: 7,
fresh: ids(&["#14"])
})
);
let three = stack_of(7, &["#12", "#13", "#14"]);
assert_eq!(
plan_stack_registration(&ids(&["#12", "#13"]), Some(&three)),
None
);
assert_eq!(
plan_stack_registration(&ids(&["#13", "#14"]), Some(&three)),
None
);
assert_eq!(plan_stack_registration(&ids(&["#13"]), Some(&three)), None);
assert_eq!(
plan_stack_registration(&ids(&["#13", "#14", "#15"]), Some(&three)),
Some(StackPlan::Extend {
number: 7,
fresh: ids(&["#15"])
})
);
assert_eq!(
plan_stack_registration(&ids(&["#11", "#12", "#13"]), Some(&recorded)),
Some(StackPlan::Mismatch { number: 7 })
);
assert_eq!(
plan_stack_registration(&ids(&["#13", "#12"]), Some(&recorded)),
Some(StackPlan::Mismatch { number: 7 })
);
assert_eq!(
plan_stack_registration(&ids(&["#14", "#12"]), Some(&three)),
Some(StackPlan::Mismatch { number: 7 })
);
assert_eq!(
plan_stack_registration(&ids(&["#20", "#21"]), Some(&recorded)),
Some(StackPlan::Mismatch { number: 7 })
);
}
use super::*;
#[test]
fn provider_cli_maps_only_the_provider_clis() {
assert!(provider_cli("gh").is_some());
assert!(provider_cli("glab").is_some());
assert!(provider_cli("git").is_none());
}
#[test]
fn looks_unauthenticated_matches_signin_failures_only() {
assert!(looks_unauthenticated(
"error: not logged into any GitHub hosts"
));
assert!(looks_unauthenticated(
"To get started, please run: gh auth login"
));
assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
assert!(!looks_unauthenticated("pull request not found"));
assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
}
#[test]
fn transient_error_is_retried_then_succeeds() {
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| {},
|| {
calls += 1;
if calls < 2 {
Err(anyhow!(
"gh failed: GraphQL: Base branch was modified. Review and try the merge again."
))
} else {
Ok("merged".to_owned())
}
},
);
assert_eq!(result.unwrap(), "merged");
assert_eq!(calls, 2, "should retry once then succeed");
}
#[test]
fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| {},
|| {
calls += 1;
if calls < 2 {
Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
} else {
Ok("merged".to_owned())
}
},
);
assert_eq!(result.unwrap(), "merged");
assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
}
#[test]
fn the_between_retry_action_runs_once_per_transient_retry() {
let mut resettles = 0;
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| resettles += 1,
|| {
calls += 1;
if calls < 3 {
Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
} else {
Ok("merged".to_owned())
}
},
);
assert_eq!(result.unwrap(), "merged");
assert_eq!(calls, 3, "should retry until the merge lands");
assert_eq!(
resettles, 2,
"re-poll once per transient retry, not after the final success"
);
}
#[test]
fn the_between_retry_action_does_not_run_on_a_real_failure() {
let mut resettles = 0;
let result: Result<String> = retry_transient_merge(
3,
|| resettles += 1,
|| {
Err(anyhow!(
"glab failed: Merge request is not mergeable: conflict"
))
},
);
assert!(result.is_err());
assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
}
#[test]
fn a_transient_5xx_from_the_api_is_retried() {
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| {},
|| {
calls += 1;
if calls < 2 {
Err(anyhow!(
"gh failed: non-200 OK status code: 502 Bad Gateway"
))
} else {
Ok("merged".to_owned())
}
},
);
assert_eq!(result.unwrap(), "merged");
assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
}
#[test]
fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| {},
|| {
calls += 1;
Err(anyhow!("gh failed: Base branch was modified"))
},
);
assert!(result.is_err());
assert_eq!(calls, 3, "should try exactly the budgeted number of times");
}
#[test]
fn a_real_failure_is_not_retried() {
let mut calls = 0;
let result: Result<String> = retry_transient_merge(
3,
|| {},
|| {
calls += 1;
Err(anyhow!(
"gh failed: Pull request is not mergeable: conflicts"
))
},
);
assert!(result.is_err());
assert_eq!(calls, 1, "a non-transient error must surface immediately");
}
#[test]
fn host_of_extracts_the_host_across_url_shapes() {
assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
assert_eq!(
host_of("ssh://git@gitlab.example.com:22/g/r"),
"gitlab.example.com"
);
assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
assert_eq!(
host_of("https://[2001:db8::1]:443/owner/repo"),
"2001:db8::1"
);
assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
}
#[test]
fn redact_url_strips_embedded_credentials() {
assert_eq!(
redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
assert_eq!(
redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
"https://gitlab.com/owner/repo"
);
assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
}
#[test]
fn redact_url_leaves_credential_free_urls_unchanged() {
assert_eq!(
redact_url("https://github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
assert_eq!(
redact_url("git@github.com:owner/repo.git"),
"git@github.com:owner/repo.git"
);
}
#[test]
fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
let remote = "git@gitlab.example.com:team/repo.git";
for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
assert_eq!(
detect_provider_from_url(remote, Some(configured), None),
Some(ProviderKind::GitLab),
"configured {configured:?} should detect the self-hosted host"
);
}
assert_eq!(
detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
None
);
}
#[test]
fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
assert_eq!(
detect_provider_from_url("git@gitea.com:o/r.git", None, None),
Some(ProviderKind::Gitea)
);
assert_eq!(
detect_provider_from_url("https://codeberg.org/o/r", None, None),
Some(ProviderKind::Gitea)
);
for configured in ["gitea.example.com", "https://gitea.example.com"] {
assert_eq!(
detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
Some(ProviderKind::Gitea),
"configured {configured:?} should detect the self-hosted Gitea host"
);
}
assert_eq!(
detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
None
);
}
}