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