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::output_envelope::{
8    GitHubReviewComment, GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition,
9    GitLabReviewPositionType, ReviewCheckConclusion, ReviewComment, ReviewEnvelopeEvent,
10    ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema, ReviewProvider,
11};
12use crate::report::emit_json;
13
14#[must_use]
15pub fn render_review_envelope(
16    command: &str,
17    provider: Provider,
18    issues: &[CiIssue],
19) -> ReviewEnvelopeOutput {
20    let max = std::env::var("FALLOW_MAX_COMMENTS")
21        .ok()
22        .and_then(|v| v.parse::<usize>().ok())
23        .unwrap_or(50);
24    let gitlab_diff_refs = (provider == Provider::Gitlab)
25        .then(gitlab_diff_refs_from_env)
26        .flatten();
27    let body = format!(
28        "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
29        command_title(command),
30        issues.len().min(max),
31        if issues.len().min(max) == 1 { "" } else { "s" },
32        provider.name(),
33    );
34    let comments: Vec<ReviewComment> = issues
35        .iter()
36        .take(max)
37        .map(|issue| render_comment(provider, issue, gitlab_diff_refs.as_ref()))
38        .collect();
39
40    match provider {
41        Provider::Github => ReviewEnvelopeOutput {
42            event: Some(ReviewEnvelopeEvent::Comment),
43            body,
44            comments,
45            meta: ReviewEnvelopeMeta {
46                schema: ReviewEnvelopeSchema::V1,
47                provider: ReviewProvider::Github,
48                check_conclusion: Some(github_check_conclusion(issues)),
49            },
50        },
51        Provider::Gitlab => ReviewEnvelopeOutput {
52            event: None,
53            body,
54            comments,
55            meta: ReviewEnvelopeMeta {
56                schema: ReviewEnvelopeSchema::V1,
57                provider: ReviewProvider::Gitlab,
58                check_conclusion: None,
59            },
60        },
61    }
62}
63
64#[must_use]
65pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
66    let issues = super::diff_filter::filter_issues_from_env(
67        super::pr_comment::issues_from_codeclimate(codeclimate),
68    );
69    let envelope = render_review_envelope(command, provider, &issues);
70    let value =
71        serde_json::to_value(&envelope).expect("ReviewEnvelopeOutput serializes infallibly");
72    emit_json(&value, "review envelope")
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76#[expect(
77    clippy::struct_field_names,
78    reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
79)]
80struct GitlabDiffRefs {
81    base_sha: String,
82    start_sha: String,
83    head_sha: String,
84}
85
86fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
87    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
88        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
89    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
90    let head_sha =
91        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
92    Some(GitlabDiffRefs {
93        base_sha,
94        start_sha,
95        head_sha,
96    })
97}
98
99fn env_nonempty(name: &str) -> Option<String> {
100    std::env::var(name)
101        .ok()
102        .filter(|value| !value.trim().is_empty())
103}
104
105fn render_comment(
106    provider: Provider,
107    issue: &CiIssue,
108    gitlab_diff_refs: Option<&GitlabDiffRefs>,
109) -> ReviewComment {
110    let label = review_label_from_codeclimate(&issue.severity);
111    let mut body = format!(
112        "**{}** `{}`: {}\n\n<!-- fallow-fingerprint: {} -->",
113        label,
114        escape_md(&issue.rule_id),
115        escape_md(&issue.description),
116        issue.fingerprint
117    );
118    if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
119        body.push_str(&suggestion);
120    }
121    match provider {
122        // Fallow findings point at the current file state. GitHub deletion-side
123        // review comments are intentionally not modeled in this envelope yet.
124        Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
125            path: issue.path.clone(),
126            // `CiIssue.line` is `u64` for legacy reasons but every callsite
127            // populates it from a `u32` line number (`begin_line: Option<u32>`
128            // in `cc_issue`); the typed envelope locks the wire to `u32`.
129            // Follow-up: narrow `CiIssue.line` to `u32` at construction time
130            // in `pr_comment.rs::issues_from_codeclimate` so this cast goes
131            // away entirely (out of scope for the #384 ladder migration).
132            line: u32::try_from(issue.line).unwrap_or(u32::MAX),
133            side: GitHubReviewSide::Right,
134            body,
135            fingerprint: issue.fingerprint.clone(),
136        }),
137        Provider::Gitlab => {
138            let position = GitLabReviewPosition {
139                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
140                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
141                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
142                position_type: GitLabReviewPositionType::Text,
143                old_path: issue.path.clone(),
144                new_path: issue.path.clone(),
145                // Same `u64 -> u32` narrowing as the GitHub branch above;
146                // see the follow-up note there.
147                new_line: u32::try_from(issue.line).unwrap_or(u32::MAX),
148            };
149            ReviewComment::GitLab(GitLabReviewComment {
150                body,
151                position,
152                fingerprint: issue.fingerprint.clone(),
153            })
154        }
155    }
156}
157
158fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
159    match severity_name {
160        "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
161        _ => severity::review_label(fallow_config::Severity::Warn),
162    }
163}
164
165fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
166    if issues
167        .iter()
168        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
169    {
170        ReviewCheckConclusion::Failure
171    } else if issues.is_empty() {
172        ReviewCheckConclusion::Success
173    } else {
174        ReviewCheckConclusion::Neutral
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
183        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
184    }
185
186    fn comment_to_value(comment: &ReviewComment) -> Value {
187        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
188    }
189
190    #[test]
191    fn github_review_envelope_matches_api_shape() {
192        let issues = vec![CiIssue {
193            rule_id: "fallow/unused-file".into(),
194            description: "File is unused".into(),
195            severity: "minor".into(),
196            path: "src/a.ts".into(),
197            line: 1,
198            fingerprint: "abc".into(),
199        }];
200        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
201        assert_eq!(envelope["event"], "COMMENT");
202        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
203        assert!(
204            envelope["comments"][0]["body"]
205                .as_str()
206                .unwrap()
207                .contains("fallow-fingerprint")
208        );
209    }
210
211    #[test]
212    fn github_comments_target_current_state_side() {
213        let issue = CiIssue {
214            rule_id: "fallow/unused-file".into(),
215            description: "File is unused".into(),
216            severity: "minor".into(),
217            path: "src/a.ts".into(),
218            line: 1,
219            fingerprint: "abc".into(),
220        };
221        let comment = comment_to_value(&render_comment(Provider::Github, &issue, None));
222        assert_eq!(comment["side"], "RIGHT");
223    }
224
225    #[test]
226    fn labels_major_issues_as_errors() {
227        let issue = CiIssue {
228            rule_id: "fallow/unused-file".into(),
229            description: "File is unused".into(),
230            severity: "major".into(),
231            path: "src/a.ts".into(),
232            line: 1,
233            fingerprint: "abc".into(),
234        };
235        let comment = comment_to_value(&render_comment(Provider::Github, &issue, None));
236        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
237    }
238
239    #[test]
240    fn gitlab_comment_accepts_diff_refs() {
241        let issue = CiIssue {
242            rule_id: "fallow/unused-file".into(),
243            description: "File is unused".into(),
244            severity: "minor".into(),
245            path: "src/a.ts".into(),
246            line: 1,
247            fingerprint: "abc".into(),
248        };
249        let refs = GitlabDiffRefs {
250            base_sha: "base".into(),
251            start_sha: "start".into(),
252            head_sha: "head".into(),
253        };
254        let comment = comment_to_value(&render_comment(Provider::Gitlab, &issue, Some(&refs)));
255        assert_eq!(comment["position"]["position_type"], "text");
256        assert_eq!(comment["position"]["base_sha"], "base");
257        assert_eq!(comment["position"]["start_sha"], "start");
258        assert_eq!(comment["position"]["head_sha"], "head");
259    }
260}