report.rs

  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}