1use std::{
2 fs::{self, File},
3 io::{BufWriter, Write},
4 path::Path,
5};
6
7use anyhow::Context as _;
8use derive_more::Display;
9use itertools::{Either, Itertools};
10
11use crate::{
12 checks::{ReviewFailure, ReviewResult, ReviewSuccess},
13 git::CommitDetails,
14};
15
16const PULL_REQUEST_BASE_URL: &str = "https://github.com/zed-industries/zed/pull";
17
18#[derive(Debug)]
19pub struct ReportEntry<R> {
20 pub commit: CommitDetails,
21 reason: R,
22}
23
24impl<R: ToString> ReportEntry<R> {
25 fn commit_cell(&self) -> String {
26 let title = escape_markdown_link_text(self.commit.title());
27
28 match self.commit.pr_number() {
29 Some(pr_number) => format!("[{title}]({PULL_REQUEST_BASE_URL}/{pr_number})"),
30 None => escape_markdown_table_text(self.commit.title()),
31 }
32 }
33
34 fn pull_request_cell(&self) -> String {
35 self.commit
36 .pr_number()
37 .map(|pr_number| format!("#{pr_number}"))
38 .unwrap_or_else(|| "—".to_owned())
39 }
40
41 fn author_cell(&self) -> String {
42 escape_markdown_table_text(&self.commit.author().to_string())
43 }
44
45 fn reason_cell(&self) -> String {
46 escape_markdown_table_text(&self.reason.to_string())
47 }
48}
49
50impl ReportEntry<ReviewFailure> {
51 fn issue_kind(&self) -> IssueKind {
52 match self.reason {
53 ReviewFailure::Other(_) => IssueKind::Error,
54 _ => IssueKind::NotReviewed,
55 }
56 }
57}
58
59impl ReportEntry<ReviewSuccess> {
60 fn reviewers_cell(&self) -> String {
61 match &self.reason.reviewers() {
62 Ok(reviewers) => escape_markdown_table_text(&reviewers),
63 Err(_) => "—".to_owned(),
64 }
65 }
66}
67
68#[derive(Debug, Default)]
69pub struct ReportSummary {
70 pub pull_requests: usize,
71 pub reviewed: usize,
72 pub not_reviewed: usize,
73 pub errors: usize,
74}
75
76pub enum ReportReviewSummary {
77 MissingReviews,
78 MissingReviewsWithErrors,
79 NoIssuesFound,
80}
81
82impl ReportSummary {
83 fn from_entries(entries: &[ReportEntry<ReviewResult>]) -> Self {
84 Self {
85 pull_requests: entries
86 .iter()
87 .filter_map(|entry| entry.commit.pr_number())
88 .unique()
89 .count(),
90 reviewed: entries.iter().filter(|entry| entry.reason.is_ok()).count(),
91 not_reviewed: entries
92 .iter()
93 .filter(|entry| {
94 matches!(
95 entry.reason,
96 Err(ReviewFailure::NoPullRequestFound | ReviewFailure::Unreviewed)
97 )
98 })
99 .count(),
100 errors: entries
101 .iter()
102 .filter(|entry| matches!(entry.reason, Err(ReviewFailure::Other(_))))
103 .count(),
104 }
105 }
106
107 pub fn review_summary(&self) -> ReportReviewSummary {
108 match self.not_reviewed {
109 0 if self.errors == 0 => ReportReviewSummary::NoIssuesFound,
110 1.. if self.errors == 0 => ReportReviewSummary::MissingReviews,
111 _ => ReportReviewSummary::MissingReviewsWithErrors,
112 }
113 }
114
115 fn has_errors(&self) -> bool {
116 self.errors > 0
117 }
118}
119
120#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)]
121enum IssueKind {
122 #[display("Error")]
123 Error,
124 #[display("Not reviewed")]
125 NotReviewed,
126}
127
128#[derive(Debug, Default)]
129pub struct Report {
130 entries: Vec<ReportEntry<ReviewResult>>,
131}
132
133impl Report {
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) {
139 self.entries.push(ReportEntry {
140 commit,
141 reason: result,
142 });
143 }
144
145 pub fn errors(&self) -> impl Iterator<Item = &ReportEntry<ReviewResult>> {
146 self.entries.iter().filter(|entry| entry.reason.is_err())
147 }
148
149 pub fn summary(&self) -> ReportSummary {
150 ReportSummary::from_entries(&self.entries)
151 }
152
153 pub fn write_markdown(self, path: impl AsRef<Path>) -> anyhow::Result<()> {
154 let path = path.as_ref();
155
156 if let Some(parent) = path
157 .parent()
158 .filter(|parent| !parent.as_os_str().is_empty())
159 {
160 fs::create_dir_all(parent).with_context(|| {
161 format!(
162 "Failed to create parent directory for markdown report at {}",
163 path.display()
164 )
165 })?;
166 }
167
168 let summary = self.summary();
169 let (successes, mut issues): (Vec<_>, Vec<_>) =
170 self.entries
171 .into_iter()
172 .partition_map(|entry| match entry.reason {
173 Ok(success) => Either::Left(ReportEntry {
174 reason: success,
175 commit: entry.commit,
176 }),
177 Err(fail) => Either::Right(ReportEntry {
178 reason: fail,
179 commit: entry.commit,
180 }),
181 });
182
183 issues.sort_by_key(|entry| entry.issue_kind());
184
185 let file = File::create(path)
186 .with_context(|| format!("Failed to create markdown report at {}", path.display()))?;
187 let mut writer = BufWriter::new(file);
188
189 writeln!(writer, "# Compliance report")?;
190 writeln!(writer)?;
191 writeln!(writer, "## Overview")?;
192 writeln!(writer)?;
193 writeln!(writer, "- PRs: {}", summary.pull_requests)?;
194 writeln!(writer, "- Reviewed: {}", summary.reviewed)?;
195 writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?;
196 if summary.has_errors() {
197 writeln!(writer, "- Errors: {}", summary.errors)?;
198 }
199 writeln!(writer)?;
200
201 write_issue_table(&mut writer, &issues, &summary)?;
202 write_success_table(&mut writer, &successes)?;
203
204 writer
205 .flush()
206 .with_context(|| format!("Failed to flush markdown report to {}", path.display()))
207 }
208}
209
210fn write_issue_table(
211 writer: &mut impl Write,
212 issues: &[ReportEntry<ReviewFailure>],
213 summary: &ReportSummary,
214) -> std::io::Result<()> {
215 if summary.has_errors() {
216 writeln!(writer, "## Errors and unreviewed commits")?;
217 } else {
218 writeln!(writer, "## Unreviewed commits")?;
219 }
220 writeln!(writer)?;
221
222 if issues.is_empty() {
223 if summary.has_errors() {
224 writeln!(writer, "No errors or unreviewed commits found.")?;
225 } else {
226 writeln!(writer, "No unreviewed commits found.")?;
227 }
228 writeln!(writer)?;
229 return Ok(());
230 }
231
232 writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?;
233 writeln!(writer, "| --- | --- | --- | --- | --- |")?;
234
235 for entry in issues {
236 let issue_kind = entry.issue_kind();
237 writeln!(
238 writer,
239 "| {} | {} | {} | {} | {} |",
240 entry.commit_cell(),
241 entry.pull_request_cell(),
242 entry.author_cell(),
243 issue_kind,
244 entry.reason_cell(),
245 )?;
246 }
247
248 writeln!(writer)?;
249 Ok(())
250}
251
252fn write_success_table(
253 writer: &mut impl Write,
254 successful_entries: &[ReportEntry<ReviewSuccess>],
255) -> std::io::Result<()> {
256 writeln!(writer, "## Successful commits")?;
257 writeln!(writer)?;
258
259 if successful_entries.is_empty() {
260 writeln!(writer, "No successful commits found.")?;
261 writeln!(writer)?;
262 return Ok(());
263 }
264
265 writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?;
266 writeln!(writer, "| --- | --- | --- | --- | --- |")?;
267
268 for entry in successful_entries {
269 writeln!(
270 writer,
271 "| {} | {} | {} | {} | {} |",
272 entry.commit_cell(),
273 entry.pull_request_cell(),
274 entry.author_cell(),
275 entry.reviewers_cell(),
276 entry.reason_cell(),
277 )?;
278 }
279
280 writeln!(writer)?;
281 Ok(())
282}
283
284fn escape_markdown_link_text(input: &str) -> String {
285 escape_markdown_table_text(input)
286 .replace('[', r"\[")
287 .replace(']', r"\]")
288}
289
290fn escape_markdown_table_text(input: &str) -> String {
291 input
292 .replace('\\', r"\\")
293 .replace('|', r"\|")
294 .replace('\r', "")
295 .replace('\n', "<br>")
296}
297
298#[cfg(test)]
299mod tests {
300 use std::str::FromStr;
301
302 use crate::{
303 checks::{ReviewFailure, ReviewSuccess},
304 git::{CommitDetails, CommitList},
305 github::{GitHubUser, PullRequestReview, ReviewState},
306 };
307
308 use super::{Report, ReportReviewSummary};
309
310 fn make_commit(
311 sha: &str,
312 author_name: &str,
313 author_email: &str,
314 title: &str,
315 body: &str,
316 ) -> CommitDetails {
317 let formatted = format!(
318 "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|"
319 );
320 CommitList::from_str(&formatted)
321 .expect("test commit should parse")
322 .into_iter()
323 .next()
324 .expect("should have one commit")
325 }
326
327 fn reviewed() -> ReviewSuccess {
328 ReviewSuccess::PullRequestReviewed(vec![PullRequestReview {
329 user: Some(GitHubUser {
330 login: "reviewer".to_owned(),
331 }),
332 state: Some(ReviewState::Approved),
333 }])
334 }
335
336 #[test]
337 fn report_summary_counts_are_accurate() {
338 let mut report = Report::new();
339
340 report.add(
341 make_commit(
342 "aaa",
343 "Alice",
344 "alice@test.com",
345 "Reviewed commit (#100)",
346 "",
347 ),
348 Ok(reviewed()),
349 );
350 report.add(
351 make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""),
352 Err(ReviewFailure::Unreviewed),
353 );
354 report.add(
355 make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""),
356 Err(ReviewFailure::NoPullRequestFound),
357 );
358 report.add(
359 make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""),
360 Err(ReviewFailure::Other(anyhow::anyhow!("some error"))),
361 );
362
363 let summary = report.summary();
364 assert_eq!(summary.pull_requests, 3);
365 assert_eq!(summary.reviewed, 1);
366 assert_eq!(summary.not_reviewed, 2);
367 assert_eq!(summary.errors, 1);
368 }
369
370 #[test]
371 fn report_summary_all_reviewed_is_no_issues() {
372 let mut report = Report::new();
373
374 report.add(
375 make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""),
376 Ok(reviewed()),
377 );
378 report.add(
379 make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""),
380 Ok(reviewed()),
381 );
382
383 let summary = report.summary();
384 assert!(matches!(
385 summary.review_summary(),
386 ReportReviewSummary::NoIssuesFound
387 ));
388 }
389
390 #[test]
391 fn report_summary_missing_reviews_only() {
392 let mut report = Report::new();
393
394 report.add(
395 make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""),
396 Ok(reviewed()),
397 );
398 report.add(
399 make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""),
400 Err(ReviewFailure::Unreviewed),
401 );
402
403 let summary = report.summary();
404 assert!(matches!(
405 summary.review_summary(),
406 ReportReviewSummary::MissingReviews
407 ));
408 }
409
410 #[test]
411 fn report_summary_errors_and_missing_reviews() {
412 let mut report = Report::new();
413
414 report.add(
415 make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""),
416 Err(ReviewFailure::Unreviewed),
417 );
418 report.add(
419 make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""),
420 Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))),
421 );
422
423 let summary = report.summary();
424 assert!(matches!(
425 summary.review_summary(),
426 ReportReviewSummary::MissingReviewsWithErrors
427 ));
428 }
429
430 #[test]
431 fn report_summary_deduplicates_pull_requests() {
432 let mut report = Report::new();
433
434 report.add(
435 make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""),
436 Ok(reviewed()),
437 );
438 report.add(
439 make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""),
440 Ok(reviewed()),
441 );
442
443 let summary = report.summary();
444 assert_eq!(summary.pull_requests, 1);
445 }
446}