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