Files
Codewhale/scripts/github-action/setup.mjs
T
CodeWhale Bot c962177c79 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.
2026-10-28 23:39:10 -07:00

185 lines
9.4 KiB
JavaScript

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; }
}