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