use pr_comments::git;
use pr_comments::models::ReviewComment;
#[test]
fn test_url_parsing() {
let urls = vec![
(
"https://github.com/rust-lang/rust.git",
("rust-lang", "rust"),
),
("git@github.com:rust-lang/rust.git", ("rust-lang", "rust")),
];
for (url, (expected_owner, expected_repo)) in urls {
let result = match git::parse_github_url(url) {
Ok(parsed) => parsed,
Err(err) => panic!("parse_github_url should succeed for {url}: {err}"),
};
assert_eq!(
result,
(expected_owner.to_string(), expected_repo.to_string())
);
}
}
#[tokio::test]
async fn test_model_serialization() {
let comment = ReviewComment {
id: 123,
user: "testuser".to_string(),
is_bot: false,
body: "Test comment".to_string(),
path: "src/main.rs".to_string(),
line: Some(42),
side: Some("RIGHT".to_string()),
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
html_url: "https://github.com/owner/repo/pull/1#discussion_r123".to_string(),
pull_request_review_id: Some(456),
in_reply_to_id: None,
};
let json = match serde_json::to_string(&comment) {
Ok(json) => json,
Err(err) => panic!("ReviewComment should serialize: {err}"),
};
let parsed: ReviewComment = match serde_json::from_str(&json) {
Ok(parsed) => parsed,
Err(err) => panic!("ReviewComment should deserialize: {err}"),
};
assert_eq!(parsed.id, comment.id);
assert_eq!(parsed.is_bot, comment.is_bot);
}
#[cfg(test)]
mod resolution_tests {
use pr_comments::models::GraphQLResponse;
use pr_comments::models::PullRequestData;
#[test]
fn test_include_resolved_default() {
fn get_include_resolved(opt: Option<bool>) -> bool {
opt.unwrap_or(false)
}
assert!(!get_include_resolved(None));
assert!(get_include_resolved(Some(true)));
assert!(!get_include_resolved(Some(false)));
}
#[test]
fn test_graphql_models() {
let json = r#"{
"data": {
"repository": {
"pullRequest": {
"reviewThreads": {
"nodes": [{
"id": "PRRT_123",
"isResolved": true,
"comments": {
"nodes": [{
"id": "RC_123",
"databaseId": 456
}]
}
}],
"pageInfo": {
"hasNextPage": false,
"endCursor": null
}
}
}
}
}
}"#;
let response: GraphQLResponse<PullRequestData> = match serde_json::from_str(json) {
Ok(response) => response,
Err(err) => panic!("GraphQLResponse should deserialize: {err}"),
};
assert!(response.data.is_some());
let Some(data) = response.data else {
panic!("GraphQLResponse should contain data");
};
assert_eq!(data.repository.pull_request.review_threads.nodes.len(), 1);
assert!(data.repository.pull_request.review_threads.nodes[0].is_resolved);
}
}
#[cfg(test)]
mod filter_pipeline_tests {
use pr_comments::github::test_helpers::FilterParams;
use pr_comments::github::test_helpers::apply_filters;
use pr_comments::models::ReviewComment;
use std::collections::HashMap;
fn rc_top(id: u64, user: &str) -> ReviewComment {
ReviewComment {
id,
user: user.into(),
is_bot: false,
body: format!("P{id}"),
path: "a.rs".into(),
line: Some(1),
side: Some("RIGHT".into()),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T00:00:00Z".into(),
html_url: format!("https://x/{id}"),
pull_request_review_id: None,
in_reply_to_id: None,
}
}
fn rc_reply(id: u64, user: &str, parent: u64) -> ReviewComment {
ReviewComment {
id,
user: user.into(),
is_bot: false,
body: format!("R{id}"),
path: "a.rs".into(),
line: Some(1),
side: Some("RIGHT".into()),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T00:00:00Z".into(),
html_url: format!("https://x/{id}"),
pull_request_review_id: None,
in_reply_to_id: Some(parent),
}
}
fn p(
include_resolved: bool,
include_replies: bool,
author: Option<&str>,
offset: usize,
limit: usize,
) -> FilterParams<'_> {
FilterParams {
include_resolved,
include_replies,
author,
offset: Some(offset),
limit: Some(limit),
resolved_ids: HashMap::new(),
}
}
fn ids(xs: &[ReviewComment]) -> Vec<u64> {
xs.iter().map(|c| c.id).collect()
}
#[test]
fn offset_skips_parent_replies_do_not_leak() {
let data = vec![
rc_top(1, "alice"),
rc_reply(11, "bob", 1),
rc_top(2, "alice"),
];
let out = apply_filters(data, &p(false, true, Some("alice"), 1, 100));
assert_eq!(ids(&out), vec![2], "reply R11 should not appear without P1");
}
#[test]
fn limit_boundary_does_not_start_completion_without_parent_in_results() {
let data = vec![
rc_top(1, "u"),
rc_reply(11, "v", 1),
rc_top(2, "u"),
rc_reply(22, "w", 2),
];
let out = apply_filters(data, &p(true, true, None, 0, 2));
assert_eq!(ids(&out), vec![1, 11]);
}
#[test]
fn page_local_thread_completion_includes_all_replies_for_included_parent() {
let data = vec![
rc_top(1, "a"),
rc_reply(11, "b", 1),
rc_reply(12, "c", 1),
rc_top(2, "d"),
];
let out = apply_filters(data, &p(true, true, None, 0, 1));
assert_eq!(ids(&out), vec![1, 11, 12]);
}
#[test]
fn multiple_parents_skipped_by_offset_block_all_their_replies() {
let data = vec![
rc_top(1, "a"),
rc_reply(11, "x", 1),
rc_top(2, "b"),
rc_reply(22, "y", 2),
rc_top(3, "c"),
rc_reply(33, "z", 3),
rc_top(4, "d"),
];
let out = apply_filters(data, &p(true, true, None, 3, 100));
assert_eq!(ids(&out), vec![4]);
}
#[test]
fn include_replies_false_preserved() {
let data = vec![rc_top(1, "u"), rc_reply(11, "v", 1), rc_top(2, "w")];
let out = apply_filters(data, &p(true, false, None, 1, 100));
assert_eq!(ids(&out), vec![2]);
}
#[test]
fn page_local_completion_does_not_fetch_next_page() {
let page1 = vec![rc_top(1, "u"), rc_reply(11, "v", 1)];
let out = apply_filters(page1, &p(true, true, None, 0, 1));
assert_eq!(ids(&out), vec![1, 11]);
}
}
#[cfg(test)]
mod thread_tests {
use pr_comments::models::CommentSourceType;
use pr_comments::models::ReviewComment;
use pr_comments::models::Thread;
use std::collections::HashMap;
fn make_comment(id: u64, user: &str, is_bot: bool, in_reply_to: Option<u64>) -> ReviewComment {
ReviewComment {
id,
user: user.into(),
is_bot,
body: format!("Comment {id}"),
path: "src/lib.rs".into(),
line: Some(10),
side: Some("RIGHT".into()),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T00:00:00Z".into(),
html_url: format!("https://example.com/{id}"),
pull_request_review_id: None,
in_reply_to_id: in_reply_to,
}
}
fn build_threads(
comments: Vec<ReviewComment>,
resolution_map: &HashMap<u64, bool>,
) -> Vec<Thread> {
let mut parents: Vec<ReviewComment> = Vec::new();
let mut replies_by_parent: HashMap<u64, Vec<ReviewComment>> = HashMap::new();
for c in comments {
if let Some(parent_id) = c.in_reply_to_id {
replies_by_parent.entry(parent_id).or_default().push(c);
} else {
parents.push(c);
}
}
parents
.into_iter()
.map(|parent| {
let is_resolved = resolution_map.get(&parent.id).copied().unwrap_or(false);
let replies = replies_by_parent.remove(&parent.id).unwrap_or_default();
Thread {
parent,
replies,
is_resolved,
}
})
.collect()
}
fn filter_threads(
threads: Vec<Thread>,
src: CommentSourceType,
include_resolved: bool,
) -> Vec<Thread> {
threads
.into_iter()
.filter(|thread| {
if !include_resolved && thread.is_resolved {
return false;
}
match src {
CommentSourceType::Robot => thread.parent.is_bot,
CommentSourceType::Human => !thread.parent.is_bot,
CommentSourceType::All => true,
}
})
.collect()
}
#[test]
fn build_threads_groups_replies_under_parent() {
let comments = vec![
make_comment(1, "alice", false, None), make_comment(2, "bob", false, Some(1)), make_comment(3, "charlie", false, Some(1)), make_comment(4, "dave", false, None), ];
let res_map = HashMap::new();
let threads = build_threads(comments, &res_map);
assert_eq!(threads.len(), 2);
assert_eq!(threads[0].parent.id, 1);
assert_eq!(threads[0].replies.len(), 2);
assert_eq!(threads[1].parent.id, 4);
assert_eq!(threads[1].replies.len(), 0);
}
#[test]
fn filter_threads_by_resolution() {
let comments = vec![
make_comment(1, "alice", false, None),
make_comment(2, "bob", false, None),
];
let mut res_map = HashMap::new();
res_map.insert(1, true); res_map.insert(2, false);
let threads = build_threads(comments, &res_map);
let filtered = filter_threads(threads, CommentSourceType::All, false);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].parent.id, 2);
}
#[test]
fn filter_threads_include_resolved() {
let comments = vec![
make_comment(1, "alice", false, None),
make_comment(2, "bob", false, None),
];
let mut res_map = HashMap::new();
res_map.insert(1, true);
res_map.insert(2, false);
let threads = build_threads(comments, &res_map);
let filtered = filter_threads(threads, CommentSourceType::All, true);
assert_eq!(filtered.len(), 2);
}
#[test]
fn filter_threads_by_robot() {
let comments = vec![
make_comment(1, "coderabbit[bot]", true, None), make_comment(2, "alice", false, None), ];
let res_map = HashMap::new();
let threads = build_threads(comments, &res_map);
let filtered = filter_threads(threads, CommentSourceType::Robot, false);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].parent.id, 1);
}
#[test]
fn filter_threads_by_human() {
let comments = vec![
make_comment(1, "coderabbit[bot]", true, None), make_comment(2, "alice", false, None), ];
let res_map = HashMap::new();
let threads = build_threads(comments, &res_map);
let filtered = filter_threads(threads, CommentSourceType::Human, false);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].parent.id, 2);
}
#[test]
fn filter_threads_all_includes_both() {
let comments = vec![
make_comment(1, "coderabbit[bot]", true, None), make_comment(2, "alice", false, None), ];
let res_map = HashMap::new();
let threads = build_threads(comments, &res_map);
let filtered = filter_threads(threads, CommentSourceType::All, false);
assert_eq!(filtered.len(), 2);
}
}