mod azure;
mod cache;
mod gitea;
mod github;
mod gitlab;
mod platform;
use std::process::Output;
use anstyle::{AnsiColor, Color, Style};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use worktrunk::git::{BranchRef, Repository, parse_owner_repo};
use worktrunk::shell_exec::Cmd;
use worktrunk::utils::epoch_now;
#[derive(Debug, Clone)]
pub struct CiBranchName {
pub full_name: String,
pub remote: Option<String>,
pub name: String,
}
impl CiBranchName {
pub fn from_branch_ref(branch_ref: &BranchRef) -> Option<Self> {
let short = branch_ref.short_name()?;
if branch_ref.is_remote() {
if let Some((remote, name)) = short.split_once('/') {
return Some(Self {
full_name: short.to_string(),
remote: Some(remote.to_string()),
name: name.to_string(),
});
}
}
Some(Self {
full_name: short.to_string(),
remote: None,
name: short.to_string(),
})
}
pub fn is_remote(&self) -> bool {
self.remote.is_some()
}
pub fn has_upstream(&self, repo: &Repository) -> bool {
self.is_remote() || repo.branch(&self.name).upstream().ok().flatten().is_some()
}
}
pub(crate) use cache::{CachedCiStatus, MaxPrNumber};
const MAX_PRS_TO_FETCH: u8 = 20;
fn non_interactive_cmd(program: &str) -> Cmd {
Cmd::new(program)
.env_remove("CLICOLOR_FORCE")
.env_remove("GH_FORCE_TTY")
.env("NO_COLOR", "1")
.env("CLICOLOR", "0")
.env("GH_PROMPT_DISABLED", "1")
}
fn tool_available(tool: &str, args: &[&str]) -> bool {
Cmd::new(tool)
.args(args.iter().copied())
.run()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_json<T: DeserializeOwned>(stdout: &[u8], command: &str, branch: &str) -> Option<T> {
serde_json::from_slice(stdout)
.map_err(|e| log::warn!("Failed to parse {} JSON for {}: {}", command, branch, e))
.ok()
}
fn output_error_text(output: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
)
}
fn retriable_pr_error(output: &Output) -> Option<PrStatus> {
is_retriable_error(&output_error_text(output)).then(PrStatus::error)
}
fn branch_owner_repo(repo: &Repository, branch: &CiBranchName) -> Option<(String, String)> {
parse_owner_repo(&branch_remote_url(repo, branch)?)
}
fn branch_remote_url(repo: &Repository, branch: &CiBranchName) -> Option<String> {
if let Some(remote_name) = &branch.remote {
repo.effective_remote_url(remote_name)
} else {
repo.branch(&branch.name).push_remote_url().or_else(|| {
let remote = repo.primary_remote().ok()?;
repo.effective_remote_url(&remote)
})
}
}
fn is_retriable_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
[
"rate limit",
"api rate",
"403",
"429",
"timeout",
"connection",
"network",
]
.iter()
.any(|p| lower.contains(p))
}
#[derive(Debug, Clone, Copy)]
pub struct CiToolsStatus {
pub gh_installed: bool,
pub gh_authenticated: bool,
pub glab_installed: bool,
pub glab_authenticated: bool,
pub tea_installed: bool,
pub tea_authenticated: bool,
pub az_installed: bool,
pub az_authenticated: bool,
}
impl CiToolsStatus {
pub fn detect(gitlab_host: Option<&str>) -> Self {
let gh_installed = tool_available("gh", &["--version"]);
let gh_authenticated = gh_installed && tool_available("gh", &["auth", "status"]);
let glab_installed = tool_available("glab", &["--version"]);
let glab_authenticated = glab_installed
&& if let Some(host) = gitlab_host {
tool_available("glab", &["auth", "status", "--hostname", host])
} else {
tool_available("glab", &["auth", "status"])
};
let tea_installed = tool_available("tea", &["--version"]);
let tea_authenticated = tea_installed && worktrunk::git::remote_ref::gitea::has_any_login();
let az_installed = tool_available("az", &["--version"]);
let az_authenticated = az_installed && tool_available("az", &["account", "show"]);
Self {
gh_installed,
gh_authenticated,
glab_installed,
glab_authenticated,
tea_installed,
tea_authenticated,
az_installed,
az_authenticated,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum CiStatus {
Passed,
Running,
Failed,
Conflicts,
NoCI,
Error,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr, JsonSchema,
)]
#[strum(serialize_all = "kebab-case")]
pub enum CiSource {
#[serde(rename = "pr", alias = "pull-request")]
PullRequest,
#[serde(rename = "branch")]
Branch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrRef {
pub number: u64,
pub sigil: char,
}
impl PrRef {
pub fn pr(number: u64) -> Self {
Self { number, sigil: '#' }
}
pub fn mr(number: u64) -> Self {
Self { number, sigil: '!' }
}
pub fn width(self) -> usize {
pr_ref_width(self.number)
}
}
impl std::fmt::Display for PrRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.sigil, self.number)
}
}
pub fn pr_ref_width(number: u64) -> usize {
2 + number.checked_ilog10().unwrap_or(0) as usize
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
Approved,
ChangesRequested,
Pending,
Draft,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrStatus {
pub ci_status: CiStatus,
pub source: CiSource,
pub is_stale: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<PrRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub review_state: Option<ReviewState>,
}
impl CiStatus {
pub fn color(&self) -> AnsiColor {
match self {
Self::Passed => AnsiColor::Green,
Self::Running => AnsiColor::Blue,
Self::Failed => AnsiColor::Red,
Self::Conflicts | Self::Error => AnsiColor::Yellow,
Self::NoCI => AnsiColor::BrightBlack,
}
}
}
impl PrStatus {
pub fn color(&self) -> AnsiColor {
match (self.ci_status, self.review_state) {
(CiStatus::Conflicts | CiStatus::Error, _) => self.ci_status.color(),
(_, Some(ReviewState::ChangesRequested)) => AnsiColor::Magenta,
(CiStatus::Running | CiStatus::Failed, _) => self.ci_status.color(),
(_, Some(ReviewState::Pending)) => AnsiColor::Cyan,
_ => self.ci_status.color(),
}
}
pub fn style(&self) -> Style {
let style = Style::new().fg_color(Some(Color::Ansi(self.color())));
if self.is_stale || self.review_state == Some(ReviewState::Draft) {
style.dimmed()
} else {
style
}
}
pub fn indicator(&self) -> &'static str {
if matches!(self.ci_status, CiStatus::Error) {
"⚠"
} else {
"#"
}
}
fn styled(&self, text: &str, include_link: bool) -> String {
if let (true, Some(url)) = (include_link, &self.url) {
let style = self.style().underline();
format!(
"{}{}{}{}{}",
style,
osc8::Hyperlink::new(url),
text,
osc8::Hyperlink::END,
style.render_reset()
)
} else {
let style = self.style();
format!("{style}{text}{style:#}")
}
}
pub fn format_cell(&self, max_width: usize, include_link: bool) -> String {
match self.number {
Some(r) if !matches!(self.ci_status, CiStatus::Error) && r.width() <= max_width => {
self.styled(&r.to_string(), include_link)
}
_ => self.styled(self.indicator(), include_link),
}
}
fn error() -> Self {
Self {
ci_status: CiStatus::Error,
source: CiSource::Branch,
is_stale: false,
url: None,
number: None,
review_state: None,
}
}
pub fn detect(repo: &Repository, branch: &CiBranchName, local_head: &str) -> Option<Self> {
let has_upstream = branch.has_upstream(repo);
let repo_path = repo.current_worktree().root().ok()?;
let now_secs = epoch_now();
let status = match CachedCiStatus::read(repo, &branch.full_name) {
Some(cached) if cached.is_valid(local_head, now_secs, &repo_path) => {
log::debug!(
"Using cached CI status for {} (age={}s, ttl={}s, status={:?})",
branch.full_name,
now_secs - cached.checked_at,
CachedCiStatus::ttl_for_repo(&repo_path),
cached.status.as_ref().map(|s| &s.ci_status)
);
cached.status
}
cached => {
if let Some(cached) = cached {
log::debug!(
"Cache expired for {} (age={}s, ttl={}s, head_match={})",
branch.full_name,
now_secs - cached.checked_at,
CachedCiStatus::ttl_for_repo(&repo_path),
cached.head == local_head
);
}
let status = Self::detect_uncached(repo, branch, local_head, has_upstream);
let cached = CachedCiStatus {
status: status.clone(),
checked_at: now_secs,
head: local_head.to_string(),
branch: branch.full_name.clone(),
};
cached.write(repo, &branch.full_name);
status
}
};
if let Some(r) = status.as_ref().and_then(|s| s.number) {
MaxPrNumber::ratchet(repo, r.number);
}
status
}
fn detect_uncached(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
has_upstream: bool,
) -> Option<Self> {
match repo.ci_platform(branch.remote.as_deref()) {
Some(p) => platform::detect_ci(p, repo, branch, local_head, has_upstream),
None => {
log::debug!(
"Could not detect CI platform from remote URL; \
set forge.platform in .config/wt.toml for CI status"
);
None
}
}
}
}
pub(crate) fn populate_from_cache(repo: &Repository, items: &mut [super::model::ListItem]) -> bool {
if !CachedCiStatus::cache_dir_exists(repo) {
return false;
}
let Ok(repo_path) = repo.current_worktree().root() else {
return false;
};
let now_secs = epoch_now();
let mut any = false;
for item in items.iter_mut() {
let Some(branch) = item.branch.as_deref() else {
continue;
};
let Some(cached) = CachedCiStatus::read(repo, branch) else {
continue;
};
if cached.is_valid(&item.head, now_secs, &repo_path) {
any |= cached.status.is_some();
item.pr_status = Some(cached.status);
} else if let Some(stale) = cached.status.filter(|s| s.number.is_some()) {
item.pr_status = Some(Some(PrStatus {
is_stale: true,
..stale
}));
any = true;
}
}
if any {
for item in items.iter_mut() {
if item.pr_status.is_none() {
item.pr_status = Some(None);
}
}
}
any
}
#[cfg(test)]
mod tests {
use super::super::model::ListItem;
use super::*;
use worktrunk::testing::TestRepo;
#[test]
fn test_is_retriable_error() {
assert!(is_retriable_error("API rate limit exceeded"));
assert!(is_retriable_error("rate limit exceeded for requests"));
assert!(is_retriable_error("Error 403: forbidden"));
assert!(is_retriable_error("HTTP 429 Too Many Requests"));
assert!(is_retriable_error("connection timed out"));
assert!(is_retriable_error("network error"));
assert!(is_retriable_error("timeout waiting for response"));
assert!(is_retriable_error("RATE LIMIT"));
assert!(is_retriable_error("Connection Reset"));
assert!(!is_retriable_error("branch not found"));
assert!(!is_retriable_error("invalid credentials"));
assert!(!is_retriable_error("permission denied"));
assert!(!is_retriable_error(""));
}
#[test]
fn test_ci_status_color() {
use anstyle::AnsiColor;
assert_eq!(CiStatus::Passed.color(), AnsiColor::Green);
assert_eq!(CiStatus::Running.color(), AnsiColor::Blue);
assert_eq!(CiStatus::Failed.color(), AnsiColor::Red);
assert_eq!(CiStatus::Conflicts.color(), AnsiColor::Yellow);
assert_eq!(CiStatus::Error.color(), AnsiColor::Yellow);
assert_eq!(CiStatus::NoCI.color(), AnsiColor::BrightBlack);
}
#[test]
fn test_pr_status_indicator() {
let pr_passed = PrStatus {
ci_status: CiStatus::Passed,
source: CiSource::PullRequest,
is_stale: false,
url: None,
number: None,
review_state: None,
};
assert_eq!(pr_passed.indicator(), "#");
let branch_running = PrStatus {
ci_status: CiStatus::Running,
source: CiSource::Branch,
is_stale: false,
url: None,
number: None,
review_state: None,
};
assert_eq!(branch_running.indicator(), "#");
let error_status = PrStatus {
ci_status: CiStatus::Error,
source: CiSource::PullRequest,
is_stale: false,
url: None,
number: None,
review_state: None,
};
assert_eq!(error_status.indicator(), "⚠");
}
#[test]
fn test_format_cell() {
use insta::assert_snapshot;
let pr = PrStatus {
ci_status: CiStatus::Passed,
source: CiSource::PullRequest,
is_stale: false,
url: Some("https://github.com/owner/repo/pull/123".to_string()),
number: Some(PrRef::pr(123)),
review_state: None,
};
assert_snapshot!(pr.format_cell(4, false), @"[32m#123[0m");
assert_snapshot!(pr.format_cell(4, true), @r"[4m[32m]8;;https://github.com/owner/repo/pull/123\#123]8;;\[0m");
assert_snapshot!(pr.format_cell(3, false), @"[32m#[0m");
assert_snapshot!(pr.format_cell(3, true), @r"[4m[32m]8;;https://github.com/owner/repo/pull/123\#]8;;\[0m");
let branch = PrStatus {
number: None,
..pr.clone()
};
assert_snapshot!(branch.format_cell(10, false), @"[32m#[0m");
let error = PrStatus {
ci_status: CiStatus::Error,
..pr.clone()
};
assert_snapshot!(error.format_cell(usize::MAX, false), @"[33m⚠[0m");
let mr = PrStatus {
number: Some(PrRef::mr(7)),
..pr
};
assert_snapshot!(mr.format_cell(usize::MAX, false), @"[32m!7[0m");
}
#[test]
fn test_pr_ref_width() {
assert_eq!(pr_ref_width(1), 2);
assert_eq!(pr_ref_width(9), 2);
assert_eq!(pr_ref_width(10), 3);
assert_eq!(pr_ref_width(3035), 5);
assert_eq!(pr_ref_width(99999), 6);
assert_eq!(PrRef::mr(3035).to_string(), "!3035");
assert_eq!(PrRef::mr(3035).width(), 5);
}
#[test]
fn test_pr_status_error_constructor() {
let error = PrStatus::error();
assert_eq!(error.ci_status, CiStatus::Error);
assert_eq!(error.source, CiSource::Branch);
assert!(!error.is_stale);
assert!(error.url.is_none());
assert!(error.number.is_none());
}
#[test]
fn test_ci_branch_name_from_local_branch_ref() {
let branch_ref = BranchRef::local_branch("feature", "abc123");
let ci = CiBranchName::from_branch_ref(&branch_ref).expect("local has short_name");
assert_eq!(ci.full_name, "feature");
assert_eq!(ci.name, "feature");
assert_eq!(ci.remote, None);
assert!(!ci.is_remote());
}
#[test]
fn test_ci_branch_name_from_remote_branch_ref() {
let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
let ci = CiBranchName::from_branch_ref(&branch_ref).expect("remote has short_name");
assert_eq!(ci.full_name, "origin/feature");
assert_eq!(ci.name, "feature");
assert_eq!(ci.remote.as_deref(), Some("origin"));
assert!(ci.is_remote());
}
#[test]
fn test_ci_branch_name_from_detached_head() {
let detached = BranchRef {
full_ref: None,
commit_sha: "abc123".to_string(),
worktree_path: None,
};
assert!(CiBranchName::from_branch_ref(&detached).is_none());
}
#[test]
fn test_pr_status_style() {
let stale = PrStatus {
ci_status: CiStatus::Running,
source: CiSource::Branch,
is_stale: true,
url: None,
number: None,
review_state: None,
};
let style = stale.style();
let _ = format!("{style}test{style:#}");
}
#[test]
fn test_pr_status_color_merges_review_state() {
let pr = |ci_status, review_state| PrStatus {
ci_status,
source: CiSource::PullRequest,
is_stale: false,
url: None,
number: None,
review_state,
};
assert_eq!(
pr(CiStatus::Running, Some(ReviewState::ChangesRequested)).color(),
AnsiColor::Magenta
);
assert_eq!(
pr(CiStatus::Passed, Some(ReviewState::ChangesRequested)).color(),
AnsiColor::Magenta
);
assert_eq!(
pr(CiStatus::Conflicts, Some(ReviewState::ChangesRequested)).color(),
AnsiColor::Yellow
);
assert_eq!(
pr(CiStatus::Error, Some(ReviewState::ChangesRequested)).color(),
AnsiColor::Yellow
);
assert_eq!(
pr(CiStatus::Passed, Some(ReviewState::Pending)).color(),
AnsiColor::Cyan
);
assert_eq!(
pr(CiStatus::NoCI, Some(ReviewState::Pending)).color(),
AnsiColor::Cyan
);
assert_eq!(
pr(CiStatus::Failed, Some(ReviewState::Pending)).color(),
AnsiColor::Red
);
assert_eq!(
pr(CiStatus::Running, Some(ReviewState::Pending)).color(),
AnsiColor::Blue
);
assert_eq!(
pr(CiStatus::Passed, Some(ReviewState::Approved)).color(),
AnsiColor::Green
);
assert_eq!(pr(CiStatus::Passed, None).color(), AnsiColor::Green);
let draft = pr(CiStatus::Passed, Some(ReviewState::Draft));
assert_eq!(draft.color(), AnsiColor::Green);
assert_eq!(
draft.style(),
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Green)))
.dimmed()
);
}
fn fake_output(stderr: &str, stdout: &str) -> Output {
Output {
status: Default::default(),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
fn test_output_error_text_combines_streams() {
let out = fake_output("transport: connection reset", r#"{"message":"rate limit"}"#);
let text = output_error_text(&out);
assert!(text.contains("transport: connection reset"));
assert!(text.contains("rate limit"));
}
#[test]
fn test_retriable_pr_error_routing() {
let out = fake_output("HTTP 429 Too Many Requests", "");
let status = retriable_pr_error(&out).expect("retriable should yield Some");
assert_eq!(status.ci_status, CiStatus::Error);
let out = fake_output("", r#"{"message":"rate limit exceeded"}"#);
assert!(retriable_pr_error(&out).is_some());
let out = fake_output("not found", "");
assert!(retriable_pr_error(&out).is_none());
let out = fake_output("", "");
assert!(retriable_pr_error(&out).is_none());
}
fn passed_pr_status(number: Option<u64>) -> PrStatus {
PrStatus {
ci_status: CiStatus::Passed,
source: CiSource::PullRequest,
is_stale: false,
url: None,
number: number.map(PrRef::pr),
review_state: None,
}
}
fn seed_cache(
repo: &Repository,
branch: &str,
status: Option<PrStatus>,
checked_at: u64,
head: &str,
) {
CachedCiStatus {
status,
checked_at,
head: head.to_string(),
branch: branch.to_string(),
}
.write(repo, branch);
}
#[test]
fn test_populate_from_cache() {
let test = TestRepo::new();
let repo = &test.repo;
let now = epoch_now();
seed_cache(repo, "fresh", Some(passed_pr_status(Some(123))), now, "aaa");
seed_cache(
repo,
"expired-pr",
Some(passed_pr_status(Some(77))),
now - 10_000,
"bbb",
);
seed_cache(
repo,
"expired-dot",
Some(passed_pr_status(None)),
now - 10_000,
"ccc",
);
seed_cache(repo, "fresh-none", None, now, "ddd");
let mut items = vec![
ListItem::new_branch("aaa".to_string(), "fresh".to_string()),
ListItem::new_branch("bbb".to_string(), "expired-pr".to_string()),
ListItem::new_branch("ccc".to_string(), "expired-dot".to_string()),
ListItem::new_branch("ddd".to_string(), "fresh-none".to_string()),
ListItem::new_branch("eee".to_string(), "uncached".to_string()),
];
assert!(populate_from_cache(repo, &mut items));
let fresh = items[0].pr_status.as_ref().unwrap().as_ref().unwrap();
assert_eq!(fresh.number.unwrap().number, 123);
assert!(!fresh.is_stale);
let expired = items[1].pr_status.as_ref().unwrap().as_ref().unwrap();
assert_eq!(expired.number.unwrap().number, 77);
assert!(expired.is_stale, "expired entries render dimmed");
assert!(
matches!(items[2].pr_status, Some(None)),
"expired dot conveys only its outdated color — resolved to blank"
);
assert!(
matches!(items[3].pr_status, Some(None)),
"fresh no-CI entry marks the cell as resolved-empty"
);
assert!(
matches!(items[4].pr_status, Some(None)),
"no task will fill the cell later — blank, not pending"
);
}
#[test]
fn test_populate_from_cache_head_moved_keeps_number_as_stale() {
let test = TestRepo::new();
let repo = &test.repo;
seed_cache(
repo,
"feature",
Some(passed_pr_status(Some(9))),
epoch_now(),
"old-head",
);
let mut items = vec![ListItem::new_branch(
"new-head".to_string(),
"feature".to_string(),
)];
assert!(populate_from_cache(repo, &mut items));
let status = items[0].pr_status.as_ref().unwrap().as_ref().unwrap();
assert_eq!(status.number.unwrap().number, 9);
assert!(status.is_stale);
}
#[test]
fn test_populate_from_cache_without_usable_entries_returns_false() {
let test = TestRepo::new();
let mut items = vec![ListItem::new_branch(
"aaa".to_string(),
"feature".to_string(),
)];
assert!(!populate_from_cache(&test.repo, &mut items));
assert!(items[0].pr_status.is_none());
seed_cache(&test.repo, "feature", None, epoch_now(), "aaa");
assert!(!populate_from_cache(&test.repo, &mut items));
assert!(matches!(items[0].pr_status, Some(None)));
}
}