[ { "path": "src/index.ts", "contents": "import { loadConfig, requireLiveConfig } from './config.js';\nimport { buildReviewTask, runReview, type ReviewTarget } from './runner.js';\nimport { createComposioClient, createGithubConnectUrl, getActiveGithubConnection } from './workbench.js';\n\ntype Command = 'review' | 'connect-github' | 'help';\n\ninterface CliOptions {\n command: Command;\n repo?: string;\n pr?: number;\n userId?: string;\n dryRun: boolean;\n}\n\nfunction usage(): string {\n return `Local PR Reviewer\n\nUsage:\n bun run review -- --repo --pr \n bun run connect\n bun run smoke\n\nOptions:\n --repo GitHub repository to review\n --pr Pull request number\n --user-id Override COMPOSIO_USER_ID for this run\n --dry-run Validate inputs and print the planned local-workbench flow\n --help Show this message`;\n}\n\nfunction parseArgs(argv: string[]): CliOptions {\n const args = [...argv];\n const command = (args[0] && !args[0].startsWith('--') ? args.shift() : 'review') as Command;\n const options: CliOptions = {\n command: command === 'connect-github' || command === 'review' || command === 'help' ? command : 'help',\n dryRun: false,\n };\n\n for (let index = 0; index < args.length; index += 1) {\n const arg = args[index];\n const next = args[index + 1];\n\n if (arg === '--repo') {\n options.repo = next;\n index += 1;\n } else if (arg === '--pr') {\n options.pr = Number(next);\n index += 1;\n } else if (arg === '--user-id') {\n options.userId = next;\n index += 1;\n } else if (arg === '--dry-run') {\n options.dryRun = true;\n } else if (arg === '--help' || arg === '-h') {\n options.command = 'help';\n } else {\n throw new Error(`Unknown argument: ${arg}`);\n }\n }\n\n return options;\n}\n\nfunction parseTarget(options: CliOptions): ReviewTarget {\n if (!options.repo || !/^[^/\\s]+\\/[^/\\s]+$/.test(options.repo)) {\n throw new Error('--repo must be in owner/repo format');\n }\n const pr = options.pr;\n if (!Number.isInteger(pr) || (pr ?? 0) <= 0) {\n throw new Error('--pr must be a positive integer');\n }\n return { repo: options.repo, pr: pr as number };\n}\n\nasync function connectGithub(options: CliOptions): Promise {\n const config = loadConfig();\n if (options.userId) config.userId = options.userId;\n if (!config.composioApiKey) throw new Error('COMPOSIO_API_KEY is required to create a GitHub connect URL');\n\n const composio = createComposioClient(config);\n const existing = await getActiveGithubConnection(composio, config.userId);\n if (existing) {\n console.log(`GitHub is already connected for ${config.userId} (${existing.id}).`);\n return;\n }\n\n const url = await createGithubConnectUrl(composio, config.userId);\n console.log(`Open this URL to connect GitHub for ${config.userId}:`);\n console.log(url);\n}\n\nfunction dryRun(target: ReviewTarget, userId: string): void {\n console.log('Dry run OK.');\n console.log(`User: ${userId}`);\n console.log(`Task: ${buildReviewTask(target)}`);\n console.log('Flow: create Tool Router session with workbench.enable=false, boot E2B, upload helper + reviewer, run checks, post one grounded PR comment.');\n}\n\nasync function review(options: CliOptions): Promise {\n const config = loadConfig();\n if (options.userId) config.userId = options.userId;\n const target = parseTarget(options);\n\n if (options.dryRun) {\n dryRun(target, config.userId);\n return;\n }\n\n requireLiveConfig(config);\n const result = await runReview({\n config,\n target,\n onEvent: (event) => console.log(`[${event.type}] ${event.detail}`),\n });\n console.log('\\nReview result:\\n');\n console.log(result);\n}\n\nasync function main(): Promise {\n const options = parseArgs(process.argv.slice(2));\n\n if (options.command === 'help') {\n console.log(usage());\n return;\n }\n\n if (options.command === 'connect-github') {\n await connectGithub(options);\n return;\n }\n\n await review(options);\n}\n\nmain().catch((error) => {\n console.error(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n", "composio": false }, { "path": "src/runner.ts", "contents": "import type { AppConfig } from './config.js';\nimport { createE2bSandbox } from './sandbox/e2b.js';\nimport { createComposioClient, createLocalWorkbench, requireGithubConnection } from './workbench.js';\nimport { executeReviewerAgent, installReviewerRuntime, uploadReviewerAgent } from './reviewer/runner.js';\nimport type { ReviewEventSink } from './reviewer/events.js';\n\nexport interface ReviewTarget {\n repo: string;\n pr: number;\n}\n\nexport interface RunReviewOptions {\n config: AppConfig;\n target: ReviewTarget;\n onEvent?: ReviewEventSink;\n}\n\nexport function buildReviewTask(target: ReviewTarget): string {\n return `Review PR #${target.pr} on ${target.repo}. Run the repository's real checks in this sandbox and post one grounded GitHub PR comment only if checks actually run.`;\n}\n\nexport async function runReview(options: RunReviewOptions): Promise {\n const onEvent = options.onEvent ?? (() => {});\n const composio = createComposioClient(options.config);\n\n onEvent({ type: 'info', detail: 'checking GitHub connection' });\n await requireGithubConnection(composio, options.config.userId);\n\n onEvent({ type: 'info', detail: 'creating local workbench session' });\n const workbench = await createLocalWorkbench(composio, options.config.userId);\n\n onEvent({ type: 'sandbox', detail: 'booting E2B sandbox' });\n const sandbox = await createE2bSandbox({\n apiKey: options.config.e2bApiKey ?? '',\n timeoutMs: options.config.e2bTimeoutMs,\n remoteDir: options.config.sandboxRemoteDir,\n helperSource: workbench.helperSource,\n env: workbench.env,\n });\n\n try {\n await uploadReviewerAgent(sandbox);\n await installReviewerRuntime(sandbox, onEvent);\n return await executeReviewerAgent(\n sandbox,\n buildReviewTask(options.target),\n {\n OPENAI_API_KEY: options.config.openaiApiKey ?? '',\n COMPOSIO_USER_ID: options.config.userId,\n },\n onEvent\n );\n } finally {\n onEvent({ type: 'sandbox', detail: 'tearing down sandbox' });\n await sandbox.teardown();\n }\n}\n", "composio": true }, { "path": "src/workbench.ts", "contents": "import { Composio } from '@composio/core';\nimport {\n experimental_createLocalWorkbenchSession,\n type LocalWorkbenchSession,\n} from '@composio/experimental/workbench';\nimport type { AppConfig } from './config.js';\n\ntype ConnectedAccount = {\n id: string;\n status?: string;\n};\n\nexport function createComposioClient(config: AppConfig): Composio {\n return new Composio({\n apiKey: config.composioApiKey,\n baseURL: config.composioBaseUrl,\n });\n}\n\nexport async function getActiveGithubConnection(composio: Composio, userId: string): Promise {\n const list = (await composio.connectedAccounts.list({\n userIds: [userId],\n toolkitSlugs: ['github'],\n statuses: ['ACTIVE'],\n })) as { items?: ConnectedAccount[] };\n\n return list.items?.[0];\n}\n\nexport async function createGithubConnectUrl(composio: Composio, userId: string): Promise {\n const request = await composio.toolkits.authorize(userId, 'github');\n if (!request.redirectUrl) throw new Error('Composio did not return a GitHub connect URL');\n return request.redirectUrl;\n}\n\nexport async function requireGithubConnection(composio: Composio, userId: string): Promise {\n const account = await getActiveGithubConnection(composio, userId);\n if (account) return account;\n\n const url = await createGithubConnectUrl(composio, userId);\n throw new Error(\n [\n `No active GitHub connection found for COMPOSIO_USER_ID=\"${userId}\".`,\n 'Open this URL, complete GitHub authorization, then run the reviewer again:',\n url,\n ].join('\\n')\n );\n}\n\nexport async function createLocalWorkbench(composio: Composio, userId: string): Promise {\n // Create the session with the remote workbench disabled so code execution runs in our own sandbox,\n // then hand it to the helper (which validates workbench.enable === false).\n const session = await composio.create(userId, {\n toolkits: ['github'],\n workbench: { enable: false },\n });\n return experimental_createLocalWorkbenchSession(composio, session);\n}\n", "composio": true }, { "path": "src/sandbox/e2b.ts", "contents": "import { Sandbox } from 'e2b';\n\nexport interface SandboxOptions {\n apiKey: string;\n timeoutMs: number;\n remoteDir: string;\n helperSource: string;\n env: Record;\n}\n\nexport interface UserSandbox {\n remoteDir: string;\n env: Record;\n writeFile(path: string, contents: string): Promise;\n run(command: string, options?: RunOptions): Promise;\n teardown(): Promise;\n}\n\nexport interface RunOptions {\n timeoutMs?: number;\n env?: Record;\n onStdout?: (chunk: string) => void;\n onStderr?: (chunk: string) => void;\n}\n\ntype CommandError = {\n result?: {\n exitCode?: number;\n stdout?: string;\n stderr?: string;\n };\n};\n\nexport function shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n\nexport function commandErrorText(error: unknown): string {\n const result = (error as CommandError).result;\n if (result) {\n const output = result.stderr || result.stdout || '';\n return `exit ${result.exitCode ?? 'unknown'}\\n${output.slice(-1200)}`;\n }\n return error instanceof Error ? error.message : String(error);\n}\n\nexport async function createE2bSandbox(options: SandboxOptions): Promise {\n const sandbox = await Sandbox.create({\n apiKey: options.apiKey,\n timeoutMs: options.timeoutMs,\n });\n\n await sandbox.commands.run(`mkdir -p ${shellQuote(options.remoteDir)}`, {\n timeoutMs: 30_000,\n });\n await sandbox.files.write(`${options.remoteDir}/composio_helper.py`, options.helperSource);\n\n return {\n remoteDir: options.remoteDir,\n env: options.env,\n async writeFile(path, contents) {\n await sandbox.files.write(path, contents);\n },\n async run(command, runOptions = {}) {\n await sandbox.commands.run(command, {\n timeoutMs: runOptions.timeoutMs,\n envs: runOptions.env,\n onStdout: runOptions.onStdout,\n onStderr: runOptions.onStderr,\n });\n },\n async teardown() {\n await sandbox.kill();\n },\n };\n}\n", "composio": true }, { "path": "src/config.ts", "contents": "import 'dotenv/config';\n\nexport interface AppConfig {\n composioApiKey?: string;\n composioBaseUrl?: string;\n openaiApiKey?: string;\n e2bApiKey?: string;\n userId: string;\n sandboxRemoteDir: string;\n e2bTimeoutMs: number;\n}\n\nconst DEFAULT_USER_ID = 'local-pr-reviewer-user';\nconst DEFAULT_REMOTE_DIR = '/home/user/local-pr-reviewer';\nconst DEFAULT_E2B_TIMEOUT_MS = 1_800_000;\n\nfunction optionalEnv(name: string): string | undefined {\n const value = process.env[name];\n return value && value.trim() ? value.trim() : undefined;\n}\n\nfunction optionalNumberEnv(name: string, fallback: number): number {\n const value = optionalEnv(name);\n if (!value) return fallback;\n\n const parsed = Number(value);\n if (!Number.isFinite(parsed) || parsed <= 0) {\n throw new Error(`${name} must be a positive number`);\n }\n return parsed;\n}\n\nexport function loadConfig(): AppConfig {\n return {\n composioApiKey: optionalEnv('COMPOSIO_API_KEY'),\n composioBaseUrl: optionalEnv('COMPOSIO_BASE_URL'),\n openaiApiKey: optionalEnv('OPENAI_API_KEY'),\n e2bApiKey: optionalEnv('E2B_API_KEY'),\n userId: optionalEnv('COMPOSIO_USER_ID') ?? DEFAULT_USER_ID,\n sandboxRemoteDir: optionalEnv('SANDBOX_REMOTE_DIR') ?? DEFAULT_REMOTE_DIR,\n e2bTimeoutMs: optionalNumberEnv('E2B_TIMEOUT_MS', DEFAULT_E2B_TIMEOUT_MS),\n };\n}\n\nexport function requireLiveConfig(config: AppConfig): Required> {\n const missing = [\n ['COMPOSIO_API_KEY', config.composioApiKey],\n ['OPENAI_API_KEY', config.openaiApiKey],\n ['E2B_API_KEY', config.e2bApiKey],\n ]\n .filter(([, value]) => !value)\n .map(([name]) => name);\n\n if (missing.length) {\n throw new Error(`Missing required env var${missing.length === 1 ? '' : 's'} for a live run: ${missing.join(', ')}`);\n }\n\n return {\n composioApiKey: config.composioApiKey,\n openaiApiKey: config.openaiApiKey,\n e2bApiKey: config.e2bApiKey,\n } as Required>;\n}\n", "composio": false }, { "path": "src/reviewer/runner.ts", "contents": "import { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { parseAgentEvent, type ReviewEventSink } from './events.js';\nimport { commandErrorText, shellQuote, type UserSandbox } from '../sandbox/e2b.js';\n\nconst INSTALL_TIMEOUT_MS = 300_000;\nconst AGENT_TIMEOUT_MS = 1_500_000;\nconst AGENT_DIR = join(process.cwd(), 'agent');\n\nconst AGENT_FILES = [\n ['agent.ts', 'agent.ts'],\n ['instructions.md', 'instructions.md'],\n ['package.json', 'package.json'],\n] as const;\n\nfunction createOutputCollector(onEvent: ReviewEventSink) {\n let output = '';\n\n return {\n onStdout(data: string) {\n output += data;\n\n for (const line of data.split('\\n')) {\n if (!line.trim()) continue;\n\n const event = parseAgentEvent(line);\n if (event) onEvent(event);\n else console.log(line);\n }\n },\n\n onStderr(data: string) {\n output += data;\n for (const line of data.split('\\n')) {\n if (line.trim()) console.error(line);\n }\n },\n\n result() {\n const match = output.match(/::result::(.*)$/m);\n if (!match) return undefined;\n\n try {\n return JSON.parse(match[1]) as string;\n } catch {\n return match[1];\n }\n },\n\n tail() {\n return output.slice(-2000);\n },\n };\n}\n\nexport async function uploadReviewerAgent(sandbox: UserSandbox): Promise {\n await Promise.all(\n AGENT_FILES.map(async ([localName, remoteName]) => {\n const contents = await readFile(join(AGENT_DIR, localName), 'utf8');\n await sandbox.writeFile(`${sandbox.remoteDir}/${remoteName}`, contents);\n })\n );\n}\n\nexport async function installReviewerRuntime(sandbox: UserSandbox, onEvent: ReviewEventSink): Promise {\n onEvent({ type: 'info', detail: 'installing reviewer runtime' });\n\n try {\n await sandbox.run(`cd ${shellQuote(sandbox.remoteDir)} && npm install --no-audit --no-fund --loglevel=error`, {\n timeoutMs: INSTALL_TIMEOUT_MS,\n });\n } catch (error) {\n throw new Error(`npm install failed:\\n${commandErrorText(error)}`);\n }\n}\n\nexport async function executeReviewerAgent(\n sandbox: UserSandbox,\n task: string,\n env: Record,\n onEvent: ReviewEventSink\n): Promise {\n onEvent({ type: 'run', detail: 'reviewer running inside your sandbox' });\n\n const output = createOutputCollector(onEvent);\n try {\n await sandbox.run(`cd ${shellQuote(sandbox.remoteDir)} && npx --yes tsx agent.ts`, {\n timeoutMs: AGENT_TIMEOUT_MS,\n env: {\n ...sandbox.env,\n ...env,\n AGENT_REMOTE_DIR: sandbox.remoteDir,\n TASK: task,\n },\n onStdout: output.onStdout,\n onStderr: output.onStderr,\n });\n } catch (error) {\n const result = output.result();\n if (result) return result;\n throw new Error(`agent run failed:\\n${commandErrorText(error)}`);\n }\n\n return output.result() ?? output.tail() ?? '(no output)';\n}\n", "composio": false }, { "path": "agent/agent.ts", "contents": "import { Agent, run, tool } from '@openai/agents';\nimport { execSync } from 'node:child_process';\nimport { readFileSync, writeFileSync } from 'node:fs';\n\nconst REMOTE = process.env.AGENT_REMOTE_DIR || '/home/user/local-pr-reviewer';\nconst CELL_TIMEOUT_MS = 900_000;\nconst MAX_BUFFER_BYTES = 32 * 1024 * 1024;\nconst TASK = process.env.TASK || 'Review the pull request.';\nconst INSTRUCTIONS = readFileSync(new URL('./instructions.md', import.meta.url), 'utf8');\n\ntype RunPythonInput = {\n note: string;\n code: string;\n};\n\ntype ExecError = {\n status?: number;\n stdout?: string;\n stderr?: string;\n};\n\nlet cellNumber = 0;\n\nfunction nextCellFile(): string {\n cellNumber += 1;\n return `cell-${cellNumber}.py`;\n}\n\nfunction cellLabel(file: string): string {\n return file.replace('cell-', 'cell ').replace('.py', '');\n}\n\nfunction buildCellSource(code: string): string {\n return [\n 'from composio_helper import run_composio_tool, invoke_llm, web_search',\n 'import json',\n 'import os',\n 'import pathlib',\n 'import shutil',\n 'import subprocess',\n 'import textwrap',\n '',\n 'def run(cmd, cwd=None, timeout=900):',\n ' completed = subprocess.run(cmd, cwd=cwd, shell=True, text=True, capture_output=True, timeout=timeout)',\n ' output = (completed.stdout or \"\") + (completed.stderr or \"\")',\n ' print(output[-12000:])',\n ' return {\"exit_code\": completed.returncode, \"output\": output}',\n '',\n code,\n '',\n ].join('\\n');\n}\n\nfunction summarizeFailure(error: ExecError): string {\n return (error.stderr || error.stdout || '').trim().split('\\n').slice(-3).join(' | ').slice(-300);\n}\n\nfunction runCell(file: string): string {\n return execSync(`python3 ${file}`, {\n cwd: REMOTE,\n encoding: 'utf8',\n timeout: CELL_TIMEOUT_MS,\n maxBuffer: MAX_BUFFER_BYTES,\n shell: '/bin/bash',\n });\n}\n\nconst runPython = tool({\n name: 'run_python',\n description:\n 'Execute a Python cell in THIS sandbox. Pre-imported and in scope: ' +\n '`run_composio_tool(slug, arguments)` to call Composio tools, `invoke_llm(query)`, `web_search(query)`, ' +\n '`run(cmd, cwd=None, timeout=900)` for shell commands, and Python stdlib modules json/os/pathlib/shutil/subprocess/textwrap. ' +\n 'Print anything you want to observe; stdout+stderr are returned to you.',\n parameters: {\n type: 'object',\n properties: {\n note: {\n type: 'string',\n description:\n 'A short present-tense description of what this cell does, shown live to the user (<=10 words). ' +\n 'e.g. \"fetching PR #1 metadata\", \"downloading repo archive\", \"running node --test\", \"posting review comment\".',\n },\n code: { type: 'string', description: 'Python to run. Do not include markdown fences.' },\n },\n required: ['note', 'code'],\n additionalProperties: false,\n },\n strict: true,\n execute: async (raw: unknown) => {\n const { note, code } = raw as RunPythonInput;\n const file = nextCellFile();\n\n console.log(`::event::step::${note} (${cellLabel(file)})`);\n writeFileSync(`${REMOTE}/${file}`, buildCellSource(code));\n\n try {\n return runCell(file);\n } catch (e: unknown) {\n const error = e as ExecError;\n console.log(`::event::error::${note} failed: ${summarizeFailure(error)}`);\n return `exit ${error.status}\\n--- stdout ---\\n${error.stdout ?? ''}\\n--- stderr ---\\n${error.stderr ?? ''}`;\n }\n },\n});\n\nconst agent = new Agent({\n name: 'Local PR Reviewer',\n instructions: INSTRUCTIONS,\n tools: [runPython],\n});\n\nconst result = await run(agent, TASK, { maxTurns: 40 });\nconsole.log('::result::' + JSON.stringify(result.finalOutput ?? '(no output)'));\n", "composio": true }, { "path": "agent/instructions.md", "contents": "# Local PR Reviewer\n\nYou are reviewing a GitHub pull request from inside the user's own sandbox.\nVerify by running commands. Do not guess.\n\nYour only tool is `run_python`. Each cell runs in this sandbox and has:\n\n- `run_composio_tool(slug, arguments)` for Composio tools.\n- `invoke_llm(query)` for model calls.\n- `web_search(query)` for web research.\n- `run(cmd, cwd=None, timeout=900)` for shell commands.\n\nUse a short, specific `note` on every `run_python` call because it is shown live\nto the user.\n\n## Composio Tool Rules\n\nUse Composio for GitHub auth and tool discovery. Do not hardcode GitHub tokens,\ntool slugs, or argument shapes.\n\nBefore every new GitHub action:\n\n1. Call `COMPOSIO_SEARCH_TOOLS` with the use case.\n2. Call `COMPOSIO_GET_TOOL_SCHEMAS` for the selected slug.\n3. Call the tool with the exact schema arguments.\n\n`run_composio_tool` returns `(response, error)`. Check `error` before using\n`response`. If a call fails because a slug or argument is invalid, fetch the\nschema again and correct the call instead of guessing another tool.\n\n## Review Workflow\n\n1. Require an explicit `owner/repo` and PR number. If either is missing, stop.\n2. Fetch the PR metadata and changed files through Composio, then print the key\n details.\n3. Download the PR source into the sandbox through Composio-managed GitHub auth.\n Prefer a GitHub archive or signed codeload URL for the PR head ref/SHA. Do\n not use a personal token or a bare `api.github.com/.../tarball` URL.\n4. Verify the extracted directory contains real source files before running\n checks.\n5. Detect the package manager from lockfiles, install dependencies, and run the\n checks the repository actually defines. Prefer `test`, `lint`, `typecheck`,\n `type-check`, `tsc`, then `build` when those scripts exist.\n6. If a required runtime is missing, install it in the sandbox and retry the\n same check. Keep the output needed to explain failures.\n7. Post one general PR comment only after checks actually ran. Cite the commands\n that passed or failed and include concrete failure output when relevant.\n\nDo not approve, request changes, or post duplicate comments. If the repository\ncould not be downloaded or no checks could run, post nothing and return the\nblocker instead.\n\nFinish with a concise summary. Include the review URL only when a comment was\nposted successfully.\n", "composio": false } ]