Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4use serde_json::Value;
5
6use super::diff_filter::DiffIndex;
7use crate::report::emit_json;
8use fallow_output::{
9    CiIssue, CiProvider as Provider, ReviewEnvelopeOutput, ReviewEnvelopeRenderInput,
10    ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
11    issues_from_codeclimate_issues,
12};
13
14#[must_use]
15pub fn render_review_envelope(
16    command: &str,
17    provider: Provider,
18    issues: &[CiIssue],
19) -> ReviewEnvelopeOutput {
20    render_review_envelope_with_diff(
21        command,
22        provider,
23        issues,
24        super::diff_filter::shared_diff_index(),
25    )
26}
27
28/// Render path the print site uses. Exposed so unit tests can pass a
29/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
30/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
31/// parallel runner).
32#[must_use]
33fn render_review_envelope_with_diff(
34    command: &str,
35    provider: Provider,
36    issues: &[CiIssue],
37    diff_index: Option<&DiffIndex>,
38) -> ReviewEnvelopeOutput {
39    let max = std::env::var("FALLOW_MAX_COMMENTS")
40        .ok()
41        .and_then(|v| v.parse::<usize>().ok())
42        .unwrap_or(50);
43    let gitlab_diff_refs = (provider == Provider::Gitlab)
44        .then(gitlab_diff_refs_from_env)
45        .flatten();
46    let include_guidance = review_guidance_enabled();
47
48    let rendered = fallow_output::render_review_envelope(&ReviewEnvelopeRenderInput {
49        command,
50        provider,
51        issues,
52        diff_index,
53        path_prefix: crate::report::github::report_prefix(),
54        max_comments: max,
55        gitlab_diff_refs: gitlab_diff_refs.as_ref(),
56        include_guidance,
57        suggestion_block: &super::suggestion::suggestion_block,
58        guidance_block: &review_guidance_block,
59    });
60    note_review_truncation(rendered.truncation);
61    rendered.envelope
62}
63
64/// Record telemetry for body-size or comment-count truncation of the review.
65fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
66    if truncation.body {
67        crate::telemetry::note_report_truncation(
68            true,
69            crate::telemetry::TruncationReason::SizeLimit,
70        );
71    } else if truncation.comment_limit {
72        crate::telemetry::note_report_truncation(
73            true,
74            crate::telemetry::TruncationReason::CommentLimit,
75        );
76    } else {
77        crate::telemetry::note_report_truncation(
78            false,
79            crate::telemetry::TruncationReason::Unknown,
80        );
81    }
82}
83
84#[must_use]
85pub(crate) fn print_review_envelope(
86    command: &str,
87    provider: Provider,
88    codeclimate: &Value,
89) -> ExitCode {
90    let issues = super::diff_filter::filter_issues_from_env(
91        super::pr_comment::issues_from_codeclimate(codeclimate),
92    );
93    print_review_envelope_from_ci_issues(command, provider, &issues)
94}
95
96#[must_use]
97pub(crate) fn print_review_envelope_from_codeclimate_issues(
98    command: &str,
99    provider: Provider,
100    codeclimate: &[CodeClimateIssue],
101) -> ExitCode {
102    let issues =
103        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
104    print_review_envelope_from_ci_issues(command, provider, &issues)
105}
106
107#[must_use]
108#[expect(
109    clippy::expect_used,
110    reason = "review envelope contains only infallibly serializable fields"
111)]
112fn print_review_envelope_from_ci_issues(
113    command: &str,
114    provider: Provider,
115    issues: &[CiIssue],
116) -> ExitCode {
117    let envelope = render_review_envelope(command, provider, issues);
118    let value = fallow_output::serialize_review_envelope_json_output(
119        envelope,
120        crate::output_runtime::current_root_envelope_mode(),
121        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
122    )
123    .expect("ReviewEnvelopeOutput serializes infallibly");
124    emit_json(&value, "review envelope")
125}
126
127fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
128    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
129        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
130    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
131    let head_sha =
132        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
133    Some(GitlabDiffRefs {
134        base_sha,
135        start_sha,
136        head_sha,
137    })
138}
139
140fn env_nonempty(name: &str) -> Option<String> {
141    std::env::var(name)
142        .ok()
143        .filter(|value| !value.trim().is_empty())
144}
145
146fn review_guidance_enabled() -> bool {
147    std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
148}
149
150fn env_truthy(value: &str) -> bool {
151    matches!(
152        value.trim().to_ascii_lowercase().as_str(),
153        "1" | "true" | "yes" | "on"
154    )
155}
156
157fn review_guidance_block(issue: &CiIssue) -> Option<String> {
158    let rule = crate::explain::rule_by_id(&issue.rule_id)?;
159    let guide = crate::explain::rule_guide(rule);
160    let docs_url = crate::explain::rule_docs_url(rule);
161
162    Some(format!(
163        "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
164        guide.how_to_fix
165    ))
166}
167
168#[cfg(test)]
169fn render_merged_comment(
170    provider: Provider,
171    group: &[&CiIssue],
172    gitlab_diff_refs: Option<&GitlabDiffRefs>,
173    diff_index: Option<&DiffIndex>,
174    include_guidance: bool,
175) -> fallow_output::ReviewComment {
176    fallow_output::render_review_comment_for_group(&fallow_output::ReviewCommentRenderInput {
177        provider,
178        group,
179        gitlab_diff_refs,
180        diff_index,
181        path_prefix: "",
182        include_guidance,
183        suggestion_block: &super::suggestion::suggestion_block,
184        guidance_block: &review_guidance_block,
185    })
186}
187
188#[cfg(test)]
189fn group_by_path_line(
190    issues: &[CiIssue],
191    max_groups: usize,
192) -> fallow_output::GroupedReviewIssues<'_> {
193    fallow_output::group_review_issues_by_path_line(issues, max_groups)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use fallow_output::{MARKER_PREFIX_V2, MARKER_SUFFIX_V2, MAX_COMMENT_BODY_BYTES};
200    use fallow_output::{MARKER_REGEX_V2, ReviewComment};
201
202    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
203        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
204    }
205
206    fn comment_to_value(comment: &ReviewComment) -> Value {
207        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
208    }
209
210    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
211        CiIssue {
212            rule_id: rule.into(),
213            description: "desc".into(),
214            severity: sev.into(),
215            path: path.into(),
216            line,
217            fingerprint: fp.into(),
218        }
219    }
220
221    fn issue_with_desc(
222        rule: &str,
223        desc: impl Into<String>,
224        sev: &str,
225        path: &str,
226        line: u64,
227        fp: &str,
228    ) -> CiIssue {
229        CiIssue {
230            rule_id: rule.into(),
231            description: desc.into(),
232            severity: sev.into(),
233            path: path.into(),
234            line,
235            fingerprint: fp.into(),
236        }
237    }
238
239    #[test]
240    fn github_review_envelope_matches_api_shape() {
241        let issues = vec![issue(
242            "fallow/unused-file",
243            "minor",
244            "src/a.ts",
245            1,
246            "abc1234567890def",
247        )];
248        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
249        assert_eq!(envelope["event"], "COMMENT");
250        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
251        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
252        assert!(
253            envelope["comments"][0]["body"]
254                .as_str()
255                .unwrap()
256                .contains("fallow-fingerprint:v2:")
257        );
258    }
259
260    #[test]
261    fn review_summary_body_leads_with_decision() {
262        let issues = vec![issue(
263            "fallow/unused-file",
264            "major",
265            "src/a.ts",
266            1,
267            "abc1234567890def",
268        )];
269        let envelope = to_value(&render_review_envelope(
270            "combined",
271            Provider::Github,
272            &issues,
273        ));
274        let body = envelope["body"].as_str().expect("body is string");
275
276        assert!(body.contains("Quality gate failed"), "{body}");
277        assert!(body.contains("1 inline finding selected"), "{body}");
278        assert!(body.contains("<!-- fallow-review -->"), "{body}");
279    }
280
281    #[test]
282    fn github_comments_target_current_state_side() {
283        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
284        let comment = comment_to_value(&render_merged_comment(
285            Provider::Github,
286            &[&issue],
287            None,
288            None,
289            false,
290        ));
291        assert_eq!(comment["side"], "RIGHT");
292    }
293
294    #[test]
295    fn labels_major_issues_as_errors() {
296        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
297        let comment = comment_to_value(&render_merged_comment(
298            Provider::Github,
299            &[&issue],
300            None,
301            None,
302            false,
303        ));
304        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
305    }
306
307    #[test]
308    fn gitlab_comment_accepts_diff_refs() {
309        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
310        let refs = GitlabDiffRefs {
311            base_sha: "base".into(),
312            start_sha: "start".into(),
313            head_sha: "head".into(),
314        };
315        let comment = comment_to_value(&render_merged_comment(
316            Provider::Gitlab,
317            &[&issue],
318            Some(&refs),
319            None,
320            false,
321        ));
322        assert_eq!(comment["position"]["position_type"], "text");
323        assert_eq!(comment["position"]["base_sha"], "base");
324        assert_eq!(comment["position"]["start_sha"], "start");
325        assert_eq!(comment["position"]["head_sha"], "head");
326    }
327
328    #[test]
329    fn guidance_toggle_accepts_common_truthy_values() {
330        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
331            assert!(env_truthy(value), "{value:?} should enable guidance");
332        }
333        for value in ["", "0", "false", "no", "off", "enabled"] {
334            assert!(!env_truthy(value), "{value:?} should not enable guidance");
335        }
336    }
337
338    #[test]
339    fn guidance_disabled_omits_details_block() {
340        let issue = issue(
341            "fallow/high-complexity",
342            "major",
343            "src/a.ts",
344            10,
345            "abc1234567890def",
346        );
347        let comment = comment_to_value(&render_merged_comment(
348            Provider::Github,
349            &[&issue],
350            None,
351            None,
352            false,
353        ));
354        let body = comment["body"].as_str().unwrap();
355        assert!(!body.contains("<details><summary>What to do</summary>"));
356        assert!(!body.contains("For function findings"));
357    }
358
359    #[test]
360    fn guidance_enabled_appends_rule_guide_details() {
361        let issue = issue(
362            "fallow/high-complexity",
363            "major",
364            "src/a.ts",
365            10,
366            "abc1234567890def",
367        );
368        let comment = comment_to_value(&render_merged_comment(
369            Provider::Github,
370            &[&issue],
371            None,
372            None,
373            true,
374        ));
375        let body = comment["body"].as_str().unwrap();
376        assert!(body.contains("<details><summary>What to do</summary>"));
377        assert!(body.contains("For function findings"));
378        assert!(body.contains("[Read the rule docs]("));
379        assert!(
380            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
381            "guidance should render before the marker"
382        );
383    }
384
385    #[test]
386    fn guidance_attaches_to_each_merged_finding() {
387        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
388        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
389        let comment = comment_to_value(&render_merged_comment(
390            Provider::Github,
391            &[&complexity, &duplication],
392            None,
393            None,
394            true,
395        ));
396        let body = comment["body"].as_str().unwrap();
397        assert_eq!(
398            body.matches("<details><summary>What to do</summary>")
399                .count(),
400            2
401        );
402        assert!(body.contains("For function findings"));
403        assert!(body.contains("Extract the shared logic"));
404    }
405
406    #[test]
407    fn envelope_emits_marker_regex_field_at_root() {
408        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
409        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
410        let regex = env["marker_regex"].as_str().expect("marker_regex present");
411        assert_eq!(regex, MARKER_REGEX_V2);
412        assert!(regex.contains("[0-9a-f]{16}"));
413        assert!(regex.starts_with('^'));
414        assert!(regex.ends_with("\\s*$"));
415        assert!(!regex.contains("(?m)"));
416        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
417        let flags = env["marker_regex_flags"]
418            .as_str()
419            .expect("marker_regex_flags present");
420        assert_eq!(flags, "m");
421    }
422
423    #[test]
424    fn envelope_emits_summary_block_with_fingerprint() {
425        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
426        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
427        assert_eq!(env["summary"]["body"], env["body"]);
428        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
429        assert_eq!(summary_fp.len(), 16);
430        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
431        let body_str = env["body"].as_str().unwrap();
432        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
433        assert!(
434            body_str.contains(&marker_line),
435            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
436        );
437    }
438
439    #[test]
440    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
441        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
442        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
443        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
444        assert_eq!(
445            env["comments"].as_array().unwrap().len(),
446            1,
447            "two same-line findings must collapse to one comment"
448        );
449        let merged = &env["comments"][0];
450        let fp = merged["fingerprint"].as_str().unwrap();
451        assert!(
452            fp.starts_with("merged:"),
453            "merged comment fingerprint must start with merged:, got {fp}"
454        );
455        assert_eq!(fp.len(), 23);
456        let body = merged["body"].as_str().unwrap();
457        assert!(body.contains("fallow/unused-export"));
458        assert!(body.contains("fallow/duplicate-export"));
459        assert_eq!(
460            body.matches("fallow-fingerprint:v2:").count(),
461            1,
462            "merged body must carry exactly one fingerprint marker"
463        );
464        assert!(
465            merged.get("constituent_fingerprints").is_none(),
466            "v2 hashed-composite design does not emit constituent_fingerprints"
467        );
468    }
469
470    #[test]
471    fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
472        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
473        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
474        let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
475        let issues = vec![a, b, c];
476
477        let max_zero = group_by_path_line(&issues, 0);
478        assert!(max_zero.groups.is_empty());
479        assert!(max_zero.truncated);
480
481        let max_one = group_by_path_line(&issues, 1);
482        assert_eq!(max_one.groups.len(), 1);
483        assert!(max_one.truncated);
484        assert_eq!(max_one.groups[0].len(), 2);
485        assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
486        assert_eq!(max_one.groups[0][0].line, 42);
487
488        let max_two = group_by_path_line(&issues, 2);
489        assert_eq!(max_two.groups.len(), 2);
490        assert!(!max_two.truncated);
491        assert_eq!(max_two.groups[0].len(), 2);
492        assert_eq!(max_two.groups[1].len(), 1);
493        assert_eq!(
494            max_two.groups[0]
495                .iter()
496                .map(|issue| issue.fingerprint.as_str())
497                .collect::<Vec<_>>(),
498            ["fp_a", "fp_b"]
499        );
500    }
501
502    #[test]
503    fn single_finding_keeps_v1_fingerprint_shape() {
504        let issues = vec![issue(
505            "fallow/unused-file",
506            "minor",
507            "src/a.ts",
508            1,
509            "abc1234567890def",
510        )];
511        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
512        let comment = &env["comments"][0];
513        assert_eq!(comment["fingerprint"], "abc1234567890def");
514        assert!(
515            comment.get("constituent_fingerprints").is_none(),
516            "single-finding comment must NOT emit constituent_fingerprints"
517        );
518        assert!(
519            comment.get("truncated").is_none(),
520            "non-truncated comment must NOT emit truncated"
521        );
522    }
523
524    #[test]
525    fn composite_fingerprint_shifts_when_constituents_change() {
526        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
527        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
528        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
529        let run1 = to_value(&render_review_envelope(
530            "check",
531            Provider::Github,
532            &[a.clone(), b, c.clone()],
533        ));
534        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
535        assert_ne!(
536            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
537            "primary fingerprint must shift when a constituent drops"
538        );
539    }
540
541    #[test]
542    fn gitlab_old_path_pulls_from_diff_rename_map() {
543        let rename_diff = "\
544diff --git a/src/old.ts b/src/new.ts
545similarity index 90%
546rename from src/old.ts
547rename to src/new.ts
548--- a/src/old.ts
549+++ b/src/new.ts
550@@ -1,2 +1,3 @@
551 keep
552+added
553 still
554";
555        let diff_index = DiffIndex::from_unified_diff(rename_diff);
556        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
557        let envelope = to_value(&render_review_envelope_with_diff(
558            "check",
559            Provider::Gitlab,
560            &[issue],
561            Some(&diff_index),
562        ));
563        let position = &envelope["comments"][0]["position"];
564        assert_eq!(position["old_path"], "src/old.ts");
565        assert_eq!(position["new_path"], "src/new.ts");
566    }
567
568    #[test]
569    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
570        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
571        let envelope = to_value(&render_review_envelope_with_diff(
572            "check",
573            Provider::Gitlab,
574            &[issue],
575            None,
576        ));
577        let position = &envelope["comments"][0]["position"];
578        assert_eq!(position["old_path"], "src/edit.ts");
579        assert_eq!(position["new_path"], "src/edit.ts");
580    }
581
582    #[test]
583    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
584        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
585        let issue = CiIssue {
586            rule_id: "fallow/unused-export".into(),
587            description: huge_desc,
588            severity: "minor".into(),
589            path: "src/a.ts".into(),
590            line: 1,
591            fingerprint: "abc1234567890def".into(),
592        };
593        let comment = comment_to_value(&render_merged_comment(
594            Provider::Github,
595            &[&issue],
596            None,
597            None,
598            false,
599        ));
600        let body = comment["body"].as_str().unwrap();
601        assert!(
602            body.len() <= MAX_COMMENT_BODY_BYTES,
603            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
604            body.len()
605        );
606        assert!(
607            body.contains("fallow-fingerprint:v2:"),
608            "marker must be preserved under truncation"
609        );
610        assert!(body.contains("<!-- fallow-truncated -->"));
611        assert!(body.contains("> Body truncated by fallow."));
612        assert_eq!(comment["truncated"], true);
613        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
614    }
615
616    #[test]
617    fn oversized_guidance_body_truncates_and_preserves_marker() {
618        let issue = issue_with_desc(
619            "fallow/high-complexity",
620            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
621            "major",
622            "src/a.ts",
623            1,
624            "abc1234567890def",
625        );
626        let comment = comment_to_value(&render_merged_comment(
627            Provider::Github,
628            &[&issue],
629            None,
630            None,
631            true,
632        ));
633        let body = comment["body"].as_str().unwrap();
634        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
635        assert!(body.contains("<!-- fallow-truncated -->"));
636        assert!(body.contains("fallow-fingerprint:v2:"));
637        assert_eq!(comment["truncated"], true);
638    }
639
640    #[test]
641    fn multibyte_body_truncates_at_char_boundary() {
642        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
643        let issue = CiIssue {
644            rule_id: "fallow/unused-export".into(),
645            description: huge_desc,
646            severity: "minor".into(),
647            path: "src/a.ts".into(),
648            line: 1,
649            fingerprint: "abc1234567890def".into(),
650        };
651        let comment = comment_to_value(&render_merged_comment(
652            Provider::Github,
653            &[&issue],
654            None,
655            None,
656            false,
657        ));
658        let body = comment["body"].as_str().unwrap();
659        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
660        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
661        assert_eq!(comment["truncated"], true);
662    }
663}