Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:

e2e:
name: Run the action on the fixture
# The fixture rule reports a finding only on changed lines, and only a pull request has changed lines.
# The summary comment needs a pull request.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
Expand All @@ -43,5 +43,26 @@ jobs:
version: 0.7.3
working-directory: test/fixture
check-name: alint (fixture)
- name: Report the outputs
run: echo "errors=${{ steps.alint.outputs.errors }} warnings=${{ steps.alint.outputs.warnings }}"
# Most pull requests do not change the fixture, so the changed-line scope finds nothing there.
files: src
# The fixture rule does not call this provider. The configuration proves that alint accepts the file.
provider-config: |
version = 1

[[providers]]
id = "fixture"
type = "openai-compatible"
endpoint = "https://provider.invalid/v1"

[[providers.models]]
id = "fixture-model"
size = "small"
aliases = [ "default" ]
capabilities = [ "tool-call" ]

[providers.models.default_params]
thinking = { type = "disabled" }
- name: Check that alint reads the provider configuration
run: alint config models show fixture/fixture-model
- name: Check the outputs
run: test "${{ steps.alint.outputs.errors }}/${{ steps.alint.outputs.warnings }}" = "0/1"
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,45 @@ If the `alint` configuration imports packages, install the project dependencies
| Input | Default | Description |
| --- | --- | --- |
| `version` | | Version of `@alint-js/cli` to install. When empty, the action uses the `alint` in `node_modules`, or installs the latest version. |
| `provider-endpoint` | | Endpoint of an OpenAI-compatible model provider. When empty, the action does not write provider configuration. |
| `provider-endpoint` | | Endpoint of an OpenAI-compatible model provider. |
| `provider-model` | | Model id for the provider. Required with `provider-endpoint`. |
| `api-key` | | API key for the provider. The action sends it as a Bearer token. |
| `provider-config` | | Complete provider configuration in TOML. Use it instead of `provider-endpoint`. |
| `files` | | Files or directories to lint, separated by spaces or lines. |
| `working-directory` | `.` | Directory that contains the `alint` configuration. |
| `check-name` | `alint` | Name of the check run. |
| `github-token` | `github.token` | Token that creates the check run and the summary comment. |

When `provider-endpoint` and `provider-config` are empty, the action does not write provider configuration.

### Provider configuration

`provider-endpoint` writes one model with no other properties. Use `provider-config` when a rule requests a model by alias or by capability, or when the model needs default parameters:

```yaml
- uses: alint-dev/setup@main
with:
provider-config: |
version = 1

[[providers]]
id = "deepseek"
type = "openai-compatible"
endpoint = "https://api.deepseek.com/v1"

[providers.headers]
Authorization = "Bearer ${{ secrets.DEEPSEEK_API_KEY }}"

[[providers.models]]
id = "deepseek-flash"
size = "small"
aliases = [ "default" ]
capabilities = [ "tool-call" ]

[providers.models.default_params]
thinking = { type = "disabled" }
```

## Outputs

| Output | Description |
Expand All @@ -62,9 +93,11 @@ If the `alint` configuration imports packages, install the project dependencies
- The step itself fails only when `alint` cannot complete the run or GitHub rejects a request.
- Each run rewrites the same summary comment. A pull request that never had findings gets no comment.
- On a pull request, the action reads the changed files from the GitHub API. The checkout does not need the full history.
- On a push, the action lints the files that the push changed and writes no comment. A new branch is compared with the default branch.
- On other events, for example a manual run, the action lints the working directory.
- When `files` is set, the action lints those files and keeps each finding.
- On other events, the action lints the working directory and writes no comment.
- A pull request from a fork gets no secrets and a read-only token. The action skips it with a notice.
- With `provider-endpoint`, an empty `api-key` also skips the action. A Dependabot pull request has no access to secrets, for example. With `provider-config`, add your own `if` condition for such runs.

## When not to use it

