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