From 75e3239a16ede2f8a36af18efd96061bf4049003 Mon Sep 17 00:00:00 2001 From: Origami404 Date: Wed, 8 Apr 2026 22:49:10 +0800 Subject: [PATCH] feat: add markdown renderer for review documents --- src/lib.rs | 1 + src/render/markdown.rs | 109 ++++++++++++++++++++++++++++++ src/render/mod.rs | 1 + tests/render_markdown_tests.rs | 118 +++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 src/render/markdown.rs create mode 100644 src/render/mod.rs create mode 100644 tests/render_markdown_tests.rs diff --git a/src/lib.rs b/src/lib.rs index 8c0f8ac..b2e6849 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod cli; pub mod error; pub mod model; pub mod output; +pub mod render; pub fn run() -> anyhow::Result<()> { Ok(()) diff --git a/src/render/markdown.rs b/src/render/markdown.rs new file mode 100644 index 0000000..8a6a4ba --- /dev/null +++ b/src/render/markdown.rs @@ -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() +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..163a4fb --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1 @@ +pub mod markdown; diff --git a/tests/render_markdown_tests.rs b/tests/render_markdown_tests.rs new file mode 100644 index 0000000..3c265fe --- /dev/null +++ b/tests/render_markdown_tests.rs @@ -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```")); +}