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}
96
97/// Renders the sticky PR summary comment body, prefixed with its identity
98/// marker and carrying finding-count truncation metadata in the envelope.
99#[must_use]
100pub fn render_pr_summary(input: &PrSummaryInput<'_>) -> PrCommentEnvelope {
101    let max_findings = input.max_findings.max(1);
102    let is_clean = input.findings.is_empty()
103        && input
104            .areas
105            .iter()
106            .all(|area| matches!(area.status, PrSummaryStatus::Pass | PrSummaryStatus::Info));
107    let status = summary_status(input.areas);
108    let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
109    let mut body = String::new();
110    body.push_str(&marker);
111    body.push('\n');
112    render_header(&mut body, input);
113    render_callout(&mut body, status, is_clean, input.findings.len());
114    match input.layout {
115        PrCommentLayout::Default | PrCommentLayout::Details => {
116            render_area_table(&mut body, input.areas);
117            render_top_findings(&mut body, input.findings, max_findings);
118        }
119        PrCommentLayout::GateOnly => {
120            render_area_table(&mut body, input.areas);
121        }
122        PrCommentLayout::Compact => {
123            render_compact_gates(&mut body, input.areas);
124        }
125    }
126    render_footer(&mut body);
127
128    let shown_findings = input.findings.len().min(max_findings);
129    PrCommentEnvelope {
130        marker_id: input.marker_id.clone(),
131        body,
132        is_clean,
133        details_url: input.details_url.map(str::to_owned),
134        check_summary: Some(status_label(status).to_owned()),
135        truncation: PrCommentTruncation {
136            truncated: input.findings.len() > max_findings,
137            shown_findings,
138            total_findings: input.findings.len(),
139        },
140    }
141}
142
143fn render_header(out: &mut String, input: &PrSummaryInput<'_>) {
144    let title = command_title(input.command);
145    let scope = scope_label(input.scope);
146    let provider = input.provider.name();
147    let target = provider_target_label(input.provider);
148    let _ = writeln!(out, "# Fallow {title}\n");
149    let _ = writeln!(out, "_{provider} {target} summary, scope: {scope}_\n");
150}
151
152fn render_callout(out: &mut String, status: PrSummaryStatus, is_clean: bool, finding_count: usize) {
153    let kind = callout_kind(status, is_clean);
154    let message = callout_message(status, is_clean, finding_count);
155    let _ = writeln!(out, "> [!{kind}]");
156    let _ = writeln!(out, "> {message}\n");
157}
158
159fn summary_status(areas: &[PrSummaryArea]) -> PrSummaryStatus {
160    if areas
161        .iter()
162        .any(|area| area.status == PrSummaryStatus::Fail)
163    {
164        return PrSummaryStatus::Fail;
165    }
166    if areas
167        .iter()
168        .any(|area| area.status == PrSummaryStatus::Warn)
169    {
170        return PrSummaryStatus::Warn;
171    }
172    if areas
173        .iter()
174        .any(|area| area.status == PrSummaryStatus::Info)
175    {
176        return PrSummaryStatus::Info;
177    }
178    PrSummaryStatus::Pass
179}
180
181fn callout_kind(status: PrSummaryStatus, is_clean: bool) -> &'static str {
182    if is_clean {
183        return "NOTE";
184    }
185    match status {
186        PrSummaryStatus::Fail => "IMPORTANT",
187        PrSummaryStatus::Warn => "WARNING",
188        PrSummaryStatus::Pass | PrSummaryStatus::Info => "NOTE",
189    }
190}
191
192fn callout_message(status: PrSummaryStatus, is_clean: bool, finding_count: usize) -> String {
193    if is_clean {
194        return "No review-visible findings were produced for this run.".to_owned();
195    }
196    let noun = if finding_count == 1 {
197        "finding"
198    } else {
199        "findings"
200    };
201    match status {
202        PrSummaryStatus::Fail => {
203            format!("Quality gates need attention. Found {finding_count} {noun}.")
204        }
205        PrSummaryStatus::Warn => format!("Review recommended. Found {finding_count} {noun}."),
206        PrSummaryStatus::Pass | PrSummaryStatus::Info => {
207            format!("No blocking gates failed. Showing {finding_count} {noun}.")
208        }
209    }
210}
211
212fn scope_label(scope: PrSummaryScope) -> &'static str {
213    match scope {
214        PrSummaryScope::Project => "project",
215        PrSummaryScope::Diff => "diff",
216        PrSummaryScope::ChangedFiles => "changed files",
217    }
218}
219
220fn provider_target_label(provider: CiProvider) -> &'static str {
221    match provider {
222        CiProvider::Github => "PR",
223        CiProvider::Gitlab => "MR",
224    }
225}
226
227fn render_area_table(out: &mut String, areas: &[PrSummaryArea]) {
228    if areas.is_empty() {
229        return;
230    }
231    out.push_str("## Checks\n\n");
232    out.push_str("| Area | Status | Result | Threshold | Details |\n");
233    out.push_str("| --- | --- | --- | --- | --- |\n");
234    for area in areas {
235        let threshold = area.threshold.as_deref().unwrap_or("n/a");
236        let details = area.details.as_deref().unwrap_or("");
237        let _ = writeln!(
238            out,
239            "| {} | {} | {} | {} | {} |",
240            escape_md(&area.name),
241            status_label(area.status),
242            escape_md(&area.result),
243            escape_md(threshold),
244            escape_md(details)
245        );
246    }
247    out.push('\n');
248}
249
250fn render_compact_gates(out: &mut String, areas: &[PrSummaryArea]) {
251    let notable = areas
252        .iter()
253        .filter(|area| !matches!(area.status, PrSummaryStatus::Pass | PrSummaryStatus::Info))
254        .collect::<Vec<_>>();
255    if notable.is_empty() {
256        out.push_str("All PR gates passed.\n\n");
257        return;
258    }
259    out.push_str("## Gates\n\n");
260    for area in notable {
261        let _ = writeln!(
262            out,
263            "- {}: {} ({})",
264            escape_md(&area.name),
265            status_label(area.status),
266            escape_md(&area.result)
267        );
268    }
269    out.push('\n');
270}
271
272fn render_top_findings(out: &mut String, findings: &[PrSummaryFinding], max_findings: usize) {
273    if findings.is_empty() {
274        return;
275    }
276    let summary = if findings.len() > max_findings {
277        format!("Top fixes (showing {max_findings} of {})", findings.len())
278    } else {
279        "Top fixes".to_owned()
280    };
281    let _ = writeln!(out, "<details open>\n<summary>{summary}</summary>\n");
282    out.push_str("| Severity | Fix | Location | Why |\n");
283    out.push_str("| --- | --- | --- | --- |\n");
284    for finding in findings.iter().take(max_findings) {
285        render_finding_row(out, finding);
286    }
287    if findings.len() > max_findings {
288        let _ = writeln!(
289            out,
290            "\nShowing {max_findings} of {} findings. Inspect the CI artifact for the full report.",
291            findings.len()
292        );
293    }
294    out.push_str("\n</details>\n\n");
295}
296
297fn render_finding_row(out: &mut String, finding: &PrSummaryFinding) {
298    let _ = writeln!(
299        out,
300        "| {} | {} | `{}` | {} |",
301        escape_md(&finding.severity),
302        escape_md(finding.fix.as_deref().unwrap_or(&finding.rule_id)),
303        escape_md(&finding.location),
304        escape_md(&finding.description)
305    );
306}
307
308fn status_label(status: PrSummaryStatus) -> &'static str {
309    match status {
310        PrSummaryStatus::Pass => "pass",
311        PrSummaryStatus::Warn => "warn",
312        PrSummaryStatus::Fail => "fail",
313        PrSummaryStatus::Info => "info",
314    }
315}
316
317fn render_footer(out: &mut String) {
318    out.push_str("Generated by fallow.");
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    const DEFAULT_MAX_FINDINGS: usize = 50;
326
327    fn input<'a>(
328        areas: &'a [PrSummaryArea],
329        findings: &'a [PrSummaryFinding],
330    ) -> PrSummaryInput<'a> {
331        PrSummaryInput {
332            command: "combined",
333            provider: CiProvider::Github,
334            marker_id: "fallow-results".to_owned(),
335            scope: PrSummaryScope::Project,
336            areas,
337            findings,
338            max_findings: DEFAULT_MAX_FINDINGS,
339            details_url: None,
340            layout: PrCommentLayout::Default,
341        }
342    }
343
344    #[test]
345    fn clean_summary_marks_envelope_without_sentinel_body_policy() {
346        let envelope = render_pr_summary(&input(&[], &[]));
347
348        assert!(envelope.is_clean);
349        assert!(envelope.body.contains("No review-visible findings"));
350        assert!(!envelope.body.contains("fallow-clean-sentinel"));
351    }
352
353    #[test]
354    fn gitlab_header_uses_mr_language() {
355        let custom = PrSummaryInput {
356            provider: CiProvider::Gitlab,
357            ..input(&[], &[])
358        };
359
360        let envelope = render_pr_summary(&custom);
361
362        assert!(envelope.body.contains("_GitLab MR summary"));
363        assert!(!envelope.body.contains("_GitLab PR summary"));
364    }
365
366    #[test]
367    fn warning_summary_leads_with_review_message_and_checks_table() {
368        let areas = [PrSummaryArea {
369            name: "Duplication".to_owned(),
370            status: PrSummaryStatus::Warn,
371            result: "2 clone groups".to_owned(),
372            threshold: Some("<= 3% duplication".to_owned()),
373            details: Some("9.1% duplicated lines".to_owned()),
374        }];
375        let findings = [PrSummaryFinding {
376            severity: "minor".to_owned(),
377            rule_id: "fallow/code-duplication".to_owned(),
378            location: "src/a.ts:10".to_owned(),
379            description: "Code clone group 1".to_owned(),
380            fix: Some("Extract the repeated block.".to_owned()),
381        }];
382
383        let envelope = render_pr_summary(&input(&areas, &findings));
384
385        assert!(!envelope.is_clean);
386        assert!(envelope.body.contains("> [!WARNING]"));
387        assert!(
388            envelope
389                .body
390                .contains("| Duplication | warn | 2 clone groups |")
391        );
392        assert!(envelope.body.contains("<details open>"));
393        assert!(envelope.body.contains("<summary>Top fixes</summary>"));
394        assert!(envelope.body.contains("Extract the repeated block."));
395    }
396
397    #[test]
398    fn findings_are_capped_and_mark_envelope_truncated() {
399        let findings = [
400            PrSummaryFinding {
401                severity: "minor".to_owned(),
402                rule_id: "fallow/a".to_owned(),
403                location: "src/a.ts:1".to_owned(),
404                description: "A".to_owned(),
405                fix: None,
406            },
407            PrSummaryFinding {
408                severity: "minor".to_owned(),
409                rule_id: "fallow/b".to_owned(),
410                location: "src/b.ts:1".to_owned(),
411                description: "B".to_owned(),
412                fix: None,
413            },
414        ];
415        let custom = PrSummaryInput {
416            max_findings: 1,
417            ..input(&[], &findings)
418        };
419
420        let envelope = render_pr_summary(&custom);
421
422        assert!(envelope.truncation.truncated);
423        assert!(envelope.body.contains("showing 1 of 2"));
424        assert!(envelope.body.contains("fallow/a"));
425        assert!(!envelope.body.contains("fallow/b"));
426    }
427
428    #[test]
429    fn details_url_is_preserved_on_the_envelope() {
430        let custom = PrSummaryInput {
431            details_url: Some("https://example.test/fallow"),
432            ..input(&[], &[])
433        };
434
435        let envelope = render_pr_summary(&custom);
436
437        assert_eq!(
438            envelope.details_url.as_deref(),
439            Some("https://example.test/fallow")
440        );
441    }
442
443    #[test]
444    fn gate_only_layout_skips_top_findings() {
445        let areas = [PrSummaryArea {
446            name: "Health".to_owned(),
447            status: PrSummaryStatus::Warn,
448            result: "1 finding".to_owned(),
449            threshold: Some("configured rules".to_owned()),
450            details: None,
451        }];
452        let findings = [PrSummaryFinding {
453            severity: "minor".to_owned(),
454            rule_id: "fallow/high-crap-score".to_owned(),
455            location: "src/a.ts:10".to_owned(),
456            description: "High CRAP score".to_owned(),
457            fix: None,
458        }];
459        let custom = PrSummaryInput {
460            layout: PrCommentLayout::GateOnly,
461            ..input(&areas, &findings)
462        };
463
464        let envelope = render_pr_summary(&custom);
465
466        assert!(envelope.body.contains("## Checks"));
467        assert!(!envelope.body.contains("Top fixes"));
468        assert!(!envelope.body.contains("High CRAP score"));
469    }
470
471    #[test]
472    fn compact_layout_renders_failed_or_warning_gates_only() {
473        let areas = [
474            PrSummaryArea {
475                name: "Dead code".to_owned(),
476                status: PrSummaryStatus::Pass,
477                result: "0 issues".to_owned(),
478                threshold: None,
479                details: None,
480            },
481            PrSummaryArea {
482                name: "Duplication".to_owned(),
483                status: PrSummaryStatus::Warn,
484                result: "2 clone groups".to_owned(),
485                threshold: None,
486                details: None,
487            },
488        ];
489        let custom = PrSummaryInput {
490            layout: PrCommentLayout::Compact,
491            ..input(&areas, &[])
492        };
493
494        let envelope = render_pr_summary(&custom);
495
496        assert!(envelope.body.contains("## Gates"));
497        assert!(
498            envelope
499                .body
500                .contains("- Duplication: warn (2 clone groups)")
501        );
502        assert!(!envelope.body.contains("| Dead code |"));
503        assert!(!envelope.body.contains("Top fixes"));
504    }
505}