Expand Down
38 changes: 34 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ inputs:
default: ''
provider-endpoint:
description: >-
Endpoint of an OpenAI-compatible model provider. When empty, the action does not write
provider configuration.
Endpoint of an OpenAI-compatible model provider. When this input and provider-config are
empty, the action does not write provider configuration.
default: ''
provider-model:
description: Model id for the provider. Required with provider-endpoint.
default: ''
api-key:
description: API key for the provider. The action sends it as a Bearer token.
description: >-
API key for the provider. The action sends it as a Bearer token. With provider-endpoint,
an empty key skips the action, because the run has no access to secrets.
default: ''
provider-config:
description: >-
Complete alint provider configuration in TOML. Use it instead of provider-endpoint when a
model needs aliases, capabilities, or default parameters.
default: ''
files:
description: >-
Expand Down Expand Up @@ -48,13 +55,26 @@ outputs:
runs:
using: composite
steps:
# A pull request from a fork gets no secrets and a read-only token, so the action cannot publish there.
- id: guard
shell: bash
env:
ALINT_API_KEY: ${{ inputs.api-key }}
ALINT_CONFIG: ${{ inputs.provider-config }}
ALINT_ENDPOINT: ${{ inputs.provider-endpoint }}
run: |
if [ -n "$ALINT_CONFIG" ] && [ -n "$ALINT_ENDPOINT" ]; then
echo "::error title=alint::Set provider-config or provider-endpoint, not the two."
exit 1
fi

# A pull request from a fork gets no secrets and a read-only token, so the action cannot publish there.
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$(jq -r '.pull_request.head.repo.full_name' "$GITHUB_EVENT_PATH")" != "$GITHUB_REPOSITORY" ]; then
echo "::notice title=alint::Skipped. A pull request from a fork cannot publish the result."
echo "skip=true" >> "$GITHUB_OUTPUT"
# Other runs without secrets, for example a Dependabot pull request, show as an empty key.
elif [ -n "$ALINT_ENDPOINT" ] && [ -z "$ALINT_API_KEY" ]; then
echo "::notice title=alint::Skipped. The api-key input is empty."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi

- id: install
Expand Down Expand Up @@ -85,6 +105,16 @@ runs:
--provider-model "$ALINT_MODEL" \
${ALINT_API_KEY:+--provider-header "Authorization=Bearer $ALINT_API_KEY"}

- if: steps.guard.outputs.skip != 'true' && inputs.provider-config != ''
shell: bash
env:
ALINT_CONFIG: ${{ inputs.provider-config }}
run: |
# alint reads the global configuration from this path on each platform.
directory="${XDG_CONFIG_HOME:-$HOME/.config}/alint"
mkdir -p "$directory"
(umask 077 && printf '%s\n' "$ALINT_CONFIG" > "$directory/config.toml")

