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