use std::{ fs::{self, File}, io::{BufWriter, Write}, path::Path, }; use anyhow::Context as _; use derive_more::Display; use itertools::{Either, Itertools}; use crate::{ checks::{ReviewFailure, ReviewResult, ReviewSuccess}, git::CommitDetails, }; const PULL_REQUEST_BASE_URL: &str = "https://github.com/zed-industries/zed/pull"; #[derive(Debug)] pub struct ReportEntry { pub commit: CommitDetails, reason: R, } impl ReportEntry { fn commit_cell(&self) -> String { let title = escape_markdown_link_text(self.commit.title()); match self.commit.pr_number() { Some(pr_number) => format!("[{title}]({PULL_REQUEST_BASE_URL}/{pr_number})"), None => escape_markdown_table_text(self.commit.title()), } } fn pull_request_cell(&self) -> String { self.commit .pr_number() .map(|pr_number| format!("#{pr_number}")) .unwrap_or_else(|| "—".to_owned()) } fn author_cell(&self) -> String { escape_markdown_table_text(&self.commit.author().to_string()) } fn reason_cell(&self) -> String { escape_markdown_table_text(&self.reason.to_string()) } } impl ReportEntry { fn issue_kind(&self) -> IssueKind { match self.reason { ReviewFailure::Other(_) => IssueKind::Error, _ => IssueKind::NotReviewed, } } } impl ReportEntry { fn reviewers_cell(&self) -> String { match &self.reason.reviewers() { Ok(reviewers) => escape_markdown_table_text(&reviewers), Err(_) => "—".to_owned(), } } } #[derive(Debug, Default)] pub struct ReportSummary { pub pull_requests: usize, pub reviewed: usize, pub not_reviewed: usize, pub errors: usize, } pub enum ReportReviewSummary { MissingReviews, MissingReviewsWithErrors, NoIssuesFound, } impl ReportSummary { fn from_entries(entries: &[ReportEntry]) -> Self { Self { pull_requests: entries .iter() .filter_map(|entry| entry.commit.pr_number()) .unique() .count(), reviewed: entries.iter().filter(|entry| entry.reason.is_ok()).count(), not_reviewed: entries .iter() .filter(|entry| { matches!( entry.reason, Err(ReviewFailure::NoPullRequestFound | ReviewFailure::Unreviewed) ) }) .count(), errors: entries .iter() .filter(|entry| matches!(entry.reason, Err(ReviewFailure::Other(_)))) .count(), } } pub fn review_summary(&self) -> ReportReviewSummary { match self.not_reviewed { 0 if self.errors == 0 => ReportReviewSummary::NoIssuesFound, 1.. if self.errors == 0 => ReportReviewSummary::MissingReviews, _ => ReportReviewSummary::MissingReviewsWithErrors, } } fn has_errors(&self) -> bool { self.errors > 0 } pub fn prs_with_errors(&self) -> usize { self.pull_requests - self.reviewed } } #[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)] enum IssueKind { #[display("Error")] Error, #[display("Not reviewed")] NotReviewed, } #[derive(Debug, Default)] pub struct Report { entries: Vec>, } impl Report { pub fn new() -> Self { Self::default() } pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) { self.entries.push(ReportEntry { commit, reason: result, }); } pub fn errors(&self) -> impl Iterator> { self.entries.iter().filter(|entry| entry.reason.is_err()) } pub fn summary(&self) -> ReportSummary { ReportSummary::from_entries(&self.entries) } pub fn write_markdown(self, path: impl AsRef) -> anyhow::Result<()> { let path = path.as_ref(); if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { fs::create_dir_all(parent).with_context(|| { format!( "Failed to create parent directory for markdown report at {}", path.display() ) })?; } let summary = self.summary(); let (successes, mut issues): (Vec<_>, Vec<_>) = self.entries .into_iter() .partition_map(|entry| match entry.reason { Ok(success) => Either::Left(ReportEntry { reason: success, commit: entry.commit, }), Err(fail) => Either::Right(ReportEntry { reason: fail, commit: entry.commit, }), }); issues.sort_by_key(|entry| entry.issue_kind()); let file = File::create(path) .with_context(|| format!("Failed to create markdown report at {}", path.display()))?; let mut writer = BufWriter::new(file); writeln!(writer, "# Compliance report")?; writeln!(writer)?; writeln!(writer, "## Overview")?; writeln!(writer)?; writeln!(writer, "- PRs: {}", summary.pull_requests)?; writeln!(writer, "- Reviewed: {}", summary.reviewed)?; writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?; if summary.has_errors() { writeln!(writer, "- Errors: {}", summary.errors)?; } writeln!(writer)?; write_issue_table(&mut writer, &issues, &summary)?; write_success_table(&mut writer, &successes)?; writer .flush() .with_context(|| format!("Failed to flush markdown report to {}", path.display())) } } fn write_issue_table( writer: &mut impl Write, issues: &[ReportEntry], summary: &ReportSummary, ) -> std::io::Result<()> { if summary.has_errors() { writeln!(writer, "## Errors and unreviewed commits")?; } else { writeln!(writer, "## Unreviewed commits")?; } writeln!(writer)?; if issues.is_empty() { if summary.has_errors() { writeln!(writer, "No errors or unreviewed commits found.")?; } else { writeln!(writer, "No unreviewed commits found.")?; } writeln!(writer)?; return Ok(()); } writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?; writeln!(writer, "| --- | --- | --- | --- | --- |")?; for entry in issues { let issue_kind = entry.issue_kind(); writeln!( writer, "| {} | {} | {} | {} | {} |", entry.commit_cell(), entry.pull_request_cell(), entry.author_cell(), issue_kind, entry.reason_cell(), )?; } writeln!(writer)?; Ok(()) } fn write_success_table( writer: &mut impl Write, successful_entries: &[ReportEntry], ) -> std::io::Result<()> { writeln!(writer, "## Successful commits")?; writeln!(writer)?; if successful_entries.is_empty() { writeln!(writer, "No successful commits found.")?; writeln!(writer)?; return Ok(()); } writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?; writeln!(writer, "| --- | --- | --- | --- | --- |")?; for entry in successful_entries { writeln!( writer, "| {} | {} | {} | {} | {} |", entry.commit_cell(), entry.pull_request_cell(), entry.author_cell(), entry.reviewers_cell(), entry.reason_cell(), )?; } writeln!(writer)?; Ok(()) } fn escape_markdown_link_text(input: &str) -> String { escape_markdown_table_text(input) .replace('[', r"\[") .replace(']', r"\]") } fn escape_markdown_table_text(input: &str) -> String { input .replace('\\', r"\\") .replace('|', r"\|") .replace('\r', "") .replace('\n', "
") } #[cfg(test)] mod tests { use std::str::FromStr; use crate::{ checks::{ReviewFailure, ReviewSuccess}, git::{CommitDetails, CommitList}, github::{GitHubUser, PullRequestReview, ReviewState}, }; use super::{Report, ReportReviewSummary}; fn make_commit( sha: &str, author_name: &str, author_email: &str, title: &str, body: &str, ) -> CommitDetails { let formatted = format!( "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|" ); CommitList::from_str(&formatted) .expect("test commit should parse") .into_iter() .next() .expect("should have one commit") } fn reviewed() -> ReviewSuccess { ReviewSuccess::PullRequestReviewed(vec![PullRequestReview { user: Some(GitHubUser { login: "reviewer".to_owned(), }), state: Some(ReviewState::Approved), body: None, }]) } #[test] fn report_summary_counts_are_accurate() { let mut report = Report::new(); report.add( make_commit( "aaa", "Alice", "alice@test.com", "Reviewed commit (#100)", "", ), Ok(reviewed()), ); report.add( make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""), Err(ReviewFailure::Unreviewed), ); report.add( make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""), Err(ReviewFailure::NoPullRequestFound), ); report.add( make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""), Err(ReviewFailure::Other(anyhow::anyhow!("some error"))), ); let summary = report.summary(); assert_eq!(summary.pull_requests, 3); assert_eq!(summary.reviewed, 1); assert_eq!(summary.not_reviewed, 2); assert_eq!(summary.errors, 1); } #[test] fn report_summary_all_reviewed_is_no_issues() { let mut report = Report::new(); report.add( make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""), Ok(reviewed()), ); report.add( make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""), Ok(reviewed()), ); let summary = report.summary(); assert!(matches!( summary.review_summary(), ReportReviewSummary::NoIssuesFound )); } #[test] fn report_summary_missing_reviews_only() { let mut report = Report::new(); report.add( make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""), Ok(reviewed()), ); report.add( make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""), Err(ReviewFailure::Unreviewed), ); let summary = report.summary(); assert!(matches!( summary.review_summary(), ReportReviewSummary::MissingReviews )); } #[test] fn report_summary_errors_and_missing_reviews() { let mut report = Report::new(); report.add( make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""), Err(ReviewFailure::Unreviewed), ); report.add( make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""), Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))), ); let summary = report.summary(); assert!(matches!( summary.review_summary(), ReportReviewSummary::MissingReviewsWithErrors )); } #[test] fn report_summary_deduplicates_pull_requests() { let mut report = Report::new(); report.add( make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""), Ok(reviewed()), ); report.add( make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""), Ok(reviewed()), ); let summary = report.summary(); assert_eq!(summary.pull_requests, 1); } }