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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A GitHub Action that automatically reviews `SKILL.md` files changed in a pull request and posts the results as a PR comment.

**No authentication required.** This action runs `tessl skill review` locally — no Tessl account or API token needed. The only token used is the GitHub-provided `GITHUB_TOKEN` for posting PR comments.
This action requires a Tessl API token. Store the token as a GitHub repository secret, for example `TESSL_TOKEN`, and pass it with the `tessl-token` input.

## Usage

Expand All @@ -23,9 +23,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: tesslio/skill-review@main
with:
tessl-token: ${{ secrets.TESSL_TOKEN }}
```

That's it. Any PR that modifies a `SKILL.md` file will get an automated review comment.
Any PR that modifies a `SKILL.md` file will get an automated review comment.

## Inputs

Expand All @@ -34,6 +36,7 @@ That's it. Any PR that modifies a `SKILL.md` file will get an automated review c
| `path` | Root path to search for SKILL.md files | `.` |
| `comment` | Whether to post results as a PR comment | `true` |
| `fail-threshold` | Minimum score (0-100) to pass. Set to `0` to never fail. | `0` |
| `tessl-token` | Tessl API token used to authenticate review requests. Store it as a GitHub secret. | unset |

### Setting a quality gate

Expand All @@ -42,6 +45,7 @@ To enforce a minimum skill quality score, set `fail-threshold`:
```yaml
- uses: tesslio/skill-review@main
with:
tessl-token: ${{ secrets.TESSL_TOKEN }}
fail-threshold: 70
```

Expand Down
9 changes: 8 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ inputs:
fail-threshold:
description: 'Minimum average score (0-100) to pass the check. Set to 0 to never fail.'
default: '0'
tessl-token:
description: 'Tessl API token used to authenticate review requests. Store this as a GitHub secret, for example secrets.TESSL_TOKEN.'
required: false
Comment on lines +16 to +18
runs:
using: 'composite'
steps:
- uses: tesslio/setup-tessl@v1
- uses: tesslio/setup-tessl@v2
with:
token: ${{ inputs.tessl-token }}
- uses: oven-sh/setup-bun@v2
- run: bun install --production --frozen-lockfile
shell: bash
Expand All @@ -27,4 +32,6 @@ runs:
INPUT_PATH: ${{ inputs.path }}
INPUT_COMMENT: ${{ inputs.comment }}
INPUT_FAIL_THRESHOLD: ${{ inputs.fail-threshold }}
TESSL_API_TOKEN: ${{ inputs.tessl-token }}
TESSL_TOKEN: ${{ inputs.tessl-token }}
GITHUB_TOKEN: ${{ github.token }}
18 changes: 17 additions & 1 deletion src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ mock.module('@actions/github', () => ({

// Import after mock registration
const { getChangedSkillFiles } = await import('./changed-files.ts');
const { runSkillReview, extractJson } = await import('./skill-review.ts');
const { runSkillReview, extractJson, isAuthErrorMessage } = await import('./skill-review.ts');
const { postOrUpdateComment } = await import('./comment.ts');
const { parseThreshold } = await import('./main.ts');

Expand Down Expand Up @@ -292,6 +292,22 @@ describe('runSkillReview', () => {
expect(result.score).toBe(-1);
});

test('auth failure with threshold 0 still fails', async () => {
// @ts-expect-error mock assignment
Bun.spawn = makeMockSpawn('', '✘ 401 Unauthorized', 1);

const result = await runSkillReview('skills/test/SKILL.md', 0);
expect(result.passed).toBe(false);
expect(result.score).toBe(-1);
expect(result.error).toContain('401 Unauthorized');
});

test('detects login-required auth failures', () => {
expect(isAuthErrorMessage('Skill review requires you to be logged in. Run tessl login to log in.')).toBe(true);
expect(isAuthErrorMessage('✘ 401 Unauthorized')).toBe(true);
expect(isAuthErrorMessage('some validation error')).toBe(false);
});

test('malformed JSON output (unclosed brace)', async () => {
// @ts-expect-error mock assignment
Bun.spawn = makeMockSpawn('{ broken json !!!', '', 0);
Expand Down
15 changes: 13 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as core from '@actions/core';
import { getChangedSkillFiles } from './changed-files.ts';
import { postOrUpdateComment } from './comment.ts';
import type { SkillReviewResult } from './skill-review.ts';
import { runSkillReview } from './skill-review.ts';
import { isAuthErrorMessage, runSkillReview } from './skill-review.ts';

const CONCURRENCY_LIMIT = 5;

Expand Down Expand Up @@ -53,7 +53,18 @@ async function main(): Promise<void> {
}
}

// 5. Check threshold
// 5. Auth failures are infrastructure failures, not score failures.
// `fail-threshold: 0` disables score gating only; it must not hide missing auth.
const authFailures = results.filter((r) => isAuthErrorMessage(r.error));
if (authFailures.length > 0) {
const summary = authFailures.map((r) => ` ${r.skillPath}`).join('\n');
core.setFailed(
`Tessl authentication failed for ${authFailures.length} skill(s). Configure the tessl-token input with a Tessl API token stored in a GitHub secret.\n${summary}`,
);
return;
}
Comment on lines +56 to +65

// 6. Check threshold
if (threshold > 0) {
const failed = results.filter((r) => !r.passed);
if (failed.length > 0) {
Expand Down
14 changes: 11 additions & 3 deletions src/skill-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ export interface SkillReviewResult {
error?: string;
}

export function isAuthErrorMessage(message: string | undefined): boolean {
if (!message) return false;
return /requires you to be logged in|run tessl login|401 unauthorized|authentication required|not authenticated/i.test(
message,
);
}

export async function runSkillReview(
skillFilePath: string,
threshold: number,
Expand All @@ -129,15 +136,16 @@ export async function runSkillReview(

const exitCode = await proc.exited;
if (exitCode !== 0) {
const error = stderr || stdout || `Process exited with code ${exitCode}`;
console.warn(
`tessl skill review failed for ${skillFilePath} (exit code ${exitCode}): ${stderr}`,
`tessl skill review failed for ${skillFilePath} (exit code ${exitCode}): ${error}`,
);
return {
skillPath: skillFilePath,
passed: threshold === 0,
passed: threshold === 0 && !isAuthErrorMessage(error),
score: -1,
output: '',
error: stderr || `Process exited with code ${exitCode}`,
error,
};
Comment on lines 138 to 149
}

Expand Down