feat(action): add self-serve GitHub review bot setup (CW-6869) [skip ci]

Generate a workflow with immutable Action refs, exact release/model selection,
separate read-only analysis and credential-isolated publication, and optional
existing GitHub App identity. Reuse the Engine review command and installer.

Accept exact review mentions from current write/maintain/admin users; recheck
original and rerunning actors, comment identity/body and current revisions.
Opt-in inert fork diffs only through trusted default-branch manual or issue
comment triggers. Mentions require the separate publisher. No candidate code,
hooks or project configuration is executed. Share provider-key metadata with
the generator and redact every supported key, including OrcaRouter.

Local verification: existing node review tests 8 passed, 0 failed; Node syntax
checks and actionlint pass for default-token and App workflows. Direct offline
execution completed mention -> findings -> inline publisher, refused 10 unsafe
request cases, refused fork analysis without opt-in and cross-attempt findings,
and confirmed provider-key isolation/redaction. No new tests or code comments.
Live GitHub/provider acceptance follows separately; no release or moving tag.
This commit is contained in:
CodeWhale Bot committed 2026-10-28 23:39:10 -07:00
1 parent cce6d3a84d
commit c962177c79
10 files changed
+389 -159

No files matched your search

+1 -1
View File
@@ -203,7 +203,7 @@ and operate other apps. It stays off until you review its access and enable it
| `codewhale` | The interactive terminal app |
| `codewhale exec "…"` | One task without the interface. On its own it answers without using tools; `--auto` enables tools with Full Access by default, `--sandbox workspace-write` restricts them, and `--output-format stream-json` streams events as JSON |
| `codewhale web` | A local web UI on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request. It posts nothing unless you add `--post`; the [GitHub Action](docs/GITHUB_ACTION.md) runs the same review in CI |
| `codewhale review --pr N` | An advisory review of a GitHub pull request. The [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) generates a pinned workflow for automatic reviews and `@codewhale review`, using your own model key and optional App identity |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals, and each turn's file changes |
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
+13 -2
View File
@@ -1,5 +1,8 @@
name: Codewhale review
description: Review a pinned pull request with a checksummed Codewhale release and an explicit outcome receipt.
name: Codewhale
description: Review pull requests automatically or on an authorized @codewhale review request, using your chosen model.
branding:
icon: code
color: blue
inputs:
version:
description: Exact released CLI tag (vX.Y.Z). Must support review --json.
@@ -15,6 +18,12 @@ inputs:
default: ${{ github.token }}
pr-number:
description: PR number for workflow_dispatch; pull_request events use the event snapshot.
trigger-phrase:
description: Bot mention used for a standalone @name review line in a new PR comment.
default: '@codewhale'
allow-forks:
description: Allow inert fork review only on a write-authorized issue_comment or manual run from the default branch.
default: 'false'
max-chars:
description: Maximum diff characters per complete pass.
default: '200000'
@@ -53,6 +62,8 @@ runs:
CW_PROVIDER: ${{ inputs.provider }}
CW_MODEL: ${{ inputs.model }}
CW_PR_NUMBER: ${{ inputs.pr-number }}
CW_TRIGGER_PHRASE: ${{ inputs.trigger-phrase }}
CW_ALLOW_FORKS: ${{ inputs.allow-forks }}
CW_MAX_CHARS: ${{ inputs.max-chars }}
CW_MAX_PASSES: ${{ inputs.max-passes }}
CW_MAX_OUTPUT_TOKENS: ${{ inputs.max-output-tokens }}
+98 -123
View File
@@ -1,4 +1,4 @@
# GitHub PR reviews
# GitHub review bot
The root `action.yml` runs the existing `codewhale review` command with an
exact, checksummed release. It needs Node.js 22+, `gh`, Git, and a macOS or
@@ -11,11 +11,77 @@ exports a validated findings artifact. The publisher validates its repository,
PR, workflow run and attempt, and current base/head before submitting a
`COMMENT` review. It never approves, requests changes, commits, or runs a model.
Review mode only. Autonomous mention/fix mode is not implemented in this
Action. The original direct-review Action has been in release tags since
`v0.10.1`; the findings output and separate publisher require a commit containing
both features. The Action ref and the exact released CLI `version` are separate
pins. Adding these source files does not enable a workflow.
Automatic reviews and `@codewhale review` comments use the same review path.
The bot posts an advisory summary and validated inline findings. It never
approves or requests changes. Coding tasks that push a branch or open a PR
remain part of the hosted agent; this Action handles code review.
## Set up your own bot
You need a GitHub repository with Actions enabled and one model API key.
Node.js 22+ and Git are needed only to generate the workflow locally; GitHub's
Ubuntu runner provides the review tools. No server or GitHub App is required.
1. From a clean Codewhale source checkout, generate a workflow in your project:
```sh
node /path/to/Codewhale/scripts/github-action/setup.mjs \
--directory /path/to/your-project \
--provider deepseek --model deepseek-flash
```
Select your own provider and exact model ID. The generator pins both
Codewhale actions to the source checkout's full commit and the CLI to
`v0.10.2`. It refuses to overwrite an existing workflow. Use `--print` to
inspect the YAML, `--version vX.Y.Z` to choose another released CLI, or
`--action-ref FULL_COMMIT_SHA` with a separately downloaded `setup.mjs`.
The generated workflow is `.github/workflows/codewhale-review.yml`.
2. Store only the selected provider's API key as a repository Actions secret:
```sh
gh secret set DEEPSEEK_API_KEY --repo OWNER/REPO
```
This command prompts for the key. DeepSeek is the example; the provider
table below lists the other supported secrets. Never put a key in YAML.
3. Commit the workflow to your repository's default branch and push it.
Same-repository PRs are reviewed automatically when opened or updated.
For any open, non-draft PR, a maintainer can add exactly this comment,
or choose **Actions → Codewhale PR Review → Run workflow**
and enter its number:
```text
@codewhale review
```
The generated workflow uses `github-actions[bot]`. To use your own existing
App, add `--app-login 'YOUR-APP[bot]'` when generating, set the
`CODEWHALE_APP_ID` variable and `CODEWHALE_APP_PRIVATE_KEY` secret, and install
the App on the repository. The publisher requests only contents read and pull
requests write. A missing App configuration fails; it does not switch identity.
See [GitHub App setup](GITHUB_APP.md) for creating a new App.
`--trigger-phrase @my-bot` changes the mention to `@my-bot review`. A mention
is an exact standalone comment; quoted text, arbitrary instructions, issue-only
comments, edited comments and bot authors do not trigger inference. New PR
review comments also work for same-repository PRs. Both the original actor
and an actor rerunning the workflow must still have write, maintain or admin
access when GitHub is queried. Association labels alone grant no access.
A deleted, edited or mismatched triggering comment is rejected.
Fork PRs do not receive automatic model reviews. The generated workflow opts
into reviewing their inert diffs only through an authorized issue comment or
manual run from the default branch. It reads pinned Git objects without
checking out PR files, reading project configuration or executing candidate
code. The provider key belongs to that trusted job; no fork workflow receives
it. Leave `allow-forks: false` on both actions to disable this opt-in.
The Action ref and exact CLI release are separate pins. Existing release tags
contain the original review action; mention support and the generator require
a commit containing these features. A moving major tag is not published by
this setup. Source installation does not prove a live provider review.
## Account configuration
@@ -31,124 +97,31 @@ below.
## Use your own provider key
Pick `orcarouter`, `deepseek`, `anthropic`, `openrouter`, `zai`, or `modelstudio-token-plan`,
an exact model ID, and a released CLI tag. Store the matching provider key as
an Actions secret. Pass only that provider's key to the analysis job. The
Action never chooses a different credential after an authentication or payment
failure. The example illustrates an explicitly selected OrcaRouter connection;
it does not set the Codewhale account default. Replace its provider and secret
together when choosing another route.
Choose a provider and exact model, then pass only its key to the analysis job:
Create or connect your own [OrcaRouter account](https://www.orcarouter.ai/ref/ref_df843907ad10d887b867),
then store its API key as `ORCAROUTER_API_KEY`. Codewhale earns a referral fee
from OrcaRouter. `orcarouter/free` is an available starting model when listed
for your workspace; it still requires an API key. A direct no-key request on
2026-10-26 returned HTTP 401. OrcaRouter's [free-route documentation](https://www.orcarouter.ai/models/orcarouter/free)
describes zero-priced usage with request-rate limits. A limit or authentication
failure does not authorize a paid fallback. GitHub runner compute has its own
cost and limits.
| Provider | Actions secret |
| --- | --- |
| `deepseek` | `DEEPSEEK_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY` |
| `openrouter` | `OPENROUTER_API_KEY` |
| `orcarouter` | `ORCAROUTER_API_KEY` |
| `zai` | `ZAI_API_KEY` |
| `modelstudio-token-plan` | `MODELSTUDIO_API_KEY` |
| `codewhale` | `CODEWHALE_API_KEY`, with a separately verified account machine-key route |
Set `CODEWHALE_REVIEW_VERSION` to an exact `vX.Y.Z` release and
`CODEWHALE_REVIEW_MODEL` to the chosen provider's exact model ID (for example
`orcarouter/free`). Replace both
`REPLACE_WITH_REVIEWED_COMMIT_SHA` placeholders with the same reviewed Action
commit containing the publisher. Pin third-party actions to reviewed commits
under your repository's Actions policy.
The Action never switches credentials after an authentication, rate-limit or
payment failure. OrcaRouter is optional; its advertised free route still needs
a key and has provider limits. GitHub runner usage has its own cost and limits.
```yaml
name: Codewhale review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
pr-number:
description: Same-repository PR number
required: true
type: string
permissions: {}
concurrency:
group: codewhale-review-${{ github.event.pull_request.number || inputs.pr-number }}
cancel-in-progress: false
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
pull-requests: read
outputs:
findings: ${{ steps.review.outputs.findings }}
steps:
- uses: actions/setup-node@v7
with:
node-version: '22'
- uses: codewhale-hq/CodeWhale@REPLACE_WITH_REVIEWED_COMMIT_SHA
id: review
with:
version: ${{ vars.CODEWHALE_REVIEW_VERSION }}
provider: orcarouter
model: ${{ vars.CODEWHALE_REVIEW_MODEL }}
pr-number: ${{ inputs.pr-number }}
post: 'false'
env:
ORCAROUTER_API_KEY: ${{ (github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.ORCAROUTER_API_KEY || '' }}
- uses: actions/upload-artifact@v7
if: steps.review.outputs.findings != ''
with:
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.review.outputs.findings }}
retention-days: 1
- uses: actions/upload-artifact@v7
if: always() && steps.review.outputs.receipt != ''
with:
name: codewhale-review-receipt-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.review.outputs.receipt }}
retention-days: 14
publish:
needs: review
if: needs.review.outputs.findings != ''
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/setup-node@v7
with:
node-version: '22'
- uses: actions/download-artifact@v7
with:
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
path: findings
- uses: codewhale-hq/CodeWhale/scripts/github-action/publish@REPLACE_WITH_REVIEWED_COMMIT_SHA
id: publish
with:
findings: findings/findings.json
pr-number: ${{ inputs.pr-number }}
- uses: actions/upload-artifact@v7
if: always() && steps.publish.outputs.receipt != ''
with:
name: codewhale-publication-receipt-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.receipt }}
retention-days: 14
```
No repository checkout is needed in either job. The publishing job must use
trusted Action code and must not run files from the findings artifact. Keep it
on a separate ephemeral runner; a later step on the analysis runner is not the
same credential boundary. The analysis CLI still receives a read-only GitHub
token for PR metadata and Git objects, alongside its selected provider key.
Never use `pull_request_target` with these actions. Fork PRs and drafts are
ineligible even on a manual run. A skipped event is not a clean review. This
publisher accepts artifacts only from the current workflow run and attempt;
cross-workflow `workflow_run` publication is not implemented.
To publish as a dedicated GitHub App, create an installation token only in the
publisher job, pass it as `github-token`, and set `reviewer-login` to that App's
exact bot login, such as `codewhale-agent[bot]`. The publisher verifies the
returned review author; a mismatch is an uncertain publication, not permission
to retry. See [App identity setup](GITHUB_APP.md).
The [workflow generator](../scripts/github-action/setup.mjs) is the complete
copy-ready example. It uses two ephemeral jobs, immutable action references,
read permissions for analysis, and write permission only for publication.
Neither job checks out repository code. The publishing job must use trusted
Action code and must not run files from the findings artifact. Do not combine
these jobs on a persistent runner: a later step on the analysis runner is not
a credential boundary. The publisher accepts only the current workflow run
and attempt; cross-workflow `workflow_run` publication is not implemented.
Never use `pull_request_target` with these actions.
### Account machine keys
@@ -169,7 +142,9 @@ your account's machine-key route has been separately verified. The Action runs
| `max-passes` | 1 | Complete ordered passes, maximum 64 |
| `max-output-tokens` | CLI automatic | Optional per-pass ceiling, 8192–1000000 |
| `timeout-seconds` | 600 | Model/publication deadline, 30–1200 |
| `post` | `true` | Legacy direct publication; set `false` for the two-job setup |
| `post` | `true` | Legacy direct publication; generated workflows use `false` |
| `trigger-phrase` | `@codewhale` | Exact mention name, followed by a standalone `review` command |
| `allow-forks` | `false` | Maintainer-triggered inert fork analysis from the default branch; set the same value on the publisher |
These bounds do not guarantee a dollar cost. Model prices and reasoning
accounting vary. Raising the pass count authorizes more model requests.
@@ -224,7 +199,7 @@ review, not proof that the PR contains no bugs.
| `incomplete` | Coverage incomplete; revise scope or limits |
| `publication_uncertain` | Check the PR before retrying; the write may have succeeded |
| `superseded` | Base or head changed; analyze the current revision |
| `not_eligible` | Fork, draft, closed PR or unsupported event; no model run/write |
| `not_eligible` | Unapproved fork, draft, closed PR, unsupported event, stale comment or missing write access; no model run/write |
Failures fail the optional job; whether it is a required merge check is the
repository owner's policy. A provider failure is not a negative verdict about
+23 -18
View File
@@ -21,9 +21,11 @@ Related docs:
Use [the reusable GitHub Action setup](GITHUB_ACTION.md) for the workflow,
account machine key, exact model, release pin, limits, outcomes and retries.
The repository workflow is now a thin caller of that action. It uses the
Codewhale account relay and a checksummed release; it does not compile a PR's
candidate source. BYOK is an explicit option in a user's own workflow.
The repository workflow is a thin caller of the same analysis and publication
actions. Select your own provider and model; the checksummed CLI release does
not compile or execute a PR's candidate source. An App controls the publishing
identity independently of the model provider. The workflow generator can use
an existing App with `--app-login 'YOUR-APP[bot]'`.
## Review evidence and precision
@@ -82,7 +84,7 @@ same-repository pull requests can post reviews as the App.
| Secret | `CODEWHALE_APP_PRIVATE_KEY` | the full `.pem` file contents |
| Secret | `CODEWHALE_API_KEY` | a Codewhale machine key for this repository workflow |
| Variable | `CODEWHALE_REVIEW_MODEL` | exact account catalog `provider/model` id (required) |
| Variable | `CODEWHALE_REVIEW_VERSION` | exact released CLI tag; default v0.10.0 |
| Variable | `CODEWHALE_REVIEW_VERSION` | exact released CLI tag; generator default v0.10.2 |
App settings control identity. Model access separately requires a review
key and, for account mode, the catalog model. Optional budget variables are
@@ -92,18 +94,21 @@ same-repository pull requests can post reviews as the App.
## How the pieces connect
[The review workflow](../.github/workflows/codewhale-review.yml) uses
`pull_request` and a manual `workflow_dispatch` recovery trigger. The action
reads the PR's exact Git objects in a fresh repository without checking them
out. Fork events receive no model key. Before any inference, the action
rejects fork, draft and closed PRs and verifies their revisions, including
on manual runs.
`pull_request` and a manual `workflow_dispatch` recovery trigger. The analysis action reads the PR's exact Git objects in a fresh repository
without checking them out. Automatic fork events receive no model key. Draft
and closed PRs are rejected. With `allow-forks: true`, an authorized
maintainer can review a fork diff through a PR issue comment or manual run
from the default branch. Both analysis and publication recheck permissions,
triggering comments and revisions.
When both `CODEWHALE_APP_ID` and `CODEWHALE_APP_PRIVATE_KEY` are present, the
workflow mints a short-lived installation token restricted to contents:read
and pull_requests:write. Otherwise it uses `github.token`. The action emits
one COMMENT review, never approval or a request for changes. CODEOWNERS stays
the human authority. Setup or provider failures fail the optional review job
and save a sanitized receipt; they do not post additional status comments.
When generated with an App login, the publisher mints a short-lived
installation token restricted to contents:read and pull_requests:write.
App credentials and model credentials are kept in separate jobs. The publisher
verifies the returned author against that exact login. Without App setup,
generated workflows use `github.token` and `github-actions[bot]`. The action
emits one COMMENT review, never approval or a request for changes. CODEOWNERS
stays the human authority. Setup or provider failures fail the optional review
job and save a sanitized receipt; they do not post additional status comments.
The Actions-only App setup above does not describe the managed hosted App.
Do not disable the webhook on an existing App that also serves hosted mentions.
@@ -136,9 +141,9 @@ token (posts as the App). The `--post` flag is always opt-in.
## Troubleshooting
- **Review posts as you, not the bot.** The variable or the private-key secret
is missing/empty; the job silently falls back to `github.token`. Check both
names character-for-character.
- **Review posts as you, not the bot.** Generate the workflow with the exact App
login, and set both App ID and private-key settings. App-enabled generated
workflows fail on missing credentials instead of changing the author.
- **No model review completed.** Read the outcome receipt and the
[repair guide](GITHUB_ACTION.md#outcomes-and-recovery).
- **Account model or provider error.** Set the provider to `codewhale` (or
+1 -1
View File
@@ -871,7 +871,7 @@ owner = "crates/tui/src/project_context/budget.rs"
[[feature]]
id = "github-action-review"
name = "GitHub Action review"
summary = "Review pull requests with the Engine and publish validated findings from a separate trusted job."
summary = "Generate a pinned GitHub review bot workflow for automatic and authorized mention reviews, with separate analysis and publication jobs."
status = "experimental"
since = "unreleased"
surfaces = { tui = "preview" }
+2 -2
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { KEYS } from './setup.mjs';
const SHA = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i;
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -86,8 +87,7 @@ export function parseArtifact(raw) {
}
export function findingsArtifact(data, receipt, env) {
const secrets = ['GH_TOKEN', 'CODEWHALE_API_KEY', 'DEEPSEEK_API_KEY', 'ANTHROPIC_API_KEY',
'OPENROUTER_API_KEY', 'ZAI_API_KEY', 'MODELSTUDIO_API_KEY'].map(key => env[key]).filter(Boolean);
const secrets = ['GH_TOKEN', ...Object.values(KEYS)].map(key => env[key]).filter(Boolean);
const clean = value => typeof value === 'string'
? secrets.reduce((result, secret) => result.split(secret).join('[REDACTED]'), value)
: Array.isArray(value) ? value.map(clean)
+4 -2
View File
@@ -4,7 +4,7 @@ import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { fingerprint, issueBody, parseArtifact, renderSummary, repositoryPath } from './findings.mjs';
import { target, validateSnapshot } from './review.mjs';
import { authorizeRequest, target, validateSnapshot } from './review.mjs';
function stop(outcome, reason) {
throw Object.assign(new Error(reason), { outcome, reason });
@@ -120,11 +120,12 @@ export function publish(env = process.env, execute = command) {
};
const snapshot = () => {
const pr = api(`repos/${repo}/pulls/${number}`);
validateSnapshot(pr, repo, event);
validateSnapshot(pr, repo, event, env);
if (pr.head.sha !== artifact.head_sha || pr.base.sha !== artifact.base_sha) stop('superseded', 'revision_changed');
return pr;
};
const pr = snapshot();
authorizeRequest(env, event, repo, number, api);
const files = api(`repos/${repo}/pulls/${number}/files?per_page=100`, true);
if (!Number.isSafeInteger(pr.changed_files) || files.length !== pr.changed_files
|| new Set(files.map(item => item.filename)).size !== files.length
@@ -135,6 +136,7 @@ export function publish(env = process.env, execute = command) {
const reviews = api(`repos/${repo}/pulls/${number}/reviews?per_page=100`, true);
const plan = planPublication(artifact, files, reviews, reviewer);
snapshot();
authorizeRequest(env, event, repo, number, api);
if (!plan) receipt = { ...receipt, outcome: 'already_published', reason: '', publication: 'deduplicated' };
else {
const posted = api(`repos/${repo}/pulls/${number}/reviews`, false, plan);
+8
View File
@@ -9,6 +9,12 @@ inputs:
default: ${{ github.token }}
pr-number:
description: PR number for workflow_dispatch; pull_request events use the event snapshot.
trigger-phrase:
description: Same bot mention as the analysis job.
default: '@codewhale'
allow-forks:
description: Same trusted-event fork policy as the analysis job.
default: 'false'
reviewer-login:
description: Exact GitHub login used by github-token; only this author's Codewhale markers deduplicate reviews.
default: github-actions[bot]
@@ -28,6 +34,8 @@ runs:
CW_ACTION_PATH: ${{ github.action_path }}
CW_FINDINGS: ${{ inputs.findings }}
CW_PR_NUMBER: ${{ inputs.pr-number }}
CW_TRIGGER_PHRASE: ${{ inputs.trigger-phrase }}
CW_ALLOW_FORKS: ${{ inputs.allow-forks }}
CW_REVIEWER_LOGIN: ${{ inputs.reviewer-login }}
GH_TOKEN: ${{ inputs.github-token }}
run: node "$CW_ACTION_PATH/../publish.mjs"
+55 -10
View File
@@ -1,7 +1,7 @@
// GitHub orchestration only: the released CLI owns diff coverage, prompts,
// model routing, anchor validation and the final pre-publication head check.
// No repository checkout, project configuration, hooks, or PR code is executed.
// Known limits: review only (mentions use the hosted App); no dollar-budget
// Known limits: review only, including explicit review mentions; no dollar-budget
// guarantee or automatic retry. Legacy direct publication has no cross-run deduplication.
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -9,15 +9,11 @@ import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { findingsArtifact } from './findings.mjs';
import { KEYS } from './setup.mjs';
const SHA = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i;
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const MODEL = /^[A-Za-z0-9][A-Za-z0-9_./:@+-]{0,199}$/;
const KEYS = {
deepseek: 'DEEPSEEK_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
openrouter: 'OPENROUTER_API_KEY', orcarouter: 'ORCAROUTER_API_KEY', zai: 'ZAI_API_KEY',
'modelstudio-token-plan': 'MODELSTUDIO_API_KEY',
};
class Stop extends Error {
constructor(outcome, reason) { super(reason); this.outcome = outcome; this.reason = reason; }
@@ -41,7 +37,7 @@ export function settings(env) {
if (provider === 'codewhale') {
if (!env.CODEWHALE_API_KEY) stop('configuration_missing', 'account_key_required');
if (!/^[A-Za-z0-9_.-]+\/.+/.test(model)) stop('configuration_missing', 'account_catalog_model_required');
} else if (!KEYS[provider] || !env[KEYS[provider]]) {
} else if (!Object.hasOwn(KEYS, provider) || !env[KEYS[provider]]) {
stop('configuration_missing', 'provider_key_required');
}
if (!env.GH_TOKEN) stop('configuration_missing', 'github_token_required');
@@ -68,6 +64,19 @@ export function target(env, event) {
number = event.number;
} else if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
number = env.CW_PR_NUMBER;
} else if (['issue_comment', 'pull_request_review_comment'].includes(env.GITHUB_EVENT_NAME)) {
if (event.action !== 'created') stop('not_eligible', 'unsupported_comment_action');
if (event.comment?.user?.type !== 'User' || event.sender?.type !== 'User'
|| event.comment.user.login !== env.GITHUB_ACTOR || event.sender.login !== env.GITHUB_ACTOR
|| !Number.isSafeInteger(event.comment.id) || event.comment.id < 1) stop('not_eligible', 'untrusted_actor');
const trigger = env.CW_TRIGGER_PHRASE || '@codewhale';
if (!/^@[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(trigger)) stop('configuration_missing', 'invalid_trigger_phrase');
if (typeof event.comment.body !== 'string'
|| event.comment.body.trim().toLowerCase() !== `${trigger.toLowerCase()} review`) stop('not_eligible', 'review_mention_required');
if (env.GITHUB_EVENT_NAME === 'issue_comment') {
if (!event.issue?.pull_request) stop('not_eligible', 'pull_request_required');
number = event.issue.number;
} else number = event.pull_request?.number;
} else {
// In particular, pull_request_target must not gain an inference path.
stop('not_eligible', 'unsupported_event');
@@ -76,9 +85,10 @@ export function target(env, event) {
return { repo: env.GITHUB_REPOSITORY, number: Number(number) };
}
export function validateSnapshot(pr, repo, event) {
export function validateSnapshot(pr, repo, event, env = {}) {
if (pr?.base?.repo?.full_name !== repo) stop('configuration_missing', 'repository_mismatch');
if (pr?.head?.repo?.full_name !== repo) stop('not_eligible', 'fork');
if (pr?.head?.repo?.full_name !== repo && !(env.CW_ALLOW_FORKS === 'true'
&& ['issue_comment', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME))) stop('not_eligible', 'fork');
if (pr.state !== 'open' || pr.draft) stop('not_eligible', 'closed_or_draft');
if (!SHA.test(pr.head.sha) || !SHA.test(pr.base.sha)) stop('configuration_missing', 'invalid_revision');
if (event.pull_request && (event.pull_request.head.sha !== pr.head.sha || event.pull_request.base.sha !== pr.base.sha)) {
@@ -86,6 +96,35 @@ export function validateSnapshot(pr, repo, event) {
}
}
export function authorizeRequest(env, event, repo, number, api) {
if (!['true', 'false'].includes(env.CW_ALLOW_FORKS || 'false')) stop('configuration_missing', 'invalid_allow_forks');
const mention = ['issue_comment', 'pull_request_review_comment'].includes(env.GITHUB_EVENT_NAME);
if (!mention && !(env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.CW_ALLOW_FORKS === 'true')) return;
const actor = env.GITHUB_ACTOR;
const rerunner = env.GITHUB_TRIGGERING_ACTOR || actor;
if (event.sender?.type !== 'User' || event.sender.login !== actor
|| ![actor, rerunner].every(login => /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(login || ''))) stop('not_eligible', 'untrusted_actor');
for (const login of new Set([actor, rerunner])) {
const permission = api(`repos/${repo}/collaborators/${login}/permission`);
if (permission.user?.login !== login || permission.user?.type !== 'User'
|| !['admin', 'maintain', 'write'].includes(permission.permission)) stop('not_eligible', 'write_access_required');
}
if (env.CW_ALLOW_FORKS === 'true') {
const repository = api(`repos/${repo}`);
if (repository.full_name !== repo || !repository.default_branch
|| env.GITHUB_REF !== `refs/heads/${repository.default_branch}`) stop('not_eligible', 'default_branch_required');
}
if (mention) {
const kind = env.GITHUB_EVENT_NAME === 'issue_comment' ? 'issues' : 'pulls';
const comment = api(`repos/${repo}/${kind}/comments/${event.comment.id}`);
const url = env.GITHUB_EVENT_NAME === 'issue_comment' ? comment.issue_url : comment.pull_request_url;
const expected = `https://api.github.com/repos/${repo}/${env.GITHUB_EVENT_NAME === 'issue_comment' ? 'issues' : 'pulls'}/${number}`;
if (comment.id !== event.comment.id || comment.user?.login !== actor || comment.user?.type !== 'User'
|| comment.body !== event.comment.body || comment.updated_at !== event.comment.updated_at
|| url !== expected) stop('not_eligible', 'comment_changed_or_mismatched');
}
}
// Do not persist raw stderr, prompts, findings, PR titles, provider messages,
// or arbitrary JSON fields: they can contain credentials or Actions commands.
export function classify(result, head, post, base) {
@@ -133,6 +172,8 @@ export function run(env = process.env, execute = command) {
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
const { repo, number } = target(env, event);
const config = settings(env);
if (config.post && (['issue_comment', 'pull_request_review_comment'].includes(env.GITHUB_EVENT_NAME)
|| env.CW_ALLOW_FORKS === 'true')) stop('configuration_missing', 'separate_publisher_required');
receipt = { ...receipt, repository: repo, pr_number: number, version: config.version,
provider: config.provider, model: config.model, limits: { max_chars: config.maxChars,
max_passes: config.maxPasses, timeout_seconds: config.timeout / 1000, max_output_tokens: config.outputTokens } };
@@ -154,7 +195,11 @@ export function run(env = process.env, execute = command) {
let pr;
try { pr = JSON.parse(checked('gh', ['api', `repos/${repo}/pulls/${number}`], 'github_snapshot_unavailable')); }
catch (error) { if (error instanceof Stop) throw error; stop('failed', 'invalid_github_response'); }
validateSnapshot(pr, repo, event);
validateSnapshot(pr, repo, event, env);
authorizeRequest(env, event, repo, number, path => {
try { return JSON.parse(checked('gh', ['api', path], 'github_authorization_unavailable')); }
catch (error) { if (error instanceof Stop) throw error; stop('failed', 'invalid_github_response'); }
});
receipt.base_sha = pr.base.sha;
receipt.head_sha = pr.head.sha;
prUrl = `https://github.com/${repo}/pull/${number}`;
+184
View File
@@ -0,0 +1,184 @@
import { lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parseArgs } from 'node:util';
export const KEYS = {
deepseek: 'DEEPSEEK_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
openrouter: 'OPENROUTER_API_KEY', orcarouter: 'ORCAROUTER_API_KEY', zai: 'ZAI_API_KEY',
'modelstudio-token-plan': 'MODELSTUDIO_API_KEY', codewhale: 'CODEWHALE_API_KEY',
};
const HELP = `Create a Codewhale review bot workflow in your repository.
node setup.mjs --provider deepseek --model MODEL_ID --directory /path/to/repo
--provider NAME ${Object.keys(KEYS).join(', ')}
--model ID Exact model ID; account mode requires provider/model
--directory PATH Destination repository (default: current directory)
--version vX.Y.Z Checksummed CLI release (default: v0.10.2)
--action-ref SHA Reviewed Action commit (default: this clean source checkout)
--trigger-phrase NAME Mention to listen for (default: @codewhale)
--app-login LOGIN Optional existing GitHub App login, including [bot]
--print Print YAML without writing files
--help Show this help
Creates .github/workflows/codewhale-review.yml without overwriting existing files.
Set the selected provider secret, then commit the workflow to the default branch.
Automatic reviews cover same-repository PRs. Maintainers can review fork PRs
with a manual run or an @codewhale review comment; PR code is never executed.
`;
export function workflow({ provider, model, version = 'v0.10.2', actionRef, triggerPhrase = '@codewhale', appLogin }) {
if (!Object.hasOwn(KEYS, provider)) throw new Error('Choose a supported --provider.');
if (!/^[A-Za-z0-9][A-Za-z0-9_./:@+-]{0,199}$/.test(model || '')
|| (provider === 'codewhale' && !/^[A-Za-z0-9_.-]+\/.+/.test(model))) throw new Error('Choose an exact --model ID.');
if (!/^v\d+\.\d+\.\d+$/.test(version)) throw new Error('--version must be an exact vX.Y.Z release.');
if (!/^[a-f0-9]{40}$/i.test(actionRef || '')) throw new Error('--action-ref must be a full reviewed commit SHA.');
if (!/^@[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(triggerPhrase)) throw new Error('--trigger-phrase must be @ followed by a GitHub-style name.');
if (appLogin && !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,99}\[bot\]$/.test(appLogin)) throw new Error('--app-login must include [bot].');
const action = `codewhale-hq/Codewhale@${actionRef}`;
const secret = KEYS[provider];
const expression = value => '${{ ' + value + ' }}';
return `name: Codewhale PR Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
workflow_dispatch:
inputs:
pr-number:
description: Pull request number
required: true
type: string
permissions: {}
concurrency:
group: codewhale-review-${expression('github.event.pull_request.number || github.event.issue.number || inputs.pr-number')}
cancel-in-progress: false
jobs:
review:
if: >-
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) ||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(github.event.issue.pull_request || github.event.pull_request) &&
github.event.comment.user.type == 'User' && contains(github.event.comment.body, '${triggerPhrase}'))
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
pull-requests: read
outputs:
findings: ${expression('steps.review.outputs.findings')}
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
- uses: ${action}
id: review
with:
version: '${version}'
provider: '${provider}'
model: '${model}'
pr-number: ${expression('inputs.pr-number')}
trigger-phrase: '${triggerPhrase}'
allow-forks: 'true'
post: 'false'
env:
${secret}: ${expression("(github.event_name == 'workflow_dispatch' || github.event_name == 'issue_comment' || github.event.pull_request.head.repo.full_name == github.repository) && secrets." + secret + " || ''")}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: steps.review.outputs.findings != ''
with:
name: codewhale-findings-${expression('github.run_id')}-${expression('github.run_attempt')}
path: ${expression('steps.review.outputs.findings')}
retention-days: 1
if-no-files-found: error
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always() && steps.review.outputs.receipt != ''
with:
name: codewhale-review-receipt-${expression('github.run_id')}-${expression('github.run_attempt')}
path: ${expression('steps.review.outputs.receipt')}
retention-days: 14
if-no-files-found: error
publish:
needs: review
if: needs.review.outputs.findings != ''
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: codewhale-findings-${expression('github.run_id')}-${expression('github.run_attempt')}
path: findings
${appLogin ? ` - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: app-token
with:
app-id: ${expression('vars.CODEWHALE_APP_ID')}
private-key: ${expression('secrets.CODEWHALE_APP_PRIVATE_KEY')}
permission-contents: read
permission-pull-requests: write
` : ''} - uses: codewhale-hq/Codewhale/scripts/github-action/publish@${actionRef}
id: publish
with:
findings: findings/findings.json
pr-number: ${expression('inputs.pr-number')}
trigger-phrase: '${triggerPhrase}'
allow-forks: 'true'
${appLogin ? ` github-token: ${expression('steps.app-token.outputs.token')}
reviewer-login: '${appLogin}'
` : ''} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always() && steps.publish.outputs.receipt != ''
with:
name: codewhale-publication-receipt-${expression('github.run_id')}-${expression('github.run_attempt')}
path: ${expression('steps.publish.outputs.receipt')}
retention-days: 14
if-no-files-found: error
`;
}
export function setup(args = process.argv.slice(2)) {
const { values } = parseArgs({ args, options: {
provider: { type: 'string' }, model: { type: 'string' }, directory: { type: 'string' },
version: { type: 'string' }, 'action-ref': { type: 'string' }, 'trigger-phrase': { type: 'string' },
'app-login': { type: 'string' }, print: { type: 'boolean' }, help: { type: 'boolean' },
} });
if (values.help) { console.log(HELP); return; }
let actionRef = values['action-ref'];
if (!actionRef) {
const root = fileURLToPath(new URL('../../', import.meta.url));
const git = args => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
const tracked = git(['cat-file', '-e', 'HEAD:scripts/github-action/setup.mjs']);
const clean = git(['diff', '--quiet', 'HEAD', '--', 'action.yml', 'scripts/github-action']);
const head = git(['rev-parse', 'HEAD']);
if (tracked.status !== 0 || clean.status !== 0 || head.status !== 0) throw new Error('Use a clean Codewhale source checkout or supply --action-ref with a reviewed commit.');
actionRef = head.stdout.trim();
}
const yaml = workflow({ provider: values.provider, model: values.model, version: values.version,
actionRef, triggerPhrase: values['trigger-phrase'], appLogin: values['app-login'] });
if (values.print) { process.stdout.write(yaml); return; }
const directory = realpathSync(values.directory || process.cwd());
if (spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).stdout.trim() !== directory) throw new Error('--directory must be a Git repository root.');
let current = directory;
for (const name of ['.github', 'workflows']) {
current = join(current, name);
try { mkdirSync(current); } catch (error) { if (error.code !== 'EEXIST') throw error; }
if (!lstatSync(current).isDirectory() || lstatSync(current).isSymbolicLink()) throw new Error('Workflow directories must be real directories.');
}
const output = join(current, 'codewhale-review.yml');
writeFileSync(output, yaml, { flag: 'wx' });
console.log(`Created ${output}\nSet the ${KEYS[values.provider]} Actions secret, then commit the workflow to your default branch.\n${values['app-login'] ? 'The publisher also needs CODEWHALE_APP_ID and CODEWHALE_APP_PRIVATE_KEY.\n' : ''}Reviews use ${values.provider}/${values.model}. GitHub runner usage and model usage follow your account limits.`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { setup(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}