use serde::Deserialize;
use std::path::Path;
use worktrunk::git::Repository;
use super::{
CiBranchName, CiSource, CiStatus, MAX_PRS_TO_FETCH, PrStatus, is_retriable_error,
non_interactive_cmd, parse_json,
};
fn gitlab_project_id(repo: &Repository) -> Option<u64> {
let repo_root = repo.current_worktree().root().ok()?;
let output = non_interactive_cmd("glab")
.args(["repo", "view", "--output", "json"])
.current_dir(&repo_root)
.env("PAGER", "cat")
.run()
.ok()?;
if !output.status.success() {
return None;
}
#[derive(Deserialize)]
struct RepoInfo {
id: u64,
}
serde_json::from_slice::<RepoInfo>(&output.stdout)
.ok()
.map(|info| info.id)
}
pub(super) fn detect_gitlab(
repo: &Repository,
branch: &CiBranchName,
local_head: &str,
) -> Option<PrStatus> {
let repo_root = repo.current_worktree().root().ok()?;
let project_id = gitlab_project_id(repo);
if project_id.is_none() {
log::debug!("Could not determine GitLab project ID");
}
let output = match non_interactive_cmd("glab")
.args([
"mr",
"list",
"--source-branch",
&branch.name, &format!("--per-page={}", MAX_PRS_TO_FETCH),
"--output",
"json",
])
.current_dir(&repo_root)
.run()
{
Ok(output) => output,
Err(e) => {
log::warn!(
"glab mr 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 mr_list: Vec<GitLabMrListEntry> =
parse_json(&output.stdout, "glab mr list", &branch.full_name)?;
let mr_entry = if let Some(proj_id) = project_id {
let matched = mr_list
.iter()
.find(|mr| mr.source_project_id == Some(proj_id));
if matched.is_none() && !mr_list.is_empty() {
log::debug!(
"Found {} MRs for branch {} but none from project ID {}",
mr_list.len(),
branch.full_name,
proj_id
);
}
matched
} else if mr_list.len() == 1 {
mr_list.first()
} else if mr_list.is_empty() {
None
} else {
log::debug!(
"Found {} MRs for branch {} but no project ID to filter - skipping to avoid ambiguity",
mr_list.len(),
branch.full_name
);
None
}?;
let mr_info = fetch_mr_details(mr_entry.iid, &repo_root);
let ci_status = if mr_entry.has_conflicts
|| mr_entry.detailed_merge_status.as_deref() == Some("conflict")
{
CiStatus::Conflicts
} else if mr_entry.detailed_merge_status.as_deref() == Some("ci_still_running") {
CiStatus::Running
} else if let Some(ref info) = mr_info {
info.ci_status()
} else {
log::debug!("Could not fetch MR details for !{}", mr_entry.iid);
return Some(PrStatus::error());
};
let is_stale = mr_entry.sha != local_head;
Some(PrStatus {
ci_status,
source: CiSource::PullRequest,
is_stale,
url: mr_entry.web_url.clone(),
})
}
pub(super) fn detect_gitlab_pipeline(
repo: &Repository,
branch: &str,
local_head: &str,
) -> Option<PrStatus> {
let repo_root = repo.current_worktree().root().ok()?;
let output = match non_interactive_cmd("glab")
.args([
"ci",
"list",
"--ref",
branch,
"--per-page",
"1",
"--output",
"json",
])
.current_dir(&repo_root)
.run()
{
Ok(output) => output,
Err(e) => {
log::warn!(
"glab ci list failed to execute for branch {}: {}",
branch,
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 pipelines: Vec<GitLabPipeline> = parse_json(&output.stdout, "glab ci list", branch)?;
let pipeline = pipelines.first()?;
let is_stale = pipeline
.sha
.as_ref()
.map(|pipeline_sha| pipeline_sha != local_head)
.unwrap_or(true);
let ci_status = pipeline.ci_status();
Some(PrStatus {
ci_status,
source: CiSource::Branch,
is_stale,
url: pipeline.web_url.clone(),
})
}
#[derive(Debug, Deserialize)]
struct GitLabMrListEntry {
pub iid: u64,
pub sha: String,
pub has_conflicts: bool,
pub detailed_merge_status: Option<String>,
pub source_project_id: Option<u64>,
pub web_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct GitLabMrInfo {
pub head_pipeline: Option<GitLabPipeline>,
pub pipeline: Option<GitLabPipeline>,
}
impl GitLabMrInfo {
pub fn ci_status(&self) -> CiStatus {
self.head_pipeline
.as_ref()
.or(self.pipeline.as_ref())
.map(GitLabPipeline::ci_status)
.unwrap_or(CiStatus::NoCI)
}
}
fn fetch_mr_details(iid: u64, repo_root: &Path) -> Option<GitLabMrInfo> {
let output = non_interactive_cmd("glab")
.args(["mr", "view", &iid.to_string(), "--output", "json"])
.current_dir(repo_root)
.run()
.ok()?;
if !output.status.success() {
log::debug!("glab mr view {} failed", iid);
return None;
}
parse_json(&output.stdout, "glab mr view", &iid.to_string())
}
#[derive(Debug, Deserialize)]
pub(super) struct GitLabPipeline {
pub status: Option<String>,
#[serde(default)]
pub sha: Option<String>,
#[serde(default)]
pub web_url: Option<String>,
}
fn parse_gitlab_status(status: Option<&str>) -> CiStatus {
match status {
Some(
"running"
| "pending"
| "preparing"
| "waiting_for_resource"
| "created"
| "scheduled"
| "manual",
) => CiStatus::Running,
Some("failed" | "canceled") => CiStatus::Failed,
Some("success") => CiStatus::Passed,
Some("skipped") | None => CiStatus::NoCI,
_ => CiStatus::NoCI,
}
}
impl GitLabPipeline {
pub fn ci_status(&self) -> CiStatus {
parse_gitlab_status(self.status.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_gitlab_status() {
for status in [
"running",
"pending",
"preparing",
"waiting_for_resource",
"created",
"scheduled",
"manual",
] {
assert_eq!(
parse_gitlab_status(Some(status)),
CiStatus::Running,
"status={status}"
);
}
for status in ["failed", "canceled"] {
assert_eq!(
parse_gitlab_status(Some(status)),
CiStatus::Failed,
"status={status}"
);
}
assert_eq!(parse_gitlab_status(Some("success")), CiStatus::Passed);
assert_eq!(parse_gitlab_status(Some("skipped")), CiStatus::NoCI);
assert_eq!(parse_gitlab_status(None), CiStatus::NoCI);
assert_eq!(parse_gitlab_status(Some("unknown")), CiStatus::NoCI);
}
#[test]
fn test_gitlab_mr_info_ci_status() {
let mr = GitLabMrInfo {
head_pipeline: None,
pipeline: None,
};
assert_eq!(mr.ci_status(), CiStatus::NoCI);
let mr = GitLabMrInfo {
head_pipeline: Some(GitLabPipeline {
status: Some("success".into()),
sha: None,
web_url: None,
}),
pipeline: Some(GitLabPipeline {
status: Some("failed".into()),
sha: None,
web_url: None,
}),
};
assert_eq!(mr.ci_status(), CiStatus::Passed);
let mr = GitLabMrInfo {
head_pipeline: None,
pipeline: Some(GitLabPipeline {
status: Some("running".into()),
sha: None,
web_url: None,
}),
};
assert_eq!(mr.ci_status(), CiStatus::Running);
}
}