use serde::Deserialize;
use std::process::Output;
use worktrunk::git::{Repository, parse_owner_repo};
use super::{
CiBranchName, CiSource, CiStatus, MAX_PRS_TO_FETCH, PrStatus, branch_owner_repo,
is_retriable_error, non_interactive_cmd, output_error_text, parse_json, retriable_pr_error,
};
fn tea_api(repo: &Repository, path: &str) -> Option<Output> {
let repo_root = repo.current_worktree().root().ok()?;
non_interactive_cmd("tea")
.args(["api", path])
.current_dir(&repo_root)
.run()
.ok()
}
fn fetch_combined_status(
repo: &Repository,
owner: &str,
repo_name: &str,
sha: &str,
) -> Option<CiStatus> {
let path = format!("repos/{owner}/{repo_name}/commits/{sha}/status");
let output = tea_api(repo, &path)?;
if !output.status.success() {
return is_retriable_error(&output_error_text(&output)).then_some(CiStatus::Error);
}
let combined: GiteaCombinedStatus = parse_json(&output.stdout, "tea api commit status", sha)?;
if combined.total_count == 0 {
return None;
}
parse_gitea_status_state(&combined.state)
}
pub(super) fn detect_gitea_pr(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
) -> Option<PrStatus> {
let primary_remote = repo.primary_remote().ok()?;
let primary_url = repo.effective_remote_url(&primary_remote)?;
let (query_owner, query_repo) = parse_owner_repo(&primary_url)?;
let branch_owner = branch_owner_repo(repo, branch).map(|(owner, _)| owner)?;
let path =
format!("repos/{query_owner}/{query_repo}/pulls?state=open&limit={MAX_PRS_TO_FETCH}");
let output = tea_api(repo, &path)?;
if !output.status.success() {
return retriable_pr_error(&output);
}
let prs: Vec<GiteaPr> = parse_json(&output.stdout, "tea api pulls", &branch.full_name)?;
let pr = prs.iter().find(|pr| {
pr.head.ref_name == branch.name
&& pr
.head
.repo
.as_ref()
.map(|r| r.owner.login.eq_ignore_ascii_case(&branch_owner))
.unwrap_or(true)
})?;
let base_status = fetch_combined_status(
repo,
&query_owner,
&query_repo,
pr.head.sha.as_deref().unwrap_or(local_head),
)
.unwrap_or(CiStatus::NoCI);
let ci_status = if pr.mergeable == Some(false) {
CiStatus::Conflicts
} else {
base_status
};
let is_stale = pr
.head
.sha
.as_deref()
.map(|sha| sha != local_head)
.unwrap_or(false);
Some(PrStatus {
ci_status,
source: CiSource::PullRequest,
is_stale,
url: Some(pr.html_url.clone()),
})
}
pub(super) fn detect_gitea_commit_status(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
) -> Option<PrStatus> {
let (owner, repo_name) = branch_owner_repo(repo, branch)?;
let ci_status = fetch_combined_status(repo, &owner, &repo_name, local_head)?;
Some(PrStatus {
ci_status,
source: CiSource::Branch,
is_stale: false,
url: None,
})
}
fn parse_gitea_status_state(state: &str) -> Option<CiStatus> {
match state {
"success" => Some(CiStatus::Passed),
"pending" => Some(CiStatus::Running),
"failure" | "error" | "warning" => Some(CiStatus::Failed),
_ => None,
}
}
#[derive(Debug, Deserialize)]
struct GiteaCombinedStatus {
#[serde(default)]
state: String,
#[serde(default)]
total_count: u32,
}
#[derive(Debug, Deserialize)]
struct GiteaPr {
#[serde(default)]
mergeable: Option<bool>,
html_url: String,
head: GiteaPrBranch,
}
#[derive(Debug, Deserialize)]
struct GiteaPrBranch {
#[serde(rename = "ref", default)]
ref_name: String,
#[serde(default)]
sha: Option<String>,
#[serde(default)]
repo: Option<GiteaPrRepo>,
}
#[derive(Debug, Deserialize)]
struct GiteaPrRepo {
owner: GiteaOwner,
}
#[derive(Debug, Deserialize)]
struct GiteaOwner {
login: String,
}
#[cfg(test)]
mod tests {
use super::*;
use worktrunk::testing::TestRepo;
#[test]
fn test_parse_gitea_status_state() {
assert_eq!(parse_gitea_status_state("success"), Some(CiStatus::Passed));
assert_eq!(parse_gitea_status_state("pending"), Some(CiStatus::Running));
assert_eq!(parse_gitea_status_state("failure"), Some(CiStatus::Failed));
assert_eq!(parse_gitea_status_state("error"), Some(CiStatus::Failed));
assert_eq!(parse_gitea_status_state("warning"), Some(CiStatus::Failed));
assert_eq!(parse_gitea_status_state(""), None);
assert_eq!(parse_gitea_status_state("bogus"), None);
}
#[test]
fn test_branch_owner_repo_local_uses_primary_remote() {
let test = TestRepo::with_initial_commit();
test.run_git(&[
"remote",
"add",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let repo = Repository::at(test.root_path()).unwrap();
let branch = CiBranchName {
full_name: "ghost-local".to_string(),
remote: None,
name: "ghost-local".to_string(),
};
assert_eq!(
branch_owner_repo(&repo, &branch),
Some(("owner".to_string(), "test-repo".to_string()))
);
}
#[test]
fn test_branch_owner_repo_returns_none_when_remote_missing() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let branch = CiBranchName {
full_name: "ghost/feature".to_string(),
remote: Some("ghost".to_string()),
name: "feature".to_string(),
};
assert_eq!(branch_owner_repo(&repo, &branch), None);
}
#[test]
fn test_branch_owner_repo_remote_uses_branch_remote() {
let test = TestRepo::with_initial_commit();
test.run_git(&[
"remote",
"add",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
test.run_git(&[
"remote",
"add",
"fork",
"https://gitea.example.com/forkowner/test-repo.git",
]);
let repo = Repository::at(test.root_path()).unwrap();
let branch = CiBranchName {
full_name: "fork/feature".to_string(),
remote: Some("fork".to_string()),
name: "feature".to_string(),
};
assert_eq!(
branch_owner_repo(&repo, &branch),
Some(("forkowner".to_string(), "test-repo".to_string()))
);
}
#[test]
fn test_branch_owner_repo_resolves_non_canonical_gitea_host() {
let test = TestRepo::with_initial_commit();
test.run_git(&[
"remote",
"add",
"origin",
"https://codeberg.org/owner/test-repo.git",
]);
let repo = Repository::at(test.root_path()).unwrap();
let branch = CiBranchName {
full_name: "ghost-local".to_string(),
remote: None,
name: "ghost-local".to_string(),
};
assert_eq!(
branch_owner_repo(&repo, &branch),
Some(("owner".to_string(), "test-repo".to_string()))
);
}
}