Skip to main content

lintel_github_action/
lib.rs

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// -----------------------------------------------------------------------
14// CLI args
15// -----------------------------------------------------------------------
16
17#[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
24/// Construct the bpaf parser for `GithubActionArgs`.
25pub fn github_action_args() -> impl bpaf::Parser<GithubActionArgs> {
26    github_action_args_inner()
27}
28
29// -----------------------------------------------------------------------
30// GitHub Checks API types
31// -----------------------------------------------------------------------
32
33#[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
67// -----------------------------------------------------------------------
68// Helpers
69// -----------------------------------------------------------------------
70
71fn 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    // First batch (up to 50 annotations) — creates the check run
137    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
226// -----------------------------------------------------------------------
227// Public runner
228// -----------------------------------------------------------------------
229
230/// Run lintel checks and post results as a GitHub Check Run.
231///
232/// Reads `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_SHA`, and
233/// (optionally) `GITHUB_API_URL` from the environment.
234///
235/// Returns `Ok(true)` if errors were found, `Ok(false)` if clean.
236///
237/// # Errors
238///
239/// Returns an error if environment variables are missing, validation
240/// fails to run, or the GitHub Checks API request fails.
241pub async fn run(args: &mut GithubActionArgs) -> Result<bool> {
242    // Save original args before merge_config modifies them.
243    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    // Read required environment variables
249    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    // Run validation
258    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    // Convert validation errors to annotations
267    let mut annotations: Vec<Annotation> = result.errors.iter().map(error_to_annotation).collect();
268
269    // Check formatting (unless --fix was passed)
270    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}