mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 10:36:05 -10:00
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.
185 lines
9.4 KiB
JavaScript
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; }
|
|
}
|