- if: steps.guard.outputs.skip != 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
Expand Down
95 changes: 65 additions & 30 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ function toChangedLines(files) {
//#region src/findings.ts
/** Identifies the summary comment so that later runs rewrite it instead of adding another one. */
const summaryMarker = "<!-- alint-summary -->";
const suggestionParagraph = /\n\s*Suggestion:/u;
const maxSummaryRows = 100;
function formatSummary(findings, context) {
const icon = toConclusion(findings) === "failure" ? "❌" : findings.length > 0 ? "⚠️" : "✅";
Expand All @@ -535,7 +536,7 @@ function formatSummary(findings, context) {
const footer = [
`Commit \`${shortSha}\``,
context.runUrl === void 0 ? void 0 : `[run](${context.runUrl})`,
`${context.totalTokens.toLocaleString("en-US")} tokens`
context.usage
].filter((part) => part !== void 0);
lines.push("", `<sub>${footer.join(" · ")}</sub>`);
return lines.join("\n");
Expand All @@ -546,6 +547,11 @@ function formatTitle(findings) {
const warnings = findings.length - errors;
return [errors > 0 ? pluralize(errors, "error") : void 0, warnings > 0 ? pluralize(warnings, "warning") : void 0].filter((part) => part !== void 0).join(", ");
}
/** States the cost of a run, so that a reader can see from the check run whether the cache works. */
function formatUsage(result) {
const tokens = `${result.usage.inputTokens.toLocaleString("en-US")} input / ${result.usage.outputTokens.toLocaleString("en-US")} output tokens`;
return result.execution === void 0 ? tokens : `${pluralize(result.execution.completed, "rule run")}, ${result.execution.cached} cached · ${tokens}`;
}
function toAnnotations(findings) {
return findings.map((finding) => ({
annotation_level: finding.severity === "error" ? "failure" : "warning",
Expand All @@ -562,14 +568,17 @@ function toConclusion(findings) {
}
/** Converts diagnostics to findings with repository-relative paths. Errors come first. */
function toFindings(diagnostics, toRepositoryPath) {
return diagnostics.map((diagnostic) => ({
line: diagnostic.loc?.start.line ?? 1,
message: diagnostic.message.trim(),
path: toRepositoryPath(diagnostic.filePath),
ruleId: diagnostic.ruleId,
severity: diagnostic.severity,
suggestion: suggestionFrom(diagnostic.evidence)
})).sort((a, b) => Number(b.severity === "error") - Number(a.severity === "error") || a.path.localeCompare(b.path) || a.line - b.line);
return diagnostics.map((diagnostic) => {
const [message = "", ...paragraphs] = diagnostic.message.split(suggestionParagraph);
return {
line: diagnostic.loc?.start.line ?? 1,
message: message.trim(),
path: toRepositoryPath(diagnostic.filePath),
ruleId: diagnostic.ruleId,
severity: diagnostic.severity,
suggestion: suggestionFrom(diagnostic.evidence) ?? (paragraphs.join("\n").trim() || void 0)
};
}).sort((a, b) => Number(b.severity === "error") - Number(a.severity === "error") || a.path.localeCompare(b.path) || a.line - b.line);
}
function escapeCell(value) {
return value.replaceAll("|", "\\|").replaceAll(/\s*\n\s*/gu, " ");
Expand All @@ -595,6 +604,11 @@ const filesSchema = /* @__PURE__ */ array(/* @__PURE__ */ object({
patch: /* @__PURE__ */ optional(/* @__PURE__ */ string()),
status: /* @__PURE__ */ string()
}));
const comparisonSchema = /* @__PURE__ */ object({ files: filesSchema });
/** Lists the files that differ between two commits. GitHub returns at most 300 files here. */
async function listComparedFiles(repository, base, head) {
return parse(comparisonSchema, await request(repository, "GET", `/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`)).files;
}
async function listPullRequestFiles(repository, pullRequest) {
const files = [];
for (let page = 1;; page += 1) {
Expand Down Expand Up @@ -1018,7 +1032,14 @@ const runResultSchema = /* @__PURE__ */ object({
ruleId: /* @__PURE__ */ string(),
severity: /* @__PURE__ */ picklist(["error", "warn"])
})),
usage: /* @__PURE__ */ object({ totalTokens: /* @__PURE__ */ number() })
execution: /* @__PURE__ */ optional(/* @__PURE__ */ object({
cached: /* @__PURE__ */ number(),
completed: /* @__PURE__ */ number()
})),
usage: /* @__PURE__ */ object({
inputTokens: /* @__PURE__ */ number(),
outputTokens: /* @__PURE__ */ number()
})
});
function parseRunResult(json) {
return parse(runResultSchema, JSON.parse(json));
Expand All @@ -1037,10 +1058,16 @@ async function runAlint(command, targets, cwd) {
}
//#endregion
//#region src/main.ts
const eventSchema = /* @__PURE__ */ object({ pull_request: /* @__PURE__ */ optional(/* @__PURE__ */ object({
head: /* @__PURE__ */ object({ sha: /* @__PURE__ */ string() }),
number: /* @__PURE__ */ number()
})) });
const eventSchema = /* @__PURE__ */ object({
after: /* @__PURE__ */ optional(/* @__PURE__ */ string()),
before: /* @__PURE__ */ optional(/* @__PURE__ */ string()),
pull_request: /* @__PURE__ */ optional(/* @__PURE__ */ object({
head: /* @__PURE__ */ object({ sha: /* @__PURE__ */ string() }),
number: /* @__PURE__ */ number()
})),
repository: /* @__PURE__ */ optional(/* @__PURE__ */ object({ default_branch: /* @__PURE__ */ string() }))
});
const zeroCommit = /^0+$/u;
async function run(env, cwd) {
const repository = {
apiUrl: env.GITHUB_API_URL ?? "https://api.github.com",
Expand All @@ -1049,13 +1076,17 @@ async function run(env, cwd) {
};
const workspace = env.GITHUB_WORKSPACE ?? cwd;
const serverUrl = env.GITHUB_SERVER_URL ?? "https://github.com";
const pullRequest = await readPullRequest(env.GITHUB_EVENT_PATH);
const sha = pullRequest?.sha ?? required(env, "GITHUB_SHA");
const event = await readEvent(env.GITHUB_EVENT_PATH);
const pullRequest = event.pull_request;
const sha = pullRequest?.head.sha ?? required(env, "GITHUB_SHA");
const toRepositoryPath = (filePath) => relative(workspace, resolve$1(cwd, filePath));
const scope = await resolveScope(repository, pullRequest, env.INPUT_FILES ?? "", cwd, workspace);
const scope = await resolveScope(repository, event, env.INPUT_FILES ?? "", cwd, workspace);
const result = scope.targets.length === 0 ? {
diagnostics: [],
usage: { totalTokens: 0 }
usage: {
inputTokens: 0,
outputTokens: 0
}
} : await runAlint(required(env, "ALINT_COMMAND"), scope.targets, cwd);
const findings = toFindings(scope.changedLines === void 0 ? result.diagnostics : filterToChangedLines(result.diagnostics, scope.changedLines, toRepositoryPath), toRepositoryPath);
const title = formatTitle(findings);
Expand All @@ -1064,7 +1095,7 @@ async function run(env, cwd) {
runUrl: env.GITHUB_RUN_ID === void 0 ? void 0 : `${serverUrl}/${repository.repository}/actions/runs/${env.GITHUB_RUN_ID}`,
serverUrl,
sha,
totalTokens: result.usage.totalTokens
usage: formatUsage(result)
});
if (env.GITHUB_STEP_SUMMARY !== void 0) await appendFile(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
if (env.GITHUB_OUTPUT !== void 0) {
Expand All @@ -1082,13 +1113,16 @@ async function run(env, cwd) {
if (pullRequest !== void 0) await publishSummaryComment(repository, pullRequest.number, summary, findings.length > 0);
console.info(`Published ${title.toLowerCase()} to ${repository.repository}@${sha.slice(0, 7)}.`);
}
async function readPullRequest(eventPath) {
if (eventPath === void 0) return;
const event = parse(eventSchema, JSON.parse(await readFile(eventPath, "utf8")));
return event.pull_request === void 0 ? void 0 : {
number: event.pull_request.number,
sha: event.pull_request.head.sha
};
/** Lists the files that the event changed. `undefined` means that the event has no changes, for example a manual run. */
async function listChangedFiles(repository, event) {
if (event.pull_request !== void 0) return listPullRequestFiles(repository, event.pull_request.number);
if (event.before === void 0 || event.after === void 0) return;
if (zeroCommit.test(event.after)) return [];
const base = zeroCommit.test(event.before) ? event.repository?.default_branch : event.before;
return base === void 0 ? void 0 : listComparedFiles(repository, base, event.after);
}
async function readEvent(eventPath) {
return eventPath === void 0 ? {} : parse(eventSchema, JSON.parse(await readFile(eventPath, "utf8")));
}
function required(env, name) {
const value = env[name];
Expand All @@ -1098,14 +1132,15 @@ function required(env, name) {
/**
* Selects what alint reads.
*
* Explicit files win. A pull request without explicit files gets its changed files,
* Explicit files win. A pull request or a push without explicit files gets its changed files,
* and the result keeps only the changed lines. Other events get the whole working directory.
*/
async function resolveScope(repository, pullRequest, files, cwd, workspace) {
async function resolveScope(repository, event, files, cwd, workspace) {
const explicit = files.split(/\s+/u).filter(Boolean);
if (explicit.length > 0) return { targets: explicit };
if (pullRequest === void 0) return { targets: ["."] };
const changedLines = toChangedLines(await listPullRequestFiles(repository, pullRequest.number));
const changedFiles = await listChangedFiles(repository, event);
if (changedFiles === void 0) return { targets: ["."] };
const changedLines = toChangedLines(changedFiles);
return {
changedLines,
targets: [...changedLines.keys()].map((path) => relative(cwd, resolve$1(workspace, path))).filter((path) => !path.startsWith("..") && existsSync(resolve$1(cwd, path)))
Expand Down
16 changes: 8 additions & 8 deletions src/changes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { Diagnostic } from './output'

export interface ChangedFile {
filename: string
/** GitHub omits the patch of a binary file and of a very large diff. */
patch?: string
status: string
}

/** Changed lines of each file. `all` means that GitHub did not send a patch, so each line counts. */
export type ChangedLines = ReadonlyMap<string, 'all' | readonly LineRange[]>

Expand All @@ -8,13 +15,6 @@ export interface LineRange {
startLine: number
}

export interface PullRequestFile {
filename: string
/** GitHub omits the patch of a binary file and of a very large diff. */
patch?: string
status: string
}

const hunkHeader = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/u

/** Keeps file-wide diagnostics and diagnostics whose location intersects a changed line. */
Expand Down Expand Up @@ -64,7 +64,7 @@ export function parsePatch(patch: string): LineRange[] {
return ranges
}

export function toChangedLines(files: readonly PullRequestFile[]): ChangedLines {
export function toChangedLines(files: readonly ChangedFile[]): ChangedLines {
return new Map(
files
.filter(file => file.status !== 'removed')
Expand Down
Loading
Loading