use chrono::{DateTime, Utc};
use serde::Deserialize;
use crate::error::{Result, TuicrError};
use crate::forge::github::review_threads::GhPageInfo;
use crate::forge::remote_comments::{RemoteReviewState, RemoteReviewSummary};
#[derive(Debug, Deserialize)]
struct GhAuthor {
#[serde(default)]
login: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhReview {
id: String,
#[serde(default)]
state: Option<String>,
#[serde(default)]
body: String,
#[serde(default)]
author: Option<GhAuthor>,
#[serde(default)]
submitted_at: Option<DateTime<Utc>>,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhReviewsConn {
#[serde(default)]
page_info: Option<GhPageInfo>,
#[serde(default)]
nodes: Vec<GhReview>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhPullRequest {
#[serde(default)]
reviews: Option<GhReviewsConn>,
}
#[derive(Debug, Deserialize)]
struct GhRepository {
#[serde(default, rename = "pullRequest")]
pull_request: Option<GhPullRequest>,
}
#[derive(Debug, Deserialize)]
struct GhData {
#[serde(default)]
repository: Option<GhRepository>,
}
#[derive(Debug, Deserialize)]
struct GhResponse {
#[serde(default)]
data: Option<GhData>,
}
#[derive(Debug)]
pub(crate) struct ParsedReviewsPage {
pub summaries: Vec<RemoteReviewSummary>,
pub page_info: Option<GhPageInfo>,
}
pub(crate) fn parse_graphql_page(json: &str) -> Result<ParsedReviewsPage> {
let response: GhResponse = serde_json::from_str(json).map_err(|e| {
TuicrError::Forge(format!(
"Failed to parse GitHub review summaries response: {e}"
))
})?;
let conn = response
.data
.and_then(|d| d.repository)
.and_then(|r| r.pull_request)
.and_then(|p| p.reviews);
let Some(conn) = conn else {
return Ok(ParsedReviewsPage {
summaries: Vec::new(),
page_info: None,
});
};
let page_info = conn.page_info;
let mut summaries = Vec::with_capacity(conn.nodes.len());
for raw in conn.nodes {
if raw.body.trim().is_empty() {
continue;
}
summaries.push(RemoteReviewSummary {
id: raw.id,
author: raw.author.and_then(|a| a.login),
body: raw.body,
state: raw
.state
.as_deref()
.map(RemoteReviewState::parse)
.unwrap_or(RemoteReviewState::Commented),
created_at: raw.submitted_at,
url: raw.url.unwrap_or_default(),
});
}
Ok(ParsedReviewsPage {
summaries,
page_info,
})
}
pub(crate) fn build_query(after_cursor: Option<&str>) -> String {
let cursor_arg = match after_cursor {
Some(_) => ", after: $after",
None => "",
};
format!(
r#"query($owner: String!, $name: String!, $number: Int!{cursor_param}) {{
repository(owner: $owner, name: $name) {{
pullRequest(number: $number) {{
reviews(first: 100{cursor_arg}) {{
pageInfo {{ hasNextPage endCursor }}
nodes {{
id
state
body
author {{ login }}
submittedAt
url
}}
}}
}}
}}
}}"#,
cursor_param = if after_cursor.is_some() {
", $after: String!"
} else {
""
},
cursor_arg = cursor_arg,
)
}
#[cfg(test)]
mod tests {
use super::*;
const TWO_REVIEWS_JSON: &str = r##"{
"data": {
"repository": {
"pullRequest": {
"reviews": {
"pageInfo": { "hasNextPage": false, "endCursor": null },
"nodes": [
{
"id": "PRR_1",
"state": "COMMENTED",
"body": "Overall this looks tight.",
"author": { "login": "alice" },
"submittedAt": "2026-05-12T18:30:00Z",
"url": "https://example.com/r/1"
},
{
"id": "PRR_2",
"state": "APPROVED",
"body": "LGTM",
"author": { "login": "bob" },
"submittedAt": "2026-05-13T09:00:00Z",
"url": "https://example.com/r/2"
}
]
}
}
}
}
}"##;
const EMPTY_BODY_JSON: &str = r##"{
"data": {
"repository": {
"pullRequest": {
"reviews": {
"nodes": [
{
"id": "PRR_empty",
"state": "APPROVED",
"body": "",
"author": { "login": "ci-bot" },
"url": "https://example.com/r/empty"
},
{
"id": "PRR_whitespace",
"state": "COMMENTED",
"body": " \n\t",
"author": { "login": "ci-bot" },
"url": "https://example.com/r/ws"
},
{
"id": "PRR_real",
"state": "COMMENTED",
"body": "Real feedback.",
"author": { "login": "alice" },
"url": "https://example.com/r/real"
}
]
}
}
}
}
}"##;
const NULL_REPO_JSON: &str = r##"{ "data": { "repository": null } }"##;
#[test]
fn should_parse_two_reviews_with_state_and_author() {
let parsed = parse_graphql_page(TWO_REVIEWS_JSON).unwrap();
assert_eq!(parsed.summaries.len(), 2);
assert_eq!(parsed.summaries[0].id, "PRR_1");
assert_eq!(parsed.summaries[0].author.as_deref(), Some("alice"));
assert_eq!(parsed.summaries[0].state, RemoteReviewState::Commented);
assert_eq!(parsed.summaries[1].state, RemoteReviewState::Approved);
}
#[test]
fn should_drop_reviews_with_empty_or_whitespace_body() {
let parsed = parse_graphql_page(EMPTY_BODY_JSON).unwrap();
assert_eq!(parsed.summaries.len(), 1);
assert_eq!(parsed.summaries[0].id, "PRR_real");
}
#[test]
fn should_tolerate_missing_repository_object() {
let parsed = parse_graphql_page(NULL_REPO_JSON).unwrap();
assert!(parsed.summaries.is_empty());
assert!(parsed.page_info.is_none());
}
#[test]
fn should_build_query_without_cursor_for_first_page() {
let q = build_query(None);
assert!(q.contains("reviews(first: 100)"));
assert!(!q.contains("after: $after"));
}
#[test]
fn should_build_query_with_cursor_for_subsequent_pages() {
let q = build_query(Some("CURSOR"));
assert!(q.contains("$after: String!"));
assert!(q.contains("after: $after"));
}
}