1use 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}