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