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 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}