返回 CodeWhale
review.mjs
根目录 / scripts / github-action / review.mjs
1 // GitHub orchestration only: the released CLI owns diff coverage, prompts,
2 // model routing, anchor validation and the final pre-publication head check.
3 // No repository checkout, project configuration, hooks, or PR code is executed.
4 // Known limits: review only (mentions use the hosted App); no dollar-budget
5 // guarantee, automatic retry, or cross-run publication deduplication.
6 import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
7 import { tmpdir } from 'node:os';
8 import { join, resolve } from 'node:path';
9 import { fileURLToPath } from 'node:url';
10 import { spawnSync } from 'node:child_process';
11
12 const SHA = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i;
13 const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14 const MODEL = /^[A-Za-z0-9][A-Za-z0-9_./:@+-]{0,199}$/;
15 const KEYS = {
16 deepseek: 'DEEPSEEK_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
17 openrouter: 'OPENROUTER_API_KEY', zai: 'ZAI_API_KEY',
18 'modelstudio-token-plan': 'MODELSTUDIO_API_KEY',
19 };
20
21 class Stop extends Error {
22 constructor(outcome, reason) { super(reason); this.outcome = outcome; this.reason = reason; }
23 }
24 const stop = (outcome, reason) => { throw new Stop(outcome, reason); };
25 function integer(value, fallback, min, max) {
26 const text = String(value || fallback);
27 if (!/^[1-9][0-9]*$/.test(text) || Number(text) < min || Number(text) > max) {
28 stop('configuration_missing', 'invalid_limits');
29 }
30 return Number(text);
31 }
32
33 export function settings(env) {
34 if (!/^v\d+\.\d+\.\d+$/.test(env.CW_VERSION || '')) stop('configuration_missing', 'exact_release_required');
35 const provider = env.CW_PROVIDER || 'codewhale';
36 const model = env.CW_MODEL || '';
37 if (!MODEL.test(model)) stop('configuration_missing', 'exact_model_required');
38 if (env.CODEWHALE_API_KEY && provider !== 'codewhale') stop('configuration_missing', 'account_key_requires_relay');
39 if (provider === 'codewhale') {
40 if (!env.CODEWHALE_API_KEY) stop('configuration_missing', 'account_key_required');
41 if (!/^[A-Za-z0-9_.-]+\/.+/.test(model)) stop('configuration_missing', 'account_catalog_model_required');
42 } else if (!KEYS[provider] || !env[KEYS[provider]]) {
43 stop('configuration_missing', 'provider_key_required');
44 }
45 if (!env.GH_TOKEN) stop('configuration_missing', 'github_token_required');
46 if (!['true', 'false'].includes(env.CW_POST || 'true')) stop('configuration_missing', 'invalid_post');
47 return {
48 version: env.CW_VERSION, provider, model, post: env.CW_POST !== 'false',
49 maxChars: integer(env.CW_MAX_CHARS, 200000, 1, 8388608),
50 maxPasses: integer(env.CW_MAX_PASSES, 1, 1, 64),
51 timeout: integer(env.CW_TIMEOUT_SECONDS, 600, 30, 1200) * 1000,
52 outputTokens: env.CW_MAX_OUTPUT_TOKENS ? integer(env.CW_MAX_OUTPUT_TOKENS, 8192, 8192, 1000000) : null,
53 };
54 }
55
56 export function target(env, event) {
57 if (env.GITHUB_SERVER_URL && env.GITHUB_SERVER_URL !== 'https://github.com') stop('configuration_missing', 'unsupported_github_host');
58 if (!REPO.test(env.GITHUB_REPOSITORY || '')) stop('configuration_missing', 'invalid_repository');
59 let number;
60 if (env.GITHUB_EVENT_NAME === 'pull_request') {
61 if (!['opened', 'synchronize', 'reopened', 'ready_for_review'].includes(event.action)) stop('not_eligible', 'unsupported_pr_action');
62 if (event.pull_request?.draft) stop('not_eligible', 'draft');
63 if (event.pull_request?.head?.repo?.full_name !== env.GITHUB_REPOSITORY) stop('not_eligible', 'fork');
64 if (event.pull_request?.base?.repo?.full_name !== env.GITHUB_REPOSITORY) stop('configuration_missing', 'repository_mismatch');
65 if (!SHA.test(event.pull_request?.head?.sha || '') || !SHA.test(event.pull_request?.base?.sha || '')) stop('configuration_missing', 'invalid_revision');
66 number = event.number;
67 } else if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
68 number = env.CW_PR_NUMBER;
69 } else {
70 // In particular, pull_request_target must not gain an inference path.
71 stop('not_eligible', 'unsupported_event');
72 }
73 if (!/^[1-9][0-9]{0,9}$/.test(String(number || ''))) stop('configuration_missing', 'invalid_pr_number');
74 return { repo: env.GITHUB_REPOSITORY, number: Number(number) };
75 }
76
77 export function validateSnapshot(pr, repo, event) {
78 if (pr?.base?.repo?.full_name !== repo) stop('configuration_missing', 'repository_mismatch');
79 if (pr?.head?.repo?.full_name !== repo) stop('not_eligible', 'fork');
80 if (pr.state !== 'open' || pr.draft) stop('not_eligible', 'closed_or_draft');
81 if (!SHA.test(pr.head.sha) || !SHA.test(pr.base.sha)) stop('configuration_missing', 'invalid_revision');
82 if (event.pull_request && (event.pull_request.head.sha !== pr.head.sha || event.pull_request.base.sha !== pr.base.sha)) {
83 stop('superseded', 'revision_changed');
84 }
85 }
86
87 // Do not persist raw stderr, prompts, findings, PR titles, provider messages,
88 // or arbitrary JSON fields: they can contain credentials or Actions commands.
89 export function classify(result, head, post, base) {
90 let data;
91 try { data = JSON.parse(result.stdout); } catch { /* fail closed below */ }
92 const publication = ['not_requested', 'not_attempted', 'uncertain', 'posted'].includes(data?.publication)
93 ? data.publication : 'unknown';
94 const usage = {};
95 for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
96 if (Number.isSafeInteger(data?.usage?.[key]) && data.usage[key] >= 0) usage[key] = data.usage[key];
97 }
98 const evidence = { publication, usage };
99 const coverage = data?.receipt?.coverage;
100 if (publication === 'uncertain' || (result.error && post)) {
101 return { ...evidence, outcome: 'publication_uncertain', reason: 'inspect_github_before_retry' };
102 }
103 if (data?.success === true && data.complete === false) {
104 return { ...evidence, outcome: 'incomplete', reason: 'coverage_incomplete' };
105 }
106 if (result.status !== 0 || data?.mode !== 'review' || data.success !== true || data.complete !== true
107 || data.pr?.head_sha !== head || !Array.isArray(data.review?.issues)
108 || !Number.isSafeInteger(data.review_passes) || data.review_passes < 1
109 || coverage?.manifest?.head_sha !== head || coverage?.manifest?.base_sha !== base
110 || !Array.isArray(coverage?.manifest?.skipped_files) || coverage.manifest.skipped_files.length !== 0
111 || coverage?.completed_passes?.length !== data.review_passes
112 || publication !== (post ? 'posted' : 'not_requested')) {
113 return { ...evidence, outcome: 'failed', reason: result.error ? 'process_failed' : 'review_not_completed' };
114 }
115 return { ...evidence, outcome: data.review.issues.length ? 'reviewed_with_findings' : 'reviewed_clean',
116 reason: '', findings: data.review.issues.length, passes: data.review_passes };
117 }
118
119 function command(program, args, options) {
120 return spawnSync(program, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
121 timeout: 120000, killSignal: 'SIGKILL', ...options });
122 }
123
124 export function run(env = process.env, execute = command) {
125 const root = mkdtempSync(join(env.RUNNER_TEMP || tmpdir(), 'codewhale-review-'));
126 const receiptPath = join(root, 'receipt.json');
127 let receipt = { schema_version: 1, outcome: 'failed', reason: 'setup_failed', publication: 'not_attempted' };
128 let prUrl = '';
129 try {
130 const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
131 const { repo, number } = target(env, event);
132 const config = settings(env);
133 receipt = { ...receipt, repository: repo, pr_number: number, version: config.version,
134 provider: config.provider, model: config.model, limits: { max_chars: config.maxChars,
135 max_passes: config.maxPasses, timeout_seconds: config.timeout / 1000, max_output_tokens: config.outputTokens } };
136 // Isolate user/project configuration and retain only the selected model key.
137 // Never execute with unrelated runner credentials or Git configuration.
138 const childEnv = Object.fromEntries(['PATH', 'SystemRoot', 'TMPDIR', 'LANG'].filter(k => env[k]).map(k => [k, env[k]]));
139 Object.assign(childEnv, { HOME: join(root, 'home'), CODEWHALE_HOME: join(root, 'home', '.codewhale'),
140 GH_TOKEN: env.GH_TOKEN, GH_HOST: 'github.com', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
141 GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1', NO_COLOR: '1', CI: 'true' });
142 childEnv[config.provider === 'codewhale' ? 'CODEWHALE_API_KEY' : KEYS[config.provider]] =
143 env[config.provider === 'codewhale' ? 'CODEWHALE_API_KEY' : KEYS[config.provider]];
144 if (config.outputTokens) childEnv.CODEWHALE_MAX_OUTPUT_TOKENS = String(config.outputTokens);
145 mkdirSync(childEnv.CODEWHALE_HOME, { recursive: true, mode: 0o700 });
146 const checked = (program, args, reason, extra = {}) => {
147 const result = execute(program, args, { env: childEnv, cwd: root, ...extra });
148 if (result.status !== 0 || result.error) stop('failed', reason);
149 return result.stdout.trim();
150 };
151 let pr;
152 try { pr = JSON.parse(checked('gh', ['api', `repos/${repo}/pulls/${number}`], 'github_snapshot_unavailable')); }
153 catch (error) { if (error instanceof Stop) throw error; stop('failed', 'invalid_github_response'); }
154 validateSnapshot(pr, repo, event);
155 receipt.base_sha = pr.base.sha;
156 receipt.head_sha = pr.head.sha;
157 prUrl = `https://github.com/${repo}/pull/${number}`;
158 // Reuse the public installer: exact release, checksums, atomic fresh install.
159 const installEnv = { PATH: childEnv.PATH, HOME: childEnv.HOME, CODEWHALE_VERSION: config.version,
160 CODEWHALE_INSTALL_DIR: join(root, 'bin') };
161 checked('sh', [join(env.CW_ACTION_PATH, 'web/public/install.sh')], 'release_install_failed', { env: installEnv, timeout: 180000 });
162 const cli = join(root, 'bin/codewhale');
163 if (config.provider === 'codewhale') checked(cli, ['--no-project-config', 'account', 'agent'], 'account_preflight_failed', { timeout: 60000 });
164 const repoDir = join(root, 'repository');
165 checked('git', ['init', '--quiet', repoDir], 'git_init_failed');
166 const git = args => checked('git', ['-C', repoDir, '-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=',
167 '-c', 'credential.helper=!gh auth git-credential', ...args], 'git_snapshot_unavailable');
168 git(['remote', 'add', 'origin', `https://github.com/${repo}.git`]);
169 // No checkout: fetch objects, then point HEAD at the trusted base. Core
170 // reads pinned blobs and diffs; filters, submodules and candidate code stay inert.
171 git(['fetch', '--no-tags', '--no-recurse-submodules', 'origin', pr.base.sha, `+refs/pull/${number}/head:refs/codewhale-review/head`]);
172 if (git(['rev-parse', 'refs/codewhale-review/head^{commit}']) !== pr.head.sha) stop('superseded', 'revision_changed');
173 if (!SHA.test(git(['merge-base', '--all', pr.base.sha, pr.head.sha]))) stop('failed', 'merge_base_unavailable');
174 git(['update-ref', 'HEAD', pr.base.sha]);
175 const args = ['--no-project-config', '--provider', config.provider, '--model', config.model,
176 'review', '--pr', String(number), '--repo', repo, '--max-chars', String(config.maxChars),
177 '--max-passes', String(config.maxPasses), '--json', '--write-receipt', '--receipt-path', join(root, 'cli-receipt.json')];
178 if (config.post) args.push('--post');
179 const result = execute(cli, args, { cwd: repoDir, env: childEnv, timeout: config.timeout });
180 receipt = { ...receipt, ...classify(result, pr.head.sha, config.post, pr.base.sha) };
181 } catch (error) {
182 receipt = { ...receipt, outcome: error instanceof Stop ? error.outcome : 'failed',
183 reason: error instanceof Stop ? error.reason : 'setup_failed' };
184 }
185 writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
186 if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `outcome=${receipt.outcome}\nreceipt=${receiptPath}\npr-url=${prUrl}\n`);
187 if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY,
188 `### Codewhale review\n\nOutcome: **${receipt.outcome}**. ${receipt.reason ? `Reason: \`${receipt.reason}\`.` : ''}\n\n` +
189 'Only `reviewed_clean` and `reviewed_with_findings` confirm complete model review. Findings are advisory.\n');
190 const ok = ['reviewed_clean', 'reviewed_with_findings', 'not_eligible', 'superseded'].includes(receipt.outcome);
191 // Constant vocabulary only; model/provider/repository text is never logged.
192 console.log(`${ok ? '::notice::' : '::error::'}Codewhale review: ${receipt.outcome} (${receipt.reason || 'complete'}).`);
193 return { exitCode: ok ? 0 : 1, receipt, receiptPath };
194 }
195
196 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = run().exitCode;
197
197 lines Plain Text