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