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/// Render the sticky comment body. `conclusion` is the gate outcome the caller
72/// already computed, folded into the severity-derived verdict by the renderer;
73/// `None` renders the severity-derived verdict alone.
74#[must_use]
75pub fn render_pr_comment(
76    command: &str,
77    provider: Provider,
78    issues: &[CiIssue],
79    conclusion: Option<PrDecisionConclusion>,
80) -> String {
81    fallow_output::render_pr_comment_with_verdict(
82        &fallow_output::PrCommentRenderInput {
83            command,
84            provider,
85            issues,
86            marker_id: sticky_marker_id(),
87            max_comments: max_comments(),
88            category_for_rule: &category_for_rule,
89        },
90        conclusion.map(super::review::review_conclusion),
91    )
92}
93
94/// Map a fallow rule id to its category for sticky-comment grouping.
95///
96/// Single source of truth lives on `RuleDef::category` in `explain.rs`. This
97/// helper does the lookup so callers don't need to know about the registry;
98/// the look-up-then-fallback shape also keeps the renderer working for
99/// rules a downstream consumer added without registering (rare). An
100/// unregistered rule id is an absence of information, not evidence of
101/// unreachable code, so it lands in "Other" rather than inflating the
102/// "Dead code" section.
103#[must_use]
104fn category_for_rule(rule_id: &str) -> &'static str {
105    crate::explain::rule_by_id(rule_id).map_or("Other", |def| def.category)
106}
107
108pub(crate) fn max_comments() -> usize {
109    std::env::var("FALLOW_MAX_COMMENTS")
110        .ok()
111        .and_then(|value| value.parse::<usize>().ok())
112        .unwrap_or(50)
113}
114
115#[must_use]
116pub(crate) fn pr_comment_layout_from_env() -> PrCommentLayout {
117    match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
118        Ok("compact") => PrCommentLayout::Compact,
119        Ok("gate-only") => PrCommentLayout::GateOnly,
120        Ok("details") => PrCommentLayout::Details,
121        _ => PrCommentLayout::Default,
122    }
123}
124
125/// Compute the sticky-comment marker id. Precedence (highest first):
126///
127/// 1. `FALLOW_COMMENT_ID` set by the user explicitly: use as-is.
128/// 2. `WORKSPACE_MARKER` populated by `main()` from `--workspace <name>`:
129///    suffix the default to avoid colliding with a sibling per-workspace
130///    job's sticky on the same PR/MR.
131/// 3. Plain `fallow-results`.
132///
133/// The collision case (2) is the common monorepo shape: parallel jobs each
134/// run fallow scoped to one workspace package and post their own sticky.
135/// Without a per-workspace suffix every job edits the same marker, racing
136/// each other's bodies on every CI re-run.
137pub(crate) fn sticky_marker_id() -> String {
138    if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
139        && !value.trim().is_empty()
140    {
141        return value;
142    }
143    let suffix = WORKSPACE_MARKER
144        .get()
145        .map(|value| value.trim())
146        .filter(|value| !value.is_empty())
147        .map(sanitize_marker_segment);
148    match suffix {
149        Some(workspace) => format!("fallow-results-{workspace}"),
150        None => "fallow-results".to_owned(),
151    }
152}
153
154/// Strip characters that would break the HTML-comment marker. The marker
155/// shape is `<!-- fallow-id: <id> -->`; `<`, `>`, and `--` are reserved by
156/// the HTML comment grammar, and whitespace would split the id when the
157/// reader scans for it.
158fn sanitize_marker_segment(value: &str) -> String {
159    value
160        .chars()
161        .map(|ch| {
162            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
163                ch
164            } else {
165                '-'
166            }
167        })
168        .collect::<String>()
169        .trim_matches('-')
170        .to_owned()
171}
172
173#[must_use]
174pub(crate) fn print_pr_comment(
175    command: &str,
176    provider: Provider,
177    codeclimate: &Value,
178    status_message: Option<&str>,
179) -> ExitCode {
180    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
181        issues_from_codeclimate(codeclimate),
182    ));
183    let conclusion = issue_decision_conclusion(issues.is_empty());
184    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
185}
186
187#[must_use]
188pub(crate) fn print_pr_comment_with_status(
189    command: &str,
190    provider: Provider,
191    codeclimate: &Value,
192    conclusion: PrDecisionConclusion,
193    status_message: Option<&str>,
194) -> ExitCode {
195    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
196        issues_from_codeclimate(codeclimate),
197    ));
198    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
199}
200
201#[must_use]
202pub(crate) fn print_pr_comment_from_codeclimate_issues(
203    command: &str,
204    provider: Provider,
205    codeclimate: &[CodeClimateIssue],
206    conclusion: Option<PrDecisionConclusion>,
207    status_message: Option<&str>,
208) -> ExitCode {
209    let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
210        issues_from_codeclimate_issues(codeclimate),
211    ));
212    let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
213    print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
214}
215
216fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
217    let prefix = crate::report::github::report_prefix();
218    if !prefix.is_empty() {
219        for issue in &mut issues {
220            issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
221        }
222    }
223    issues
224}
225
226#[must_use]
227fn print_pr_comment_from_ci_issues(
228    command: &str,
229    provider: Provider,
230    issues: &[CiIssue],
231    conclusion: PrDecisionConclusion,
232    status_message: Option<&str>,
233) -> ExitCode {
234    let mut body = render_pr_comment(command, provider, issues, Some(conclusion));
235    if let Some(message) = status_message {
236        body.push_str("\n\n> ");
237        body.push_str(message);
238    }
239    let max_comments = max_comments();
240    let envelope = PrCommentEnvelope {
241        marker_id: sticky_marker_id(),
242        body,
243        is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
244        details_url: None,
245        check_summary: Some(decision_summary_label(conclusion).to_owned()),
246        truncation: PrCommentTruncation {
247            truncated: issues.len() > max_comments,
248            shown_findings: issues.len().min(max_comments),
249            total_findings: issues.len(),
250        },
251    };
252    let decision =
253        build_issue_decision_surface(command, issues, &envelope, conclusion, status_message);
254    let details = build_pr_details_artifact(command, issues);
255    write_pr_comment_envelope_sidecar(&envelope);
256    write_pr_decision_sidecar(&decision);
257    write_pr_details_sidecar(&details);
258    outln!("{}", envelope.body());
259    ExitCode::SUCCESS
260}
261
262#[must_use]
263fn build_issue_decision_surface(
264    command: &str,
265    issues: &[CiIssue],
266    envelope: &PrCommentEnvelope,
267    conclusion: PrDecisionConclusion,
268    status_message: Option<&str>,
269) -> PrDecisionSurface {
270    PrDecisionSurface {
271        schema: PR_DECISION_SCHEMA.to_owned(),
272        title: "Fallow".to_owned(),
273        conclusion,
274        gates: vec![PrDecisionGate {
275            id: command.to_owned(),
276            label: command_title(command).to_owned(),
277            status: conclusion,
278            observed: count_label(issues.len(), "finding", "findings"),
279            threshold: None,
280            scope: "new code".to_owned(),
281        }],
282        annotations: issues
283            .iter()
284            .take(max_comments())
285            .map(decision_annotation_from_issue)
286            .collect(),
287        details: PrDecisionDetails {
288            summary_markdown: decision_summary_markdown(conclusion, issues.len(), status_message),
289            full_report_path: None,
290            details_url: envelope.details_url.clone(),
291        },
292    }
293}
294
295/// Gate outcome for the PR decision surface, which `ci_check_run` maps
296/// straight onto the GitHub check-run `conclusion`. Findings alone stay
297/// `Neutral` here on purpose: promoting them to `Failure` would turn an
298/// advisory check into a merge blocker for every consumer with a required
299/// check. The sticky comment's verdict line answers a different question
300/// (how severe are the findings), so the two intentionally differ.
301fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
302    if is_clean {
303        PrDecisionConclusion::Success
304    } else {
305        PrDecisionConclusion::Neutral
306    }
307}
308
309fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
310    match conclusion {
311        PrDecisionConclusion::Success => "pass",
312        PrDecisionConclusion::Failure => "fail",
313        PrDecisionConclusion::Neutral => "warn",
314        PrDecisionConclusion::Skipped => "skipped",
315    }
316}
317
318fn decision_summary_markdown(
319    conclusion: PrDecisionConclusion,
320    issue_count: usize,
321    status_message: Option<&str>,
322) -> String {
323    let summary = if issue_count == 0 {
324        match conclusion {
325            PrDecisionConclusion::Failure => {
326                "Fallow quality gates failed without renderable findings.".to_owned()
327            }
328            PrDecisionConclusion::Neutral => {
329                "Fallow needs review without renderable findings.".to_owned()
330            }
331            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
332                "Fallow found no actionable PR findings.".to_owned()
333            }
334        }
335    } else {
336        let findings = count_label(issue_count, "finding", "findings");
337        match conclusion {
338            PrDecisionConclusion::Failure => {
339                format!("Fallow quality gates failed with {findings}.")
340            }
341            PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
342            PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
343                format!("Fallow found {findings}.")
344            }
345        }
346    };
347    match status_message {
348        Some(message) => format!("{summary}\n\n> {message}"),
349        None => summary,
350    }
351}
352
353#[must_use]
354pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
355    PrDetailsArtifact {
356        schema: PR_DETAILS_SCHEMA.to_owned(),
357        title: format!("Fallow {}", command_title(command)),
358        sections: vec![PrDetailsSection {
359            id: "findings".to_owned(),
360            title: "Findings".to_owned(),
361            rows: issues.iter().map(pr_details_row_from_issue).collect(),
362        }],
363    }
364}
365
366fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
367    PrDetailsRow {
368        location: format!("{}:{}", issue.path, issue.line),
369        rule: issue.rule_id.clone(),
370        description: issue.description.clone(),
371        fix: super::suggestion::fix_intent(issue).map(str::to_owned),
372        fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
373    }
374}
375
376#[must_use]
377pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
378    PrDecisionAnnotation {
379        path: issue.path.clone(),
380        line: u32::try_from(issue.line).unwrap_or(u32::MAX),
381        level: decision_level_from_severity(&issue.severity),
382        title: issue.rule_id.clone(),
383        message: issue.description.clone(),
384        raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
385    }
386}
387
388fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
389    match severity {
390        "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
391        "minor" => PrDecisionAnnotationLevel::Warning,
392        _ => PrDecisionAnnotationLevel::Notice,
393    }
394}
395
396fn count_label(count: usize, singular: &str, plural: &str) -> String {
397    let noun = if count == 1 { singular } else { plural };
398    format!("{count} {noun}")
399}
400
401pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
402    let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
403        return;
404    };
405    if path.trim().is_empty() {
406        return;
407    }
408    match serde_json::to_string_pretty(envelope)
409        .map_err(|e| e.to_string())
410        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
411    {
412        Ok(()) => {}
413        Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
414    }
415}
416
417pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
418    let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
419        return;
420    };
421    if path.trim().is_empty() {
422        return;
423    }
424    match serde_json::to_string_pretty(surface)
425        .map_err(|e| e.to_string())
426        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
427    {
428        Ok(()) => {}
429        Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
430    }
431}
432
433pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
434    let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
435        return;
436    };
437    if path.trim().is_empty() {
438        return;
439    }
440    match serde_json::to_string_pretty(artifact)
441        .map_err(|e| e.to_string())
442        .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
443    {
444        Ok(()) => {}
445        Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use fallow_output::{
453        CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
454    };
455
456    #[test]
457    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
458        let severities = [
459            (CodeClimateSeverity::Info, "info"),
460            (CodeClimateSeverity::Minor, "minor"),
461            (CodeClimateSeverity::Major, "major"),
462            (CodeClimateSeverity::Critical, "critical"),
463            (CodeClimateSeverity::Blocker, "blocker"),
464        ];
465        let typed = severities
466            .iter()
467            .enumerate()
468            .map(|(index, (severity, _))| CodeClimateIssue {
469                kind: CodeClimateIssueKind::Issue,
470                check_name: format!("fallow/rule-{index}"),
471                description: format!("Finding {index}"),
472                categories: vec!["Complexity".to_owned()],
473                severity: *severity,
474                fingerprint: format!("fp-{index}"),
475                location: CodeClimateLocation {
476                    path: format!("src/{index}.ts"),
477                    lines: CodeClimateLines {
478                        begin: u32::try_from(index + 1).expect("small fixture index"),
479                        end: None,
480                    },
481                },
482                other_locations: Vec::new(),
483                owner: None,
484                group: None,
485            })
486            .collect::<Vec<_>>();
487        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
488
489        assert_eq!(
490            issues_from_codeclimate_issues(&typed),
491            issues_from_codeclimate(&value)
492        );
493        let typed_labels = issues_from_codeclimate_issues(&typed)
494            .into_iter()
495            .map(|issue| issue.severity)
496            .collect::<Vec<_>>();
497        let expected_labels = severities
498            .iter()
499            .map(|(_, label)| (*label).to_owned())
500            .collect::<Vec<_>>();
501        assert_eq!(typed_labels, expected_labels);
502    }
503
504    #[test]
505    fn sticky_marker_id_default_when_nothing_set() {
506        let body = render_pr_comment("check", Provider::Github, &[], None);
507        assert!(body.contains("<!-- fallow-id: fallow-results"));
508        assert!(body.contains("No findings for this pull request."));
509    }
510
511    #[test]
512    fn short_hex_hash_is_deterministic_and_six_chars() {
513        let a = short_hex_hash("api,worker");
514        assert_eq!(a.len(), 6);
515        assert_eq!(a, short_hex_hash("api,worker"));
516        assert_ne!(a, short_hex_hash("admin,web"));
517    }
518
519    #[test]
520    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
521        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
522        assert_eq!(
523            sanitize_marker_segment("packages/web ui"),
524            "packages-web-ui"
525        );
526        assert_eq!(sanitize_marker_segment("plain"), "plain");
527        assert_eq!(
528            sanitize_marker_segment("--leading-trailing--"),
529            "leading-trailing"
530        );
531    }
532
533    #[test]
534    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
535        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
536            assert!(
537                is_project_level_rule(rule_id),
538                "{rule_id} must be project-level"
539            );
540        }
541        for rule_id in [
542            "fallow/unused-file",
543            "fallow/unused-export",
544            "fallow/unused-type",
545            "fallow/unused-enum-member",
546            "fallow/unused-class-member",
547            "fallow/unused-store-member",
548            "fallow/unresolved-import",
549            "fallow/unlisted-dependency",
550            "fallow/duplicate-export",
551            "fallow/circular-dependency",
552            "fallow/re-export-cycle",
553            "fallow/boundary-violation",
554            "fallow/stale-suppression",
555            "fallow/private-type-leak",
556            "fallow/high-complexity",
557            "fallow/high-crap-score",
558        ] {
559            assert!(
560                !is_project_level_rule(rule_id),
561                "{rule_id} must NOT be project-level"
562            );
563        }
564    }
565
566    #[test]
567    fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
568        let issues = [CiIssue {
569            path: "src/app.ts".to_owned(),
570            line: 12,
571            end_line: None,
572            other_locations: Vec::new(),
573            rule_id: "fallow/high-crap-score".to_owned(),
574            description: "Function is hard to safely change.".to_owned(),
575            severity: "minor".to_owned(),
576            fingerprint: "abc".to_owned(),
577        }];
578        let envelope = PrCommentEnvelope {
579            marker_id: "fallow-results".to_owned(),
580            body: "body".to_owned(),
581            is_clean: false,
582            details_url: None,
583            check_summary: Some("fail".to_owned()),
584            truncation: PrCommentTruncation {
585                truncated: false,
586                shown_findings: 1,
587                total_findings: 1,
588            },
589        };
590
591        let decision = build_issue_decision_surface(
592            "audit",
593            &issues,
594            &envelope,
595            PrDecisionConclusion::Failure,
596            Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
597        );
598
599        assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
600        assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
601        assert!(decision.details.summary_markdown.contains("incomplete"));
602        assert!(
603            decision
604                .details
605                .summary_markdown
606                .contains("quality gates failed")
607        );
608    }
609
610    #[test]
611    fn project_level_rule_ids_each_register_in_explain_registry() {
612        for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
613            assert!(
614                crate::explain::rule_by_id(rule_id).is_some(),
615                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
616            );
617        }
618    }
619}