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