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::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    // First batch (up to 50 annotations) — creates the check run
135    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
224// -----------------------------------------------------------------------
225// Public runner
226// -----------------------------------------------------------------------
227
228/// Run lintel checks and post results as a GitHub Check Run.
229///
230/// Reads `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_SHA`, and
231/// (optionally) `GITHUB_API_URL` from the environment.
232///
233/// Returns `Ok(true)` if errors were found, `Ok(false)` if clean.
234///
235/// # Errors
236///
237/// Returns an error if environment variables are missing, validation
238/// fails to run, or the GitHub Checks API request fails.
239pub async fn run(args: &mut GithubActionArgs) -> Result<bool> {
240    // Save original args before merge_config modifies them.
241    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    // Read required environment variables
247    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    // Run validation
256    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    // Convert validation errors to annotations
265    let mut annotations: Vec<Annotation> = result.errors.iter().map(error_to_annotation).collect();
266
267    // Check formatting (unless --fix was passed)
268    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}