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 pub fn prs_with_errors(&self) -> usize {
120 self.pull_requests - self.reviewed
121 }
122}
123
124#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)]
125enum IssueKind {
126 #[display("Error")]
127 Error,
128 #[display("Not reviewed")]
129 NotReviewed,
130}
131
132#[derive(Debug, Default)]
133pub struct Report {
134 entries: Vec<ReportEntry<ReviewResult>>,
135}
136
137impl Report {
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) {
143 self.entries.push(ReportEntry {
144 commit,
145 reason: result,
146 });
147 }
148
149 pub fn errors(&self) -> impl Iterator<Item = &ReportEntry<ReviewResult>> {
150 self.entries.iter().filter(|entry| entry.reason.is_err())
151 }
152
153 pub fn summary(&self) -> ReportSummary {
154 ReportSummary::from_entries(&self.entries)
155 }
156
157 pub fn write_markdown(self, path: impl AsRef<Path>) -> anyhow::Result<()> {
158 let path = path.as_ref();
159
160 if let Some(parent) = path
161 .parent()
162 .filter(|parent| !parent.as_os_str().is_empty())
163 {
164 fs::create_dir_all(parent).with_context(|| {
165 format!(
166 "Failed to create parent directory for markdown report at {}",
167 path.display()
168 )
169 })?;
170 }
171
172 let summary = self.summary();
173 let (successes, mut issues): (Vec<_>, Vec<_>) =
174 self.entries
175 .into_iter()
176 .partition_map(|entry| match entry.reason {
177 Ok(success) => Either::Left(ReportEntry {
178 reason: success,
179 commit: entry.commit,
180 }),
181 Err(fail) => Either::Right(ReportEntry {
182 reason: fail,
183 commit: entry.commit,
184 }),
185 });
186
187 issues.sort_by_key(|entry| entry.issue_kind());
188
189 let file = File::create(path)
190 .with_context(|| format!("Failed to create markdown report at {}", path.display()))?;
191 let mut writer = BufWriter::new(file);
192
193 writeln!(writer, "# Compliance report")?;
194 writeln!(writer)?;
195 writeln!(writer, "## Overview")?;
196 writeln!(writer)?;
197 writeln!(writer, "- PRs: {}", summary.pull_requests)?;
198 writeln!(writer, "- Reviewed: {}", summary.reviewed)?;
199 writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?;
200 if summary.has_errors() {
201 writeln!(writer, "- Errors: {}", summary.errors)?;
202 }
203 writeln!(writer)?;
204
205 write_issue_table(&mut writer, &issues, &summary)?;
206 write_success_table(&mut writer, &successes)?;
207
208 writer
209 .flush()
210 .with_context(|| format!("Failed to flush markdown report to {}", path.display()))
211 }
212}
213
214fn write_issue_table(
215 writer: &mut impl Write,
216 issues: &[ReportEntry<ReviewFailure>],
217 summary: &ReportSummary,
218) -> std::io::Result<()> {
219 if summary.has_errors() {
220 writeln!(writer, "## Errors and unreviewed commits")?;
221 } else {
222 writeln!(writer, "## Unreviewed commits")?;
223 }
224 writeln!(writer)?;
225
226 if issues.is_empty() {
227 if summary.has_errors() {
228 writeln!(writer, "No errors or unreviewed commits found.")?;
229 } else {
230 writeln!(writer, "No unreviewed commits found.")?;
231 }
232 writeln!(writer)?;
233 return Ok(());
234 }
235
236 writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?;
237 writeln!(writer, "| --- | --- | --- | --- | --- |")?;
238
239 for entry in issues {
240 let issue_kind = entry.issue_kind();
241 writeln!(
242 writer,
243 "| {} | {} | {} | {} | {} |",
244 entry.commit_cell(),
245 entry.pull_request_cell(),
246 entry.author_cell(),
247 issue_kind,
248 entry.reason_cell(),
249 )?;
250 }
251
252 writeln!(writer)?;
253 Ok(())
254}
255
256fn write_success_table(
257 writer: &mut impl Write,
258 successful_entries: &[ReportEntry<ReviewSuccess>],
259) -> std::io::Result<()> {
260 writeln!(writer, "## Successful commits")?;
261 writeln!(writer)?;
262
263 if successful_entries.is_empty() {
264 writeln!(writer, "No successful commits found.")?;
265 writeln!(writer)?;
266 return Ok(());
267 }
268
269 writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?;
270 writeln!(writer, "| --- | --- | --- | --- | --- |")?;
271
272 for entry in successful_entries {
273 writeln!(
274 writer,
275 "| {} | {} | {} | {} | {} |",
276 entry.commit_cell(),
277 entry.pull_request_cell(),
278 entry.author_cell(),
279 entry.reviewers_cell(),
280 entry.reason_cell(),
281 )?;
282 }
283
284 writeln!(writer)?;
285 Ok(())
286}
287
288fn escape_markdown_link_text(input: &str) -> String {
289 escape_markdown_table_text(input)
290 .replace('[', r"\[")
291 .replace(']', r"\]")
292}
293
294fn escape_markdown_table_text(input: &str) -> String {
295 input
296 .replace('\\', r"\\")
297 .replace('|', r"\|")
298 .replace('\r', "")
299 .replace('\n', "<br>")
300}
301
302#[cfg(test)]
303mod tests {
304 use std::str::FromStr;
305
306 use crate::{
307 checks::{ReviewFailure, ReviewSuccess},
308 git::{CommitDetails, CommitList},
309 github::{GitHubUser, PullRequestReview, ReviewState},
310 };
311
312 use super::{Report, ReportReviewSummary};
313
314 fn make_commit(
315 sha: &str,
316 author_name: &str,
317 author_email: &str,
318 title: &str,
319 body: &str,
320 ) -> CommitDetails {
321 let formatted = format!(
322 "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|"
323 );
324 CommitList::from_str(&formatted)
325 .expect("test commit should parse")
326 .into_iter()
327 .next()
328 .expect("should have one commit")
329 }
330
331 fn reviewed() -> ReviewSuccess {
332 ReviewSuccess::PullRequestReviewed(vec![PullRequestReview {
333 user: Some(GitHubUser {
334 login: "reviewer".to_owned(),
335 }),
336 state: Some(ReviewState::Approved),
337 body: None,
338 }])
339 }
340
341 #[test]
342 fn report_summary_counts_are_accurate() {
343 let mut report = Report::new();
344
345 report.add(
346 make_commit(
347 "aaa",
348 "Alice",
349 "alice@test.com",
350 "Reviewed commit (#100)",
351 "",
352 ),
353 Ok(reviewed()),
354 );
355 report.add(
356 make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""),
357 Err(ReviewFailure::Unreviewed),
358 );
359 report.add(
360 make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""),
361 Err(ReviewFailure::NoPullRequestFound),
362 );
363 report.add(
364 make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""),
365 Err(ReviewFailure::Other(anyhow::anyhow!("some error"))),
366 );
367
368 let summary = report.summary();
369 assert_eq!(summary.pull_requests, 3);
370 assert_eq!(summary.reviewed, 1);
371 assert_eq!(summary.not_reviewed, 2);
372 assert_eq!(summary.errors, 1);
373 }
374
375 #[test]
376 fn report_summary_all_reviewed_is_no_issues() {
377 let mut report = Report::new();
378
379 report.add(
380 make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""),
381 Ok(reviewed()),
382 );
383 report.add(
384 make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""),
385 Ok(reviewed()),
386 );
387
388 let summary = report.summary();
389 assert!(matches!(
390 summary.review_summary(),
391 ReportReviewSummary::NoIssuesFound
392 ));
393 }
394
395 #[test]
396 fn report_summary_missing_reviews_only() {
397 let mut report = Report::new();
398
399 report.add(
400 make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""),
401 Ok(reviewed()),
402 );
403 report.add(
404 make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""),
405 Err(ReviewFailure::Unreviewed),
406 );
407
408 let summary = report.summary();
409 assert!(matches!(
410 summary.review_summary(),
411 ReportReviewSummary::MissingReviews
412 ));
413 }
414
415 #[test]
416 fn report_summary_errors_and_missing_reviews() {
417 let mut report = Report::new();
418
419 report.add(
420 make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""),
421 Err(ReviewFailure::Unreviewed),
422 );
423 report.add(
424 make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""),
425 Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))),
426 );
427
428 let summary = report.summary();
429 assert!(matches!(
430 summary.review_summary(),
431 ReportReviewSummary::MissingReviewsWithErrors
432 ));
433 }
434
435 #[test]
436 fn report_summary_deduplicates_pull_requests() {
437 let mut report = Report::new();
438
439 report.add(
440 make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""),
441 Ok(reviewed()),
442 );
443 report.add(
444 make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""),
445 Ok(reviewed()),
446 );
447
448 let summary = report.summary();
449 assert_eq!(summary.pull_requests, 1);
450 }
451}