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