diff --git a/Cargo.lock b/Cargo.lock index 1bc4170..1d85ee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -98,6 +107,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -155,6 +170,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -210,6 +239,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "difflib" version = "0.4.0" @@ -371,6 +406,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "chrono", "clap", "mockito", "reqwest", @@ -527,6 +563,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -775,6 +835,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1693,12 +1762,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index d84a27e..a099749 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/lib.rs b/src/lib.rs index 42d71f2..e179de8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod cli; pub mod error; pub mod gitea; pub mod model; +pub mod normalize; pub mod output; pub mod render; diff --git a/src/normalize.rs b/src/normalize.rs new file mode 100644 index 0000000..f6802fd --- /dev/null +++ b/src/normalize.rs @@ -0,0 +1,251 @@ +use std::collections::BTreeMap; +use std::convert::TryFrom; + +use chrono::{DateTime, FixedOffset}; + +use crate::gitea::dto::{ChangedFileDto, CommitDto, PullBundleDto, ReviewCommentDto, ReviewDto}; +use crate::model::{ + CommentItem, CommentThread, CommitItem, DiffStat, FileStat, PrMeta, PrReviewDocument, + ReviewItem, +}; + +pub fn normalize_bundle(repo: &str, bundle: PullBundleDto) -> PrReviewDocument { + let PullBundleDto { + pull, + reviews, + comments, + commits, + files, + } = bundle; + + let mut threads = normalize_threads(comments); + threads.sort_by(|a, b| { + comment_item_sort_key(&a.root_comment).cmp(&comment_item_sort_key(&b.root_comment)) + }); + + let commits = normalize_commits(commits); + let reviews = normalize_reviews(reviews); + let diff_stat = normalize_diff_stat(&pull, files); + + PrReviewDocument { + meta: PrMeta { + repo: repo.to_string(), + pr_index: pull.number, + title: pull.title, + state: pull.state, + author: pull.user.login, + base_branch: pull.base.ref_name, + head_branch: pull.head.ref_name, + created_at: pull.created_at, + updated_at: pull.updated_at, + merged_at: pull.merged_at, + }, + commits, + diff_stat, + reviews, + threads, + } +} + +fn normalize_threads(comments: Vec) -> Vec { + let mut grouped: BTreeMap> = BTreeMap::new(); + + for comment in comments { + let key = thread_key(&comment); + grouped.entry(key).or_default().push(comment); + } + + grouped + .into_iter() + .map(|(thread_id, mut group)| { + group.sort_by(|a, b| comment_sort_key(a).cmp(&comment_sort_key(b))); + + let root = group.remove(0); + let file_path = root + .path + .clone() + .or_else(|| group.iter().find_map(|comment| comment.path.clone())); + let line = root + .line + .or_else(|| group.iter().find_map(|comment| comment.line)); + + CommentThread { + thread_id, + file_path, + line, + root_comment: to_comment_item(root), + replies: group.into_iter().map(to_comment_item).collect(), + } + }) + .collect() +} + +fn thread_key(comment: &ReviewCommentDto) -> String { + match comment.pull_request_review_id { + Some(review_id) => format!( + "review-{review_id}-{path}-{line}-{position}-{commit}", + path = comment.path.as_deref().unwrap_or(""), + line = comment + .line + .map(|value| value.to_string()) + .unwrap_or_default(), + position = comment + .original_position + .or(comment.position) + .map(|value| value.to_string()) + .unwrap_or_default(), + commit = comment + .original_commit_id + .as_deref() + .or(comment.commit_id.as_deref()) + .unwrap_or("") + ), + None => format!("comment-{}", comment.id), + } +} + +fn normalize_commits(commits: Vec) -> Vec { + let mut commits = commits; + commits.sort_by(|a, b| { + commit_sort_key(a) + .cmp(&commit_sort_key(b)) + .then_with(|| a.sha.cmp(&b.sha)) + }); + + commits.into_iter().map(to_commit_item).collect() +} + +fn normalize_reviews(reviews: Vec) -> Vec { + let mut reviews = reviews; + reviews.sort_by(|a, b| { + review_sort_key(a) + .cmp(&review_sort_key(b)) + .then_with(|| a.id.cmp(&b.id)) + }); + + reviews + .into_iter() + .map(|review| ReviewItem { + id: review.id, + state: review.state, + reviewer: review.user.login, + submitted_at: review.submitted_at, + }) + .collect() +} + +fn normalize_diff_stat(pull: &crate::gitea::dto::PullDto, files: Vec) -> DiffStat { + let file_stats: Vec = files + .into_iter() + .map(|file| FileStat { + path: file.filename, + additions: file.additions, + deletions: file.deletions, + }) + .collect(); + + let files_changed = if file_stats.is_empty() { + pull.changed_files + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default() + } else { + file_stats.len() + }; + + let additions: i64 = if file_stats.is_empty() { + pull.additions.unwrap_or_default() + } else { + file_stats.iter().map(|file| file.additions).sum() + }; + let deletions: i64 = if file_stats.is_empty() { + pull.deletions.unwrap_or_default() + } else { + file_stats.iter().map(|file| file.deletions).sum() + }; + + DiffStat { + files_changed, + additions, + deletions, + files: file_stats, + } +} + +fn to_commit_item(commit: CommitDto) -> CommitItem { + let title = commit + .commit + .message + .lines() + .next() + .unwrap_or_default() + .to_string(); + let short_sha = commit.sha.chars().take(7).collect::(); + let author = commit + .commit + .author + .as_ref() + .map(|author| author.name.clone()) + .unwrap_or_default(); + let date = commit + .commit + .author + .as_ref() + .map(|author| author.date.clone()) + .unwrap_or_default(); + + CommitItem { + sha: commit.sha, + short_sha, + title, + author, + date, + } +} + +fn to_comment_item(comment: ReviewCommentDto) -> CommentItem { + CommentItem { + id: comment.id, + user: comment.user.login, + created_at: comment.created_at, + body: comment.body, + } +} + +fn review_sort_key(review: &ReviewDto) -> (i64, i64) { + (timestamp_millis(review.submitted_at.as_deref()), review.id) +} + +fn commit_sort_key(commit: &CommitDto) -> (i64, String) { + ( + timestamp_millis(Some( + commit + .commit + .author + .as_ref() + .map(|author| author.date.as_str()) + .unwrap_or_default(), + )), + commit.sha.clone(), + ) +} + +fn comment_sort_key(comment: &ReviewCommentDto) -> (i64, i64) { + ( + timestamp_millis(Some(comment.created_at.as_str())), + comment.id, + ) +} + +fn comment_item_sort_key(comment: &CommentItem) -> (i64, i64) { + ( + timestamp_millis(Some(comment.created_at.as_str())), + comment.id, + ) +} + +fn timestamp_millis(input: Option<&str>) -> i64 { + input + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt: DateTime| dt.timestamp_millis()) + .unwrap_or(0) +} diff --git a/tests/normalize_tests.rs b/tests/normalize_tests.rs new file mode 100644 index 0000000..af3e4b7 --- /dev/null +++ b/tests/normalize_tests.rs @@ -0,0 +1,172 @@ +use gitea_pr_review::gitea::dto::*; +use gitea_pr_review::normalize::normalize_bundle; + +#[test] +fn normalize_groups_replies_and_sorts_threads_by_time() { + let bundle = PullBundleDto { + pull: PullDto { + number: 42, + title: "Fix parser".into(), + state: "open".into(), + body: Some("desc".into()), + user: UserDto { + login: "alice".into(), + }, + base: PullBranchDto { + ref_name: "main".into(), + }, + head: PullBranchDto { + ref_name: "feature/x".into(), + }, + created_at: "2026-04-08T10:00:00Z".into(), + updated_at: "2026-04-08T11:00:00Z".into(), + merged_at: None, + additions: Some(99), + deletions: Some(33), + changed_files: Some(7), + }, + reviews: vec![ + ReviewDto { + id: 8, + state: "APPROVED".into(), + user: UserDto { + login: "carol".into(), + }, + submitted_at: Some("2026-04-08T13:00:00Z".into()), + }, + ReviewDto { + id: 7, + state: "COMMENT".into(), + user: UserDto { + login: "bob".into(), + }, + submitted_at: Some("2026-04-08T12:00:00Z".into()), + }, + ], + comments: vec![ + ReviewCommentDto { + id: 2, + body: "reply".into(), + created_at: "2026-04-08T12:02:00Z".into(), + updated_at: Some("2026-04-08T12:03:00Z".into()), + user: UserDto { + login: "bob".into(), + }, + path: Some("src/main.rs".into()), + line: Some(10), + pull_request_review_id: Some(7), + original_position: Some(10), + position: Some(10), + commit_id: Some("abc123".into()), + original_commit_id: Some("abc123".into()), + diff_hunk: Some("@@ -1 +1 @@".into()), + }, + ReviewCommentDto { + id: 1, + body: "root".into(), + created_at: "2026-04-08T12:01:00Z".into(), + updated_at: Some("2026-04-08T12:02:00Z".into()), + user: UserDto { + login: "bob".into(), + }, + path: Some("src/main.rs".into()), + line: Some(10), + pull_request_review_id: Some(7), + original_position: Some(10), + position: Some(10), + commit_id: Some("abc123".into()), + original_commit_id: Some("abc123".into()), + diff_hunk: Some("@@ -1 +1 @@".into()), + }, + ReviewCommentDto { + id: 3, + body: "other thread".into(), + created_at: "2026-04-08T11:59:00Z".into(), + updated_at: None, + user: UserDto { + login: "dave".into(), + }, + path: Some("src/lib.rs".into()), + line: Some(22), + pull_request_review_id: Some(8), + original_position: Some(22), + position: Some(22), + commit_id: Some("def456".into()), + original_commit_id: Some("def456".into()), + diff_hunk: Some("@@ -2 +2 @@".into()), + }, + ], + commits: vec![ + CommitDto { + sha: "bbbbbbbccccccc".into(), + commit: RepoCommitDto { + message: "fix: later commit\n\nbody".into(), + author: Some(CommitUserDto { + name: "Bob".into(), + date: "2026-04-08T12:30:00Z".into(), + }), + }, + }, + CommitDto { + sha: "aaaaaaabbbbbbb".into(), + commit: RepoCommitDto { + message: "feat: earlier commit".into(), + author: Some(CommitUserDto { + name: "Alice".into(), + date: "2026-04-08T11:30:00Z".into(), + }), + }, + }, + ], + files: vec![ + ChangedFileDto { + filename: "src/main.rs".into(), + additions: 5, + deletions: 1, + changes: Some(6), + status: Some("modified".into()), + previous_filename: None, + }, + ChangedFileDto { + filename: "src/lib.rs".into(), + additions: 3, + deletions: 2, + changes: Some(5), + status: Some("modified".into()), + previous_filename: None, + }, + ], + }; + + let doc = normalize_bundle("org/repo", bundle); + + assert_eq!(doc.meta.repo, "org/repo"); + assert_eq!(doc.meta.pr_index, 42); + assert_eq!(doc.meta.base_branch, "main"); + assert_eq!(doc.meta.head_branch, "feature/x"); + + assert_eq!(doc.commits.len(), 2); + assert_eq!(doc.commits[0].title, "feat: earlier commit"); + assert_eq!(doc.commits[0].author, "Alice"); + assert_eq!(doc.commits[1].title, "fix: later commit"); + + assert_eq!(doc.reviews.len(), 2); + assert_eq!(doc.reviews[0].id, 7); + assert_eq!(doc.reviews[1].id, 8); + + assert_eq!(doc.diff_stat.files_changed, 2); + assert_eq!(doc.diff_stat.additions, 8); + assert_eq!(doc.diff_stat.deletions, 3); + assert_eq!(doc.diff_stat.files[0].path, "src/main.rs"); + assert_eq!(doc.diff_stat.files[1].path, "src/lib.rs"); + + assert_eq!(doc.threads.len(), 2); + assert_eq!(doc.threads[0].root_comment.id, 3); + assert_eq!(doc.threads[0].file_path.as_deref(), Some("src/lib.rs")); + assert_eq!(doc.threads[0].line, Some(22)); + assert_eq!(doc.threads[1].root_comment.id, 1); + assert_eq!(doc.threads[1].replies.len(), 1); + assert_eq!(doc.threads[1].replies[0].id, 2); + assert_eq!(doc.threads[1].file_path.as_deref(), Some("src/main.rs")); + assert_eq!(doc.threads[1].line, Some(10)); +}