use serde::Deserialize;
use worktrunk::git::{Repository, parse_owner_repo};
use super::{
CiBranchName, CiSource, CiStatus, MAX_PRS_TO_FETCH, PrStatus, is_retriable_error,
non_interactive_cmd, parse_json,
};
fn github_owner_repo(repo: &Repository) -> Option<(String, String)> {
let remote = repo.primary_remote().ok()?;
let url = repo.remote_url(&remote)?;
parse_owner_repo(&url)
}
fn github_owner_repo_for_branch(
repo: &Repository,
branch: &CiBranchName,
) -> Option<(String, String)> {
let url = if let Some(remote_name) = &branch.remote {
repo.effective_remote_url(remote_name)
} else {
repo.branch(&branch.name).github_push_url()
}?;
parse_owner_repo(&url)
}
pub(super) fn detect_github(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
) -> Option<PrStatus> {
let repo_root = repo.current_worktree().root().ok()?;
let branch_owner = github_owner_repo_for_branch(repo, branch).map(|(owner, _)| owner);
let Some(branch_owner) = branch_owner else {
log::debug!(
"Branch {} has no GitHub push remote; skipping PR-based CI detection",
branch.full_name
);
return None;
};
let output = match non_interactive_cmd("gh")
.args([
"pr",
"list",
"--head",
&branch.name, "--state",
"open",
"--limit",
&MAX_PRS_TO_FETCH.to_string(),
"--json",
"headRefOid,mergeStateStatus,statusCheckRollup,url,headRepositoryOwner",
])
.current_dir(&repo_root)
.run()
{
Ok(output) => output,
Err(e) => {
log::warn!(
"gh pr list failed to execute for branch {}: {}",
branch.full_name,
e
);
return None;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_retriable_error(&stderr) {
return Some(PrStatus::error());
}
return None;
}
let pr_list: Vec<GitHubPrInfo> = parse_json(&output.stdout, "gh pr list", &branch.full_name)?;
let pr_info = pr_list.iter().find(|pr| {
pr.head_repository_owner
.as_ref()
.map(|h| h.login.eq_ignore_ascii_case(&branch_owner))
.unwrap_or(true) });
if pr_info.is_none() && !pr_list.is_empty() {
log::debug!(
"Found {} PRs for branch {} but none from owner {}",
pr_list.len(),
branch.full_name,
branch_owner
);
}
let pr_info = pr_info?;
let ci_status = if pr_info.merge_state_status.as_deref() == Some("DIRTY") {
CiStatus::Conflicts
} else {
pr_info.ci_status()
};
let is_stale = pr_info
.head_ref_oid
.as_ref()
.map(|pr_head| pr_head != local_head)
.unwrap_or(false);
Some(PrStatus {
ci_status,
source: CiSource::PullRequest,
is_stale,
url: pr_info.url.clone(),
})
}
pub(super) fn detect_github_commit_checks(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
) -> Option<PrStatus> {
let repo_root = repo.current_worktree().root().ok()?;
let (owner, repo_name) =
github_owner_repo_for_branch(repo, branch).or_else(|| github_owner_repo(repo))?;
let hostname = repo
.load_project_config()
.ok()
.flatten()
.and_then(|c| c.forge_hostname().map(String::from));
let api_path = format!("repos/{owner}/{repo_name}/commits/{local_head}/check-runs");
let mut args = vec!["api", api_path.as_str()];
if let Some(h) = &hostname {
args.extend(["--hostname", h.as_str()]);
}
args.extend(["--jq", ".check_runs | map({status, conclusion})"]);
let output = match non_interactive_cmd("gh")
.args(args)
.current_dir(&repo_root)
.run()
{
Ok(output) => output,
Err(e) => {
log::warn!(
"gh api check-runs failed to execute for {}: {}",
local_head,
e
);
return None;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_retriable_error(&stderr) {
return Some(PrStatus::error());
}
return None;
}
let checks: Vec<GitHubCheck> = parse_json(&output.stdout, "gh api check-runs", local_head)?;
if checks.is_empty() {
return None;
}
let ci_status = aggregate_github_checks(&checks);
Some(PrStatus {
ci_status,
source: CiSource::Branch,
is_stale: false, url: None,
})
}
#[derive(Debug, Deserialize)]
pub(super) struct GitHubPrInfo {
#[serde(rename = "headRefOid")]
pub head_ref_oid: Option<String>,
#[serde(rename = "mergeStateStatus")]
pub merge_state_status: Option<String>,
#[serde(rename = "statusCheckRollup")]
pub status_check_rollup: Option<Vec<GitHubCheck>>,
pub url: Option<String>,
#[serde(rename = "headRepositoryOwner")]
pub head_repository_owner: Option<HeadRepositoryOwner>,
}
#[derive(Debug, Deserialize)]
pub(super) struct HeadRepositoryOwner {
pub login: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct GitHubCheck {
pub status: Option<String>,
pub conclusion: Option<String>,
pub state: Option<String>,
}
impl GitHubPrInfo {
pub fn ci_status(&self) -> CiStatus {
match &self.status_check_rollup {
None => CiStatus::NoCI,
Some(checks) if checks.is_empty() => CiStatus::NoCI,
Some(checks) => aggregate_github_checks(checks),
}
}
}
pub(super) fn aggregate_github_checks(checks: &[GitHubCheck]) -> CiStatus {
let mut has_running = false;
let mut has_failure = false;
let mut has_success = false;
for check in checks {
if let Some(status) = &check.status {
let s = status.to_ascii_lowercase();
if matches!(
s.as_str(),
"in_progress" | "queued" | "pending" | "expected"
) {
has_running = true;
}
}
if let Some(state) = &check.state {
let s = state.to_ascii_lowercase();
if s == "pending" {
has_running = true;
} else if matches!(s.as_str(), "failure" | "error") {
has_failure = true;
} else if s == "success" {
has_success = true;
}
}
if let Some(conclusion) = &check.conclusion {
let c = conclusion.to_ascii_lowercase();
match c.as_str() {
"failure" | "error" | "cancelled" | "timed_out" | "action_required" => {
has_failure = true;
}
"success" => {
has_success = true;
}
_ => {}
}
}
}
if has_running {
CiStatus::Running
} else if has_failure {
CiStatus::Failed
} else if has_success {
CiStatus::Passed
} else {
CiStatus::NoCI
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_github_pr_info_ci_status() {
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: None,
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::NoCI);
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::NoCI);
for status in ["IN_PROGRESS", "QUEUED", "PENDING", "EXPECTED"] {
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![GitHubCheck {
status: Some(status.into()),
conclusion: None,
state: None,
}]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::Running, "status={status}");
}
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![GitHubCheck {
status: None,
conclusion: None,
state: Some("PENDING".into()),
}]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::Running);
for conclusion in ["FAILURE", "ERROR", "CANCELLED"] {
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![GitHubCheck {
status: Some("COMPLETED".into()),
conclusion: Some(conclusion.into()),
state: None,
}]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::Failed, "conclusion={conclusion}");
}
for state in ["FAILURE", "ERROR"] {
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![GitHubCheck {
status: None,
conclusion: None,
state: Some(state.into()),
}]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::Failed, "state={state}");
}
let pr = GitHubPrInfo {
head_ref_oid: None,
merge_state_status: None,
status_check_rollup: Some(vec![GitHubCheck {
status: Some("COMPLETED".into()),
conclusion: Some("SUCCESS".into()),
state: None,
}]),
url: None,
head_repository_owner: None,
};
assert_eq!(pr.ci_status(), CiStatus::Passed);
}
#[test]
fn test_aggregate_github_checks() {
fn check(status: &str, conclusion: Option<&str>) -> GitHubCheck {
GitHubCheck {
status: Some(status.into()),
conclusion: conclusion.map(|c| c.into()),
state: None,
}
}
assert_eq!(aggregate_github_checks(&[]), CiStatus::NoCI);
let checks = vec![
check("completed", Some("skipped")),
check("completed", Some("neutral")),
];
assert_eq!(aggregate_github_checks(&checks), CiStatus::NoCI);
for status in ["in_progress", "queued", "pending"] {
let checks = vec![check("completed", Some("success")), check(status, None)];
assert_eq!(
aggregate_github_checks(&checks),
CiStatus::Running,
"status={status}"
);
}
for conclusion in ["failure", "cancelled", "timed_out", "action_required"] {
let checks = vec![
check("completed", Some("success")),
check("completed", Some(conclusion)),
];
assert_eq!(
aggregate_github_checks(&checks),
CiStatus::Failed,
"conclusion={conclusion}"
);
}
let checks = vec![
check("in_progress", None),
check("completed", Some("failure")),
];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Running);
let checks = vec![
check("completed", Some("success")),
check("completed", Some("success")),
];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Passed);
let checks = vec![
check("completed", Some("success")),
check("completed", Some("skipped")),
];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Passed);
let checks = vec![check("COMPLETED", Some("FAILURE"))];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Failed);
let checks = vec![GitHubCheck {
status: None,
conclusion: None,
state: Some("PENDING".into()),
}];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Running);
let checks = vec![GitHubCheck {
status: None,
conclusion: None,
state: Some("failure".into()),
}];
assert_eq!(aggregate_github_checks(&checks), CiStatus::Failed);
}
}