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