Skip to main content

fallow_output/
pr_summary.rs

1//! Pure renderer for sticky PR summary comments.
2
3use std::fmt::Write as _;
4
5use crate::{CiProvider, PrCommentEnvelope, PrCommentTruncation, command_title, escape_md};
6
7/// Per-area gate status shown in the PR summary table.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum PrSummaryStatus {
10    /// Area is within its threshold.
11    Pass,
12    /// Area has advisory findings but does not fail the gate.
13    Warn,
14    /// Area breaches its threshold and fails the gate.
15    Fail,
16    /// Informational area with no gate semantics.
17    Info,
18}
19
20/// What slice of the codebase the summarized run analysed.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum PrSummaryScope {
23    /// Whole-project analysis.
24    Project,
25    /// Diff-scoped analysis against the PR base.
26    Diff,
27    /// Analysis restricted to the files changed in the PR.
28    ChangedFiles,
29}
30
31/// One analysis area row in the PR summary table.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct PrSummaryArea {
34    /// Display name of the area, e.g. "Duplication".
35    pub name: String,
36    /// Gate status rendered as the row's status icon.
37    pub status: PrSummaryStatus,
38    /// Observed result text, e.g. "9.1% on changed code".
39    pub result: String,
40    /// Configured threshold text, when the area gates.
41    pub threshold: Option<String>,
42    /// Extra context appended to the row, when available.
43    pub details: Option<String>,
44}
45
46/// One finding row in the PR summary's top-findings list.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct PrSummaryFinding {
49    /// Severity label, e.g. "error" or "warning".
50    pub severity: String,
51    /// Rule identifier the finding belongs to.
52    pub rule_id: String,
53    /// `path:line` location text.
54    pub location: String,
55    /// Human-readable finding description.
56    pub description: String,
57    /// Suggested fix text, when one is known.
58    pub fix: Option<String>,
59}
60
61/// Body layout variant for the sticky PR comment.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PrCommentLayout {
64    /// Area table plus top findings.
65    Default,
66    /// Single-line-per-area condensed body.
67    Compact,
68    /// Gate outcome only, no per-finding rows.
69    GateOnly,
70    /// Same sections as `Default`; consumers may render extra detail around it.
71    Details,
72}
73
74/// Inputs for [`render_pr_summary`].
75pub struct PrSummaryInput<'a> {
76    /// Fallow command the summary reports on, e.g. `audit`.
77    pub command: &'a str,
78    /// CI provider whose comment conventions apply.
79    pub provider: CiProvider,
80    /// Identity token embedded as an HTML marker so reruns update the same
81    /// sticky comment instead of posting a new one.
82    pub marker_id: String,
83    /// Analysis scope reported in the header.
84    pub scope: PrSummaryScope,
85    /// Area rows for the summary table.
86    pub areas: &'a [PrSummaryArea],
87    /// Findings eligible for the top-findings list.
88    pub findings: &'a [PrSummaryFinding],
89    /// Maximum findings to render; clamped to at least 1.
90    pub max_findings: usize,
91    /// Link to the full report, when hosted output exists.
92    pub details_url: Option<&'a str>,
93    /// Body layout variant.
94    pub layout: PrCommentLayout,
95    /// The status note, rendered as one blockquote line under the callout. The
96    /// caller joins it with the same function as the saved render, so both
97    /// bodies state the same clauses in the same order.
98    pub status_note: Option<&'a str>,
99    /// A Markdown section after the findings and before the footer, in every
100    /// layout. The CLI uses it for the unmatched config patterns.
101    pub trailing_section: Option<&'a str>,
102}
103
104/// Renders the sticky PR summary comment body, prefixed with its identity
105/// marker and carrying finding-count truncation metadata in the envelope.
106#[must_use]
107pub fn render_pr_summary(input: &PrSummaryInput<'_>) -> PrCommentEnvelope {
108    let max_findings = input.max_findings.max(1);
109    let is_clean = input.findings.is_empty()
110        && input
111            .areas
112            .iter()
113            .all(|area| matches!(area.status, PrSummaryStatus::Pass | PrSummaryStatus::Info));
114    let status = summary_status(input.areas);
115    let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
116    let mut body = String::new();
117    body.push_str(&marker);
118    body.push('\n');
119    render_header(&mut body, input);
120    render_callout(&mut body, status, is_clean, input.findings.len());
121    if let Some(note) = input.status_note.filter(|note| !note.is_empty()) {
122        let _ = writeln!(body, "> {note}\n");
123    }
124    match input.layout {
125        PrCommentLayout::Default | PrCommentLayout::Details => {
126            render_area_table(&mut body, input.areas);
127            render_top_findings(&mut body, input.findings, max_findings);
128        }
129        PrCommentLayout::GateOnly => {
130            render_area_table(&mut body, input.areas);
131        }
132        PrCommentLayout::Compact => {
133            render_compact_gates(&mut body, input.areas);
134        }
135    }
136    if let Some(section) = input
137        .trailing_section
138        .map(str::trim)
139        .filter(|section| !section.is_empty())
140    {
141        body.push_str(section);
142        body.push_str("\n\n");
143    }
144    render_footer(&mut body);
145
146    let shown_findings = input.findings.len().min(max_findings);
147    PrCommentEnvelope {
148        marker_id: input.marker_id.clone(),
149        body,
150        is_clean,
151        details_url: input.details_url.map(str::to_owned),
152        check_summary: Some(status_label(status).to_owned()),
153        truncation: PrCommentTruncation {
154            truncated: input.findings.len() > max_findings,
155            shown_findings,
156            total_findings: input.findings.len(),
157        },
158    }
159}
160
161fn render_header(out: &mut String, input: &PrSummaryInput<'_>) {
162    let title = command_title(input.command);
163    let scope = scope_label(input.scope);
164    let provider = input.provider.name();
165    let target = provider_target_label(input.provider);
166    let _ = writeln!(out, "# Fallow {title}\n");
167    let _ = writeln!(out, "_{provider} {target} summary, scope: {scope}_\n");
168}
169
170fn render_callout(out: &mut String, status: PrSummaryStatus, is_clean: bool, finding_count: usize) {
171    let kind = callout_kind(status, is_clean);
172    let message = callout_message(status, is_clean, finding_count);
173    let _ = writeln!(out, "> [!{kind}]");
174    let _ = writeln!(out, "> {message}\n");
175}
176
177fn summary_status(areas: &[PrSummaryArea]) -> PrSummaryStatus {
178    if areas
179        .iter()
180        .any(|area| area.status == PrSummaryStatus::Fail)
181    {
182        return PrSummaryStatus::Fail;
183    }
184    if areas
185        .iter()
186        .any(|area| area.status == PrSummaryStatus::Warn)
187    {
188        return PrSummaryStatus::Warn;
189    }
190    if areas
191        .iter()
192        .any(|area| area.status == PrSummaryStatus::Info)
193    {
194        return PrSummaryStatus::Info;
195    }
196    PrSummaryStatus::Pass
197}
198
199fn callout_kind(status: PrSummaryStatus, is_clean: bool) -> &'static str {
200    if is_clean {
201        return "NOTE";
202    }
203    match status {
204        PrSummaryStatus::Fail => "IMPORTANT",
205        PrSummaryStatus::Warn => "WARNING",
206        PrSummaryStatus::Pass | PrSummaryStatus::Info => "NOTE",
207    }
208}
209
210fn callout_message(status: PrSummaryStatus, is_clean: bool, finding_count: usize) -> String {
211    if is_clean {
212        return "No review-visible findings were produced for this run.".to_owned();
213    }
214    let noun = if finding_count == 1 {
215        "finding"
216    } else {
217        "findings"
218    };
219    match status {
220        PrSummaryStatus::Fail => {
221            format!("Quality gates need attention. Found {finding_count} {noun}.")
222        }
223        PrSummaryStatus::Warn => format!("Review recommended. Found {finding_count} {noun}."),
224        PrSummaryStatus::Pass | PrSummaryStatus::Info => {
225            format!("No blocking gates failed. Showing {finding_count} {noun}.")
226        }
227    }
228}
229
230fn scope_label(scope: PrSummaryScope) -> &'static str {
231    match scope {
232        PrSummaryScope::Project => "project",
233        PrSummaryScope::Diff => "diff",
234        PrSummaryScope::ChangedFiles => "changed files",
235    }
236}
237
238fn provider_target_label(provider: CiProvider) -> &'static str {
239    match provider {
240        CiProvider::Github => "PR",
241        CiProvider::Gitlab => "MR",
242    }
243}
244
245fn render_area_table(out: &mut String, areas: &[PrSummaryArea]) {
246    if areas.is_empty() {
247        return;
248    }
249    out.push_str("## Checks\n\n");
250    out.push_str("| Area | Status | Result | Threshold | Details |\n");
251    out.push_str("| --- | --- | --- | --- | --- |\n");
252    for area in areas {
253        let threshold = area.threshold.as_deref().unwrap_or("n/a");
254        let details = area.details.as_deref().unwrap_or("");
255        let _ = writeln!(
256            out,
257            "| {} | {} | {} | {} | {} |",
258            escape_md(&area.name),
259            status_label(area.status),
260            escape_md(&area.result),
261            escape_md(threshold),
262            escape_md(details)
263        );
264    }
265    out.push('\n');
266}
267
268fn render_compact_gates(out: &mut String, areas: &[PrSummaryArea]) {
269    let notable = areas
270        .iter()
271        .filter(|area| !matches!(area.status, PrSummaryStatus::Pass | PrSummaryStatus::Info))
272        .collect::<Vec<_>>();
273    if notable.is_empty() {
274        out.push_str("All PR gates passed.\n\n");
275        return;
276    }
277    out.push_str("## Gates\n\n");
278    for area in notable {
279        let _ = writeln!(
280            out,
281            "- {}: {} ({})",
282            escape_md(&area.name),
283            status_label(area.status),
284            escape_md(&area.result)
285        );
286    }
287    out.push('\n');
288}
289
290fn render_top_findings(out: &mut String, findings: &[PrSummaryFinding], max_findings: usize) {
291    if findings.is_empty() {
292        return;
293    }
294    let summary = if findings.len() > max_findings {
295        format!("Top fixes (showing {max_findings} of {})", findings.len())
296    } else {
297        "Top fixes".to_owned()
298    };
299    let _ = writeln!(out, "<details open>\n<summary>{summary}</summary>\n");
300    out.push_str("| Severity | Fix | Location | Why |\n");
301    out.push_str("| --- | --- | --- | --- |\n");
302    for finding in findings.iter().take(max_findings) {
303        render_finding_row(out, finding);
304    }
305    if findings.len() > max_findings {
306        let _ = writeln!(
307            out,
308            "\nShowing {max_findings} of {} findings. Inspect the CI artifact for the full report.",
309            findings.len()
310        );
311    }
312    out.push_str("\n</details>\n\n");
313}
314
315fn render_finding_row(out: &mut String, finding: &PrSummaryFinding) {
316    let _ = writeln!(
317        out,
318        "| {} | {} | `{}` | {} |",
319        escape_md(&finding.severity),
320        escape_md(finding.fix.as_deref().unwrap_or(&finding.rule_id)),
321        escape_md(&finding.location),
322        escape_md(&finding.description)
323    );
324}
325
326fn status_label(status: PrSummaryStatus) -> &'static str {
327    match status {
328        PrSummaryStatus::Pass => "pass",
329        PrSummaryStatus::Warn => "warn",
330        PrSummaryStatus::Fail => "fail",
331        PrSummaryStatus::Info => "info",
332    }
333}
334
335fn render_footer(out: &mut String) {
336    out.push_str("Generated by fallow.");
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    const DEFAULT_MAX_FINDINGS: usize = 50;
344
345    fn input<'a>(
346        areas: &'a [PrSummaryArea],
347        findings: &'a [PrSummaryFinding],
348    ) -> PrSummaryInput<'a> {
349        PrSummaryInput {
350            command: "combined",
351            provider: CiProvider::Github,
352            marker_id: "fallow-results".to_owned(),
353            scope: PrSummaryScope::Project,
354            areas,
355            findings,
356            max_findings: DEFAULT_MAX_FINDINGS,
357            details_url: None,
358            layout: PrCommentLayout::Default,
359            status_note: None,
360            trailing_section: None,
361        }
362    }
363
364    #[test]
365    fn trailing_section_renders_before_the_footer_in_every_layout() {
366        for layout in [
367            PrCommentLayout::Default,
368            PrCommentLayout::Details,
369            PrCommentLayout::GateOnly,
370            PrCommentLayout::Compact,
371        ] {
372            let custom = PrSummaryInput {
373                layout,
374                trailing_section: Some("\n## Unmatched config patterns\n\n- `@typo/*`"),
375                ..input(&[], &[])
376            };
377            let body = render_pr_summary(&custom).body;
378            assert!(
379                body.ends_with(
380                    "## Unmatched config patterns\n\n- `@typo/*`\n\nGenerated by fallow."
381                ),
382                "{layout:?}: {body}"
383            );
384        }
385        assert!(
386            !render_pr_summary(&input(&[], &[]))
387                .body
388                .contains("Unmatched config patterns"),
389            "no section, no text"
390        );
391    }
392
393    #[test]
394    fn status_note_renders_as_one_blockquote_line_under_the_callout() {
395        let custom = PrSummaryInput {
396            status_note: Some("Request outcomes: not applied diff-filter (unreadable)."),
397            ..input(&[], &[])
398        };
399
400        let body = render_pr_summary(&custom).body;
401
402        let callout = body.find("> [!").expect("callout");
403        let note = body
404            .find("\n> Request outcomes: not applied diff-filter (unreadable).\n")
405            .expect("note line");
406        assert!(note > callout, "the note follows the callout: {body}");
407        assert!(
408            !render_pr_summary(&input(&[], &[]))
409                .body
410                .contains("Request outcomes"),
411            "no note, no line"
412        );
413    }
414
415    #[test]
416    fn clean_summary_marks_envelope_without_sentinel_body_policy() {
417        let envelope = render_pr_summary(&input(&[], &[]));
418
419        assert!(envelope.is_clean);
420        assert!(envelope.body.contains("No review-visible findings"));
421        assert!(!envelope.body.contains("fallow-clean-sentinel"));
422    }
423
424    #[test]
425    fn gitlab_header_uses_mr_language() {
426        let custom = PrSummaryInput {
427            provider: CiProvider::Gitlab,
428            ..input(&[], &[])
429        };
430
431        let envelope = render_pr_summary(&custom);
432
433        assert!(envelope.body.contains("_GitLab MR summary"));
434        assert!(!envelope.body.contains("_GitLab PR summary"));
435    }
436
437    #[test]
438    fn warning_summary_leads_with_review_message_and_checks_table() {
439        let areas = [PrSummaryArea {
440            name: "Duplication".to_owned(),
441            status: PrSummaryStatus::Warn,
442            result: "2 clone groups".to_owned(),
443            threshold: Some("<= 3% duplication".to_owned()),
444            details: Some("9.1% duplicated lines".to_owned()),
445        }];
446        let findings = [PrSummaryFinding {
447            severity: "minor".to_owned(),
448            rule_id: "fallow/code-duplication".to_owned(),
449            location: "src/a.ts:10".to_owned(),
450            description: "Code clone group 1".to_owned(),
451            fix: Some("Extract the repeated block.".to_owned()),
452        }];
453
454        let envelope = render_pr_summary(&input(&areas, &findings));
455
456        assert!(!envelope.is_clean);
457        assert!(envelope.body.contains("> [!WARNING]"));
458        assert!(
459            envelope
460                .body
461                .contains("| Duplication | warn | 2 clone groups |")
462        );
463        assert!(envelope.body.contains("<details open>"));
464        assert!(envelope.body.contains("<summary>Top fixes</summary>"));
465        assert!(envelope.body.contains("Extract the repeated block."));
466    }
467
468    #[test]
469    fn findings_are_capped_and_mark_envelope_truncated() {
470        let findings = [
471            PrSummaryFinding {
472                severity: "minor".to_owned(),
473                rule_id: "fallow/a".to_owned(),
474                location: "src/a.ts:1".to_owned(),
475                description: "A".to_owned(),
476                fix: None,
477            },
478            PrSummaryFinding {
479                severity: "minor".to_owned(),
480                rule_id: "fallow/b".to_owned(),
481                location: "src/b.ts:1".to_owned(),
482                description: "B".to_owned(),
483                fix: None,
484            },
485        ];
486        let custom = PrSummaryInput {
487            max_findings: 1,
488            ..input(&[], &findings)
489        };
490
491        let envelope = render_pr_summary(&custom);
492
493        assert!(envelope.truncation.truncated);
494        assert!(envelope.body.contains("showing 1 of 2"));
495        assert!(envelope.body.contains("fallow/a"));
496        assert!(!envelope.body.contains("fallow/b"));
497    }
498
499    #[test]
500    fn details_url_is_preserved_on_the_envelope() {
501        let custom = PrSummaryInput {
502            details_url: Some("https://example.test/fallow"),
503            ..input(&[], &[])
504        };
505
506        let envelope = render_pr_summary(&custom);
507
508        assert_eq!(
509            envelope.details_url.as_deref(),
510            Some("https://example.test/fallow")
511        );
512    }
513
514    #[test]
515    fn gate_only_layout_skips_top_findings() {
516        let areas = [PrSummaryArea {
517            name: "Health".to_owned(),
518            status: PrSummaryStatus::Warn,
519            result: "1 finding".to_owned(),
520            threshold: Some("configured rules".to_owned()),
521            details: None,
522        }];
523        let findings = [PrSummaryFinding {
524            severity: "minor".to_owned(),
525            rule_id: "fallow/high-crap-score".to_owned(),
526            location: "src/a.ts:10".to_owned(),
527            description: "High CRAP score".to_owned(),
528            fix: None,
529        }];
530        let custom = PrSummaryInput {
531            layout: PrCommentLayout::GateOnly,
532            ..input(&areas, &findings)
533        };
534
535        let envelope = render_pr_summary(&custom);
536
537        assert!(envelope.body.contains("## Checks"));
538        assert!(!envelope.body.contains("Top fixes"));
539        assert!(!envelope.body.contains("High CRAP score"));
540    }
541
542    #[test]
543    fn compact_layout_renders_failed_or_warning_gates_only() {
544        let areas = [
545            PrSummaryArea {
546                name: "Dead code".to_owned(),
547                status: PrSummaryStatus::Pass,
548                result: "0 issues".to_owned(),
549                threshold: None,
550                details: None,
551            },
552            PrSummaryArea {
553                name: "Duplication".to_owned(),
554                status: PrSummaryStatus::Warn,
555                result: "2 clone groups".to_owned(),
556                threshold: None,
557                details: None,
558            },
559        ];
560        let custom = PrSummaryInput {
561            layout: PrCommentLayout::Compact,
562            ..input(&areas, &[])
563        };
564
565        let envelope = render_pr_summary(&custom);
566
567        assert!(envelope.body.contains("## Gates"));
568        assert!(
569            envelope
570                .body
571                .contains("- Duplication: warn (2 clone groups)")
572        );
573        assert!(!envelope.body.contains("| Dead code |"));
574        assert!(!envelope.body.contains("Top fixes"));
575    }
576}