use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::client::{gh_api_graphql, gh_api_post, gh_command, FieldValue};
use super::pr::{Label, User};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueComment {
pub id: String,
pub body: String,
pub author: User,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "authorAssociation", default)]
pub author_association: String,
#[serde(default)]
pub url: String,
}
pub fn parse_issue_comments(raw: &[serde_json::Value]) -> Vec<IssueComment> {
raw.iter()
.filter_map(
|v| match serde_json::from_value::<IssueComment>(v.clone()) {
Ok(c) => Some(c),
Err(e) => {
tracing::warn!("Failed to parse issue comment: {}", e);
None
}
},
)
.collect()
}
pub async fn create_issue_comment(
repo: &str,
issue_number: u32,
body: &str,
) -> Result<IssueComment> {
let endpoint = format!("repos/{}/issues/{}/comments", repo, issue_number);
let json = gh_api_post(&endpoint, &[("body", FieldValue::String(body))]).await?;
parse_rest_issue_comment(&json)
}
fn parse_rest_issue_comment(json: &serde_json::Value) -> Result<IssueComment> {
let id = json
.get("id")
.and_then(|v| {
v.as_u64()
.map(|n| n.to_string())
.or_else(|| v.as_str().map(String::from))
})
.context("missing id")?;
let body = json
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let login = json
.pointer("/user/login")
.or_else(|| json.pointer("/author/login"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let created_at = json
.get("created_at")
.or_else(|| json.get("createdAt"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let author_association = json
.get("author_association")
.or_else(|| json.get("authorAssociation"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let url = json
.get("html_url")
.or_else(|| json.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(IssueComment {
id,
body,
author: User { login },
created_at,
author_association,
url,
})
}
pub fn build_reply_template(author_login: &str, body: &str) -> String {
let quote_lines: Vec<&str> = body.lines().take(3).collect();
let quote = quote_lines.join("\n> ");
let ellipsis = if body.lines().count() > 3 {
"\n> ..."
} else {
""
};
format!("> @{} wrote:\n> {}{}\n\n", author_login, quote, ellipsis)
}
define_state_filter!(IssueStateFilter);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueSummary {
pub number: u32,
pub title: String,
pub state: String,
pub author: User,
pub labels: Vec<Label>,
#[serde(rename = "updatedAt")]
pub updated_at: String,
#[serde(default)]
pub comments: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueDetail {
pub number: u32,
pub title: String,
pub body: Option<String>,
pub state: String,
pub author: User,
pub labels: Vec<Label>,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "updatedAt")]
pub updated_at: String,
#[serde(default)]
pub comments: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkedPr {
pub number: u32,
pub title: String,
pub state: String,
pub repo: Option<String>,
}
pub struct IssueListPage {
pub items: Vec<IssueSummary>,
pub has_more: bool,
}
pub async fn fetch_issue_list(
repo: &str,
state: IssueStateFilter,
limit: u32,
) -> Result<IssueListPage> {
fetch_issue_list_with_offset(repo, state, 0, limit).await
}
pub async fn fetch_issue_list_with_offset(
repo: &str,
state: IssueStateFilter,
offset: u32,
limit: u32,
) -> Result<IssueListPage> {
let fetch_count = offset + limit + 1;
let output = gh_command(&[
"issue",
"list",
"-R",
repo,
"-s",
state.as_gh_arg(),
"--json",
"number,title,state,author,labels,updatedAt,comments",
"--limit",
&fetch_count.to_string(),
])
.await?;
let all_items: Vec<IssueSummary> =
serde_json::from_str(&output).context("Failed to parse issue list response")?;
let has_more = all_items.len() > (offset + limit) as usize;
let items: Vec<IssueSummary> = all_items
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok(IssueListPage { items, has_more })
}
pub async fn fetch_issue_detail(repo: &str, issue_number: u32) -> Result<IssueDetail> {
let output = gh_command(&[
"issue",
"view",
&issue_number.to_string(),
"-R",
repo,
"--json",
"number,title,body,state,author,labels,createdAt,updatedAt,comments",
])
.await?;
serde_json::from_str(&output).context("Failed to parse issue detail response")
}
fn parse_linked_prs_from_graphql(data: &serde_json::Value, current_repo: &str) -> Vec<LinkedPr> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
let Some(nodes) = data
.pointer("/data/repository/issue/timelineItems/nodes")
.and_then(|v| v.as_array())
else {
return result;
};
for node in nodes {
let typename = node.get("__typename").and_then(|v| v.as_str());
let pr_data = match typename {
Some("CrossReferencedEvent") => node.pointer("/source"),
Some("ConnectedEvent") => node.pointer("/subject"),
_ => continue,
};
let Some(pr) = pr_data else { continue };
if pr.get("__typename").and_then(|v| v.as_str()) != Some("PullRequest") {
continue;
}
let Some(number) = pr.get("number").and_then(|v| v.as_u64()) else {
continue;
};
let number = number as u32;
let title = pr
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let state = pr
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("OPEN")
.to_string();
let repo = pr
.pointer("/repository/nameWithOwner")
.and_then(|v| v.as_str())
.and_then(|name| {
if name == current_repo {
None
} else {
Some(name.to_string())
}
});
let key = (repo.clone(), number);
if seen.insert(key) {
result.push(LinkedPr {
number,
title,
state,
repo,
});
}
}
result
}
const LINKED_PRS_QUERY: &str = r#"
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {
nodes {
__typename
... on ConnectedEvent {
subject {
__typename
... on PullRequest {
number
title
state
repository { nameWithOwner }
}
}
}
... on CrossReferencedEvent {
source {
__typename
... on PullRequest {
number
title
state
repository { nameWithOwner }
}
}
}
}
}
}
}
}
"#;
pub async fn fetch_linked_prs(repo: &str, issue_number: u32) -> Result<Vec<LinkedPr>> {
let Some((owner, name)) = repo.split_once('/') else {
anyhow::bail!("Invalid repo format: expected 'owner/repo', got '{}'", repo);
};
let data = gh_api_graphql(
LINKED_PRS_QUERY,
&[
("owner", FieldValue::String(owner)),
("name", FieldValue::String(name)),
("number", FieldValue::Raw(&issue_number.to_string())),
],
)
.await?;
Ok(parse_linked_prs_from_graphql(&data, repo))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_comment_deserialize() {
let json = r#"{"id":"IC_kwDOTest","body":"Hello world","author":{"login":"user1"},"createdAt":"2026-01-01T00:00:00Z","authorAssociation":"OWNER","url":"https://github.com/test/repo/issues/1#issuecomment-1"}"#;
let comment: IssueComment = serde_json::from_str(json).unwrap();
assert_eq!(comment.id, "IC_kwDOTest");
assert_eq!(comment.body, "Hello world");
assert_eq!(comment.author.login, "user1");
assert_eq!(comment.created_at, "2026-01-01T00:00:00Z");
assert_eq!(comment.author_association, "OWNER");
assert_eq!(
comment.url,
"https://github.com/test/repo/issues/1#issuecomment-1"
);
}
#[test]
fn test_parse_issue_comments_from_gh_cli_output() {
let raw = vec![
serde_json::json!({
"id": "IC_1",
"body": "First comment",
"author": {"login": "user1"},
"createdAt": "2026-01-01T00:00:00Z",
"authorAssociation": "OWNER",
"url": "https://example.com/1"
}),
serde_json::json!({
"id": "IC_2",
"body": "Second comment",
"author": {"login": "user2"},
"createdAt": "2026-01-02T00:00:00Z",
"authorAssociation": "CONTRIBUTOR",
"url": "https://example.com/2"
}),
];
let comments = parse_issue_comments(&raw);
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, "IC_1");
assert_eq!(comments[1].author.login, "user2");
}
#[test]
fn test_parse_issue_comments_empty() {
let raw: Vec<serde_json::Value> = vec![];
let comments = parse_issue_comments(&raw);
assert!(comments.is_empty());
}
#[test]
fn test_parse_issue_comments_malformed_entry_skipped() {
let raw = vec![
serde_json::json!({
"id": "IC_1",
"body": "Valid",
"author": {"login": "user1"},
"createdAt": "2026-01-01T00:00:00Z"
}),
serde_json::json!({"bad": "data"}),
serde_json::json!({
"id": "IC_3",
"body": "Also valid",
"author": {"login": "user3"},
"createdAt": "2026-01-03T00:00:00Z"
}),
];
let comments = parse_issue_comments(&raw);
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, "IC_1");
assert_eq!(comments[1].id, "IC_3");
}
#[test]
fn test_parse_rest_issue_comment() {
let json = serde_json::json!({
"id": 123456789,
"body": "Hello from REST",
"user": {"login": "testuser"},
"created_at": "2026-01-01T00:00:00Z",
"author_association": "OWNER",
"html_url": "https://github.com/test/repo/issues/1#issuecomment-123456789"
});
let comment = parse_rest_issue_comment(&json).unwrap();
assert_eq!(comment.id, "123456789");
assert_eq!(comment.body, "Hello from REST");
assert_eq!(comment.author.login, "testuser");
assert_eq!(comment.created_at, "2026-01-01T00:00:00Z");
assert_eq!(comment.author_association, "OWNER");
assert_eq!(
comment.url,
"https://github.com/test/repo/issues/1#issuecomment-123456789"
);
}
#[test]
fn test_parse_rest_issue_comment_graphql_fallback() {
let json = serde_json::json!({
"id": "IC_kwDOTest",
"body": "Hello from GraphQL",
"author": {"login": "gqluser"},
"createdAt": "2026-02-01T00:00:00Z",
"authorAssociation": "CONTRIBUTOR",
"url": "https://github.com/test/repo/issues/2#issuecomment-2"
});
let comment = parse_rest_issue_comment(&json).unwrap();
assert_eq!(comment.id, "IC_kwDOTest");
assert_eq!(comment.author.login, "gqluser");
assert_eq!(comment.created_at, "2026-02-01T00:00:00Z");
assert_eq!(comment.author_association, "CONTRIBUTOR");
}
#[test]
fn test_issue_state_filter_as_gh_arg() {
assert_eq!(IssueStateFilter::Open.as_gh_arg(), "open");
assert_eq!(IssueStateFilter::Closed.as_gh_arg(), "closed");
assert_eq!(IssueStateFilter::All.as_gh_arg(), "all");
}
#[test]
fn test_issue_state_filter_display_name() {
assert_eq!(IssueStateFilter::Open.display_name(), "open");
assert_eq!(IssueStateFilter::Closed.display_name(), "closed");
assert_eq!(IssueStateFilter::All.display_name(), "all");
}
#[test]
fn test_issue_state_filter_next_cycles() {
assert_eq!(IssueStateFilter::Open.next(), IssueStateFilter::Closed);
assert_eq!(IssueStateFilter::Closed.next(), IssueStateFilter::All);
assert_eq!(IssueStateFilter::All.next(), IssueStateFilter::Open);
}
#[test]
fn test_issue_summary_deserialize() {
let json = r#"{"number":42,"title":"Bug report","state":"OPEN","author":{"login":"user1"},"labels":[],"updatedAt":"2026-01-01T00:00:00Z","comments":[{"body":"hello"},{"body":"world"}]}"#;
let summary: IssueSummary = serde_json::from_str(json).unwrap();
assert_eq!(summary.number, 42);
assert_eq!(summary.title, "Bug report");
assert_eq!(summary.state, "OPEN");
assert_eq!(summary.author.login, "user1");
assert!(summary.labels.is_empty());
assert_eq!(summary.comments.len(), 2);
}
#[test]
fn test_issue_summary_deserialize_no_comments() {
let json = r#"{"number":1,"title":"T","state":"OPEN","author":{"login":"u"},"labels":[],"updatedAt":"2026-01-01T00:00:00Z"}"#;
let summary: IssueSummary = serde_json::from_str(json).unwrap();
assert!(summary.comments.is_empty());
}
#[test]
fn test_issue_detail_deserialize() {
let json = r#"{"number":42,"title":"Bug","body":"description","state":"OPEN","author":{"login":"user1"},"labels":[{"name":"bug"}],"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z","comments":[{"body":"c1"},{"body":"c2"},{"body":"c3"}]}"#;
let detail: IssueDetail = serde_json::from_str(json).unwrap();
assert_eq!(detail.number, 42);
assert_eq!(detail.body.as_deref(), Some("description"));
assert_eq!(detail.created_at, "2026-01-01T00:00:00Z");
assert_eq!(detail.updated_at, "2026-01-02T00:00:00Z");
assert_eq!(detail.labels.len(), 1);
assert_eq!(detail.labels[0].name, "bug");
assert_eq!(detail.comments.len(), 3);
}
#[test]
fn test_issue_detail_deserialize_null_body() {
let json = r#"{"number":1,"title":"T","body":null,"state":"OPEN","author":{"login":"u"},"labels":[],"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","comments":[]}"#;
let detail: IssueDetail = serde_json::from_str(json).unwrap();
assert!(detail.body.is_none());
}
#[test]
fn test_linked_pr_deserialize() {
let json = r#"{"number":45,"title":"Fix bug","state":"open","repo":null}"#;
let pr: LinkedPr = serde_json::from_str(json).unwrap();
assert_eq!(pr.number, 45);
assert_eq!(pr.title, "Fix bug");
assert_eq!(pr.state, "open");
assert!(pr.repo.is_none());
}
#[test]
fn test_linked_pr_deserialize_with_repo() {
let json = r#"{"number":45,"title":"Fix bug","state":"open","repo":"other/repo"}"#;
let pr: LinkedPr = serde_json::from_str(json).unwrap();
assert_eq!(pr.repo.as_deref(), Some("other/repo"));
}
fn graphql_response(nodes: Vec<serde_json::Value>) -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"timelineItems": {
"nodes": nodes
}
}
}
}
})
}
#[test]
fn test_empty_graphql_response_returns_empty_vec() {
let data = graphql_response(vec![]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert!(result.is_empty());
}
#[test]
fn test_cross_referenced_pr_is_extracted() {
let node = serde_json::json!({
"__typename": "CrossReferencedEvent",
"source": {
"__typename": "PullRequest",
"number": 45,
"title": "Fix bug",
"state": "OPEN",
"repository": { "nameWithOwner": "owner/repo" }
}
});
let data = graphql_response(vec![node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert_eq!(result.len(), 1);
assert_eq!(result[0].number, 45);
assert_eq!(result[0].title, "Fix bug");
assert_eq!(result[0].state, "OPEN");
assert!(result[0].repo.is_none()); }
#[test]
fn test_connected_pr_is_extracted() {
let node = serde_json::json!({
"__typename": "ConnectedEvent",
"subject": {
"__typename": "PullRequest",
"number": 50,
"title": "Linked via sidebar",
"state": "MERGED",
"repository": { "nameWithOwner": "owner/repo" }
}
});
let data = graphql_response(vec![node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert_eq!(result.len(), 1);
assert_eq!(result[0].number, 50);
assert_eq!(result[0].title, "Linked via sidebar");
assert_eq!(result[0].state, "MERGED");
}
#[test]
fn test_cross_referenced_issue_is_ignored() {
let node = serde_json::json!({
"__typename": "CrossReferencedEvent",
"source": {
"__typename": "Issue",
"number": 99,
"title": "Related issue",
"state": "OPEN"
}
});
let data = graphql_response(vec![node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert!(result.is_empty());
}
#[test]
fn test_unknown_event_type_is_ignored() {
let node = serde_json::json!({
"__typename": "LabeledEvent",
"label": {"name": "bug"}
});
let data = graphql_response(vec![node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert!(result.is_empty());
}
#[test]
fn test_duplicate_prs_are_deduplicated() {
let node = serde_json::json!({
"__typename": "CrossReferencedEvent",
"source": {
"__typename": "PullRequest",
"number": 45,
"title": "Fix",
"state": "OPEN",
"repository": { "nameWithOwner": "owner/repo" }
}
});
let data = graphql_response(vec![node.clone(), node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert_eq!(result.len(), 1);
}
#[test]
fn test_cross_repo_pr_has_repo_field() {
let node = serde_json::json!({
"__typename": "CrossReferencedEvent",
"source": {
"__typename": "PullRequest",
"number": 10,
"title": "Cross-repo fix",
"state": "OPEN",
"repository": { "nameWithOwner": "other/repo" }
}
});
let data = graphql_response(vec![node]);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert_eq!(result.len(), 1);
assert_eq!(result[0].repo.as_deref(), Some("other/repo"));
}
#[test]
fn test_mixed_connected_and_cross_referenced() {
let nodes = vec![
serde_json::json!({
"__typename": "CrossReferencedEvent",
"source": {
"__typename": "PullRequest",
"number": 10,
"title": "PR via reference",
"state": "OPEN",
"repository": { "nameWithOwner": "owner/repo" }
}
}),
serde_json::json!({
"__typename": "ConnectedEvent",
"subject": {
"__typename": "PullRequest",
"number": 20,
"title": "PR via sidebar",
"state": "MERGED",
"repository": { "nameWithOwner": "owner/repo" }
}
}),
];
let data = graphql_response(nodes);
let result = parse_linked_prs_from_graphql(&data, "owner/repo");
assert_eq!(result.len(), 2);
assert_eq!(result[0].number, 10);
assert_eq!(result[1].number, 20);
}
#[test]
fn snapshot_parse_rest_issue_comment() {
let json = serde_json::json!({
"id": 123456789,
"body": "This is a REST API comment",
"user": {"login": "restuser"},
"created_at": "2026-03-15T10:30:00Z",
"author_association": "MEMBER",
"html_url": "https://github.com/owner/repo/issues/42#issuecomment-123456789"
});
let comment = parse_rest_issue_comment(&json).unwrap();
insta::assert_debug_snapshot!(comment, @r#"
IssueComment {
id: "123456789",
body: "This is a REST API comment",
author: User {
login: "restuser",
},
created_at: "2026-03-15T10:30:00Z",
author_association: "MEMBER",
url: "https://github.com/owner/repo/issues/42#issuecomment-123456789",
}
"#);
}
#[test]
fn snapshot_parse_rest_issue_comment_graphql_format() {
let json = serde_json::json!({
"id": "IC_kwDOABC123",
"body": "GraphQL format comment",
"author": {"login": "gqluser"},
"createdAt": "2026-03-15T12:00:00Z",
"authorAssociation": "OWNER",
"url": "https://github.com/owner/repo/issues/10#issuecomment-456"
});
let comment = parse_rest_issue_comment(&json).unwrap();
insta::assert_debug_snapshot!(comment, @r#"
IssueComment {
id: "IC_kwDOABC123",
body: "GraphQL format comment",
author: User {
login: "gqluser",
},
created_at: "2026-03-15T12:00:00Z",
author_association: "OWNER",
url: "https://github.com/owner/repo/issues/10#issuecomment-456",
}
"#);
}
#[test]
fn snapshot_parse_issue_comments_multiple() {
let raw = vec![
serde_json::json!({
"id": "IC_1",
"body": "First comment\nwith multiple lines",
"author": {"login": "alice"},
"createdAt": "2026-01-01T00:00:00Z",
"authorAssociation": "OWNER",
"url": "https://example.com/1"
}),
serde_json::json!({
"id": "IC_2",
"body": "Second comment",
"author": {"login": "bob"},
"createdAt": "2026-01-02T00:00:00Z",
"authorAssociation": "CONTRIBUTOR",
"url": "https://example.com/2"
}),
serde_json::json!({"bad": "data"}),
serde_json::json!({
"id": "IC_3",
"body": "",
"author": {"login": "charlie"},
"createdAt": "2026-01-03T00:00:00Z"
}),
];
let comments = parse_issue_comments(&raw);
insta::assert_debug_snapshot!(comments, @r#"
[
IssueComment {
id: "IC_1",
body: "First comment\nwith multiple lines",
author: User {
login: "alice",
},
created_at: "2026-01-01T00:00:00Z",
author_association: "OWNER",
url: "https://example.com/1",
},
IssueComment {
id: "IC_2",
body: "Second comment",
author: User {
login: "bob",
},
created_at: "2026-01-02T00:00:00Z",
author_association: "CONTRIBUTOR",
url: "https://example.com/2",
},
IssueComment {
id: "IC_3",
body: "",
author: User {
login: "charlie",
},
created_at: "2026-01-03T00:00:00Z",
author_association: "",
url: "",
},
]
"#);
}
#[test]
fn snapshot_reply_template_single_line() {
let template = build_reply_template("alice", "Simple one-line comment");
insta::assert_snapshot!(template, @r"
> @alice wrote:
> Simple one-line comment
");
}
#[test]
fn snapshot_reply_template_multiline() {
let template = build_reply_template("bob", "Line one\nLine two\nLine three");
insta::assert_snapshot!(template, @r"
> @bob wrote:
> Line one
> Line two
> Line three
");
}
#[test]
fn snapshot_reply_template_truncated() {
let template =
build_reply_template("charlie", "Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
insta::assert_snapshot!(template, @r"
> @charlie wrote:
> Line 1
> Line 2
> Line 3
> ...
");
}
#[test]
fn snapshot_reply_template_empty_body() {
let template = build_reply_template("dave", "");
insta::assert_snapshot!(template, @r"
> @dave wrote:
>
");
}
#[test]
fn snapshot_reply_template_japanese() {
let template = build_reply_template(
"tanaka",
"これはテストです\n二行目のコメント\n三行目\n四行目は省略されるはず",
);
insta::assert_snapshot!(template, @r"
> @tanaka wrote:
> これはテストです
> 二行目のコメント
> 三行目
> ...
");
}
}