use processkit::{Error, Result};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::BINARY;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub state: String,
pub merged: bool,
pub head_branch: String,
pub base_branch: String,
pub url: String,
}
#[derive(Deserialize)]
struct PrJson {
index: String,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
#[serde(default)]
head: String,
#[serde(default)]
base: String,
#[serde(default)]
url: String,
}
impl TryFrom<PrJson> for PullRequest {
type Error = Error;
fn try_from(raw: PrJson) -> Result<Self> {
Ok(PullRequest {
number: parse_index(&raw.index)?,
title: raw.title,
merged: raw.state.eq_ignore_ascii_case("merged"),
state: raw.state,
head_branch: raw.head,
base_branch: raw.base,
url: raw.url,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Issue {
pub number: u64,
pub title: String,
pub state: String,
pub body: String,
pub url: String,
}
#[derive(Deserialize)]
struct IssueListJson {
index: String,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
#[serde(default)]
body: String,
#[serde(default)]
url: String,
}
impl TryFrom<IssueListJson> for Issue {
type Error = Error;
fn try_from(raw: IssueListJson) -> Result<Self> {
Ok(Issue {
number: parse_index(&raw.index)?,
title: raw.title,
state: raw.state,
body: raw.body,
url: raw.url,
})
}
}
#[derive(Deserialize)]
struct IssueDetailJson {
index: u64,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
#[serde(default)]
body: String,
#[serde(default)]
url: String,
}
impl From<IssueDetailJson> for Issue {
fn from(raw: IssueDetailJson) -> Self {
Issue {
number: raw.index,
title: raw.title,
state: raw.state,
body: raw.body,
url: raw.url,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Release {
pub tag: String,
pub title: String,
pub published_at: String,
pub draft: bool,
pub prerelease: bool,
pub url: String,
}
#[derive(Deserialize)]
struct ReleaseJson {
#[serde(rename = "tag-_name")]
tag_name: String,
#[serde(default)]
title: String,
#[serde(rename = "published _at", default)]
published_at: String,
#[serde(default)]
status: String,
}
impl From<ReleaseJson> for Release {
fn from(raw: ReleaseJson) -> Self {
Release {
tag: raw.tag_name,
title: raw.title,
published_at: raw.published_at,
draft: raw.status.eq_ignore_ascii_case("draft"),
prerelease: raw.status.eq_ignore_ascii_case("prerelease"),
url: String::new(),
}
}
}
fn parse_index(value: &str) -> Result<u64> {
value.trim().parse().map_err(|_| Error::Parse {
program: BINARY.to_string(),
message: format!("expected a numeric index, got {value:?}"),
})
}
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_pr_list(json: &str) -> Result<Vec<PullRequest>> {
let raw: Vec<PrJson> = from_json(json)?;
raw.into_iter().map(PullRequest::try_from).collect()
}
pub(crate) fn parse_issue_list(json: &str) -> Result<Vec<Issue>> {
let raw: Vec<IssueListJson> = from_json(json)?;
raw.into_iter().map(Issue::try_from).collect()
}
pub(crate) fn parse_issue(json: &str) -> Result<Issue> {
let raw: IssueDetailJson = from_json(json)?;
Ok(Issue::from(raw))
}
pub(crate) fn parse_release_list(json: &str) -> Result<Vec<Release>> {
let raw: Vec<ReleaseJson> = from_json(json)?;
Ok(raw.into_iter().map(Release::from).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_pr_list_table_row() {
let json = r#"[
{"index": "7", "title": "Add X", "state": "open",
"head": "feat/x", "base": "main", "url": "https://gitea/pr/7"}
]"#;
let prs = parse_pr_list(json).expect("parse prs");
assert_eq!(prs.len(), 1);
assert_eq!(
prs[0],
PullRequest {
number: 7,
title: "Add X".into(),
state: "open".into(),
merged: false,
head_branch: "feat/x".into(),
base_branch: "main".into(),
url: "https://gitea/pr/7".into(),
}
);
}
#[test]
fn pr_state_merged_derives_the_flag() {
let json = r#"[{"index": "9", "title": "done", "state": "merged",
"head": "f", "base": "main", "url": "u"}]"#;
let prs = parse_pr_list(json).expect("parse prs");
assert_eq!(prs[0].number, 9);
assert!(prs[0].merged);
assert_eq!(prs[0].state, "merged");
}
#[test]
fn pr_non_numeric_index_is_a_parse_error() {
match parse_pr_list(r#"[{"index": "x", "title": "t", "state": "open"}]"#).unwrap_err() {
Error::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn malformed_json_is_a_parse_error() {
match parse_pr_list("not json").unwrap_err() {
Error::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn parses_issue_list_table_row() {
let json = r#"[
{"index": "12", "title": "Bug", "state": "open", "body": "broken",
"url": "https://gitea/issues/12"}
]"#;
let issues = parse_issue_list(json).expect("parse issues");
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0],
Issue {
number: 12,
title: "Bug".into(),
state: "open".into(),
body: "broken".into(),
url: "https://gitea/issues/12".into(),
}
);
}
#[test]
fn issue_list_tolerates_trimmed_columns() {
let json = r#"[{"index": "4", "title": "wip", "state": "open"}]"#;
let issues = parse_issue_list(json).expect("parse issues");
assert_eq!(issues[0].number, 4);
assert_eq!(issues[0].body, "");
assert_eq!(issues[0].url, "");
}
#[test]
fn parses_single_issue_detail_object() {
let json = r#"{"index": 7, "title": "One", "state": "closed", "body": "b",
"url": "https://gitea/issues/7"}"#;
let issue = parse_issue(json).expect("parse issue");
assert_eq!(issue.number, 7);
assert_eq!(issue.title, "One");
assert_eq!(issue.state, "closed");
assert_eq!(issue.url, "https://gitea/issues/7");
}
#[test]
fn parses_release_list_table_row() {
let json = r#"[
{"tag-_name": "0.1", "title": "First", "status": "released",
"published _at": "2023-07-26T13:02:36Z",
"tar/_zip url": "https://gitea/0.1.tar.gz\nhttps://gitea/0.1.zip"}
]"#;
let releases = parse_release_list(json).expect("parse releases");
assert_eq!(releases.len(), 1);
assert_eq!(
releases[0],
Release {
tag: "0.1".into(),
title: "First".into(),
published_at: "2023-07-26T13:02:36Z".into(),
draft: false,
prerelease: false,
url: String::new(), }
);
}
#[test]
fn release_status_drives_draft_flag() {
let json = r#"[{"tag-_name": "v2", "title": "Two", "status": "draft",
"published _at": ""}]"#;
let releases = parse_release_list(json).expect("parse releases");
assert_eq!(releases[0].tag, "v2");
assert!(releases[0].draft);
assert_eq!(releases[0].published_at, "");
assert!(!releases[0].prerelease);
}
#[test]
fn release_status_drives_prerelease_flag() {
let json = r#"[{"tag-_name": "v3-rc1", "title": "RC", "status": "prerelease",
"published _at": "2026-01-02T03:04:05Z"}]"#;
let releases = parse_release_list(json).expect("parse releases");
assert!(releases[0].prerelease);
assert!(!releases[0].draft);
}
#[test]
fn release_missing_tag_is_a_parse_error() {
match parse_release_list(r#"[{"title": "no tag"}]"#).unwrap_err() {
Error::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn login_array_counts() {
let some: Vec<serde_json::Value> =
from_json(r#"[{"name":"gitea"}]"#).expect("parse logins");
assert!(!some.is_empty());
let none: Vec<serde_json::Value> = from_json("[]").expect("parse empty");
assert!(none.is_empty());
}
}