Files
CodeWhale Bot 17c45d3431 fix(action): check App installation before model usage (CW-6869) [skip ci]
Live run 40832077629 completed a full DeepSeek review (4387 input, 544 output, zero reasoning tokens), then correctly stopped publication because the existing App was not installed on codewhale-hq/Codewhale. Generated App workflows now validate installation in a separate credential-isolated job before admitting model usage. Reuse one installation-token step template; the analysis job still receives only a read-only GitHub token and its selected provider key. Local verification: actionlint passes for both App and default-token generated workflows; Node syntax and whitespace checks pass. Existing review runtime source/evidence is unchanged. Installation approval is pending; no App permission was granted by this source commit.
2026-10-29 00:01:52 -07:00

202 lines
10 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
--reasoning-effort E auto, off, low, medium, high, max (default: off)
--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]
--max-output-tokens N Review output allowance (default: 16384; minimum: 8192)
--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, maxOutputTokens = '16384', reasoningEffort = 'off' }) {
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].');
if (!/^[1-9][0-9]*$/.test(String(maxOutputTokens)) || Number(maxOutputTokens) < 8192 || Number(maxOutputTokens) > 1000000) throw new Error('--max-output-tokens must be between 8192 and 1000000.');
if (!['auto', 'off', 'low', 'medium', 'high', 'max'].includes(reasoningEffort)) throw new Error('Choose a supported --reasoning-effort.');
const action = `codewhale-hq/Codewhale@${actionRef}`;
const secret = KEYS[provider];
const expression = value => '${{ ' + value + ' }}';
const eligible = ` (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}'))`;
const appToken = ` - 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
`;
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:
${appLogin ? ` installation:
if: >-
${eligible}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
${appToken}` : ''} review:
${appLogin ? ' needs: installation\n' : ''} if: >-
${eligible}
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}'
reasoning-effort: '${reasoningEffort}'
max-output-tokens: '${maxOutputTokens}'
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 ? appToken : ''} - 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' },
'max-output-tokens': { type: 'string' },
'reasoning-effort': { type: 'string' },
} });
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'], maxOutputTokens: values['max-output-tokens'], reasoningEffort: values['reasoning-effort'] });
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; }
}