use processkit::Result;
use serde::Deserialize;
use crate::BINARY;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub state: String,
#[serde(
rename = "headRefName",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub head_ref_name: String,
#[serde(
rename = "baseRefName",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub base_ref_name: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Issue {
pub number: u64,
pub title: String,
pub state: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub body: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct WorkflowRun {
#[serde(rename = "databaseId")]
pub database_id: u64,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub name: String,
#[serde(
rename = "displayTitle",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub display_title: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub status: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub conclusion: String,
#[serde(
rename = "workflowName",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub workflow_name: String,
#[serde(
rename = "headBranch",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub head_branch: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub event: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub url: String,
#[serde(
rename = "createdAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub created_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CheckBucket {
Pass,
Fail,
Pending,
Skipping,
Cancel,
#[default]
#[serde(other)]
Unknown,
}
impl CheckBucket {
pub fn is_failing(self) -> bool {
matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
}
pub fn is_pending(self) -> bool {
matches!(self, CheckBucket::Pending)
}
pub fn is_passing(self) -> bool {
matches!(self, CheckBucket::Pass)
}
pub fn is_unknown(self) -> bool {
matches!(self, CheckBucket::Unknown)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct CheckRun {
pub name: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub state: String,
#[serde(default)]
pub bucket: CheckBucket,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub workflow: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub link: String,
#[serde(
rename = "startedAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub started_at: String,
#[serde(
rename = "completedAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub completed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Release {
#[serde(rename = "tagName")]
pub tag_name: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub name: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub body: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
pub url: String,
#[serde(
rename = "publishedAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
pub published_at: String,
#[serde(rename = "isDraft", default)]
pub is_draft: bool,
#[serde(rename = "isPrerelease", default)]
pub is_prerelease: bool,
#[serde(rename = "isLatest", default)]
pub is_latest: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Review {
pub author: String,
pub state: String,
pub body: String,
pub submitted_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Comment {
pub author: String,
pub body: String,
pub url: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrFeedback {
pub reviews: Vec<Review>,
pub comments: Vec<Comment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RepoView {
pub name: String,
pub owner: String,
pub description: Option<String>,
pub url: String,
pub is_private: bool,
pub default_branch: String,
}
#[derive(Deserialize)]
struct RepoJson {
name: String,
owner: OwnerJson,
#[serde(default)]
description: Option<String>,
url: String,
#[serde(rename = "isPrivate")]
is_private: bool,
#[serde(rename = "defaultBranchRef", default)]
default_branch_ref: Option<BranchRefJson>,
}
#[derive(Deserialize)]
struct OwnerJson {
login: String,
}
#[derive(Deserialize)]
struct BranchRefJson {
name: String,
}
pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
Ok(RepoView {
name: raw.name,
owner: raw.owner.login,
description: raw.description,
url: raw.url,
is_private: raw.is_private,
default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
})
}
#[derive(Deserialize)]
struct FeedbackJson {
#[serde(default)]
reviews: Vec<ReviewJson>,
#[serde(default)]
comments: Vec<CommentJson>,
}
#[derive(Deserialize)]
struct ReviewJson {
#[serde(default)]
author: Option<AuthorJson>,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
state: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
body: String,
#[serde(
rename = "submittedAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
submitted_at: String,
}
#[derive(Deserialize)]
struct CommentJson {
#[serde(default)]
author: Option<AuthorJson>,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
body: String,
#[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
url: String,
#[serde(
rename = "createdAt",
default,
deserialize_with = "vcs_cli_support::json::null_to_empty"
)]
created_at: String,
}
#[derive(Deserialize)]
struct AuthorJson {
#[serde(default)]
login: String,
}
pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
Ok(PrFeedback {
reviews: raw
.reviews
.into_iter()
.map(|r| Review {
author: r.author.map(|a| a.login).unwrap_or_default(),
state: r.state,
body: r.body,
submitted_at: r.submitted_at,
})
.collect(),
comments: raw
.comments
.into_iter()
.map(|c| Comment {
author: c.author.map(|a| a.login).unwrap_or_default(),
body: c.body,
url: c.url,
created_at: c.created_at,
})
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::Error;
#[test]
fn parses_pr_list() {
let json = r#"[
{"number": 12, "title": "Add feature", "state": "OPEN",
"headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
]"#;
let prs: Vec<PullRequest> =
vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
assert_eq!(prs.len(), 1);
assert_eq!(
prs[0],
PullRequest {
number: 12,
title: "Add feature".into(),
state: "OPEN".into(),
head_ref_name: "feat/x".into(),
base_ref_name: "main".into(),
url: "https://gh/pr/12".into(),
}
);
}
#[test]
fn parses_issue_list() {
let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
let issues: Vec<Issue> =
vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
assert_eq!(issues[0].number, 3);
}
#[test]
fn null_optional_fields_parse_to_empty() {
let pr: PullRequest = vcs_cli_support::json::from_json(
BINARY,
r#"{"number": 1, "title": "t", "state": "CLOSED",
"headRefName": null, "baseRefName": null, "url": null}"#,
)
.expect("PR with null head/base/url (deleted-branch PR)");
assert_eq!(pr.head_ref_name, "");
assert_eq!(pr.base_ref_name, "");
assert_eq!(pr.url, "");
let issue: Issue = vcs_cli_support::json::from_json(
BINARY,
r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
)
.expect("issue with null body/url");
assert_eq!(issue.body, "");
assert_eq!(issue.url, "");
let release: Release = vcs_cli_support::json::from_json(
BINARY,
r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
)
.expect("release with null name/body/url/publishedAt");
assert_eq!(release.name, "");
assert_eq!(release.body, "");
}
#[test]
fn parses_repo_flattening_nested_objects() {
let json = r#"{
"name": "vcs-toolkit-rs",
"owner": {"login": "ZelAnton"},
"description": null,
"url": "https://gh/repo",
"isPrivate": false,
"defaultBranchRef": {"name": "main"}
}"#;
let repo = parse_repo(json).expect("parse repo");
assert_eq!(repo.name, "vcs-toolkit-rs");
assert_eq!(repo.owner, "ZelAnton");
assert_eq!(repo.description, None);
assert_eq!(repo.default_branch, "main");
assert!(!repo.is_private);
}
#[test]
fn empty_repo_has_blank_default_branch() {
let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
let repo = parse_repo(json).expect("parse repo");
assert_eq!(repo.default_branch, "");
assert!(repo.is_private);
}
#[test]
fn malformed_json_is_a_parse_error() {
match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json").unwrap_err() {
Error::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn parses_run_list_with_blank_in_progress_conclusion() {
let json = r#"[
{"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
"status": "in_progress", "conclusion": "", "workflowName": "CI",
"headBranch": "main", "event": "push",
"url": "https://gh/runs/27023111945",
"createdAt": "2026-06-05T10:00:00Z"}
]"#;
let runs: Vec<WorkflowRun> =
vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
assert_eq!(runs[0].database_id, 27023111945);
assert_eq!(runs[0].status, "in_progress");
assert_eq!(runs[0].conclusion, "");
assert_eq!(runs[0].workflow_name, "CI");
}
#[test]
fn parses_check_runs_across_buckets() {
let json = r#"[
{"name": "build", "state": "SUCCESS", "bucket": "pass",
"workflow": "CI", "link": "https://gh/c/1",
"startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
{"name": "lint", "state": "FAILURE", "bucket": "fail",
"workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
{"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
"workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
{"name": "docs", "state": "SKIPPED", "bucket": "skipping",
"workflow": "", "link": "", "startedAt": "", "completedAt": ""},
{"name": "bench", "state": "CANCELLED", "bucket": "cancel",
"workflow": "", "link": "", "startedAt": "", "completedAt": ""}
]"#;
let checks: Vec<CheckRun> =
vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
assert_eq!(
buckets,
[
CheckBucket::Pass,
CheckBucket::Fail,
CheckBucket::Pending,
CheckBucket::Skipping,
CheckBucket::Cancel,
]
);
let exotic: CheckRun =
serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
assert_eq!(exotic.bucket, CheckBucket::Unknown);
assert_eq!(checks[0].name, "build");
}
#[test]
fn parses_release_list_and_view_shapes() {
let list = r#"[
{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
"isLatest": true, "isDraft": false, "isPrerelease": false,
"publishedAt": "2026-06-04T12:00:00Z"}
]"#;
let releases: Vec<Release> =
vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
assert!(releases[0].is_latest);
assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
assert_eq!(releases[0].body, "", "list doesn't fetch the body");
let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
"body": "Added\n- stuff", "url": "https://gh/releases/1",
"publishedAt": "2026-06-04T12:00:00Z",
"isDraft": false, "isPrerelease": false}"#;
let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
assert!(!release.is_latest, "view has no isLatest → default false");
assert_eq!(release.body, "Added\n- stuff");
assert_eq!(release.url, "https://gh/releases/1");
}
#[test]
fn parses_feedback_flattening_nested_authors() {
let json = r#"{
"reviews": [
{"author": {"login": "steiza"}, "state": "APPROVED",
"body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
{"author": null, "state": "COMMENTED", "body": "ghost",
"submittedAt": ""}
],
"comments": [
{"author": {"login": "andyfeller"}, "body": "nice",
"url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
]
}"#;
let feedback = parse_feedback(json).expect("parse feedback");
assert_eq!(feedback.reviews.len(), 2);
assert_eq!(feedback.reviews[0].author, "steiza");
assert_eq!(feedback.reviews[0].state, "APPROVED");
assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
assert_eq!(feedback.comments[0].author, "andyfeller");
assert_eq!(feedback.comments[0].url, "https://gh/c/9");
}
#[test]
fn issue_parses_with_and_without_view_fields() {
let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
let issues: Vec<Issue> =
vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
assert_eq!(issues[0].body, "");
assert_eq!(issues[0].url, "");
let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
"body": "Write them.", "url": "https://gh/issues/3"}"#;
let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
assert_eq!(issue.body, "Write them.");
assert_eq!(issue.url, "https://gh/issues/3");
}
}