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