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