use processkit::{Error, Result};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::BINARY;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct MergeRequest {
pub iid: u64,
pub title: String,
pub state: String,
#[serde(default)]
pub source_branch: String,
#[serde(default)]
pub target_branch: String,
#[serde(default)]
pub web_url: String,
#[serde(default)]
pub draft: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Project {
pub name: String,
#[serde(default)]
pub path_with_namespace: String,
#[serde(default)]
pub default_branch: String,
#[serde(default)]
pub web_url: String,
#[serde(default)]
pub visibility: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Issue {
#[serde(rename = "iid")]
pub number: u64,
pub title: String,
pub state: String,
#[serde(rename = "description", default)]
pub body: String,
#[serde(rename = "web_url", default)]
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Release {
pub tag_name: String,
#[serde(default)]
pub name: String,
#[serde(rename = "_links", default, deserialize_with = "self_link")]
pub url: String,
#[serde(rename = "released_at", default)]
pub published_at: String,
}
fn self_link<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Links {
#[serde(rename = "self", default)]
self_url: String,
}
let links = Option::<Links>::deserialize(deserializer)?;
Ok(links.map(|l| l.self_url).unwrap_or_default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CiStatus {
Passing,
Failing,
Pending,
None,
}
impl CiStatus {
pub(crate) fn from_gitlab(status: &str) -> Self {
match status {
"success" => CiStatus::Passing,
"failed" | "canceled" | "cancelled" => CiStatus::Failing,
"skipped" | "" => CiStatus::None,
"running"
| "pending"
| "created"
| "preparing"
| "scheduled"
| "waiting_for_resource"
| "manual" => CiStatus::Pending,
_ => CiStatus::Pending,
}
}
}
pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
serde_json::from_str(json).map_err(|e| Error::Parse {
program: BINARY.to_string(),
message: e.to_string(),
})
}
#[derive(Deserialize)]
struct MrPipelineJson {
#[serde(default)]
head_pipeline: Option<PipelineJson>,
#[serde(default)]
pipeline: Option<PipelineJson>,
}
#[derive(Deserialize)]
struct PipelineJson {
#[serde(default)]
status: String,
}
pub(crate) fn parse_ci_status(json: &str) -> Result<CiStatus> {
let raw: MrPipelineJson = from_json(json)?;
let status = raw
.head_pipeline
.or(raw.pipeline)
.map(|p| p.status)
.unwrap_or_default();
Ok(CiStatus::from_gitlab(&status))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_mr_list() {
let json = r#"[
{"iid": 12, "title": "Add feature", "state": "opened",
"source_branch": "feat/x", "target_branch": "main",
"web_url": "https://gl/mr/12", "draft": false}
]"#;
let mrs: Vec<MergeRequest> = from_json(json).expect("parse mrs");
assert_eq!(mrs.len(), 1);
assert_eq!(
mrs[0],
MergeRequest {
iid: 12,
title: "Add feature".into(),
state: "opened".into(),
source_branch: "feat/x".into(),
target_branch: "main".into(),
web_url: "https://gl/mr/12".into(),
draft: false,
}
);
}
#[test]
fn mr_tolerates_missing_optional_fields() {
let json = r#"{"iid": 5, "title": "wip", "state": "opened", "draft": true}"#;
let mr: MergeRequest = from_json(json).expect("parse mr");
assert_eq!(mr.source_branch, "");
assert_eq!(mr.web_url, "");
assert!(mr.draft);
}
#[test]
fn parses_issue_list() {
let json = r#"[
{"iid": 1, "title": "Fix bug", "state": "opened",
"description": "the body", "web_url": "https://gl/i/1"}
]"#;
let issues: Vec<Issue> = from_json(json).expect("parse issues");
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0],
Issue {
number: 1,
title: "Fix bug".into(),
state: "opened".into(),
body: "the body".into(),
url: "https://gl/i/1".into(),
}
);
}
#[test]
fn issue_tolerates_missing_optional_fields() {
let json = r#"{"iid": 9, "title": "wip", "state": "closed"}"#;
let issue: Issue = from_json(json).expect("parse issue");
assert_eq!(issue.body, "");
assert_eq!(issue.url, "");
}
#[test]
fn parses_release_view() {
let json = r#"{
"tag_name": "v1.0", "name": "Release 1.0",
"released_at": "2026-01-02T03:04:05.000Z",
"_links": {"self": "https://gl/-/releases/v1.0"}
}"#;
let rel: Release = from_json(json).expect("parse release");
assert_eq!(
rel,
Release {
tag_name: "v1.0".into(),
name: "Release 1.0".into(),
url: "https://gl/-/releases/v1.0".into(),
published_at: "2026-01-02T03:04:05.000Z".into(),
}
);
}
#[test]
fn release_tolerates_missing_links_and_date() {
let json = r#"{"tag_name": "v2.0"}"#;
let rel: Release = from_json(json).expect("parse release");
assert_eq!(rel.name, "");
assert_eq!(rel.url, "");
assert_eq!(rel.published_at, "");
}
#[test]
fn parses_project_view() {
let json = r#"{
"name": "cli", "path_with_namespace": "gitlab-org/cli",
"default_branch": "main", "web_url": "https://gl/p",
"visibility": "public"
}"#;
let p: Project = from_json(json).expect("parse project");
assert_eq!(p.name, "cli");
assert_eq!(p.path_with_namespace, "gitlab-org/cli");
assert_eq!(p.default_branch, "main");
assert_eq!(p.visibility.as_deref(), Some("public"));
}
#[test]
fn project_tolerates_missing_visibility() {
let json = r#"{"name":"cli","path_with_namespace":"o/cli","default_branch":"main"}"#;
let p: Project = from_json(json).expect("parse project");
assert_eq!(p.visibility, None);
}
#[test]
fn malformed_json_is_a_parse_error() {
match from_json::<Vec<MergeRequest>>("not json").unwrap_err() {
Error::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn ci_status_buckets_pipeline_states() {
assert_eq!(CiStatus::from_gitlab("success"), CiStatus::Passing);
assert_eq!(CiStatus::from_gitlab("failed"), CiStatus::Failing);
assert_eq!(CiStatus::from_gitlab("canceled"), CiStatus::Failing);
assert_eq!(CiStatus::from_gitlab("running"), CiStatus::Pending);
assert_eq!(CiStatus::from_gitlab("manual"), CiStatus::Pending);
assert_eq!(CiStatus::from_gitlab("skipped"), CiStatus::None);
assert_eq!(CiStatus::from_gitlab(""), CiStatus::None);
assert_eq!(CiStatus::from_gitlab("brand_new"), CiStatus::Pending);
}
#[test]
fn parse_ci_status_reads_head_pipeline_then_falls_back() {
let json =
r#"{"iid":1,"head_pipeline":{"status":"success"},"pipeline":{"status":"failed"}}"#;
assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Passing);
let json = r#"{"iid":1,"pipeline":{"status":"failed"}}"#;
assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Failing);
let json = r#"{"iid":1}"#;
assert_eq!(parse_ci_status(json).unwrap(), CiStatus::None);
}
}