diff --git a/README.md b/README.md index 9066dee..d535c36 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ queued/running, success when reviewed or policy-skipped, failure when stupify po reviewer itself failed. Set `GITHUB_STATUS=0` in `~/.stupify/config.env` to turn that off, or `GITHUB_STATUS_CONTEXT=your/context` to rename it. +If the `gh` identity the sweep runs under can't write commit statuses (e.g. a proxy integration whose token is +statuses:read-only), give stupify its own GitHub App: create an App with **Commit statuses: Read & write**, +install it on the repo, then set `GITHUB_STATUS_APP_ID=` and `GITHUB_STATUS_APP_KEY=` in `config.env`. Statuses then post via the App (short-lived installation tokens, minted and cached by the +sweep); everything else still goes through `gh`. + ### Connect your accounts The reviews run on Codex. On exe.dev that's a keyless **LLM integration**: it fronts your ChatGPT/Codex plan, so diff --git a/src/review-sweep.test.ts b/src/review-sweep.test.ts index ff046d3..4ec615c 100644 --- a/src/review-sweep.test.ts +++ b/src/review-sweep.test.ts @@ -3,8 +3,9 @@ // prefix is what the provider caches across diff threads — if a per-PR token ever leaked into it, the cache // would thrash and this test would go red. We render against the repo's own real .review/ (no mocks). import { expect, test } from 'bun:test' +import { createVerify, generateKeyPairSync } from 'node:crypto' import { join } from 'node:path' -import { type Config, commitStatusDescription, commitStatusForSweepResult, dismissedFindings, diffRightLines, finalCodexMessage, FIXED_TOKEN, isFixedReview, isNoopReview, isRateLimited, NOOP_TOKEN, parseFindings, type Pr, pidAlive, priorReviewThread, reviewPrompt, stablePrefix, STILL_NOTE, stripSignoff } from './review-sweep' +import { type Config, appJwt, commitStatusDescription, commitStatusForSweepResult, dismissedFindings, diffRightLines, finalCodexMessage, FIXED_TOKEN, isFixedReview, isNoopReview, isRateLimited, NOOP_TOKEN, parseFindings, type Pr, pidAlive, priorReviewThread, reviewPrompt, stablePrefix, STILL_NOTE, stripSignoff } from './review-sweep' const REVIEW_DIR = join(import.meta.dir, '..', '.review') // the real spec/rubric/corpus shipped in this repo const THIS_PR = '===== THIS PR' // the boundary between the cached prefix and the per-PR tail @@ -28,6 +29,8 @@ const cfg = (): Config => ({ codexModel: '', githubStatus: true, githubStatusContext: 'stupify/review', + statusAppId: '', + statusAppKeyPath: '', gatewayPool: '', rotateCooldownMs: 600_000, }) @@ -300,6 +303,15 @@ test('commitStatusDescription fits GitHub commit status limits', () => { expect(commitStatusDescription(long).endsWith('...')).toBe(true) }) +test('appJwt signs verifiable RS256 claims for the status App', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwt = appJwt('12345', privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), 1_752_900_000) + const [header = '', payload = '', signature = ''] = jwt.split('.') + expect(JSON.parse(Buffer.from(header, 'base64url').toString())).toEqual({ alg: 'RS256', typ: 'JWT' }) + expect(JSON.parse(Buffer.from(payload, 'base64url').toString())).toEqual({ iat: 1_752_899_940, exp: 1_752_900_540, iss: '12345' }) + expect(createVerify('RSA-SHA256').update(`${header}.${payload}`).verify(publicKey, signature, 'base64url')).toBe(true) +}) + test('commitStatusForSweepResult keeps unresolved prior findings red', () => { expect(commitStatusForSweepResult('open')).toEqual({ state: 'failure', description: 'prior stupify findings are still open' }) expect(commitStatusForSweepResult(123)).toEqual({ state: 'failure', description: 'stupify found issues; see review' }) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index a3f4ab6..45593b2 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -18,6 +18,7 @@ * Single-flight: the sweep takes its own lockfile (state/sweep.lock) so two cron ticks never overlap — no * `flock` dependency. Every knob lives in config.env next to this file (read fresh each run). Run: `bun review-sweep.ts`. */ +import { createSign } from 'node:crypto' import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -45,6 +46,8 @@ export interface Config { codexProvider: string // optional `-c model_provider=...`; empty = codex's own default/auth codexModel: string // optional `-c model=...`; empty = codex's default model githubStatus: boolean // post GitHub commit statuses (`stupify/review`) for PR-head workflow visibility + statusAppId: string // GitHub App id for posting commit statuses under our own app identity; empty = post via gh + statusAppKeyPath: string // path to that App's PEM private key; statuses fall back to gh when unset/unreadable githubStatusContext: string gatewayPool: string // CODEX_GATEWAY_POOL: ordered comma-separated gateway hostnames codex may rotate through; empty = rotation off rotateCooldownMs: number // min gap between gateway rotations, so a fully-drained pool cycles calmly instead of thrashing @@ -108,6 +111,8 @@ function loadConfig(): Config { codexModel: pick('CODEX_MODEL', ''), githubStatus: bool('GITHUB_STATUS', true, false), // default visible in GitHub; typo disables instead of surprise-posting githubStatusContext: pick('GITHUB_STATUS_CONTEXT', 'stupify/review').trim() || 'stupify/review', + statusAppId: pick('GITHUB_STATUS_APP_ID', '').trim(), + statusAppKeyPath: pick('GITHUB_STATUS_APP_KEY', '').trim(), gatewayPool: pick('CODEX_GATEWAY_POOL', ''), rotateCooldownMs: int('CODEX_ROTATE_COOLDOWN_MIN', 10, 0) * 60_000, } @@ -740,6 +745,82 @@ function isCommitStatusState(raw: unknown): raw is CommitStatusState { export const commitStatusDescription = (description: string): string => description.length <= 140 ? description : `${description.slice(0, 137)}...` +// The short-lived JWT that authenticates US as our GitHub App (not yet as an installation). iat is backdated 60s +// for clock skew, exp stays under GitHub's 10-minute cap. +export function appJwt(appId: string, privateKeyPem: string, nowSec: number): string { + const enc = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64url') + const signed = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iat: nowSec - 60, exp: nowSec + 540, iss: appId })}` + return `${signed}.${createSign('RSA-SHA256').update(signed).sign(privateKeyPem, 'base64url')}` +} + +// curl (not gh) for App-authenticated calls: gh on the VMs is wired to the exe.dev proxy via GH_HOST, and these +// calls must hit api.github.com with OUR credentials. The bearer token goes through curl's stdin config, never argv. +function ghAppApi(method: 'GET' | 'POST', path: string, bearer: string, body?: string): { ok: boolean; raw: string } { + const args = ['-sS', '--fail-with-body', '--max-time', '30', '-X', method, '--config', '-', `https://api.github.com${path}`] + if (body !== undefined) args.push('-d', body) + const r = exec('curl', args, { input: `header = "Authorization: Bearer ${bearer}"\nheader = "Accept: application/vnd.github+json"\n` }) + return { ok: r.ok, raw: r.combined } +} + +interface CachedAppToken { + token: string + expiresAtMs: number +} + +const appTokenPath = (cfg: Config): string => join(cfg.stateDir, 'gh-app-token.json') + +/** Mint (or reuse) an installation token for our commit-status App. Cached on disk so the every-minute cron mints + * roughly once an hour, not once a sweep. Returns null (with a log) on any failure — the caller skips the status, + * same degraded state as a gh outage. */ +function appStatusToken(cfg: Config): string | null { + try { + const raw: unknown = JSON.parse(readFileSync(appTokenPath(cfg), 'utf8')) + if (typeof raw === 'object' && raw !== null && 'token' in raw && typeof raw.token === 'string' && 'expiresAtMs' in raw && typeof raw.expiresAtMs === 'number' && raw.expiresAtMs - Date.now() > 5 * 60_000) { + return raw.token + } + } catch { + /* no usable cache — mint below */ + } + let pem: string + try { + pem = readFileSync(cfg.statusAppKeyPath, 'utf8') + } catch { + log(` couldn't read GITHUB_STATUS_APP_KEY at ${cfg.statusAppKeyPath} — skipping commit status`) + return null + } + const jwt = appJwt(cfg.statusAppId, pem, Math.floor(Date.now() / 1000)) + const field = (r: { ok: boolean; raw: string }, key: string): unknown => { + if (!r.ok) return undefined + try { + const parsed: unknown = JSON.parse(r.raw) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined + return (parsed as Record)[key] + } catch { + return undefined + } + } + const inst = ghAppApi('GET', `/repos/${cfg.slug}/installation`, jwt) + const instId = field(inst, 'id') + if (typeof instId !== 'number') { + log(` status App isn't installed on ${cfg.slug} (or the key/app id is wrong) — ${inst.raw.slice(0, 180).replace(/\s+/g, ' ').trim()}`) + return null + } + const minted = ghAppApi('POST', `/app/installations/${instId}/access_tokens`, jwt, JSON.stringify({ permissions: { statuses: 'write' } })) + const token = field(minted, 'token') + if (typeof token !== 'string') { + log(` couldn't mint status App token — ${minted.raw.slice(0, 180).replace(/\s+/g, ' ').trim()}`) + return null + } + // GitHub installation tokens live 1h; we cache 55min (the 5-min freshness floor above trims the rest). + const cache: CachedAppToken = { token, expiresAtMs: Date.now() + 55 * 60_000 } + try { + writeFileSync(appTokenPath(cfg), JSON.stringify(cache)) + } catch { + /* best-effort — re-minting next sweep is just one extra round-trip */ + } + return token +} + function setCommitStatus(cfg: Config, posted: Record, pr: Pr, state: CommitStatusState, description: string): void { if (!cfg.githubStatus || cfg.dryRun) return const safeDescription = commitStatusDescription(description) @@ -753,7 +834,17 @@ function setCommitStatus(cfg: Config, posted: Record description: safeDescription, target_url: `https://github.com/${cfg.slug}/pull/${pr.number}`, } - const r = exec('gh', ['api', `repos/${cfg.slug}/statuses/${pr.headRefOid}`, '--method', 'POST', '--input', '-'], { input: JSON.stringify(payload) }) + // Our own App (when configured) posts the status so it carries our bot identity and statuses:write; the exe.dev + // integration's gh token is statuses:read-only. gh remains the fallback for setups without an App. + let r: { ok: boolean; combined: string } + if (cfg.statusAppId && cfg.statusAppKeyPath) { + const token = appStatusToken(cfg) + if (token === null) return // already logged + const post = ghAppApi('POST', `/repos/${cfg.slug}/statuses/${pr.headRefOid}`, token, JSON.stringify(payload)) + r = { ok: post.ok, combined: post.raw } + } else { + r = exec('gh', ['api', `repos/${cfg.slug}/statuses/${pr.headRefOid}`, '--method', 'POST', '--input', '-'], { input: JSON.stringify(payload) }) + } if (!r.ok) { log(` couldn't post GitHub status for #${pr.number} (${state}) — ${r.combined.slice(0, 180).replace(/\s+/g, ' ').trim()}`) return