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