1use std::process::ExitCode;
2
3use serde_json::Value;
4
5use super::diff_filter::DiffIndex;
6use super::fingerprint::{composite_fingerprint, summary_fingerprint};
7use super::pr_comment::{CiIssue, Provider, command_title, escape_md};
8use super::severity;
9use crate::output_envelope::{
10 GitHubReviewComment, GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition,
11 GitLabReviewPositionType, ReviewCheckConclusion, ReviewComment, ReviewEnvelopeEvent,
12 ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema, ReviewEnvelopeSummary,
13 ReviewProvider, default_marker_regex, default_marker_regex_flags,
14};
15use crate::report::emit_json;
16
17const MAX_COMMENT_BODY_BYTES: usize = 65_536;
28
29pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
36
37const MARKER_SUFFIX_V2: &str = " -->";
39
40const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
49
50#[must_use]
51pub fn render_review_envelope(
52 command: &str,
53 provider: Provider,
54 issues: &[CiIssue],
55) -> ReviewEnvelopeOutput {
56 render_review_envelope_with_diff(
57 command,
58 provider,
59 issues,
60 super::diff_filter::shared_diff_index(),
61 )
62}
63
64#[must_use]
69pub fn render_review_envelope_with_diff(
70 command: &str,
71 provider: Provider,
72 issues: &[CiIssue],
73 diff_index: Option<&DiffIndex>,
74) -> ReviewEnvelopeOutput {
75 let max = std::env::var("FALLOW_MAX_COMMENTS")
76 .ok()
77 .and_then(|v| v.parse::<usize>().ok())
78 .unwrap_or(50);
79 let gitlab_diff_refs = (provider == Provider::Gitlab)
80 .then(gitlab_diff_refs_from_env)
81 .flatten();
82
83 let merged_groups = group_by_path_line(issues);
86
87 let comments: Vec<ReviewComment> = merged_groups
91 .iter()
92 .take(max)
93 .map(|group| render_merged_comment(provider, group, gitlab_diff_refs.as_ref(), diff_index))
94 .collect();
95
96 let summary_text = format!(
97 "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
98 command_title(command),
99 comments.len(),
100 if comments.len() == 1 { "" } else { "s" },
101 provider.name(),
102 );
103 let summary_fp = summary_fingerprint(&summary_text);
104 let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
105 let body = format!("{summary_text}{summary_marker}");
106 let summary = ReviewEnvelopeSummary {
107 body: body.clone(),
108 fingerprint: summary_fp,
109 };
110
111 match provider {
112 Provider::Github => ReviewEnvelopeOutput {
113 event: Some(ReviewEnvelopeEvent::Comment),
114 body,
115 summary,
116 comments,
117 marker_regex: default_marker_regex(),
118 marker_regex_flags: default_marker_regex_flags(),
119 meta: ReviewEnvelopeMeta {
120 schema: ReviewEnvelopeSchema::V2,
121 provider: ReviewProvider::Github,
122 check_conclusion: Some(github_check_conclusion(issues)),
123 },
124 },
125 Provider::Gitlab => ReviewEnvelopeOutput {
126 event: None,
127 body,
128 summary,
129 comments,
130 marker_regex: default_marker_regex(),
131 marker_regex_flags: default_marker_regex_flags(),
132 meta: ReviewEnvelopeMeta {
133 schema: ReviewEnvelopeSchema::V2,
134 provider: ReviewProvider::Gitlab,
135 check_conclusion: None,
136 },
137 },
138 }
139}
140
141#[must_use]
142pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
143 let issues = super::diff_filter::filter_issues_from_env(
144 super::pr_comment::issues_from_codeclimate(codeclimate),
145 );
146 let envelope = render_review_envelope(command, provider, &issues);
147 let value =
148 serde_json::to_value(&envelope).expect("ReviewEnvelopeOutput serializes infallibly");
149 emit_json(&value, "review envelope")
150}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
153#[expect(
154 clippy::struct_field_names,
155 reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
156)]
157struct GitlabDiffRefs {
158 base_sha: String,
159 start_sha: String,
160 head_sha: String,
161}
162
163fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
164 let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
165 .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
166 let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
167 let head_sha =
168 env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
169 Some(GitlabDiffRefs {
170 base_sha,
171 start_sha,
172 head_sha,
173 })
174}
175
176fn env_nonempty(name: &str) -> Option<String> {
177 std::env::var(name)
178 .ok()
179 .filter(|value| !value.trim().is_empty())
180}
181
182fn group_by_path_line(issues: &[CiIssue]) -> Vec<Vec<&CiIssue>> {
185 let mut groups: Vec<Vec<&CiIssue>> = Vec::new();
186 let mut current: Vec<&CiIssue> = Vec::new();
187 let mut current_key: Option<(&str, u64)> = None;
188 for issue in issues {
189 let key = (issue.path.as_str(), issue.line);
190 if Some(key) != current_key {
191 if !current.is_empty() {
192 groups.push(std::mem::take(&mut current));
193 }
194 current_key = Some(key);
195 }
196 current.push(issue);
197 }
198 if !current.is_empty() {
199 groups.push(current);
200 }
201 groups
202}
203
204fn render_merged_comment(
213 provider: Provider,
214 group: &[&CiIssue],
215 gitlab_diff_refs: Option<&GitlabDiffRefs>,
216 diff_index: Option<&DiffIndex>,
217) -> ReviewComment {
218 assert!(!group.is_empty(), "group_by_path_line never yields empty");
219 let representative = group[0];
220 let fingerprint = if group.len() == 1 {
221 representative.fingerprint.clone()
222 } else {
223 let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
224 composite_fingerprint(&constituents)
225 };
226
227 use std::fmt::Write as _;
230 let mut content = String::new();
231 for (index, issue) in group.iter().enumerate() {
232 let label = review_label_from_codeclimate(&issue.severity);
233 if index > 0 {
234 content.push_str("\n\n");
235 }
236 write!(
237 content,
238 "**{}** `{}`: {}",
239 label,
240 escape_md(&issue.rule_id),
241 escape_md(&issue.description)
242 )
243 .expect("write to String is infallible");
244 if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
245 content.push_str(&suggestion);
246 }
247 }
248
249 let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
250 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
251
252 match provider {
253 Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
256 path: representative.path.clone(),
257 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
264 side: GitHubReviewSide::Right,
265 body,
266 fingerprint,
267 truncated,
268 }),
269 Provider::Gitlab => {
270 let new_path = representative.path.clone();
277 let old_path = diff_index
278 .and_then(|di| di.old_path_for(&new_path))
279 .map_or_else(|| new_path.clone(), str::to_owned);
280 let position = GitLabReviewPosition {
281 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
282 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
283 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
284 position_type: GitLabReviewPositionType::Text,
285 old_path,
286 new_path,
287 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
290 };
291 ReviewComment::GitLab(GitLabReviewComment {
292 body,
293 position,
294 fingerprint,
295 truncated,
296 })
297 }
298 }
299}
300
301fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
307 let intact_len = content.len() + marker_line.len();
308 if intact_len <= MAX_COMMENT_BODY_BYTES {
309 let mut out = String::with_capacity(intact_len);
310 out.push_str(content);
311 out.push_str(marker_line);
312 return (out, false);
313 }
314 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
320 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
321 let mut cut = budget.min(content.len());
322 while cut > 0 && !content.is_char_boundary(cut) {
323 cut -= 1;
324 }
325 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
326 out.push_str(&content[..cut]);
327 out.push_str(TRUNCATION_SUFFIX);
328 out.push_str(marker_line);
329 (out, true)
330}
331
332fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
333 match severity_name {
334 "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
335 _ => severity::review_label(fallow_config::Severity::Warn),
336 }
337}
338
339fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
340 if issues
341 .iter()
342 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
343 {
344 ReviewCheckConclusion::Failure
345 } else if issues.is_empty() {
346 ReviewCheckConclusion::Success
347 } else {
348 ReviewCheckConclusion::Neutral
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::output_envelope::MARKER_REGEX_V2;
356
357 fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
358 serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
359 }
360
361 fn comment_to_value(comment: &ReviewComment) -> Value {
362 serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
363 }
364
365 fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
366 CiIssue {
367 rule_id: rule.into(),
368 description: "desc".into(),
369 severity: sev.into(),
370 path: path.into(),
371 line,
372 fingerprint: fp.into(),
373 }
374 }
375
376 #[test]
377 fn github_review_envelope_matches_api_shape() {
378 let issues = vec![issue(
379 "fallow/unused-file",
380 "minor",
381 "src/a.ts",
382 1,
383 "abc1234567890def",
384 )];
385 let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
386 assert_eq!(envelope["event"], "COMMENT");
387 assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
388 assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
389 assert!(
390 envelope["comments"][0]["body"]
391 .as_str()
392 .unwrap()
393 .contains("fallow-fingerprint:v2:")
394 );
395 }
396
397 #[test]
398 fn github_comments_target_current_state_side() {
399 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
400 let comment = comment_to_value(&render_merged_comment(
401 Provider::Github,
402 &[&issue],
403 None,
404 None,
405 ));
406 assert_eq!(comment["side"], "RIGHT");
407 }
408
409 #[test]
410 fn labels_major_issues_as_errors() {
411 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
412 let comment = comment_to_value(&render_merged_comment(
413 Provider::Github,
414 &[&issue],
415 None,
416 None,
417 ));
418 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
419 }
420
421 #[test]
422 fn gitlab_comment_accepts_diff_refs() {
423 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
424 let refs = GitlabDiffRefs {
425 base_sha: "base".into(),
426 start_sha: "start".into(),
427 head_sha: "head".into(),
428 };
429 let comment = comment_to_value(&render_merged_comment(
430 Provider::Gitlab,
431 &[&issue],
432 Some(&refs),
433 None,
434 ));
435 assert_eq!(comment["position"]["position_type"], "text");
436 assert_eq!(comment["position"]["base_sha"], "base");
437 assert_eq!(comment["position"]["start_sha"], "start");
438 assert_eq!(comment["position"]["head_sha"], "head");
439 }
440
441 #[test]
442 fn envelope_emits_marker_regex_field_at_root() {
443 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
444 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
445 let regex = env["marker_regex"].as_str().expect("marker_regex present");
446 assert_eq!(regex, MARKER_REGEX_V2);
447 assert!(regex.contains("[0-9a-f]{16}"));
450 assert!(regex.starts_with('^'));
454 assert!(regex.ends_with("\\s*$"));
455 assert!(!regex.contains("(?m)"));
458 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
462 let flags = env["marker_regex_flags"]
464 .as_str()
465 .expect("marker_regex_flags present");
466 assert_eq!(flags, "m");
467 }
468
469 #[test]
470 fn envelope_emits_summary_block_with_fingerprint() {
471 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
472 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
473 assert_eq!(env["summary"]["body"], env["body"]);
474 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
475 assert_eq!(summary_fp.len(), 16);
476 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
477 let body_str = env["body"].as_str().unwrap();
479 let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
480 assert!(
481 body_str.contains(&marker_line),
482 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
483 );
484 }
485
486 #[test]
487 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
488 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
489 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
490 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
491 assert_eq!(
492 env["comments"].as_array().unwrap().len(),
493 1,
494 "two same-line findings must collapse to one comment"
495 );
496 let merged = &env["comments"][0];
497 let fp = merged["fingerprint"].as_str().unwrap();
498 assert!(
499 fp.starts_with("merged:"),
500 "merged comment fingerprint must start with merged:, got {fp}"
501 );
502 assert_eq!(fp.len(), 23);
504 let body = merged["body"].as_str().unwrap();
506 assert!(body.contains("fallow/unused-export"));
507 assert!(body.contains("fallow/duplicate-export"));
508 assert_eq!(
509 body.matches("fallow-fingerprint:v2:").count(),
510 1,
511 "merged body must carry exactly one fingerprint marker"
512 );
513 assert!(
516 merged.get("constituent_fingerprints").is_none(),
517 "v2 hashed-composite design does not emit constituent_fingerprints"
518 );
519 }
520
521 #[test]
522 fn single_finding_keeps_v1_fingerprint_shape() {
523 let issues = vec![issue(
524 "fallow/unused-file",
525 "minor",
526 "src/a.ts",
527 1,
528 "abc1234567890def",
529 )];
530 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
531 let comment = &env["comments"][0];
532 assert_eq!(comment["fingerprint"], "abc1234567890def");
533 assert!(
534 comment.get("constituent_fingerprints").is_none(),
535 "single-finding comment must NOT emit constituent_fingerprints"
536 );
537 assert!(
538 comment.get("truncated").is_none(),
539 "non-truncated comment must NOT emit truncated"
540 );
541 }
542
543 #[test]
544 fn composite_fingerprint_shifts_when_constituents_change() {
545 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
549 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
550 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
551 let run1 = to_value(&render_review_envelope(
552 "check",
553 Provider::Github,
554 &[a.clone(), b, c.clone()],
555 ));
556 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
557 assert_ne!(
558 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
559 "primary fingerprint must shift when a constituent drops"
560 );
561 }
562
563 #[test]
564 fn gitlab_old_path_pulls_from_diff_rename_map() {
565 let rename_diff = "\
566diff --git a/src/old.ts b/src/new.ts
567similarity index 90%
568rename from src/old.ts
569rename to src/new.ts
570--- a/src/old.ts
571+++ b/src/new.ts
572@@ -1,2 +1,3 @@
573 keep
574+added
575 still
576";
577 let diff_index = DiffIndex::from_unified_diff(rename_diff);
578 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
579 let envelope = to_value(&render_review_envelope_with_diff(
580 "check",
581 Provider::Gitlab,
582 &[issue],
583 Some(&diff_index),
584 ));
585 let position = &envelope["comments"][0]["position"];
586 assert_eq!(position["old_path"], "src/old.ts");
587 assert_eq!(position["new_path"], "src/new.ts");
588 }
589
590 #[test]
591 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
592 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
593 let envelope = to_value(&render_review_envelope_with_diff(
594 "check",
595 Provider::Gitlab,
596 &[issue],
597 None,
598 ));
599 let position = &envelope["comments"][0]["position"];
600 assert_eq!(position["old_path"], "src/edit.ts");
601 assert_eq!(position["new_path"], "src/edit.ts");
602 }
603
604 #[test]
605 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
606 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
608 let issue = CiIssue {
609 rule_id: "fallow/unused-export".into(),
610 description: huge_desc,
611 severity: "minor".into(),
612 path: "src/a.ts".into(),
613 line: 1,
614 fingerprint: "abc1234567890def".into(),
615 };
616 let comment = comment_to_value(&render_merged_comment(
617 Provider::Github,
618 &[&issue],
619 None,
620 None,
621 ));
622 let body = comment["body"].as_str().unwrap();
623 assert!(
624 body.len() <= MAX_COMMENT_BODY_BYTES,
625 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
626 body.len()
627 );
628 assert!(
630 body.contains("fallow-fingerprint:v2:"),
631 "marker must be preserved under truncation"
632 );
633 assert!(body.contains("<!-- fallow-truncated -->"));
636 assert!(body.contains("> Body truncated by fallow."));
637 assert_eq!(comment["truncated"], true);
639 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
641 }
642
643 #[test]
644 fn multibyte_body_truncates_at_char_boundary() {
645 let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
648 let issue = CiIssue {
649 rule_id: "fallow/unused-export".into(),
650 description: huge_desc,
651 severity: "minor".into(),
652 path: "src/a.ts".into(),
653 line: 1,
654 fingerprint: "abc1234567890def".into(),
655 };
656 let comment = comment_to_value(&render_merged_comment(
657 Provider::Github,
658 &[&issue],
659 None,
660 None,
661 ));
662 let body = comment["body"].as_str().unwrap();
663 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
668 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
669 assert_eq!(comment["truncated"], true);
670 }
671}