1#![doc = include_str!("../README.md")]
2
3use std::time::Instant;
4
5use anyhow::{Context, Result, bail};
6use bpaf::Bpaf;
7use serde::Serialize;
8
9use lintel_diagnostics::{DEFAULT_LABEL, LintelDiagnostic, offset_to_line_col};
10
11#[derive(Debug, Clone, Bpaf)]
16#[bpaf(generate(github_action_args_inner))]
17pub struct GithubActionArgs {
18 #[bpaf(external(lintel_check::check_args))]
19 pub check: lintel_check::CheckArgs,
20}
21
22pub fn github_action_args() -> impl bpaf::Parser<GithubActionArgs> {
24 github_action_args_inner()
25}
26
27#[derive(Debug, Serialize)]
32struct CreateCheckRun {
33 name: String,
34 head_sha: String,
35 status: String,
36 conclusion: String,
37 output: CheckRunOutput,
38}
39
40#[derive(Debug, Serialize)]
41struct UpdateCheckRun {
42 output: CheckRunOutput,
43}
44
45#[derive(Debug, Serialize)]
46struct CheckRunOutput {
47 title: String,
48 summary: String,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 annotations: Vec<Annotation>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54#[allow(clippy::struct_field_names)]
55struct Annotation {
56 path: String,
57 start_line: usize,
58 end_line: usize,
59 annotation_level: String,
60 message: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 title: Option<String>,
63}
64
65fn error_to_annotation(error: &LintelDiagnostic) -> Annotation {
70 let path = error.path().replace('\\', "/");
71 let (line, _col) = match error {
72 LintelDiagnostic::Parse { src, span, .. } => offset_to_line_col(src.inner(), span.offset()),
73 LintelDiagnostic::Validation(v) => offset_to_line_col(v.src.inner(), v.span.offset()),
74 LintelDiagnostic::SchemaMismatch { line_number, .. } => (*line_number, 1),
75 LintelDiagnostic::Io { .. }
76 | LintelDiagnostic::SchemaFetch { .. }
77 | LintelDiagnostic::SchemaCompile { .. }
78 | LintelDiagnostic::Format { .. } => (1, 1),
79 };
80
81 let title = match error {
82 LintelDiagnostic::Parse { .. } => Some("parse error".to_string()),
83 LintelDiagnostic::Validation(v) if v.instance_path != DEFAULT_LABEL => {
84 Some(v.instance_path.clone())
85 }
86 LintelDiagnostic::Validation(_) => Some("validation error".to_string()),
87 LintelDiagnostic::SchemaMismatch { .. } => Some("schema mismatch".to_string()),
88 LintelDiagnostic::Io { .. } => Some("io error".to_string()),
89 LintelDiagnostic::SchemaFetch { .. } => Some("schema fetch error".to_string()),
90 LintelDiagnostic::SchemaCompile { .. } => Some("schema compile error".to_string()),
91 LintelDiagnostic::Format { .. } => Some("format error".to_string()),
92 };
93
94 Annotation {
95 path,
96 start_line: line,
97 end_line: line,
98 annotation_level: "failure".to_string(),
99 message: error.message().to_string(),
100 title,
101 }
102}
103
104fn build_summary(files_checked: usize, ms: u128, annotations: &[Annotation]) -> String {
105 use core::fmt::Write;
106
107 if annotations.is_empty() {
108 return format!("Checked **{files_checked}** files in **{ms}ms**. No errors found.");
109 }
110
111 let mut s = format!("Checked **{files_checked}** files in **{ms}ms**.\n\n");
112 s.push_str("| File | Line | Error |\n");
113 s.push_str("|------|------|-------|\n");
114 for ann in annotations {
115 let _ = writeln!(
116 s,
117 "| `{}` | {} | {} |",
118 ann.path, ann.start_line, ann.message
119 );
120 }
121 s
122}
123
124#[allow(clippy::too_many_arguments)]
125async fn post_check_run(
126 client: &reqwest::Client,
127 url: &str,
128 token: &str,
129 title: &str,
130 summary: &str,
131 annotations: &[Annotation],
132 sha: &str,
133 conclusion: &str,
134) -> Result<reqwest::Response> {
135 let first_batch: Vec<Annotation> = annotations.iter().take(50).cloned().collect();
137 let body = CreateCheckRun {
138 name: "Lintel".to_string(),
139 head_sha: sha.to_string(),
140 status: "completed".to_string(),
141 conclusion: conclusion.to_string(),
142 output: CheckRunOutput {
143 title: title.to_string(),
144 summary: summary.to_string(),
145 annotations: first_batch,
146 },
147 };
148
149 let response = client
150 .post(url)
151 .header("Authorization", format!("token {token}"))
152 .header("Accept", "application/vnd.github+json")
153 .header("User-Agent", "lintel-github-action")
154 .header("X-GitHub-Api-Version", "2022-11-28")
155 .json(&body)
156 .send()
157 .await
158 .context("failed to create check run")?;
159
160 if !response.status().is_success() {
161 let status = response.status();
162 let body = response
163 .text()
164 .await
165 .unwrap_or_else(|_| "<no body>".to_string());
166 bail!("GitHub API returned {status}: {body}");
167 }
168
169 Ok(response)
170}
171
172#[allow(clippy::too_many_arguments)]
173async fn patch_remaining_annotations(
174 client: &reqwest::Client,
175 url: &str,
176 token: &str,
177 title: &str,
178 summary: &str,
179 annotations: &[Annotation],
180 response: reqwest::Response,
181) -> Result<()> {
182 if annotations.len() <= 50 {
183 return Ok(());
184 }
185
186 let check_run: serde_json::Value = response.json().await?;
187 let check_run_id = check_run["id"]
188 .as_u64()
189 .context("missing check run id in response")?;
190 let patch_url = format!("{url}/{check_run_id}");
191
192 for chunk in annotations[50..].chunks(50) {
193 let patch_body = UpdateCheckRun {
194 output: CheckRunOutput {
195 title: title.to_string(),
196 summary: summary.to_string(),
197 annotations: chunk.to_vec(),
198 },
199 };
200
201 let resp = client
202 .patch(&patch_url)
203 .header("Authorization", format!("token {token}"))
204 .header("Accept", "application/vnd.github+json")
205 .header("User-Agent", "lintel-github-action")
206 .header("X-GitHub-Api-Version", "2022-11-28")
207 .json(&patch_body)
208 .send()
209 .await
210 .context("failed to update check run with annotations")?;
211
212 if !resp.status().is_success() {
213 let status = resp.status();
214 let body = resp
215 .text()
216 .await
217 .unwrap_or_else(|_| "<no body>".to_string());
218 bail!("GitHub API returned {status} on PATCH: {body}");
219 }
220 }
221
222 Ok(())
223}
224
225pub async fn run(args: &mut GithubActionArgs) -> Result<bool> {
241 let token =
243 std::env::var("GITHUB_TOKEN").context("GITHUB_TOKEN environment variable is required")?;
244 let repository = std::env::var("GITHUB_REPOSITORY")
245 .context("GITHUB_REPOSITORY environment variable is required")?;
246 let sha = std::env::var("GITHUB_SHA").context("GITHUB_SHA environment variable is required")?;
247 let api_url =
248 std::env::var("GITHUB_API_URL").unwrap_or_else(|_| "https://api.github.com".to_string());
249
250 let start = Instant::now();
252 let result = lintel_check::check(&mut args.check, |_| {}).await?;
253 let elapsed = start.elapsed();
254
255 let files_checked = result.files_checked();
256 let ms = elapsed.as_millis();
257
258 let annotations: Vec<Annotation> = result.errors.iter().map(error_to_annotation).collect();
260
261 let had_errors = !annotations.is_empty();
262 let error_count = annotations.len();
263
264 let error_label = if error_count == 1 { "error" } else { "errors" };
265 let title = if error_count > 0 {
266 format!("{error_count} {error_label} found")
267 } else {
268 "No errors".to_string()
269 };
270 let summary = build_summary(files_checked, ms, &annotations);
271 let conclusion = if had_errors { "failure" } else { "success" };
272
273 let client = reqwest::Client::new();
274 let url = format!("{api_url}/repos/{repository}/check-runs");
275
276 let response = post_check_run(
277 &client,
278 &url,
279 &token,
280 &title,
281 &summary,
282 &annotations,
283 &sha,
284 conclusion,
285 )
286 .await?;
287
288 patch_remaining_annotations(
289 &client,
290 &url,
291 &token,
292 &title,
293 &summary,
294 &annotations,
295 response,
296 )
297 .await?;
298
299 eprintln!(
300 "Checked {files_checked} files in {ms}ms. {error_count} {error_label}. Check run: {conclusion}."
301 );
302
303 Ok(had_errors)
304}