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
7#[cfg(test)]
8use fallow_output::is_project_level_rule;
9use fallow_output::issues_from_codeclimate_issues;
10pub use fallow_output::{
11    CiIssue, CiProvider as Provider, CodeClimateIssue, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA,
12    PrCommentEnvelope, PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation,
13    PrDecisionAnnotationLevel, PrDecisionConclusion, PrDecisionDetails, PrDecisionGate,
14    PrDecisionSurface, PrDetailsArtifact, PrDetailsRow, PrDetailsSection, command_title,
15    issues_from_codeclimate,
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]
91fn 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(crate) 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(crate) 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(crate) 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(crate) fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
162    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
163        issues_from_codeclimate(codeclimate),
164    ));
165    let conclusion = issue_decision_conclusion(issues.is_empty());
166    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, None)
167}
168
169#[must_use]
170pub(crate) fn print_pr_comment_with_status(
171    command: &str,
172    provider: Provider,
173    codeclimate: &Value,
174    conclusion: PrDecisionConclusion,
175    status_message: Option<&str>,
176) -> ExitCode {
177    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
178        issues_from_codeclimate(codeclimate),
179    ));
180    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
181}
182
183#[must_use]
184pub(crate) fn print_pr_comment_from_codeclimate_issues(
185    command: &str,
186    provider: Provider,
187    codeclimate: &[CodeClimateIssue],
188    conclusion: Option<PrDecisionConclusion>,
189    status_message: Option<&str>,
190) -> ExitCode {
191    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
192        issues_from_codeclimate_issues(codeclimate),
193    ));
194    let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
195    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
196}
197
198fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
199    let prefix = crate::report::github::report_prefix();
200    if !prefix.is_empty() {
201        for issue in &mut issues {
202            issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
203        }
204    }
205    issues
206}
207
208#[must_use]
209fn print_pr_comment_from_ci_issues(
210    command: &str,
211    provider: Provider,
212    issues: &[CiIssue],
213    conclusion: PrDecisionConclusion,
214    status_message: Option<&str>,
215) -> ExitCode {
216    let mut body = render_pr_comment(command, provider, issues);
217    if let Some(message) = status_message {
218        body.push_str("\n\n> ");
219        body.push_str(message);
220    }
221    let max_comments = max_comments();
222    let envelope = PrCommentEnvelope {
223        marker_id: sticky_marker_id(),
224        body,
225        is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
226        details_url: None,
227        check_summary: Some(decision_summary_label(conclusion).to_owned()),
228        truncation: PrCommentTruncation {
229            truncated: issues.len() > max_comments,
230            shown_findings: issues.len().min(max_comments),
231            total_findings: issues.len(),
232        },
233    };
234    let decision =
235        build_issue_decision_surface(command, issues, &envelope, conclusion, status_message);
236    let details = build_pr_details_artifact(command, issues);
237    write_pr_comment_envelope_sidecar(&envelope);
238    write_pr_decision_sidecar(&decision);
239    write_pr_details_sidecar(&details);
240    outln!("{}", envelope.body());
241    ExitCode::SUCCESS
242}
243
244#[must_use]
245fn build_issue_decision_surface(
246    command: &str,
247    issues: &[CiIssue],
248    envelope: &PrCommentEnvelope,
249    conclusion: PrDecisionConclusion,
250    status_message: Option<&str>,
251) -> PrDecisionSurface {
252    PrDecisionSurface {
253        schema: PR_DECISION_SCHEMA.to_owned(),
254        title: "Fallow".to_owned(),
255        conclusion,
256        gates: vec![PrDecisionGate {
257            id: command.to_owned(),
258            label: command_title(command).to_owned(),
259            status: conclusion,
260            observed: count_label(issues.len(), "finding", "findings"),
261            threshold: None,
262            scope: "new code".to_owned(),
263        }],
264        annotations: issues
265            .iter()
266            .take(max_comments())
267            .map(decision_annotation_from_issue)
268            .collect(),
269        details: PrDecisionDetails {
270            summary_markdown: decision_summary_markdown(conclusion, issues.len(), status_message),
271            full_report_path: None,
272            details_url: envelope.details_url.clone(),
273        },
274    }
275}
276
277fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
278    if is_clean {
279        PrDecisionConclusion::Success
280    } else {
281        PrDecisionConclusion::Neutral
282    }
283}
284
285fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
286    match conclusion {
287        PrDecisionConclusion::Success => "pass",
288        PrDecisionConclusion::Failure => "fail",
289        PrDecisionConclusion::Neutral => "warn",
290        PrDecisionConclusion::Skipped => "skipped",
291    }
292}
293
294fn decision_summary_markdown(
295    conclusion: PrDecisionConclusion,
296    issue_count: usize,
297    status_message: Option<&str>,
298) -> String {
299    let summary = if issue_count == 0 {
300        match conclusion {
301            PrDecisionConclusion::Failure => {
302                "Fallow quality gates failed without renderable findings.".to_owned()
303            }
304            PrDecisionConclusion::Neutral => {
305                "Fallow needs review without renderable findings.".to_owned()
306            }
307            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
308                "Fallow found no actionable PR findings.".to_owned()
309            }
310        }
311    } else {
312        let findings = count_label(issue_count, "finding", "findings");
313        match conclusion {
314            PrDecisionConclusion::Failure => {
315                format!("Fallow quality gates failed with {findings}.")
316            }
317            PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
318            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
319                format!("Fallow found {findings}.")
320            }
321        }
322    };
323    match status_message {
324        Some(message) => format!("{summary}\n\n> {message}"),
325        None => summary,
326    }
327}
328
329#[must_use]
330pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
331    PrDetailsArtifact {
332        schema: PR_DETAILS_SCHEMA.to_owned(),
333        title: format!("Fallow {}", command_title(command)),
334        sections: vec![PrDetailsSection {
335            id: "findings".to_owned(),
336            title: "Findings".to_owned(),
337            rows: issues.iter().map(pr_details_row_from_issue).collect(),
338        }],
339    }
340}
341
342fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
343    PrDetailsRow {
344        location: format!("{}:{}", issue.path, issue.line),
345        rule: issue.rule_id.clone(),
346        description: issue.description.clone(),
347        fix: super::suggestion::fix_intent(issue).map(str::to_owned),
348        fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
349    }
350}
351
352#[must_use]
353pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
354    PrDecisionAnnotation {
355        path: issue.path.clone(),
356        line: u32::try_from(issue.line).unwrap_or(u32::MAX),
357        level: decision_level_from_severity(&issue.severity),
358        title: issue.rule_id.clone(),
359        message: issue.description.clone(),
360        raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
361    }
362}
363
364fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
365    match severity {
366        "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
367        "minor" => PrDecisionAnnotationLevel::Warning,
368        _ => PrDecisionAnnotationLevel::Notice,
369    }
370}
371
372fn count_label(count: usize, singular: &str, plural: &str) -> String {
373    let noun = if count == 1 { singular } else { plural };
374    format!("{count} {noun}")
375}
376
377pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
378    let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
379        return;
380    };
381    if path.trim().is_empty() {
382        return;
383    }
384    match serde_json::to_string_pretty(envelope)
385        .map_err(|e| e.to_string())
386        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
387    {
388        Ok(()) => {}
389        Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
390    }
391}
392
393pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
394    let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
395        return;
396    };
397    if path.trim().is_empty() {
398        return;
399    }
400    match serde_json::to_string_pretty(surface)
401        .map_err(|e| e.to_string())
402        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
403    {
404        Ok(()) => {}
405        Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
406    }
407}
408
409pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
410    let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
411        return;
412    };
413    if path.trim().is_empty() {
414        return;
415    }
416    match serde_json::to_string_pretty(artifact)
417        .map_err(|e| e.to_string())
418        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
419    {
420        Ok(()) => {}
421        Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use fallow_output::{
429        CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
430    };
431
432    #[test]
433    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
434        let severities = [
435            (CodeClimateSeverity::Info, "info"),
436            (CodeClimateSeverity::Minor, "minor"),
437            (CodeClimateSeverity::Major, "major"),
438            (CodeClimateSeverity::Critical, "critical"),
439            (CodeClimateSeverity::Blocker, "blocker"),
440        ];
441        let typed = severities
442            .iter()
443            .enumerate()
444            .map(|(index, (severity, _))| CodeClimateIssue {
445                kind: CodeClimateIssueKind::Issue,
446                check_name: format!("fallow/rule-{index}"),
447                description: format!("Finding {index}"),
448                categories: vec!["Complexity".to_owned()],
449                severity: *severity,
450                fingerprint: format!("fp-{index}"),
451                location: CodeClimateLocation {
452                    path: format!("src/{index}.ts"),
453                    lines: CodeClimateLines {
454                        begin: u32::try_from(index + 1).expect("small fixture index"),
455                        end: None,
456                    },
457                },
458                other_locations: Vec::new(),
459                owner: None,
460                group: None,
461            })
462            .collect::<Vec<_>>();
463        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
464
465        assert_eq!(
466            issues_from_codeclimate_issues(&typed),
467            issues_from_codeclimate(&value)
468        );
469        let typed_labels = issues_from_codeclimate_issues(&typed)
470            .into_iter()
471            .map(|issue| issue.severity)
472            .collect::<Vec<_>>();
473        let expected_labels = severities
474            .iter()
475            .map(|(_, label)| (*label).to_owned())
476            .collect::<Vec<_>>();
477        assert_eq!(typed_labels, expected_labels);
478    }
479
480    #[test]
481    fn sticky_marker_id_default_when_nothing_set() {
482        let body = render_pr_comment("check", Provider::Github, &[]);
483        assert!(body.contains("<!-- fallow-id: fallow-results"));
484        assert!(body.contains("No GitHub PR/MR findings."));
485    }
486
487    #[test]
488    fn short_hex_hash_is_deterministic_and_six_chars() {
489        let a = short_hex_hash("api,worker");
490        assert_eq!(a.len(), 6);
491        assert_eq!(a, short_hex_hash("api,worker"));
492        assert_ne!(a, short_hex_hash("admin,web"));
493    }
494
495    #[test]
496    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
497        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
498        assert_eq!(
499            sanitize_marker_segment("packages/web ui"),
500            "packages-web-ui"
501        );
502        assert_eq!(sanitize_marker_segment("plain"), "plain");
503        assert_eq!(
504            sanitize_marker_segment("--leading-trailing--"),
505            "leading-trailing"
506        );
507    }
508
509    #[test]
510    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
511        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
512            assert!(
513                is_project_level_rule(rule_id),
514                "{rule_id} must be project-level"
515            );
516        }
517        for rule_id in [
518            "fallow/unused-file",
519            "fallow/unused-export",
520            "fallow/unused-type",
521            "fallow/unused-enum-member",
522            "fallow/unused-class-member",
523            "fallow/unused-store-member",
524            "fallow/unresolved-import",
525            "fallow/unlisted-dependency",
526            "fallow/duplicate-export",
527            "fallow/circular-dependency",
528            "fallow/re-export-cycle",
529            "fallow/boundary-violation",
530            "fallow/stale-suppression",
531            "fallow/private-type-leak",
532            "fallow/high-complexity",
533            "fallow/high-crap-score",
534        ] {
535            assert!(
536                !is_project_level_rule(rule_id),
537                "{rule_id} must NOT be project-level"
538            );
539        }
540    }
541
542    #[test]
543    fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
544        let issues = [CiIssue {
545            path: "src/app.ts".to_owned(),
546            line: 12,
547            end_line: None,
548            other_locations: Vec::new(),
549            rule_id: "fallow/high-crap-score".to_owned(),
550            description: "Function is hard to safely change.".to_owned(),
551            severity: "minor".to_owned(),
552            fingerprint: "abc".to_owned(),
553        }];
554        let envelope = PrCommentEnvelope {
555            marker_id: "fallow-results".to_owned(),
556            body: "body".to_owned(),
557            is_clean: false,
558            details_url: None,
559            check_summary: Some("fail".to_owned()),
560            truncation: PrCommentTruncation {
561                truncated: false,
562                shown_findings: 1,
563                total_findings: 1,
564            },
565        };
566
567        let decision = build_issue_decision_surface(
568            "audit",
569            &issues,
570            &envelope,
571            PrDecisionConclusion::Failure,
572            Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
573        );
574
575        assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
576        assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
577        assert!(decision.details.summary_markdown.contains("incomplete"));
578        assert!(
579            decision
580                .details
581                .summary_markdown
582                .contains("quality gates failed")
583        );
584    }
585
586    #[test]
587    fn project_level_rule_ids_each_register_in_explain_registry() {
588        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
589            assert!(
590                crate::explain::rule_by_id(rule_id).is_some(),
591                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
592            );
593        }
594    }
595}