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