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 | 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}