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