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