Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use serde_json::Value;
4
5use super::pr_comment::{CiIssue, Provider, command_title, escape_md};
6use super::severity;
7use crate::report::emit_json;
8
9#[must_use]
10pub fn render_review_envelope(command: &str, provider: Provider, issues: &[CiIssue]) -> Value {
11    let max = std::env::var("FALLOW_MAX_COMMENTS")
12        .ok()
13        .and_then(|v| v.parse::<usize>().ok())
14        .unwrap_or(50);
15    let gitlab_diff_refs = (provider == Provider::Gitlab)
16        .then(gitlab_diff_refs_from_env)
17        .flatten();
18    let body = format!(
19        "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
20        command_title(command),
21        issues.len().min(max),
22        if issues.len().min(max) == 1 { "" } else { "s" },
23        provider.name(),
24    );
25    let comments = issues
26        .iter()
27        .take(max)
28        .map(|issue| render_comment(provider, issue, gitlab_diff_refs.as_ref()))
29        .collect::<Vec<_>>();
30
31    match provider {
32        Provider::Github => serde_json::json!({
33            "event": "COMMENT",
34            "body": body,
35            "comments": comments,
36            "meta": {
37                "schema": "fallow-review-envelope/v1",
38                "provider": "github",
39                "check_conclusion": github_check_conclusion(issues),
40            }
41        }),
42        Provider::Gitlab => serde_json::json!({
43            "body": body,
44            "comments": comments,
45            "meta": {
46                "schema": "fallow-review-envelope/v1",
47                "provider": "gitlab"
48            }
49        }),
50    }
51}
52
53#[must_use]
54pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
55    let issues = super::diff_filter::filter_issues_from_env(
56        super::pr_comment::issues_from_codeclimate(codeclimate),
57    );
58    let envelope = render_review_envelope(command, provider, &issues);
59    emit_json(&envelope, "review envelope")
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63#[expect(
64    clippy::struct_field_names,
65    reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
66)]
67struct GitlabDiffRefs {
68    base_sha: String,
69    start_sha: String,
70    head_sha: String,
71}
72
73fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
74    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
75        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
76    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
77    let head_sha =
78        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
79    Some(GitlabDiffRefs {
80        base_sha,
81        start_sha,
82        head_sha,
83    })
84}
85
86fn env_nonempty(name: &str) -> Option<String> {
87    std::env::var(name)
88        .ok()
89        .filter(|value| !value.trim().is_empty())
90}
91
92fn render_comment(
93    provider: Provider,
94    issue: &CiIssue,
95    gitlab_diff_refs: Option<&GitlabDiffRefs>,
96) -> Value {
97    let label = review_label_from_codeclimate(&issue.severity);
98    let mut body = format!(
99        "**{}** `{}`: {}\n\n<!-- fallow-fingerprint: {} -->",
100        label,
101        escape_md(&issue.rule_id),
102        escape_md(&issue.description),
103        issue.fingerprint
104    );
105    if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
106        body.push_str(&suggestion);
107    }
108    match provider {
109        // Fallow findings point at the current file state. GitHub deletion-side
110        // review comments are intentionally not modeled in this envelope yet.
111        Provider::Github => serde_json::json!({
112            "path": issue.path,
113            "line": issue.line,
114            "side": "RIGHT",
115            "body": body,
116            "fingerprint": issue.fingerprint,
117        }),
118        Provider::Gitlab => {
119            let mut position = serde_json::json!({
120                "position_type": "text",
121                "old_path": issue.path,
122                "new_path": issue.path,
123                "new_line": issue.line,
124            });
125            if let Some(diff_refs) = gitlab_diff_refs {
126                position["base_sha"] = serde_json::json!(diff_refs.base_sha);
127                position["start_sha"] = serde_json::json!(diff_refs.start_sha);
128                position["head_sha"] = serde_json::json!(diff_refs.head_sha);
129            }
130            serde_json::json!({
131                "body": body,
132                "position": position,
133                "fingerprint": issue.fingerprint,
134            })
135        }
136    }
137}
138
139fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
140    match severity_name {
141        "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
142        _ => severity::review_label(fallow_config::Severity::Warn),
143    }
144}
145
146fn github_check_conclusion(issues: &[CiIssue]) -> &'static str {
147    if issues
148        .iter()
149        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
150    {
151        severity::github_check_conclusion(fallow_config::Severity::Error)
152    } else if issues.is_empty() {
153        severity::github_check_conclusion(fallow_config::Severity::Off)
154    } else {
155        severity::github_check_conclusion(fallow_config::Severity::Warn)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn github_review_envelope_matches_api_shape() {
165        let issues = vec![CiIssue {
166            rule_id: "fallow/unused-file".into(),
167            description: "File is unused".into(),
168            severity: "minor".into(),
169            path: "src/a.ts".into(),
170            line: 1,
171            fingerprint: "abc".into(),
172        }];
173        let envelope = render_review_envelope("check", Provider::Github, &issues);
174        assert_eq!(envelope["event"], "COMMENT");
175        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
176        assert!(
177            envelope["comments"][0]["body"]
178                .as_str()
179                .unwrap()
180                .contains("fallow-fingerprint")
181        );
182    }
183
184    #[test]
185    fn github_comments_target_current_state_side() {
186        let issue = CiIssue {
187            rule_id: "fallow/unused-file".into(),
188            description: "File is unused".into(),
189            severity: "minor".into(),
190            path: "src/a.ts".into(),
191            line: 1,
192            fingerprint: "abc".into(),
193        };
194        let comment = render_comment(Provider::Github, &issue, None);
195        assert_eq!(comment["side"], "RIGHT");
196    }
197
198    #[test]
199    fn labels_major_issues_as_errors() {
200        let issue = CiIssue {
201            rule_id: "fallow/unused-file".into(),
202            description: "File is unused".into(),
203            severity: "major".into(),
204            path: "src/a.ts".into(),
205            line: 1,
206            fingerprint: "abc".into(),
207        };
208        let comment = render_comment(Provider::Github, &issue, None);
209        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
210    }
211
212    #[test]
213    fn gitlab_comment_accepts_diff_refs() {
214        let issue = CiIssue {
215            rule_id: "fallow/unused-file".into(),
216            description: "File is unused".into(),
217            severity: "minor".into(),
218            path: "src/a.ts".into(),
219            line: 1,
220            fingerprint: "abc".into(),
221        };
222        let refs = GitlabDiffRefs {
223            base_sha: "base".into(),
224            start_sha: "start".into(),
225            head_sha: "head".into(),
226        };
227        let comment = render_comment(Provider::Gitlab, &issue, Some(&refs));
228        assert_eq!(comment["position"]["position_type"], "text");
229        assert_eq!(comment["position"]["base_sha"], "base");
230        assert_eq!(comment["position"]["start_sha"], "start");
231        assert_eq!(comment["position"]["head_sha"], "head");
232    }
233}