1use std::fmt::Write as _;
4
5use crate::{CiProvider, PrCommentEnvelope, PrCommentTruncation, command_title, escape_md};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum PrSummaryStatus {
10 Pass,
12 Warn,
14 Fail,
16 Info,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum PrSummaryScope {
23 Project,
25 Diff,
27 ChangedFiles,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct PrSummaryArea {
34 pub name: String,
36 pub status: PrSummaryStatus,
38 pub result: String,
40 pub threshold: Option<String>,
42 pub details: Option<String>,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct PrSummaryFinding {
49 pub severity: String,
51 pub rule_id: String,
53 pub location: String,
55 pub description: String,
57 pub fix: Option<String>,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PrCommentLayout {
64 Default,
66 Compact,
68 GateOnly,
70 Details,
72}
73
74pub struct PrSummaryInput<'a> {
76 pub command: &'a str,
78 pub provider: CiProvider,
80 pub marker_id: String,
83 pub scope: PrSummaryScope,
85 pub areas: &'a [PrSummaryArea],
87 pub findings: &'a [PrSummaryFinding],
89 pub max_findings: usize,
91 pub details_url: Option<&'a str>,
93 pub layout: PrCommentLayout,
95}
96
97#[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}