Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<app id>` and `GITHUB_STATUS_APP_KEY=<path to the App's
.pem>` 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
Expand Down
14 changes: 13 additions & 1 deletion src/review-sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,8 @@ const cfg = (): Config => ({
codexModel: '',
githubStatus: true,
githubStatusContext: 'stupify/review',
statusAppId: '',
statusAppKeyPath: '',
gatewayPool: '',
rotateCooldownMs: 600_000,
})
Expand Down Expand Up @@ -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' })
Expand Down
93 changes: 92 additions & 1 deletion src/review-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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<string, unknown>)[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<string, PostedCommitStatus>, pr: Pr, state: CommitStatusState, description: string): void {
if (!cfg.githubStatus || cfg.dryRun) return
const safeDescription = commitStatusDescription(description)
Expand All @@ -753,7 +834,17 @@ function setCommitStatus(cfg: Config, posted: Record<string, PostedCommitStatus>
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
Expand Down