feat: add markdown renderer for review documents
This commit is contained in:
@@ -2,6 +2,7 @@ pub mod cli;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
|
pub mod render;
|
||||||
|
|
||||||
pub fn run() -> anyhow::Result<()> {
|
pub fn run() -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::model::{CommentThread, PrReviewDocument};
|
||||||
|
|
||||||
|
fn backtick_fence(body: &str) -> String {
|
||||||
|
let mut max_run = 0usize;
|
||||||
|
let mut current_run = 0usize;
|
||||||
|
|
||||||
|
for ch in body.chars() {
|
||||||
|
if ch == '`' {
|
||||||
|
current_run += 1;
|
||||||
|
max_run = max_run.max(current_run);
|
||||||
|
} else {
|
||||||
|
current_run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fence_len = std::cmp::max(3, max_run + 1);
|
||||||
|
"`".repeat(fence_len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_body(body: &str) -> String {
|
||||||
|
let fence = backtick_fence(body);
|
||||||
|
format!("{fence}md\n{body}\n{fence}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_thread(thread_index: usize, thread: &CommentThread) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!("### Comment {}.1\n", thread_index));
|
||||||
|
if let Some(file_path) = &thread.file_path {
|
||||||
|
if let Some(line) = thread.line {
|
||||||
|
out.push_str(&format!("{file_path}:{line}\n"));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("{file_path}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{}:\n", thread.root_comment.user));
|
||||||
|
out.push_str(&render_body(&thread.root_comment.body));
|
||||||
|
|
||||||
|
for (reply_index, reply) in thread.replies.iter().enumerate() {
|
||||||
|
let reply_number = format!("{thread_index}.1.{}", reply_index + 1);
|
||||||
|
out.push_str(&format!("\n### Reply {reply_number}\n"));
|
||||||
|
out.push_str(&format!("{}:\n", reply.user));
|
||||||
|
out.push_str(&render_body(&reply.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_markdown(doc: &PrReviewDocument) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
out.push_str(&format!(
|
||||||
|
"# {} `#{}` {}\n\n",
|
||||||
|
doc.meta.repo, doc.meta.pr_index, doc.meta.title
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("## Metadata\n\n");
|
||||||
|
out.push_str(&format!("- state: {}\n", doc.meta.state));
|
||||||
|
out.push_str(&format!("- author: {}\n", doc.meta.author));
|
||||||
|
out.push_str(&format!("- base branch: {}\n", doc.meta.base_branch));
|
||||||
|
out.push_str(&format!("- head branch: {}\n", doc.meta.head_branch));
|
||||||
|
out.push_str(&format!("- created at: {}\n", doc.meta.created_at));
|
||||||
|
out.push_str(&format!("- updated at: {}\n", doc.meta.updated_at));
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- merged at: {}\n",
|
||||||
|
doc.meta.merged_at.as_deref().unwrap_or("null")
|
||||||
|
));
|
||||||
|
out.push_str("\n");
|
||||||
|
|
||||||
|
out.push_str("## Commits\n\n");
|
||||||
|
for commit in &doc.commits {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- {} {} ({}, {})\n",
|
||||||
|
commit.short_sha, commit.title, commit.author, commit.date
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("\n");
|
||||||
|
|
||||||
|
out.push_str("## Diff Stat\n\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- files changed: {}\n- additions: {}\n- deletions: {}\n",
|
||||||
|
doc.diff_stat.files_changed, doc.diff_stat.additions, doc.diff_stat.deletions
|
||||||
|
));
|
||||||
|
for file in &doc.diff_stat.files {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- {}: +{}, -{}\n",
|
||||||
|
file.path, file.additions, file.deletions
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("\n");
|
||||||
|
|
||||||
|
for (review_index, review) in doc.reviews.iter().enumerate() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"## Review {} ({})\n\n",
|
||||||
|
review_index + 1,
|
||||||
|
review.state
|
||||||
|
));
|
||||||
|
out.push_str(&format!("> {}\n\n", review.reviewer));
|
||||||
|
if let Some(submitted_at) = &review.submitted_at {
|
||||||
|
out.push_str(&format!("- submitted at: {}\n\n", submitted_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (thread_index, thread) in doc.threads.iter().enumerate() {
|
||||||
|
out.push_str(&render_thread(thread_index + 1, thread));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
out.trim_end().to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod markdown;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use gitea_pr_review::model::{
|
||||||
|
CommentItem, CommentThread, CommitItem, DiffStat, FileStat, PrMeta, PrReviewDocument,
|
||||||
|
ReviewItem,
|
||||||
|
};
|
||||||
|
use gitea_pr_review::render::markdown::render_markdown;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_includes_expected_sections_and_preserves_comment_markdown() {
|
||||||
|
let doc = PrReviewDocument {
|
||||||
|
meta: PrMeta {
|
||||||
|
repo: "org/repo".into(),
|
||||||
|
pr_index: 7,
|
||||||
|
title: "Fix parser".into(),
|
||||||
|
state: "open".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
base_branch: "main".into(),
|
||||||
|
head_branch: "feat/a".into(),
|
||||||
|
created_at: "2026-04-08T10:00:00Z".into(),
|
||||||
|
updated_at: "2026-04-08T11:00:00Z".into(),
|
||||||
|
merged_at: None,
|
||||||
|
},
|
||||||
|
commits: vec![CommitItem {
|
||||||
|
sha: "abcdef0123456789".into(),
|
||||||
|
short_sha: "abcdef0".into(),
|
||||||
|
title: "fix: parser".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
date: "2026-04-08T10:10:00Z".into(),
|
||||||
|
}],
|
||||||
|
diff_stat: DiffStat {
|
||||||
|
files_changed: 1,
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
files: vec![FileStat {
|
||||||
|
path: "src/main.rs".into(),
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
reviews: vec![ReviewItem {
|
||||||
|
id: 1,
|
||||||
|
state: "COMMENT".into(),
|
||||||
|
reviewer: "bob".into(),
|
||||||
|
submitted_at: Some("2026-04-08T11:00:00Z".into()),
|
||||||
|
}],
|
||||||
|
threads: vec![CommentThread {
|
||||||
|
thread_id: "t1".into(),
|
||||||
|
file_path: Some("src/main.rs".into()),
|
||||||
|
line: Some(9),
|
||||||
|
root_comment: CommentItem {
|
||||||
|
id: 11,
|
||||||
|
user: "bob".into(),
|
||||||
|
created_at: "2026-04-08T11:01:00Z".into(),
|
||||||
|
body: "```rs\nlet x = 1;\n```".into(),
|
||||||
|
},
|
||||||
|
replies: vec![CommentItem {
|
||||||
|
id: 12,
|
||||||
|
user: "alice".into(),
|
||||||
|
created_at: "2026-04-08T11:02:00Z".into(),
|
||||||
|
body: "looks good".into(),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let md = render_markdown(&doc);
|
||||||
|
|
||||||
|
assert!(md.contains("# org/repo `#7` Fix parser"));
|
||||||
|
assert!(md.contains("## Metadata"));
|
||||||
|
assert!(md.contains("## Commits"));
|
||||||
|
assert!(md.contains("## Diff Stat"));
|
||||||
|
assert!(md.contains("## Review 1 (COMMENT)"));
|
||||||
|
assert!(md.contains("### Comment 1.1"));
|
||||||
|
assert!(md.contains("### Reply 1.1"));
|
||||||
|
assert!(md.contains("````md"));
|
||||||
|
assert!(md.contains("```rs\nlet x = 1;\n```"));
|
||||||
|
assert!(md.contains("looks good"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_uses_minimal_fence_for_plain_text() {
|
||||||
|
let body = "plain text";
|
||||||
|
let doc = PrReviewDocument {
|
||||||
|
meta: PrMeta {
|
||||||
|
repo: "org/repo".into(),
|
||||||
|
pr_index: 1,
|
||||||
|
title: "T".into(),
|
||||||
|
state: "open".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
base_branch: "main".into(),
|
||||||
|
head_branch: "feat".into(),
|
||||||
|
created_at: "2026-04-08T10:00:00Z".into(),
|
||||||
|
updated_at: "2026-04-08T10:00:00Z".into(),
|
||||||
|
merged_at: None,
|
||||||
|
},
|
||||||
|
commits: vec![],
|
||||||
|
diff_stat: DiffStat {
|
||||||
|
files_changed: 0,
|
||||||
|
additions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
files: vec![],
|
||||||
|
},
|
||||||
|
reviews: vec![],
|
||||||
|
threads: vec![CommentThread {
|
||||||
|
thread_id: "t1".into(),
|
||||||
|
file_path: None,
|
||||||
|
line: None,
|
||||||
|
root_comment: CommentItem {
|
||||||
|
id: 1,
|
||||||
|
user: "bob".into(),
|
||||||
|
created_at: "2026-04-08T11:00:00Z".into(),
|
||||||
|
body: body.into(),
|
||||||
|
},
|
||||||
|
replies: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let md = render_markdown(&doc);
|
||||||
|
assert!(md.contains("```md\nplain text\n```"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user