From 7b4bb77e1dde7a83bfdc1cb547b8362c16f84725 Mon Sep 17 00:00:00 2001 From: za0012 Date: Thu, 2 Jul 2026 15:08:14 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20automated=20code=20re?= =?UTF-8?q?view=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/automated-code-review.yml | 50 + .github/workflows/scheduled-study-report.yml | 46 + .gitignore | 1 + AGENTS.md | 10 +- docs/automated-code-review.md | 60 + package.json | 1 + review-agents/architecture-reviewer.md | 13 + review-agents/bug-reviewer.md | 13 + review-agents/performance-reviewer.md | 12 + review-agents/reporter.md | 10 + scripts/review-harness.ts | 1953 ++++++++++-------- 11 files changed, 1336 insertions(+), 833 deletions(-) create mode 100644 .github/workflows/automated-code-review.yml create mode 100644 .github/workflows/scheduled-study-report.yml create mode 100644 docs/automated-code-review.md diff --git a/.github/workflows/automated-code-review.yml b/.github/workflows/automated-code-review.yml new file mode 100644 index 0000000..96b6bb0 --- /dev/null +++ b/.github/workflows/automated-code-review.yml @@ -0,0 +1,50 @@ +name: Automated Code Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + review: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run automated review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_REVIEW_MODEL: ${{ vars.OPENAI_REVIEW_MODEL || 'gpt-5.5' }} + REVIEW_REPORT_TITLE: "Automated PR Code Review" + REVIEW_CADENCE: "pull-request" + REVIEW_BASE: ${{ github.event.pull_request.base.sha }} + REVIEW_HEAD: ${{ github.event.pull_request.head.sha }} + REVIEW_GITHUB_COMMENT: "1" + REVIEW_EXPORT_NOTION: ${{ vars.REVIEW_EXPORT_NOTION || '0' }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} + NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bun run review -- --fail-on P1 + + - name: Upload review report + if: always() + uses: actions/upload-artifact@v4 + with: + name: automated-code-review + path: review-output/ diff --git a/.github/workflows/scheduled-study-report.yml b/.github/workflows/scheduled-study-report.yml new file mode 100644 index 0000000..3ece2c1 --- /dev/null +++ b/.github/workflows/scheduled-study-report.yml @@ -0,0 +1,46 @@ +name: Scheduled Study Review Report + +on: + schedule: + - cron: "0 23 * * *" + - cron: "30 23 * * 4" + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Publish scheduled review report + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_REVIEW_MODEL: ${{ vars.OPENAI_REVIEW_MODEL || 'gpt-5.5' }} + REVIEW_REPORT_TITLE: "Scheduled Study Code Review" + REVIEW_CADENCE: ${{ github.event.schedule == '30 23 * * 4' && 'weekly-friday' || 'daily-morning' }} + REVIEW_BASE: "HEAD~1" + REVIEW_HEAD: "HEAD" + REVIEW_EXPORT_NOTION: "1" + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} + NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }} + run: bun run review + + - name: Upload scheduled report + if: always() + uses: actions/upload-artifact@v4 + with: + name: scheduled-study-review-report + path: review-output/ diff --git a/.gitignore b/.gitignore index 9062b7a..300d093 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/review-output # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md index 410036d..ebe751f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,14 @@ bugs, regressions, security issues, auth problems, CORS problems, token exposure missing validation, missing tests, broken API contracts, performance risks, and maintainability problems. +## Primary Review Criteria + +Use these criteria as the first pass for every review: + +1. Meaningful variable and function naming +2. Readability improvements through early returns when they reduce nesting +3. Runtime performance and complexity risks + ## Severity - P0: outage, data loss, critical security issue @@ -23,4 +31,4 @@ and maintainability problems. - Separate existing failures from newly introduced failures. - Prefer concrete, fixable findings. - Include impact, suggested fix, and verification step. -- Do not report generic style preferences. \ No newline at end of file +- Do not report generic style preferences. diff --git a/docs/automated-code-review.md b/docs/automated-code-review.md new file mode 100644 index 0000000..17529f0 --- /dev/null +++ b/docs/automated-code-review.md @@ -0,0 +1,60 @@ +# Automated Code Review Pipeline + +This repository uses an LLM-backed review harness to review pull request diffs +and publish structured feedback. + +## Flow + +1. A pull request is opened, synchronized, reopened, or marked ready for review. +2. GitHub Actions checks out the repository and installs dependencies with Bun. +3. `bun run review` collects the base/head diff and changed files. +4. The harness runs local verification checks such as lint, typecheck, test, and build. +5. `AGENTS.md` and `review-agents/*.md` are sent with the diff to the OpenAI Responses API. +6. Findings are normalized into JSON and Markdown reports. +7. The workflow creates or updates a single automated PR comment. +8. Scheduled runs can publish the same report format to Notion. + +## Review Criteria + +The primary review criteria are: + +1. Meaningful variable and function naming +2. Early returns where they improve readability and reduce nesting +3. Runtime performance and complexity risks + +The harness also reviews bugs, regressions, security issues, auth/CORS risks, +token exposure, missing validation, missing tests, broken API contracts, and +maintainability risks. + +## Required Secrets + +- `OPENAI_API_KEY`: required for LLM review. +- `NOTION_TOKEN`: required only when Notion publishing is enabled. +- `NOTION_DATABASE_ID`: required only when Notion publishing is enabled. + +## Optional Variables + +- `OPENAI_REVIEW_MODEL`: defaults to `gpt-5.5`. +- `REVIEW_EXPORT_NOTION`: set to `1` to publish scheduled or PR reports to Notion. +- `NOTION_TITLE_PROPERTY`: defaults to `Name`. + +## Workflows + +- `.github/workflows/automated-code-review.yml` runs on pull requests and posts + a review comment. +- `.github/workflows/scheduled-study-report.yml` runs every morning and once + again on Friday morning for a weekly study report cadence. + +## Local Verification + +Run the harness without calling OpenAI: + +```bash +bun run review -- --skip-checks --skip-agents --base HEAD~1 --head HEAD +``` + +Run the full review locally after setting `OPENAI_API_KEY`: + +```bash +bun run review -- --base HEAD~1 --head HEAD +``` diff --git a/package.json b/package.json index 5903aec..dd226a5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "bun next build", "start": "bun ext start", "lint": "bun biome check --apply .", + "review": "bun scripts/review-harness.ts", "test": "bun test ./src/**/*.test.ts", "format": "biome format --write", "prepare": "husky" diff --git a/review-agents/architecture-reviewer.md b/review-agents/architecture-reviewer.md index e69de29..3a9dc80 100644 --- a/review-agents/architecture-reviewer.md +++ b/review-agents/architecture-reviewer.md @@ -0,0 +1,13 @@ +You are the architecture and maintainability reviewer. + +Focus only on: +- code structure and responsibility boundaries +- duplicated logic introduced by the change +- hard-to-maintain control flow +- unclear variable or function naming that can cause misunderstanding +- early return opportunities that reduce nested branches and improve readability +- broken API contracts between modules + +Return only concrete, actionable findings. +Do not report broad style preferences. +Use P0/P1/P2/P3 severity. diff --git a/review-agents/bug-reviewer.md b/review-agents/bug-reviewer.md index e69de29..ebdef18 100644 --- a/review-agents/bug-reviewer.md +++ b/review-agents/bug-reviewer.md @@ -0,0 +1,13 @@ +You are the bug reviewer. + +Focus only on: +- user-facing regressions +- runtime exceptions +- incorrect async or state handling +- broken edge cases +- invalid assumptions about nullable or optional data +- behavior that differs from the apparent product intent + +Return only concrete, actionable findings. +Separate existing failures from newly introduced failures. +Use P0/P1/P2/P3 severity. diff --git a/review-agents/performance-reviewer.md b/review-agents/performance-reviewer.md index e69de29..df033da 100644 --- a/review-agents/performance-reviewer.md +++ b/review-agents/performance-reviewer.md @@ -0,0 +1,12 @@ +You are the performance reviewer. + +Focus only on: +- unnecessary repeated network calls +- expensive re-renders or unstable React dependencies +- inefficient loops or data transformations +- avoidable bundle or runtime cost +- complexity risks introduced by the change + +Return only concrete, actionable findings. +Do not report micro-optimizations without user-visible impact. +Use P0/P1/P2/P3 severity. diff --git a/review-agents/reporter.md b/review-agents/reporter.md index e69de29..bc891d7 100644 --- a/review-agents/reporter.md +++ b/review-agents/reporter.md @@ -0,0 +1,10 @@ +You are the report quality reviewer. + +Focus only on: +- whether findings include file, line, impact, suggested fix, and verification +- whether existing failures are clearly separated from new failures +- whether the final report is understandable enough to publish to a study log +- whether high-severity findings are prioritized correctly + +Return only concrete, actionable findings about the review report or automation output. +Use P0/P1/P2/P3 severity. diff --git a/scripts/review-harness.ts b/scripts/review-harness.ts index c32e158..4a45137 100644 --- a/scripts/review-harness.ts +++ b/scripts/review-harness.ts @@ -1,832 +1,1121 @@ -// import { execFile, spawn } from "node:child_process"; -// import { existsSync } from "node:fs"; -// import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; -// import path from "node:path"; -// import { fileURLToPath } from "node:url"; - -// type Severity = "P0" | "P1" | "P2" | "P3"; - -// type Finding = { -// severity: Severity; -// title: string; -// file?: string; -// line?: number; -// impact: string; -// suggestedFix: string; -// verification: string; -// source: string; -// existingFailure?: boolean; -// }; - -// type CheckResult = { -// name: string; -// command?: string; -// exitCode?: number; -// durationMs?: number; -// stdout?: string; -// stderr?: string; -// skipped?: boolean; -// reason?: string; -// }; - -// type AgentPrompt = { -// name: string; -// prompt: string; -// filePath: string; -// }; - -// type ReviewReport = { -// generatedAt: string; -// repoRoot: string; -// base: string; -// head: string; -// changedFiles: string[]; -// checks: CheckResult[]; -// findings: Finding[]; -// warnings: string[]; -// }; - -// type CliOptions = { -// base?: string; -// head?: string; -// agentCommand?: string; -// failOn?: Severity; -// jsonOut: string; -// markdownOut: string; -// skipChecks: boolean; -// skipAgents: boolean; -// notion: boolean; -// githubComment: boolean; -// prNumber?: string; -// maxDiffBytes: number; -// }; - -// const __filename = fileURLToPath(import.meta.url); -// const __dirname = path.dirname(__filename); -// const repoRoot = path.resolve(__dirname, ".."); -// const severityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 }; - -// async function main() { -// const options = parseArgs(process.argv.slice(2)); -// const warnings: string[] = []; - -// // 1. Read AGENTS.md. -// const agentsInstructions = await readOptionalFile(path.join(repoRoot, "AGENTS.md")); -// if (!agentsInstructions) { -// warnings.push("AGENTS.md was not found or was empty."); -// } - -// // 2. Read sub-agent prompts. -// const agentPrompts = await readAgentPrompts(path.join(repoRoot, "review-agents"), warnings); - -// // 3. Resolve base/head commits. -// const refs = await computeRefs(options); - -// // 4. Collect git diff. -// const diff = await collectDiff(refs.base, refs.head, options.maxDiffBytes, warnings); - -// // 5. Collect changed files. -// const changedFiles = await collectChangedFiles(refs.base, refs.head); - -// // 6. Run lint/typecheck/test/build. -// const checks = options.skipChecks ? [] : await runChecks(warnings); - -// // 7. Run each sub-agent review. -// const agentFindings = options.skipAgents -// ? [] -// : await runReviewAgents({ -// agentPrompts, -// agentCommand: options.agentCommand, -// agentsInstructions, -// base: refs.base, -// changedFiles, -// checks, -// diff, -// head: refs.head, -// warnings, -// }); - -// const findingsFromChecks = checks.flatMap(checkResultToFinding); - -// // 8. Merge duplicate findings. -// // 9. Sort by severity. -// const findings = mergeAndSortFindings([...findingsFromChecks, ...agentFindings]); - -// const report: ReviewReport = { -// generatedAt: new Date().toISOString(), -// repoRoot, -// base: refs.base, -// head: refs.head, -// changedFiles, -// checks, -// findings, -// warnings, -// }; - -// await mkdir(path.dirname(options.jsonOut), { recursive: true }); -// await mkdir(path.dirname(options.markdownOut), { recursive: true }); -// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8"); -// await writeFile(options.markdownOut, renderMarkdown(report), "utf8"); - -// // 10. Export to Notion. -// if (options.notion) { -// await exportToNotion(report, warnings); -// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8"); -// await writeFile(options.markdownOut, renderMarkdown(report), "utf8"); -// } - -// // 11. Write a GitHub comment when this is a PR. -// if (options.githubComment) { -// await commentOnGitHubPr(report, options.prNumber, warnings); -// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8"); -// await writeFile(options.markdownOut, renderMarkdown(report), "utf8"); -// } - -// printSummary(report, options); - -// // 12. Optionally fail on P0/P1 or another configured threshold. -// if (options.failOn && findings.some((finding) => severityRank[finding.severity] <= severityRank[options.failOn!])) { -// process.exitCode = 1; -// } -// } - -// function parseArgs(args: string[]): CliOptions { -// const options: CliOptions = { -// base: process.env.REVIEW_BASE, -// head: process.env.REVIEW_HEAD, -// agentCommand: process.env.REVIEW_AGENT_CMD, -// failOn: parseSeverity(process.env.REVIEW_FAIL_ON), -// jsonOut: path.resolve(repoRoot, process.env.REVIEW_JSON_OUT ?? "review-output/report.json"), -// markdownOut: path.resolve(repoRoot, process.env.REVIEW_MARKDOWN_OUT ?? "review-output/report.md"), -// skipChecks: process.env.REVIEW_SKIP_CHECKS === "1", -// skipAgents: process.env.REVIEW_SKIP_AGENTS === "1", -// notion: process.env.REVIEW_EXPORT_NOTION === "1", -// githubComment: process.env.REVIEW_GITHUB_COMMENT === "1", -// prNumber: process.env.PR_NUMBER, -// maxDiffBytes: Number(process.env.REVIEW_MAX_DIFF_BYTES ?? 180_000), -// }; - -// for (let index = 0; index < args.length; index += 1) { -// const arg = args[index]; -// const next = args[index + 1]; - -// if (arg === "--base" && next) { -// options.base = next; -// index += 1; -// } else if (arg === "--head" && next) { -// options.head = next; -// index += 1; -// } else if (arg === "--agent-cmd" && next) { -// options.agentCommand = next; -// index += 1; -// } else if (arg === "--fail-on" && next) { -// options.failOn = parseSeverity(next); -// index += 1; -// } else if (arg === "--json-out" && next) { -// options.jsonOut = path.resolve(repoRoot, next); -// index += 1; -// } else if (arg === "--markdown-out" && next) { -// options.markdownOut = path.resolve(repoRoot, next); -// index += 1; -// } else if (arg === "--max-diff-bytes" && next) { -// options.maxDiffBytes = Number(next); -// index += 1; -// } else if (arg === "--pr" && next) { -// options.prNumber = next; -// index += 1; -// } else if (arg === "--skip-checks") { -// options.skipChecks = true; -// } else if (arg === "--skip-agents") { -// options.skipAgents = true; -// } else if (arg === "--notion") { -// options.notion = true; -// } else if (arg === "--github-comment") { -// options.githubComment = true; -// } -// } - -// return options; -// } - -// async function computeRefs(options: CliOptions) { -// const head = options.head ?? (await git(["rev-parse", "HEAD"])).stdout.trim(); -// const base = options.base ?? (await detectBaseRef(head)); - -// return { base, head }; -// } - -// async function detectBaseRef(head: string) { -// const candidates = [ -// process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : undefined, -// process.env.GITHUB_BASE_REF, -// "origin/main", -// "origin/master", -// "main", -// "master", -// `${head}~1`, -// ].filter(Boolean) as string[]; - -// for (const candidate of candidates) { -// const mergeBase = await git(["merge-base", head, candidate], { allowFailure: true }); -// if (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) { -// return mergeBase.stdout.trim(); -// } -// } - -// throw new Error("Could not detect a base ref. Pass --base or set REVIEW_BASE."); -// } - -// async function collectDiff(base: string, head: string, maxBytes: number, warnings: string[]) { -// const diff = (await git(["diff", "--find-renames", "--find-copies", "--unified=80", `${base}...${head}`])).stdout; - -// if (Buffer.byteLength(diff, "utf8") <= maxBytes) { -// return diff; -// } - -// warnings.push(`Diff was truncated to ${maxBytes} bytes for agent prompts.`); -// return `${Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8")}\n\n[diff truncated]\n`; -// } - -// async function collectChangedFiles(base: string, head: string) { -// const output = (await git(["diff", "--name-only", `${base}...${head}`])).stdout.trim(); -// return output ? output.split(/\r?\n/).filter(Boolean) : []; -// } - -// async function readAgentPrompts(agentDir: string, warnings: string[]) { -// if (!existsSync(agentDir)) { -// warnings.push("review-agents directory was not found."); -// return []; -// } - -// const entries = await readdir(agentDir, { withFileTypes: true }); -// const prompts: AgentPrompt[] = []; - -// for (const entry of entries) { -// if (!entry.isFile() || !entry.name.endsWith(".md")) { -// continue; -// } - -// const filePath = path.join(agentDir, entry.name); -// const prompt = (await readOptionalFile(filePath)).trim(); -// if (!prompt) { -// warnings.push(`${entry.name} is empty and was skipped.`); -// continue; -// } - -// prompts.push({ -// name: entry.name.replace(/\.md$/u, ""), -// prompt, -// filePath, -// }); -// } - -// return prompts; -// } - -// async function runChecks(warnings: string[]) { -// const packageJson = await readPackageJson(); -// const packageManager = detectPackageManager(); -// const scripts = packageJson?.scripts ?? {}; -// const requestedChecks = (process.env.REVIEW_CHECKS ?? "lint,typecheck,test,build") -// .split(",") -// .map((check) => check.trim()) -// .filter(Boolean); - -// const results: CheckResult[] = []; - -// for (const check of requestedChecks) { -// const command = resolveCheckCommand(check, scripts, packageManager); -// if (!command) { -// results.push({ -// name: check, -// skipped: true, -// reason: `No ${check} script or safe fallback was found.`, -// }); -// continue; -// } - -// const startedAt = Date.now(); -// const result = await runShellCommand(command, { allowFailure: true }); -// const checkResult: CheckResult = { -// name: check, -// command, -// exitCode: result.exitCode, -// durationMs: Date.now() - startedAt, -// stdout: trimOutput(result.stdout), -// stderr: trimOutput(result.stderr), -// }; -// results.push(checkResult); - -// if (result.exitCode !== 0) { -// warnings.push(`${check} failed with exit code ${result.exitCode}.`); -// } -// } - -// return results; -// } - -// function resolveCheckCommand(check: string, scripts: Record, packageManager: string) { -// if (check === "lint" && scripts.lint && /\bbiome\b/u.test(scripts.lint) && /--(?:apply|write)\b/u.test(scripts.lint)) { -// return `${packageManager} biome check .`; -// } - -// if (scripts[check]) { -// return `${packageManager} run ${check}`; -// } - -// if (check === "typecheck" && existsSync(path.join(repoRoot, "tsconfig.json"))) { -// const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc"); -// if (existsSync(localTsc)) { -// return `${quote(process.execPath)} ${quote(localTsc)} --noEmit --pretty false`; -// } -// } - -// return undefined; -// } - -// async function runReviewAgents(input: { -// agentPrompts: AgentPrompt[]; -// agentCommand?: string; -// agentsInstructions: string; -// base: string; -// head: string; -// changedFiles: string[]; -// checks: CheckResult[]; -// diff: string; -// warnings: string[]; -// }) { -// if (!input.agentCommand) { -// input.warnings.push("No REVIEW_AGENT_CMD or --agent-cmd was provided, so sub-agent review was skipped."); -// return []; -// } - -// const findings: Finding[] = []; - -// for (const agentPrompt of input.agentPrompts) { -// const prompt = buildAgentPrompt({ -// agentPrompt, -// agentsInstructions: input.agentsInstructions, -// base: input.base, -// changedFiles: input.changedFiles, -// checks: input.checks, -// diff: input.diff, -// head: input.head, -// }); - -// const result = await runShellCommand(input.agentCommand, { -// allowFailure: true, -// stdin: prompt, -// timeoutMs: Number(process.env.REVIEW_AGENT_TIMEOUT_MS ?? 300_000), -// }); - -// if (result.exitCode !== 0) { -// input.warnings.push(`${agentPrompt.name} exited with code ${result.exitCode}.`); -// } - -// const parsed = parseAgentFindings(result.stdout || result.stderr, agentPrompt.name, input.warnings); -// findings.push(...parsed); -// } - -// return findings; -// } - -// function buildAgentPrompt(input: { -// agentPrompt: AgentPrompt; -// agentsInstructions: string; -// base: string; -// head: string; -// changedFiles: string[]; -// checks: CheckResult[]; -// diff: string; -// }) { -// return [ -// input.agentsInstructions, -// "", -// `# Sub-agent role: ${input.agentPrompt.name}`, -// input.agentPrompt.prompt, -// "", -// "# Required output", -// "Return only JSON in this shape:", -// JSON.stringify( -// { -// findings: [ -// { -// severity: "P1", -// title: "Short actionable title", -// file: "src/example.ts", -// line: 10, -// impact: "What breaks or what risk is introduced", -// suggestedFix: "Concrete fix", -// verification: "How to verify the fix", -// existingFailure: false, -// }, -// ], -// }, -// null, -// 2, -// ), -// "Use an empty findings array if there are no actionable findings.", -// "", -// "# Review context", -// `Base: ${input.base}`, -// `Head: ${input.head}`, -// "", -// "# Changed files", -// input.changedFiles.join("\n") || "(none)", -// "", -// "# Check results", -// JSON.stringify(input.checks, null, 2), -// "", -// "# Diff", -// input.diff || "(empty diff)", -// ].join("\n"); -// } - -// function parseAgentFindings(rawOutput: string, source: string, warnings: string[]) { -// const jsonText = extractJson(rawOutput); -// if (!jsonText) { -// warnings.push(`${source} did not return parseable JSON.`); -// return []; -// } - -// try { -// const parsed = JSON.parse(jsonText) as { findings?: Partial[] }; -// if (!Array.isArray(parsed.findings)) { -// warnings.push(`${source} JSON did not include a findings array.`); -// return []; -// } - -// return parsed.findings -// .map((finding) => normalizeFinding(finding, source)) -// .filter((finding): finding is Finding => Boolean(finding)); -// } catch (error) { -// warnings.push(`${source} JSON parse failed: ${error instanceof Error ? error.message : String(error)}`); -// return []; -// } -// } - -// function normalizeFinding(finding: Partial, source: string): Finding | undefined { -// const severity = parseSeverity(finding.severity); -// if (!severity || !finding.title || !finding.impact || !finding.suggestedFix || !finding.verification) { -// return undefined; -// } - -// const line = Number(finding.line); - -// return { -// severity, -// title: finding.title, -// file: finding.file, -// line: Number.isFinite(line) ? line : undefined, -// impact: finding.impact, -// suggestedFix: finding.suggestedFix, -// verification: finding.verification, -// source, -// existingFailure: Boolean(finding.existingFailure), -// }; -// } - -// function extractJson(rawOutput: string) { -// const fenced = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/u); -// if (fenced?.[1]) { -// return fenced[1].trim(); -// } - -// const start = rawOutput.indexOf("{"); -// const end = rawOutput.lastIndexOf("}"); -// if (start >= 0 && end > start) { -// return rawOutput.slice(start, end + 1).trim(); -// } - -// return undefined; -// } - -// function checkResultToFinding(check: CheckResult): Finding[] { -// if (check.skipped || check.exitCode === undefined || check.exitCode === 0) { -// return []; -// } - -// const severity: Severity = check.name === "build" || check.name === "typecheck" || check.name === "test" ? "P1" : "P2"; -// const output = [check.stderr, check.stdout].filter(Boolean).join("\n").trim(); - -// return [ -// { -// severity, -// title: `${check.name} failed`, -// file: "package.json", -// impact: `The ${check.name} check exits with code ${check.exitCode}, so the change is not currently passing CI-equivalent verification.`, -// suggestedFix: `Run \`${check.command}\` locally, fix the reported failure, and rerun the harness.`, -// verification: `\`${check.command}\` exits with code 0.`, -// source: "harness", -// existingFailure: false, -// ...(output ? { suggestedFix: `Run \`${check.command}\` locally and fix the reported failure. First output:\n${trimOutput(output, 1_000)}` } : {}), -// }, -// ]; -// } - -// function mergeAndSortFindings(findings: Finding[]) { -// const merged = new Map(); - -// for (const finding of findings) { -// const key = [ -// finding.severity, -// finding.file ?? "", -// finding.line ?? "", -// normalizeText(finding.title), -// ].join("|"); - -// const existing = merged.get(key); -// if (!existing) { -// merged.set(key, finding); -// continue; -// } - -// existing.source = Array.from(new Set([...existing.source.split(", "), finding.source])).join(", "); -// } - -// return Array.from(merged.values()).sort((left, right) => { -// const severityDelta = severityRank[left.severity] - severityRank[right.severity]; -// if (severityDelta !== 0) { -// return severityDelta; -// } - -// return `${left.file ?? ""}:${left.line ?? 0}:${left.title}`.localeCompare( -// `${right.file ?? ""}:${right.line ?? 0}:${right.title}`, -// ); -// }); -// } - -// async function exportToNotion(report: ReviewReport, warnings: string[]) { -// const token = process.env.NOTION_TOKEN; -// const databaseId = process.env.NOTION_DATABASE_ID; -// const titleProperty = process.env.NOTION_TITLE_PROPERTY ?? "Name"; - -// if (!token || !databaseId) { -// warnings.push("Notion export skipped because NOTION_TOKEN or NOTION_DATABASE_ID is missing."); -// return; -// } - -// const markdown = renderMarkdown(report); -// const response = await fetch("https://api.notion.com/v1/pages", { -// method: "POST", -// headers: { -// Authorization: `Bearer ${token}`, -// "Content-Type": "application/json", -// "Notion-Version": "2022-06-28", -// }, -// body: JSON.stringify({ -// parent: { database_id: databaseId }, -// properties: { -// [titleProperty]: { -// title: [{ text: { content: `Code review ${shortSha(report.head)}` } }], -// }, -// }, -// children: chunkText(markdown, 1_900).slice(0, 90).map((chunk) => ({ -// object: "block", -// type: "paragraph", -// paragraph: { -// rich_text: [{ type: "text", text: { content: chunk } }], -// }, -// })), -// }), -// }); - -// if (!response.ok) { -// warnings.push(`Notion export failed: ${response.status} ${await response.text()}`); -// } -// } - -// async function commentOnGitHubPr(report: ReviewReport, prNumber: string | undefined, warnings: string[]) { -// const token = process.env.GITHUB_TOKEN; -// const repository = process.env.GITHUB_REPOSITORY; -// const pullRequestNumber = prNumber ?? (await detectGitHubPrNumber()); - -// if (!token || !repository || !pullRequestNumber) { -// warnings.push("GitHub PR comment skipped because GITHUB_TOKEN, GITHUB_REPOSITORY, or PR number is missing."); -// return; -// } - -// const response = await fetch(`https://api.github.com/repos/${repository}/issues/${pullRequestNumber}/comments`, { -// method: "POST", -// headers: { -// Authorization: `Bearer ${token}`, -// Accept: "application/vnd.github+json", -// "Content-Type": "application/json", -// "X-GitHub-Api-Version": "2022-11-28", -// }, -// body: JSON.stringify({ body: renderMarkdown(report).slice(0, 60_000) }), -// }); - -// if (!response.ok) { -// warnings.push(`GitHub comment failed: ${response.status} ${await response.text()}`); -// } -// } - -// async function detectGitHubPrNumber() { -// const eventPath = process.env.GITHUB_EVENT_PATH; -// if (!eventPath || !existsSync(eventPath)) { -// return undefined; -// } - -// const event = JSON.parse(await readFile(eventPath, "utf8")) as { pull_request?: { number?: number } }; -// return event.pull_request?.number ? String(event.pull_request.number) : undefined; -// } - -// function renderMarkdown(report: ReviewReport) { -// const lines = [ -// "# Automated Code Review", -// "", -// `- Generated: ${report.generatedAt}`, -// `- Base: \`${report.base}\``, -// `- Head: \`${report.head}\``, -// `- Changed files: ${report.changedFiles.length}`, -// `- Findings: ${report.findings.length}`, -// "", -// "## Checks", -// "", -// ...report.checks.map((check) => { -// if (check.skipped) { -// return `- ${check.name}: skipped (${check.reason})`; -// } - -// return `- ${check.name}: ${check.exitCode === 0 ? "passed" : `failed (${check.exitCode})`} - \`${check.command}\``; -// }), -// "", -// "## Findings", -// "", -// ]; - -// if (report.findings.length === 0) { -// lines.push("No actionable findings."); -// } else { -// for (const finding of report.findings) { -// const location = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ""}` : "No file"; -// lines.push( -// `### ${finding.severity} ${finding.title}`, -// "", -// `- Location: ${location}`, -// `- Source: ${finding.source}`, -// `- Existing failure: ${finding.existingFailure ? "yes" : "no"}`, -// `- Impact: ${finding.impact}`, -// `- Suggested fix: ${finding.suggestedFix}`, -// `- Verification: ${finding.verification}`, -// "", -// ); -// } -// } - -// if (report.warnings.length > 0) { -// lines.push("", "## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`)); -// } - -// return `${lines.join("\n")}\n`; -// } - -// function printSummary(report: ReviewReport, options: CliOptions) { -// console.log(renderMarkdown(report)); -// console.log(`JSON report: ${options.jsonOut}`); -// console.log(`Markdown report: ${options.markdownOut}`); -// } - -// async function readPackageJson() { -// const packagePath = path.join(repoRoot, "package.json"); -// if (!existsSync(packagePath)) { -// return undefined; -// } - -// return JSON.parse(await readFile(packagePath, "utf8")) as { scripts?: Record }; -// } - -// function detectPackageManager() { -// if (existsSync(path.join(repoRoot, "bun.lock"))) { -// return "bun"; -// } - -// if (existsSync(path.join(repoRoot, "pnpm-lock.yaml"))) { -// return "pnpm"; -// } - -// if (existsSync(path.join(repoRoot, "yarn.lock"))) { -// return "yarn"; -// } - -// return "npm"; -// } - -// async function readOptionalFile(filePath: string) { -// if (!existsSync(filePath)) { -// return ""; -// } - -// return readFile(filePath, "utf8"); -// } - -// async function git(args: string[], options: { allowFailure?: boolean } = {}) { -// return execFileCapture("git", ["-c", `safe.directory=${repoRoot.replaceAll("\\", "/")}`, ...args], options); -// } - -// async function execFileCapture(command: string, args: string[], options: { allowFailure?: boolean } = {}) { -// return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => { -// execFile(command, args, { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => { -// const exitCode = -// error && typeof error === "object" && "code" in error && typeof error.code === "number" ? error.code : 0; -// const result = { stdout, stderr, exitCode }; - -// if (error && !options.allowFailure) { -// reject(Object.assign(error, result)); -// return; -// } - -// resolve(result); -// }); -// }); -// } - -// async function runShellCommand( -// command: string, -// options: { allowFailure?: boolean; stdin?: string; timeoutMs?: number } = {}, -// ) { -// return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => { -// const child = spawn(command, { -// cwd: repoRoot, -// shell: true, -// stdio: ["pipe", "pipe", "pipe"], -// timeout: options.timeoutMs, -// }); - -// let stdout = ""; -// let stderr = ""; - -// child.stdout.on("data", (chunk: Buffer) => { -// stdout += chunk.toString("utf8"); -// }); - -// child.stderr.on("data", (chunk: Buffer) => { -// stderr += chunk.toString("utf8"); -// }); - -// child.on("error", (error) => { -// if (options.allowFailure) { -// resolve({ stdout, stderr: `${stderr}\n${error.message}`.trim(), exitCode: 1 }); -// return; -// } - -// reject(error); -// }); - -// child.on("close", (code) => { -// const exitCode = code ?? 1; -// const result = { stdout, stderr, exitCode }; - -// if (exitCode !== 0 && !options.allowFailure) { -// reject(Object.assign(new Error(`Command failed: ${command}`), result)); -// return; -// } - -// resolve(result); -// }); - -// if (options.stdin) { -// child.stdin.write(options.stdin); -// } -// child.stdin.end(); -// }); -// } - -// function parseSeverity(value: unknown): Severity | undefined { -// return value === "P0" || value === "P1" || value === "P2" || value === "P3" ? value : undefined; -// } - -// function normalizeText(value: string) { -// return value.toLowerCase().replace(/\s+/gu, " ").trim(); -// } - -// function quote(value: string) { -// return `"${value.replace(/"/gu, '\\"')}"`; -// } - -// function shortSha(value: string) { -// return value.slice(0, 7); -// } - -// function chunkText(value: string, chunkSize: number) { -// const chunks: string[] = []; -// for (let index = 0; index < value.length; index += chunkSize) { -// chunks.push(value.slice(index, index + chunkSize)); -// } -// return chunks; -// } - -// function trimOutput(value = "", maxLength = 4_000) { -// const normalized = value.trim(); -// if (normalized.length <= maxLength) { -// return normalized; -// } - -// return `${normalized.slice(0, maxLength)}\n[output truncated]`; -// } - -// main().catch((error) => { -// console.error(error instanceof Error ? error.stack ?? error.message : error); -// process.exitCode = 1; -// }); +import { execFile, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type Severity = "P0" | "P1" | "P2" | "P3"; + +type Finding = { + severity: Severity; + title: string; + file?: string; + line?: number; + impact: string; + suggestedFix: string; + verification: string; + source: string; + existingFailure: boolean; +}; + +type CheckResult = { + name: string; + command?: string; + exitCode?: number; + durationMs?: number; + stdout?: string; + stderr?: string; + skipped?: boolean; + reason?: string; +}; + +type AgentPrompt = { + name: string; + prompt: string; + filePath: string; +}; + +type ReviewReport = { + title: string; + cadence: string; + generatedAt: string; + repoRoot: string; + base: string; + head: string; + changedFiles: string[]; + checks: CheckResult[]; + findings: Finding[]; + warnings: string[]; +}; + +type CliOptions = { + base?: string; + head?: string; + title: string; + cadence: string; + failOn?: Severity; + jsonOut: string; + markdownOut: string; + skipChecks: boolean; + skipAgents: boolean; + notion: boolean; + githubComment: boolean; + prNumber?: string; + maxDiffBytes: number; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const severityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 }; +const githubCommentMarker = ""; + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const warnings: string[] = []; + + const agentsInstructions = await readOptionalFile( + path.join(repoRoot, "AGENTS.md"), + ); + if (!agentsInstructions.trim()) { + warnings.push("AGENTS.md was not found or was empty."); + } + + const agentPrompts = await readAgentPrompts( + path.join(repoRoot, "review-agents"), + warnings, + ); + const refs = await computeRefs(options); + const diff = await collectDiff( + refs.base, + refs.head, + options.maxDiffBytes, + warnings, + ); + const changedFiles = await collectChangedFiles(refs.base, refs.head); + const checks = options.skipChecks ? [] : await runChecks(warnings); + const agentFindings = options.skipAgents + ? [] + : await runOpenAiReviewAgents({ + agentPrompts, + agentsInstructions, + base: refs.base, + changedFiles, + checks, + diff, + head: refs.head, + warnings, + }); + const findings = mergeAndSortFindings([ + ...checks.flatMap(checkResultToFinding), + ...agentFindings, + ]); + + const report: ReviewReport = { + title: options.title, + cadence: options.cadence, + generatedAt: new Date().toISOString(), + repoRoot, + base: refs.base, + head: refs.head, + changedFiles, + checks, + findings, + warnings, + }; + + await writeReport(report, options); + + if (options.notion) { + await exportToNotion(report, warnings); + await writeReport(report, options); + } + + if (options.githubComment) { + await commentOnGitHubPr(report, options.prNumber, warnings); + await writeReport(report, options); + } + + printSummary(report, options); + + const failOn = options.failOn; + if ( + failOn && + findings.some( + (finding) => severityRank[finding.severity] <= severityRank[failOn], + ) + ) { + process.exitCode = 1; + } +} + +function parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + base: process.env.REVIEW_BASE, + head: process.env.REVIEW_HEAD, + title: process.env.REVIEW_REPORT_TITLE ?? "Automated Code Review", + cadence: process.env.REVIEW_CADENCE ?? "pull-request", + failOn: parseSeverity(process.env.REVIEW_FAIL_ON), + jsonOut: path.resolve( + repoRoot, + process.env.REVIEW_JSON_OUT ?? "review-output/report.json", + ), + markdownOut: path.resolve( + repoRoot, + process.env.REVIEW_MARKDOWN_OUT ?? "review-output/report.md", + ), + skipChecks: process.env.REVIEW_SKIP_CHECKS === "1", + skipAgents: process.env.REVIEW_SKIP_AGENTS === "1", + notion: process.env.REVIEW_EXPORT_NOTION === "1", + githubComment: process.env.REVIEW_GITHUB_COMMENT === "1", + prNumber: process.env.PR_NUMBER, + maxDiffBytes: Number(process.env.REVIEW_MAX_DIFF_BYTES ?? 180_000), + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const next = args[index + 1]; + + if (arg === "--base" && next) { + options.base = next; + index += 1; + } else if (arg === "--head" && next) { + options.head = next; + index += 1; + } else if (arg === "--title" && next) { + options.title = next; + index += 1; + } else if (arg === "--cadence" && next) { + options.cadence = next; + index += 1; + } else if (arg === "--fail-on" && next) { + options.failOn = parseSeverity(next); + index += 1; + } else if (arg === "--json-out" && next) { + options.jsonOut = path.resolve(repoRoot, next); + index += 1; + } else if (arg === "--markdown-out" && next) { + options.markdownOut = path.resolve(repoRoot, next); + index += 1; + } else if (arg === "--max-diff-bytes" && next) { + options.maxDiffBytes = Number(next); + index += 1; + } else if (arg === "--pr" && next) { + options.prNumber = next; + index += 1; + } else if (arg === "--skip-checks") { + options.skipChecks = true; + } else if (arg === "--skip-agents") { + options.skipAgents = true; + } else if (arg === "--notion") { + options.notion = true; + } else if (arg === "--github-comment") { + options.githubComment = true; + } + } + + return options; +} + +async function computeRefs(options: CliOptions) { + const head = options.head ?? (await git(["rev-parse", "HEAD"])).stdout.trim(); + const base = options.base ?? (await detectBaseRef(head)); + + return { base, head }; +} + +async function detectBaseRef(head: string) { + const candidates = [ + process.env.GITHUB_BASE_REF + ? `origin/${process.env.GITHUB_BASE_REF}` + : undefined, + process.env.GITHUB_BASE_REF, + "origin/main", + "origin/master", + "main", + "master", + `${head}~1`, + ].filter(Boolean) as string[]; + + for (const candidate of candidates) { + const mergeBase = await git(["merge-base", head, candidate], { + allowFailure: true, + }); + if (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) { + return mergeBase.stdout.trim(); + } + } + + throw new Error( + "Could not detect a base ref. Pass --base or set REVIEW_BASE.", + ); +} + +async function collectDiff( + base: string, + head: string, + maxBytes: number, + warnings: string[], +) { + const diff = ( + await git([ + "diff", + "--find-renames", + "--find-copies", + "--unified=80", + `${base}...${head}`, + ]) + ).stdout; + + if (Buffer.byteLength(diff, "utf8") <= maxBytes) { + return diff; + } + + warnings.push( + `Diff was truncated to ${maxBytes} bytes for the model prompt.`, + ); + return `${Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8")}\n\n[diff truncated]\n`; +} + +async function collectChangedFiles(base: string, head: string) { + const output = ( + await git(["diff", "--name-only", `${base}...${head}`]) + ).stdout.trim(); + return output ? output.split(/\r?\n/u).filter(Boolean) : []; +} + +async function readAgentPrompts(agentDir: string, warnings: string[]) { + if (!existsSync(agentDir)) { + warnings.push("review-agents directory was not found."); + return []; + } + + const entries = await readdir(agentDir, { withFileTypes: true }); + const prompts: AgentPrompt[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) { + continue; + } + + const filePath = path.join(agentDir, entry.name); + const prompt = (await readOptionalFile(filePath)).trim(); + if (!prompt) { + warnings.push(`${entry.name} is empty and was skipped.`); + continue; + } + + prompts.push({ + name: entry.name.replace(/\.md$/u, ""), + prompt, + filePath, + }); + } + + return prompts; +} + +async function runChecks(warnings: string[]) { + const packageJson = await readPackageJson(); + const packageManager = detectPackageManager(); + const scripts = packageJson?.scripts ?? {}; + const requestedChecks = ( + process.env.REVIEW_CHECKS ?? "lint,typecheck,test,build" + ) + .split(",") + .map((check) => check.trim()) + .filter(Boolean); + const results: CheckResult[] = []; + + for (const check of requestedChecks) { + const command = resolveCheckCommand(check, scripts, packageManager); + if (!command) { + results.push({ + name: check, + skipped: true, + reason: `No ${check} script or safe fallback was found.`, + }); + continue; + } + + const startedAt = Date.now(); + const result = await runShellCommand(command, { allowFailure: true }); + results.push({ + name: check, + command, + exitCode: result.exitCode, + durationMs: Date.now() - startedAt, + stdout: trimOutput(result.stdout), + stderr: trimOutput(result.stderr), + }); + + if (result.exitCode !== 0) { + warnings.push(`${check} failed with exit code ${result.exitCode}.`); + } + } + + return results; +} + +function resolveCheckCommand( + check: string, + scripts: Record, + packageManager: string, +) { + if ( + check === "lint" && + scripts.lint && + /\bbiome\b/u.test(scripts.lint) && + /--(?:apply|write)\b/u.test(scripts.lint) + ) { + return `${packageManager} biome check .`; + } + + if (scripts[check]) { + return `${packageManager} run ${check}`; + } + + if ( + check === "typecheck" && + existsSync(path.join(repoRoot, "tsconfig.json")) + ) { + const localTsc = path.join( + repoRoot, + "node_modules", + "typescript", + "bin", + "tsc", + ); + if (existsSync(localTsc)) { + return `${quote(process.execPath)} ${quote(localTsc)} --noEmit --pretty false`; + } + } + + return undefined; +} + +async function runOpenAiReviewAgents(input: { + agentPrompts: AgentPrompt[]; + agentsInstructions: string; + base: string; + head: string; + changedFiles: string[]; + checks: CheckResult[]; + diff: string; + warnings: string[]; +}) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + input.warnings.push( + "OpenAI review skipped because OPENAI_API_KEY is missing.", + ); + return []; + } + + if (input.agentPrompts.length === 0) { + input.warnings.push( + "OpenAI review skipped because there are no non-empty review agent prompts.", + ); + return []; + } + + const findings: Finding[] = []; + + for (const agentPrompt of input.agentPrompts) { + const prompt = buildAgentPrompt({ + agentPrompt, + agentsInstructions: input.agentsInstructions, + base: input.base, + changedFiles: input.changedFiles, + checks: input.checks, + diff: input.diff, + head: input.head, + }); + + try { + const rawOutput = await createOpenAiReview(prompt); + findings.push( + ...parseAgentFindings(rawOutput, agentPrompt.name, input.warnings), + ); + } catch (error) { + input.warnings.push( + `${agentPrompt.name} OpenAI review failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return findings; +} + +async function createOpenAiReview(prompt: string) { + const model = process.env.OPENAI_REVIEW_MODEL ?? "gpt-5.5"; + const response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + instructions: + "You are a strict automated code review agent. Return only JSON that matches the requested shape. Do not include markdown fences.", + input: prompt, + text: { + format: { + type: "json_schema", + name: "code_review_findings", + strict: true, + schema: { + type: "object", + additionalProperties: false, + properties: { + findings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + severity: { + type: "string", + enum: ["P0", "P1", "P2", "P3"], + }, + title: { type: "string" }, + file: { type: "string" }, + line: { type: "integer" }, + impact: { type: "string" }, + suggestedFix: { type: "string" }, + verification: { type: "string" }, + existingFailure: { type: "boolean" }, + }, + required: [ + "severity", + "title", + "file", + "line", + "impact", + "suggestedFix", + "verification", + "existingFailure", + ], + }, + }, + }, + required: ["findings"], + }, + }, + }, + }), + }); + + const body = await response.text(); + if (!response.ok) { + throw new Error( + `OpenAI API returned ${response.status}: ${trimOutput(body, 1_000)}`, + ); + } + + return extractOpenAiText(JSON.parse(body)); +} + +function buildAgentPrompt(input: { + agentPrompt: AgentPrompt; + agentsInstructions: string; + base: string; + head: string; + changedFiles: string[]; + checks: CheckResult[]; + diff: string; +}) { + return [ + input.agentsInstructions, + "", + `# Sub-agent role: ${input.agentPrompt.name}`, + input.agentPrompt.prompt, + "", + "# Required output", + "Return only JSON in this shape:", + JSON.stringify( + { + findings: [ + { + severity: "P1", + title: "Short actionable title", + file: "src/example.ts", + line: 10, + impact: "What breaks or what risk is introduced", + suggestedFix: "Concrete fix", + verification: "How to verify the fix", + existingFailure: false, + }, + ], + }, + null, + 2, + ), + "Use an empty findings array if there are no actionable findings.", + "", + "# Review context", + `Base: ${input.base}`, + `Head: ${input.head}`, + "", + "# Changed files", + input.changedFiles.join("\n") || "(none)", + "", + "# Check results", + JSON.stringify(input.checks, null, 2), + "", + "# Diff", + input.diff || "(empty diff)", + ].join("\n"); +} + +function parseAgentFindings( + rawOutput: string, + source: string, + warnings: string[], +) { + const jsonText = extractJson(rawOutput); + if (!jsonText) { + warnings.push(`${source} did not return parseable JSON.`); + return []; + } + + try { + const parsed = JSON.parse(jsonText) as { findings?: Partial[] }; + if (!Array.isArray(parsed.findings)) { + warnings.push(`${source} JSON did not include a findings array.`); + return []; + } + + return parsed.findings + .map((finding) => normalizeFinding(finding, source)) + .filter((finding): finding is Finding => Boolean(finding)); + } catch (error) { + warnings.push( + `${source} JSON parse failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } +} + +function normalizeFinding( + finding: Partial, + source: string, +): Finding | undefined { + const severity = parseSeverity(finding.severity); + const line = Number(finding.line); + + if ( + !severity || + !finding.title || + !finding.impact || + !finding.suggestedFix || + !finding.verification + ) { + return undefined; + } + + return { + severity, + title: finding.title, + file: finding.file, + line: Number.isFinite(line) && line > 0 ? line : undefined, + impact: finding.impact, + suggestedFix: finding.suggestedFix, + verification: finding.verification, + source, + existingFailure: Boolean(finding.existingFailure), + }; +} + +function extractOpenAiText(response: unknown) { + const maybe = response as { + output_text?: string; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + + if (maybe.output_text) { + return maybe.output_text; + } + + const parts = + maybe.output?.flatMap( + (item) => + item.content + ?.filter((content) => content.type === "output_text" && content.text) + .map((content) => content.text ?? "") ?? [], + ) ?? []; + + return parts.join("\n"); +} + +function extractJson(rawOutput: string) { + const fenced = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/u); + if (fenced?.[1]) { + return fenced[1].trim(); + } + + const start = rawOutput.indexOf("{"); + const end = rawOutput.lastIndexOf("}"); + if (start >= 0 && end > start) { + return rawOutput.slice(start, end + 1).trim(); + } + + return undefined; +} + +function checkResultToFinding(check: CheckResult): Finding[] { + if (check.skipped || check.exitCode === undefined || check.exitCode === 0) { + return []; + } + + const severity: Severity = + check.name === "build" || + check.name === "typecheck" || + check.name === "test" + ? "P1" + : "P2"; + const output = [check.stderr, check.stdout].filter(Boolean).join("\n").trim(); + + return [ + { + severity, + title: `${check.name} failed`, + file: "package.json", + impact: `The ${check.name} check exits with code ${check.exitCode}, so the change is not currently passing CI-equivalent verification.`, + suggestedFix: output + ? `Run \`${check.command}\` locally and fix the reported failure. First output:\n${trimOutput(output, 1_000)}` + : `Run \`${check.command}\` locally, fix the reported failure, and rerun the harness.`, + verification: `\`${check.command}\` exits with code 0.`, + source: "harness", + existingFailure: false, + }, + ]; +} + +function mergeAndSortFindings(findings: Finding[]) { + const merged = new Map(); + + for (const finding of findings) { + const key = [ + finding.severity, + finding.file ?? "", + finding.line ?? "", + normalizeText(finding.title), + ].join("|"); + const existing = merged.get(key); + + if (!existing) { + merged.set(key, finding); + continue; + } + + existing.source = Array.from( + new Set([...existing.source.split(", "), finding.source]), + ).join(", "); + } + + return Array.from(merged.values()).sort((left, right) => { + const severityDelta = + severityRank[left.severity] - severityRank[right.severity]; + if (severityDelta !== 0) { + return severityDelta; + } + + return `${left.file ?? ""}:${left.line ?? 0}:${left.title}`.localeCompare( + `${right.file ?? ""}:${right.line ?? 0}:${right.title}`, + ); + }); +} + +async function exportToNotion(report: ReviewReport, warnings: string[]) { + const token = process.env.NOTION_TOKEN; + const databaseId = process.env.NOTION_DATABASE_ID; + const titleProperty = process.env.NOTION_TITLE_PROPERTY ?? "Name"; + + if (!token || !databaseId) { + warnings.push( + "Notion export skipped because NOTION_TOKEN or NOTION_DATABASE_ID is missing.", + ); + return; + } + + const markdown = renderMarkdown(report); + const response = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + }, + body: JSON.stringify({ + parent: { database_id: databaseId }, + properties: { + [titleProperty]: { + title: [ + { + text: { content: `${report.title} ${shortSha(report.head)}` }, + }, + ], + }, + }, + children: chunkText(markdown, 1_900) + .slice(0, 90) + .map((chunk) => ({ + object: "block", + type: "paragraph", + paragraph: { + rich_text: [{ type: "text", text: { content: chunk } }], + }, + })), + }), + }); + + if (!response.ok) { + warnings.push( + `Notion export failed: ${response.status} ${await response.text()}`, + ); + } +} + +async function commentOnGitHubPr( + report: ReviewReport, + prNumber: string | undefined, + warnings: string[], +) { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const pullRequestNumber = prNumber ?? (await detectGitHubPrNumber()); + + if (!token || !repository || !pullRequestNumber) { + warnings.push( + "GitHub PR comment skipped because GITHUB_TOKEN, GITHUB_REPOSITORY, or PR number is missing.", + ); + return; + } + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }; + const commentsUrl = `https://api.github.com/repos/${repository}/issues/${pullRequestNumber}/comments`; + const commentBody = renderGitHubComment(report).slice(0, 60_000); + const existingCommentId = await findExistingReviewComment( + commentsUrl, + headers, + ); + const response = existingCommentId + ? await fetch( + `https://api.github.com/repos/${repository}/issues/comments/${existingCommentId}`, + { + method: "PATCH", + headers, + body: JSON.stringify({ body: commentBody }), + }, + ) + : await fetch(commentsUrl, { + method: "POST", + headers, + body: JSON.stringify({ body: commentBody }), + }); + + if (!response.ok) { + warnings.push( + `GitHub comment failed: ${response.status} ${await response.text()}`, + ); + } +} + +async function findExistingReviewComment( + commentsUrl: string, + headers: Record, +) { + const response = await fetch(`${commentsUrl}?per_page=100`, { headers }); + if (!response.ok) { + return undefined; + } + + const comments = (await response.json()) as Array<{ + id?: number; + body?: string; + user?: { type?: string }; + }>; + const existing = comments.find( + (comment) => + comment.body?.includes(githubCommentMarker) && + comment.user?.type === "Bot", + ); + + return existing?.id; +} + +async function detectGitHubPrNumber() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !existsSync(eventPath)) { + return undefined; + } + + const event = JSON.parse(await readFile(eventPath, "utf8")) as { + pull_request?: { number?: number }; + }; + return event.pull_request?.number + ? String(event.pull_request.number) + : undefined; +} + +async function writeReport(report: ReviewReport, options: CliOptions) { + await mkdir(path.dirname(options.jsonOut), { recursive: true }); + await mkdir(path.dirname(options.markdownOut), { recursive: true }); + await writeFile( + options.jsonOut, + `${JSON.stringify(report, null, 2)}\n`, + "utf8", + ); + await writeFile(options.markdownOut, renderMarkdown(report), "utf8"); +} + +function renderMarkdown(report: ReviewReport) { + const lines = [ + `# ${report.title}`, + "", + `- Cadence: ${report.cadence}`, + `- Generated: ${report.generatedAt}`, + `- Base: \`${report.base}\``, + `- Head: \`${report.head}\``, + `- Changed files: ${report.changedFiles.length}`, + `- Findings: ${report.findings.length}`, + "", + "## Checks", + "", + ...report.checks.map((check) => { + if (check.skipped) { + return `- ${check.name}: skipped (${check.reason})`; + } + + return `- ${check.name}: ${check.exitCode === 0 ? "passed" : `failed (${check.exitCode})`} - \`${check.command}\``; + }), + "", + "## Findings", + "", + ]; + + if (report.findings.length === 0) { + lines.push("No actionable findings."); + } else { + for (const finding of report.findings) { + const location = finding.file + ? `${finding.file}${finding.line ? `:${finding.line}` : ""}` + : "No file"; + lines.push( + `### ${finding.severity} ${finding.title}`, + "", + `- Location: ${location}`, + `- Source: ${finding.source}`, + `- Existing failure: ${finding.existingFailure ? "yes" : "no"}`, + `- Impact: ${finding.impact}`, + `- Suggested fix: ${finding.suggestedFix}`, + `- Verification: ${finding.verification}`, + "", + ); + } + } + + if (report.warnings.length > 0) { + lines.push( + "", + "## Warnings", + "", + ...report.warnings.map((warning) => `- ${warning}`), + ); + } + + return `${lines.join("\n")}\n`; +} + +function renderGitHubComment(report: ReviewReport) { + return `${githubCommentMarker}\n${renderMarkdown(report)}`; +} + +function printSummary(report: ReviewReport, options: CliOptions) { + console.log(renderMarkdown(report)); + console.log(`JSON report: ${options.jsonOut}`); + console.log(`Markdown report: ${options.markdownOut}`); +} + +async function readPackageJson() { + const packagePath = path.join(repoRoot, "package.json"); + if (!existsSync(packagePath)) { + return undefined; + } + + return JSON.parse(await readFile(packagePath, "utf8")) as { + scripts?: Record; + }; +} + +function detectPackageManager() { + if (existsSync(path.join(repoRoot, "bun.lock"))) { + return "bun"; + } + + if (existsSync(path.join(repoRoot, "pnpm-lock.yaml"))) { + return "pnpm"; + } + + if (existsSync(path.join(repoRoot, "yarn.lock"))) { + return "yarn"; + } + + return "npm"; +} + +async function readOptionalFile(filePath: string) { + if (!existsSync(filePath)) { + return ""; + } + + return readFile(filePath, "utf8"); +} + +async function git(args: string[], options: { allowFailure?: boolean } = {}) { + return execFileCapture( + "git", + ["-c", `safe.directory=${repoRoot.replaceAll("\\", "/")}`, ...args], + options, + ); +} + +async function execFileCapture( + command: string, + args: string[], + options: { allowFailure?: boolean } = {}, +) { + return new Promise<{ stdout: string; stderr: string; exitCode: number }>( + (resolve, reject) => { + execFile( + command, + args, + { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 }, + (error, stdout, stderr) => { + const exitCode = + error && + typeof error === "object" && + "code" in error && + typeof error.code === "number" + ? error.code + : 0; + const result = { stdout, stderr, exitCode }; + + if (error && !options.allowFailure) { + reject(Object.assign(error, result)); + return; + } + + resolve(result); + }, + ); + }, + ); +} + +async function runShellCommand( + command: string, + options: { allowFailure?: boolean; stdin?: string; timeoutMs?: number } = {}, +) { + return new Promise<{ stdout: string; stderr: string; exitCode: number }>( + (resolve, reject) => { + const child = spawn(command, { + cwd: repoRoot, + shell: true, + stdio: ["pipe", "pipe", "pipe"], + timeout: options.timeoutMs, + }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + child.on("error", (error) => { + if (options.allowFailure) { + resolve({ + stdout, + stderr: `${stderr}\n${error.message}`.trim(), + exitCode: 1, + }); + return; + } + + reject(error); + }); + + child.on("close", (code) => { + const exitCode = code ?? 1; + const result = { stdout, stderr, exitCode }; + + if (exitCode !== 0 && !options.allowFailure) { + reject( + Object.assign(new Error(`Command failed: ${command}`), result), + ); + return; + } + + resolve(result); + }); + + if (options.stdin) { + child.stdin.write(options.stdin); + } + child.stdin.end(); + }, + ); +} + +function parseSeverity(value: unknown): Severity | undefined { + return value === "P0" || value === "P1" || value === "P2" || value === "P3" + ? value + : undefined; +} + +function normalizeText(value: string) { + return value.toLowerCase().replace(/\s+/gu, " ").trim(); +} + +function quote(value: string) { + return `"${value.replace(/"/gu, '\\"')}"`; +} + +function shortSha(value: string) { + return value.slice(0, 7); +} + +function chunkText(value: string, chunkSize: number) { + const chunks: string[] = []; + for (let index = 0; index < value.length; index += chunkSize) { + chunks.push(value.slice(index, index + chunkSize)); + } + return chunks; +} + +function trimOutput(value = "", maxLength = 4_000) { + const normalized = value.trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength)}\n[output truncated]`; +} + +main().catch((error) => { + console.error( + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; +});