use processkit::{Error, Result};
use serde::Deserialize;
use serde::de::DeserializeOwned;
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)]
pub head_ref_name: String,
#[serde(rename = "baseRefName", default)]
pub base_ref_name: String,
#[serde(default)]
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)]
pub body: String,
#[serde(default)]
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct WorkflowRun {
#[serde(rename = "databaseId")]
pub database_id: u64,
#[serde(default)]
pub name: String,
#[serde(rename = "displayTitle", default)]
pub display_title: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub conclusion: String,
#[serde(rename = "workflowName", default)]
pub workflow_name: String,
#[serde(rename = "headBranch", default)]
pub head_branch: String,
#[serde(default)]
pub event: String,
#[serde(default)]
pub url: String,
#[serde(rename = "createdAt", default)]
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct CheckRun {
pub name: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub bucket: String,
#[serde(default)]
pub workflow: String,
#[serde(default)]
pub link: String,
#[serde(rename = "startedAt", default)]
pub started_at: String,
#[serde(rename = "completedAt", default)]
pub completed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct Release {
#[serde(rename = "tagName")]
pub tag_name: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub url: String,
#[serde(rename = "publishedAt", default)]
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 Repo {
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 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(),
})
}
pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
let raw: RepoJson = from_json(json)?;
Ok(Repo {
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)]
state: String,
#[serde(default)]
body: String,
#[serde(rename = "submittedAt", default)]
submitted_at: String,
}
#[derive(Deserialize)]
struct CommentJson {
#[serde(default)]
author: Option<AuthorJson>,
#[serde(default)]
body: String,
#[serde(default)]
url: String,
#[serde(rename = "createdAt", default)]
created_at: String,
}
#[derive(Deserialize)]
struct AuthorJson {
#[serde(default)]
login: String,
}
pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
let raw: FeedbackJson = from_json(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::*;
#[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> = from_json(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> = from_json(json).expect("parse issues");
assert_eq!(issues[0].number, 3);
}
#[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 from_json::<Vec<Issue>>("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> = from_json(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> = from_json(json).expect("parse checks");
let buckets: Vec<&str> = checks.iter().map(|c| c.bucket.as_str()).collect();
assert_eq!(buckets, ["pass", "fail", "pending", "skipping", "cancel"]);
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> = from_json(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 = from_json(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> = from_json(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 = from_json(view).expect("parse view");
assert_eq!(issue.body, "Write them.");
assert_eq!(issue.url, "https://gh/issues/3");
}
}