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