Skip to main content

fallow_cli/report/ci/
pr_comment.rs

1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5use serde_json::Value;
6
7pub use fallow_output::{
8    CiIssue, CiProvider as Provider, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA, PrCommentEnvelope,
9    PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation, PrDecisionAnnotationLevel,
10    PrDecisionConclusion, PrDecisionDetails, PrDecisionGate, PrDecisionSurface, PrDetailsArtifact,
11    PrDetailsRow, PrDetailsSection, command_title, issues_from_codeclimate,
12};
13#[cfg(test)]
14use fallow_output::{
15    CodeClimateIssue, escape_md, is_project_level_rule, issues_from_codeclimate_issues,
16};
17
18/// Workspace name, set once by `main()` when the binary is invoked with
19/// `--workspace <name>`. Read by `sticky_marker_id` to auto-suffix the
20/// sticky-comment marker per workspace, which keeps parallel per-workspace
21/// jobs from racing each other's sticky body on the same PR/MR.
22///
23/// `OnceLock` gives us safe cross-function read-after-set without env-var
24/// indirection. Only main writes; readers always observe the post-CLI-parse
25/// state.
26static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
27
28/// Set the workspace marker from a `--workspace` selection list.
29///
30/// Single workspace -> the name itself, sanitised for marker grammar.
31/// N>1 workspaces -> a stable 6-char hex hash of the sorted, comma-joined
32/// list, prefixed with `w-`. Sort + join is deterministic so the same
33/// selection produces the same suffix across runs; two jobs with disjoint
34/// selections get distinct markers and don't race.
35#[allow(
36    dead_code,
37    reason = "called from main.rs bin target; lib target sees no caller"
38)]
39pub fn set_workspace_marker_from_list(values: &[String]) {
40    let trimmed: Vec<&str> = values
41        .iter()
42        .map(|value| value.trim())
43        .filter(|value| !value.is_empty())
44        .collect();
45    if trimmed.is_empty() {
46        return;
47    }
48    let marker = if let [single] = trimmed.as_slice() {
49        (*single).to_owned()
50    } else {
51        let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
52        sorted.sort();
53        let joined = sorted.join(",");
54        format!("w-{}", short_hex_hash(&joined))
55    };
56    let _ = WORKSPACE_MARKER.set(marker);
57}
58
59/// 6-char FNV-1a hex digest. Stable across Rust versions (FNV is content-
60/// determined), short enough for a marker suffix, wide enough that the
61/// chance of two real-world workspace selections colliding is ~1/16M.
62fn short_hex_hash(value: &str) -> String {
63    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
64    for byte in value.bytes() {
65        hash ^= u64::from(byte);
66        hash = hash.wrapping_mul(0x0100_0000_01b3);
67    }
68    format!("{:06x}", (hash & 0x00ff_ffff) as u32)
69}
70
71#[must_use]
72pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
73    fallow_output::render_pr_comment(&fallow_output::PrCommentRenderInput {
74        command,
75        provider,
76        issues,
77        marker_id: sticky_marker_id(),
78        max_comments: max_comments(),
79        category_for_rule: &category_for_rule,
80    })
81}
82
83/// Map a fallow rule id to its category for sticky-comment grouping.
84///
85/// Single source of truth lives on `RuleDef::category` in `explain.rs`. This
86/// helper does the lookup so callers don't need to know about the registry;
87/// the look-up-then-fallback shape also keeps the renderer working for
88/// rules a downstream consumer added without registering (rare; produces
89/// the conservative "Dead code" default).
90#[must_use]
91pub fn category_for_rule(rule_id: &str) -> &'static str {
92    crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
93}
94
95pub fn max_comments() -> usize {
96    std::env::var("FALLOW_MAX_COMMENTS")
97        .ok()
98        .and_then(|value| value.parse::<usize>().ok())
99        .unwrap_or(50)
100}
101
102#[must_use]
103pub fn pr_comment_layout_from_env() -> PrCommentLayout {
104    match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
105        Ok("compact") => PrCommentLayout::Compact,
106        Ok("gate-only") => PrCommentLayout::GateOnly,
107        Ok("details") => PrCommentLayout::Details,
108        _ => PrCommentLayout::Default,
109    }
110}
111
112/// Compute the sticky-comment marker id. Precedence (highest first):
113///
114/// 1. `FALLOW_COMMENT_ID` set by the user explicitly: use as-is.
115/// 2. `WORKSPACE_MARKER` populated by `main()` from `--workspace <name>`:
116///    suffix the default to avoid colliding with a sibling per-workspace
117///    job's sticky on the same PR/MR.
118/// 3. Plain `fallow-results`.
119///
120/// The collision case (2) is the common monorepo shape: parallel jobs each
121/// run fallow scoped to one workspace package and post their own sticky.
122/// Without a per-workspace suffix every job edits the same marker, racing
123/// each other's bodies on every CI re-run.
124pub fn sticky_marker_id() -> String {
125    if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
126        && !value.trim().is_empty()
127    {
128        return value;
129    }
130    let suffix = WORKSPACE_MARKER
131        .get()
132        .map(|value| value.trim())
133        .filter(|value| !value.is_empty())
134        .map(sanitize_marker_segment);
135    match suffix {
136        Some(workspace) => format!("fallow-results-{workspace}"),
137        None => "fallow-results".to_owned(),
138    }
139}
140
141/// Strip characters that would break the HTML-comment marker. The marker
142/// shape is `<!-- fallow-id: <id> -->`; `<`, `>`, and `--` are reserved by
143/// the HTML comment grammar, and whitespace would split the id when the
144/// reader scans for it.
145fn sanitize_marker_segment(value: &str) -> String {
146    value
147        .chars()
148        .map(|ch| {
149            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
150                ch
151            } else {
152                '-'
153            }
154        })
155        .collect::<String>()
156        .trim_matches('-')
157        .to_owned()
158}
159
160#[must_use]
161pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
162    let issues =
163        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
164    let conclusion = issue_decision_conclusion(issues.is_empty());
165    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion)
166}
167
168#[must_use]
169pub fn print_pr_comment_with_conclusion(
170    command: &str,
171    provider: Provider,
172    codeclimate: &Value,
173    conclusion: PrDecisionConclusion,
174) -> ExitCode {
175    let issues =
176        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
177    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion)
178}
179
180#[must_use]
181fn print_pr_comment_from_ci_issues(
182    command: &str,
183    provider: Provider,
184    issues: &[CiIssue],
185    conclusion: PrDecisionConclusion,
186) -> ExitCode {
187    let body = render_pr_comment(command, provider, issues);
188    let max_comments = max_comments();
189    let envelope = PrCommentEnvelope {
190        marker_id: sticky_marker_id(),
191        body,
192        is_clean: issues.is_empty(),
193        details_url: None,
194        check_summary: Some(decision_summary_label(conclusion).to_owned()),
195        truncation: PrCommentTruncation {
196            truncated: issues.len() > max_comments,
197            shown_findings: issues.len().min(max_comments),
198            total_findings: issues.len(),
199        },
200    };
201    let decision = build_issue_decision_surface(command, issues, &envelope, conclusion);
202    let details = build_pr_details_artifact(command, issues);
203    write_pr_comment_envelope_sidecar(&envelope);
204    write_pr_decision_sidecar(&decision);
205    write_pr_details_sidecar(&details);
206    outln!("{}", envelope.body());
207    ExitCode::SUCCESS
208}
209
210#[must_use]
211pub fn build_issue_decision_surface(
212    command: &str,
213    issues: &[CiIssue],
214    envelope: &PrCommentEnvelope,
215    conclusion: PrDecisionConclusion,
216) -> PrDecisionSurface {
217    let effective_conclusion = if issues.is_empty() {
218        PrDecisionConclusion::Success
219    } else {
220        conclusion
221    };
222    PrDecisionSurface {
223        schema: PR_DECISION_SCHEMA.to_owned(),
224        title: "Fallow".to_owned(),
225        conclusion: effective_conclusion,
226        gates: vec![PrDecisionGate {
227            id: command.to_owned(),
228            label: command_title(command).to_owned(),
229            status: effective_conclusion,
230            observed: count_label(issues.len(), "finding", "findings"),
231            threshold: None,
232            scope: "new code".to_owned(),
233        }],
234        annotations: issues
235            .iter()
236            .take(max_comments())
237            .map(decision_annotation_from_issue)
238            .collect(),
239        details: PrDecisionDetails {
240            summary_markdown: decision_summary_markdown(effective_conclusion, issues.len()),
241            full_report_path: None,
242            details_url: envelope.details_url.clone(),
243        },
244    }
245}
246
247fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
248    if is_clean {
249        PrDecisionConclusion::Success
250    } else {
251        PrDecisionConclusion::Neutral
252    }
253}
254
255fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
256    match conclusion {
257        PrDecisionConclusion::Success => "pass",
258        PrDecisionConclusion::Failure => "fail",
259        PrDecisionConclusion::Neutral => "warn",
260        PrDecisionConclusion::Skipped => "skipped",
261    }
262}
263
264fn decision_summary_markdown(conclusion: PrDecisionConclusion, issue_count: usize) -> String {
265    if issue_count == 0 {
266        return "Fallow found no actionable PR findings.".to_owned();
267    }
268    let findings = count_label(issue_count, "finding", "findings");
269    match conclusion {
270        PrDecisionConclusion::Failure => format!("Fallow quality gates failed with {findings}."),
271        PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
272        PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
273            format!("Fallow found {findings}.")
274        }
275    }
276}
277
278#[must_use]
279pub fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
280    PrDetailsArtifact {
281        schema: PR_DETAILS_SCHEMA.to_owned(),
282        title: format!("Fallow {}", command_title(command)),
283        sections: vec![PrDetailsSection {
284            id: "findings".to_owned(),
285            title: "Findings".to_owned(),
286            rows: issues.iter().map(pr_details_row_from_issue).collect(),
287        }],
288    }
289}
290
291fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
292    PrDetailsRow {
293        location: format!("{}:{}", issue.path, issue.line),
294        rule: issue.rule_id.clone(),
295        description: issue.description.clone(),
296        fix: super::suggestion::fix_intent(issue).map(str::to_owned),
297        fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
298    }
299}
300
301#[must_use]
302pub fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
303    PrDecisionAnnotation {
304        path: issue.path.clone(),
305        line: u32::try_from(issue.line).unwrap_or(u32::MAX),
306        level: decision_level_from_severity(&issue.severity),
307        title: issue.rule_id.clone(),
308        message: issue.description.clone(),
309        raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
310    }
311}
312
313fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
314    match severity {
315        "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
316        "minor" => PrDecisionAnnotationLevel::Warning,
317        _ => PrDecisionAnnotationLevel::Notice,
318    }
319}
320
321fn count_label(count: usize, singular: &str, plural: &str) -> String {
322    let noun = if count == 1 { singular } else { plural };
323    format!("{count} {noun}")
324}
325
326pub fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
327    let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
328        return;
329    };
330    if path.trim().is_empty() {
331        return;
332    }
333    match serde_json::to_string_pretty(envelope)
334        .map_err(|e| e.to_string())
335        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
336    {
337        Ok(()) => {}
338        Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
339    }
340}
341
342pub fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
343    let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
344        return;
345    };
346    if path.trim().is_empty() {
347        return;
348    }
349    match serde_json::to_string_pretty(surface)
350        .map_err(|e| e.to_string())
351        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
352    {
353        Ok(()) => {}
354        Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
355    }
356}
357
358pub fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
359    let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
360        return;
361    };
362    if path.trim().is_empty() {
363        return;
364    }
365    match serde_json::to_string_pretty(artifact)
366        .map_err(|e| e.to_string())
367        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
368    {
369        Ok(()) => {}
370        Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use fallow_output::{
378        CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
379    };
380
381    #[test]
382    fn extracts_issues_from_codeclimate() {
383        let value = serde_json::json!([{
384            "check_name": "fallow/unused-export",
385            "description": "Export x is never imported",
386            "severity": "minor",
387            "fingerprint": "abc",
388            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
389        }]);
390        let issues = issues_from_codeclimate(&value);
391        assert_eq!(issues.len(), 1);
392        assert_eq!(issues[0].path, "src/a.ts");
393        assert_eq!(issues[0].line, 7);
394    }
395
396    #[test]
397    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
398        let severities = [
399            (CodeClimateSeverity::Info, "info"),
400            (CodeClimateSeverity::Minor, "minor"),
401            (CodeClimateSeverity::Major, "major"),
402            (CodeClimateSeverity::Critical, "critical"),
403            (CodeClimateSeverity::Blocker, "blocker"),
404        ];
405        let typed = severities
406            .iter()
407            .enumerate()
408            .map(|(index, (severity, _))| CodeClimateIssue {
409                kind: CodeClimateIssueKind::Issue,
410                check_name: format!("fallow/rule-{index}"),
411                description: format!("Finding {index}"),
412                categories: vec!["Complexity".to_owned()],
413                severity: *severity,
414                fingerprint: format!("fp-{index}"),
415                location: CodeClimateLocation {
416                    path: format!("src/{index}.ts"),
417                    lines: CodeClimateLines {
418                        begin: u32::try_from(index + 1).expect("small fixture index"),
419                    },
420                },
421                owner: None,
422                group: None,
423            })
424            .collect::<Vec<_>>();
425        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
426
427        assert_eq!(
428            issues_from_codeclimate_issues(&typed),
429            issues_from_codeclimate(&value)
430        );
431        let typed_labels = issues_from_codeclimate_issues(&typed)
432            .into_iter()
433            .map(|issue| issue.severity)
434            .collect::<Vec<_>>();
435        let expected_labels = severities
436            .iter()
437            .map(|(_, label)| (*label).to_owned())
438            .collect::<Vec<_>>();
439        assert_eq!(typed_labels, expected_labels);
440    }
441
442    #[test]
443    fn sticky_marker_id_default_when_nothing_set() {
444        let body = render_pr_comment("check", Provider::Github, &[]);
445        assert!(body.contains("<!-- fallow-id: fallow-results"));
446        assert!(body.contains("No GitHub PR/MR findings."));
447    }
448
449    #[test]
450    fn short_hex_hash_is_deterministic_and_six_chars() {
451        let a = short_hex_hash("api,worker");
452        assert_eq!(a.len(), 6);
453        assert_eq!(a, short_hex_hash("api,worker"));
454        assert_ne!(a, short_hex_hash("admin,web"));
455    }
456
457    #[test]
458    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
459        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
460        assert_eq!(
461            sanitize_marker_segment("packages/web ui"),
462            "packages-web-ui"
463        );
464        assert_eq!(sanitize_marker_segment("plain"), "plain");
465        assert_eq!(
466            sanitize_marker_segment("--leading-trailing--"),
467            "leading-trailing"
468        );
469    }
470
471    #[test]
472    fn escape_md_escapes_inline_commonmark_specials() {
473        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
474        let escaped = escape_md(raw);
475        for ch in [
476            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
477        ] {
478            let raw_count = raw.chars().filter(|c| c == &ch).count();
479            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
480            assert_eq!(
481                raw_count, escaped_count,
482                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
483            );
484        }
485    }
486
487    #[test]
488    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
489        let raw = "value &#42;suspicious&#42; here";
490        let escaped = escape_md(raw);
491        assert!(escaped.contains(r"\&"), "got: {escaped}");
492        assert!(escaped.contains(r"\#"), "got: {escaped}");
493        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
494    }
495
496    #[test]
497    fn summary_label_foreshadows_truncation() {
498        assert_eq!(
499            fallow_output::summary_label("Duplication", 160, 50),
500            "Duplication (160, showing 50)"
501        );
502        assert_eq!(
503            fallow_output::summary_label("Health", 12, 50),
504            "Health (12)"
505        );
506        assert_eq!(
507            fallow_output::summary_label("Dependencies", 50, 50),
508            "Dependencies (50)"
509        );
510    }
511
512    #[test]
513    fn escape_md_does_not_escape_block_only_markers() {
514        let raw = "fallow/test-only-dependency package.json:12";
515        let escaped = escape_md(raw);
516        assert!(!escaped.contains("\\-"), "should not escape `-`");
517        assert!(!escaped.contains("\\."), "should not escape `.`");
518        assert_eq!(escaped, raw);
519    }
520
521    #[test]
522    fn escape_md_collapses_newlines_to_spaces() {
523        let raw = "first\nsecond\nthird";
524        assert_eq!(escape_md(raw), "first second third");
525    }
526
527    #[test]
528    fn escape_md_leaves_safe_chars_unchanged() {
529        let raw = "Export 'helperFn' is never imported by other modules";
530        assert_eq!(
531            escape_md(raw),
532            r"Export 'helperFn' is never imported by other modules"
533        );
534    }
535
536    #[test]
537    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
538        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
539            assert!(
540                is_project_level_rule(rule_id),
541                "{rule_id} must be project-level"
542            );
543        }
544        for rule_id in [
545            "fallow/unused-file",
546            "fallow/unused-export",
547            "fallow/unused-type",
548            "fallow/unused-enum-member",
549            "fallow/unused-class-member",
550            "fallow/unused-store-member",
551            "fallow/unresolved-import",
552            "fallow/unlisted-dependency",
553            "fallow/duplicate-export",
554            "fallow/circular-dependency",
555            "fallow/re-export-cycle",
556            "fallow/boundary-violation",
557            "fallow/stale-suppression",
558            "fallow/private-type-leak",
559            "fallow/high-complexity",
560            "fallow/high-crap-score",
561        ] {
562            assert!(
563                !is_project_level_rule(rule_id),
564                "{rule_id} must NOT be project-level"
565            );
566        }
567    }
568
569    #[test]
570    fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
571        let issues = [CiIssue {
572            path: "src/app.ts".to_owned(),
573            line: 12,
574            rule_id: "fallow/high-crap-score".to_owned(),
575            description: "Function is hard to safely change.".to_owned(),
576            severity: "minor".to_owned(),
577            fingerprint: "abc".to_owned(),
578        }];
579        let envelope = PrCommentEnvelope {
580            marker_id: "fallow-results".to_owned(),
581            body: "body".to_owned(),
582            is_clean: false,
583            details_url: None,
584            check_summary: Some("fail".to_owned()),
585            truncation: PrCommentTruncation {
586                truncated: false,
587                shown_findings: 1,
588                total_findings: 1,
589            },
590        };
591
592        let decision = build_issue_decision_surface(
593            "audit",
594            &issues,
595            &envelope,
596            PrDecisionConclusion::Failure,
597        );
598
599        assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
600        assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
601        assert!(
602            decision
603                .details
604                .summary_markdown
605                .contains("quality gates failed")
606        );
607    }
608
609    #[test]
610    fn project_level_rule_ids_each_register_in_explain_registry() {
611        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
612            assert!(
613                crate::explain::rule_by_id(rule_id).is_some(),
614                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
615            );
616        }
617    }
618
619    #[test]
620    fn escape_md_double_apply_is_safe() {
621        let raw = "code with `backticks` and *stars*";
622        let once = escape_md(raw);
623        let twice = escape_md(&once);
624        assert!(twice.contains(r"\\"));
625    }
626}