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
+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)
}