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