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