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