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 Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
125 path: issue.path.clone(),
126 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 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}