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