feat: normalize review bundle into review document

This commit is contained in:
2026-04-08 22:56:24 +08:00
parent 442b699b0f
commit d829f854f8
5 changed files with 547 additions and 0 deletions
Generated
+122
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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;
+251
View File
@@ -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<ReviewCommentDto>) -> Vec<CommentThread> {
let mut grouped: BTreeMap<String, Vec<ReviewCommentDto>> = 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<CommitDto>) -> Vec<CommitItem> {
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<ReviewDto>) -> Vec<ReviewItem> {
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<ChangedFileDto>) -> DiffStat {
let file_stats: Vec<FileStat> = 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::<String>();
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<FixedOffset>| dt.timestamp_millis())
.unwrap_or(0)
}
+172
View File
@@ -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));
}