diff --git a/.changeset/remove-review-cli.md b/.changeset/remove-review-cli.md new file mode 100644 index 0000000..6b78b27 --- /dev/null +++ b/.changeset/remove-review-cli.md @@ -0,0 +1,6 @@ +--- +"baton": major +--- + +Remove the dedicated `scripts/review/` CLI, its docs, and its tests. The Copilot workflow +example remains available in `examples/WORKFLOW.copilot.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0697600..68cbb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --foreground-scripts - run: npm run lint typecheck: @@ -35,7 +35,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --foreground-scripts - run: npm run typecheck test: @@ -51,5 +51,5 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --foreground-scripts - run: npm run test diff --git a/README.md b/README.md index 7165ab1..a236676 100644 --- a/README.md +++ b/README.md @@ -252,58 +252,6 @@ npm run triage [TRIAGE.md] Running every 30 minutes keeps the `ai-ready` queue populated without human intervention — pair it with a Baton instance polling the same board. -## Review (agent PR review) - -The `scripts/review/` companion CLI automates the `Agent Review` loop for Baton-managed pull -requests. It loads `REVIEW.md`, checks the configured GitHub Projects v2 board for issues in the -review state, and runs the review agent against the current PR head inside an isolated workspace. - -**Minimal `REVIEW.md` config** - -```yaml -tracker: - token: $GITHUB_TOKEN - owner: my-org - owner_type: organization - project_number: 5 - status_field: Status - active_states: [Agent Review] - -workspace: - root: ~/baton_review_workspaces - -hooks: - after_create: | - gh repo clone "$BATON_ISSUE_REPO" . -- --depth 50 --no-single-branch - before_run: | - git fetch origin - BRANCH="agent/$BATON_ISSUE_IDENTIFIER" - git switch -C "$BRANCH" --track "origin/$BRANCH" - -agent: - kind: copilot # or: claude_code -``` - -See [`examples/REVIEW.md`](examples/REVIEW.md) for the full reference config with inline -comments, the review prompt template, and the branch-reset behavior used to review the pushed PR -head. - -**Run once** - -```sh -npm run review [REVIEW.md] -# or: npx tsx scripts/review/index.ts [REVIEW.md] -``` - -**Run on a schedule (cron)** - -```sh -*/5 * * * * cd /path/to/repo && npx tsx scripts/review/index.ts path/to/REVIEW.md -``` - -Running every few minutes keeps the review queue moving without manual intervention — pair it with -a Baton implementation workflow that opens and updates the PR. - ## How it works (one paragraph) Every `polling.interval_ms`, Baton queries the configured Project board for issues whose Status is diff --git a/examples/REVIEW.md b/examples/REVIEW.md deleted file mode 100644 index 2186d99..0000000 --- a/examples/REVIEW.md +++ /dev/null @@ -1,224 +0,0 @@ ---- -# Usage: node --experimental-strip-types scripts/review/index.ts [path/to/REVIEW.md] -# Cron example (every 5 minutes): -# */5 * * * * cd /path/to/repo && node --experimental-strip-types scripts/review/index.ts path/to/REVIEW.md >> /var/log/review.log 2>&1 -# -# The YAML body after the closing --- is the LiquidJS prompt template. -# Available template variables: `issue` (Issue object). - -tracker: - # GitHub authentication token. - # Use $GITHUB_TOKEN (recommended) or a literal PAT with repo + project scopes. - token: $GITHUB_TOKEN - - # GitHub user or organization that owns the project board. - owner: my-org - - # "organization" or "user" — must match the owner type. - owner_type: organization - - # GitHub Projects v2 project number (visible in the project URL: /projects/). - project_number: 5 - - # Name of the single-select Status field on the project board. - status_field: Status - - # Status option whose issues should be reviewed in this pass. - active_states: [Agent Review] - - # Optional: restrict review to specific repos. - # repos: - # - my-org/api - -workspace: - # Use a separate root from the implementation workflow (examples/WORKFLOW.md) so the two - # Baton processes never share a working tree. The reviewer always re-syncs to origin below, - # so it reviews the published PR head rather than the implementer's local state. - root: ~/baton_review_workspaces - - # Optional: override the hook timeout in milliseconds. - # hook_timeout_ms: 120000 - -hooks: - after_create: | - gh repo clone "$BATON_ISSUE_REPO" . -- --depth 50 --no-single-branch - before_run: | - git fetch origin - BRANCH="agent/$BATON_ISSUE_IDENTIFIER" - # Review-only: never push, so always hard-reset to the origin head. This avoids reviewing a - # stale local branch when re-reviewing after the implementer pushed new commits. - if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH" && \ - gh pr list --repo "$BATON_ISSUE_REPO" --head "$BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then - git switch -C "$BRANCH" --track "origin/$BRANCH" - else - git switch --detach origin/HEAD - fi - -agent: - # "claude_code" (uses the `claude` CLI) or "copilot" (uses the `copilot` CLI). - kind: copilot - - # Optional overrides: - # model: gpt-5.4 - # timeout_ms: 300000 - # max_concurrent: 1 - timeout_ms: 300000 - max_concurrent: 1 - -copilot: - # The runner adds --no-ask-user automatically so the agent never prompts the - # operator interactively. Tool permissions follow a minimal-allowlist posture. - allow_all_tools: false - allow_tools: - # Bare tool names are permitted as-is; shell subcommands need a wildcard to - # match any argument. - - "report_intent" - - "bash" - - "rg" - - "view" - - "shell(gh:*)" - - "shell(git:*)" - # deny_tools: - # - "shell(rm:*)" - -# Optional Claude Code settings if this file is reused with agent.kind: claude_code. -claude_code: - # Permission mode passed to `claude`. - permission_mode: bypassPermissions - # deny_tools: - # - "Edit" ---- -You are working on GitHub issue {{ issue.repository }}#{{ issue.number }}: {{ issue.title }}. - -{{ issue.description }} - -Rules: - -- Work only inside this workspace. This workflow is review-only: inspect the current PR and leave - review feedback through GitHub. Do not create commits, push branch changes, or implement the - fix yourself. -- This workflow is paired with an implementation workflow. It should only act while the issue - status is "Agent Review". -- The implementation workflow, this reviewer workflow, and human reviewers may all share the same - GitHub account. Do not rely on comment author identity to tell human and agent activity apart; - use Baton markers instead. -- Resolve the Baton branch and confirm there is an open PR for it: - `BRANCH="agent/{{ issue.identifier }}"` - `OPEN_PR_COUNT=$(gh pr list --repo "{{ issue.repository }}" --head "$BRANCH" --state open --json number --jq 'length')` -- If no open PR exists for the Baton branch, treat that as an implementation-side blocker: - update the progress comment, move the issue status back to "In Progress", and stop. -- Once an open PR exists, resolve the PR number: - `PR_NUMBER=$(gh pr view "$BRANCH" --repo "{{ issue.repository }}" --json number --jq '.number')` -- Review the PR as it exists now. Use `gh pr diff`, `gh pr view`, `gh pr checks`, `gh api`, and - local read-only inspection as needed. Take existing review threads and comments into account so - you do not re-raise feedback that is already resolved in the current diff. -- Every PR comment that this workflow creates must include a Baton reviewer marker plus a visible - label: - - summary comment marker: - `` - - finding comment marker: `` - - visible prefix: `[Baton Reviewer]` -- Treat the reviewer summary comment as the single source of truth for reviewer-owned machine state. - Keep the marker line machine-readable and stable, and add a visible note such as `Managed by - Baton; do not edit the marker line manually.` below it. -- Every reviewer finding should also carry exactly one intent prefix after `[Baton Reviewer]`: - - `[must]` for a concrete defect, regression, security problem, or other change that should be fixed - - `[ask]` for an ambiguity, missing context, or specification question that needs clarification - - `[imo]` for a non-blocking suggestion that still has clear technical value -- Do not use `[nits]`. Keep the workflow high-signal. -- Treat comments without a Baton reviewer marker as human-authored for workflow purposes, even if - they were posted by the same GitHub account. Do not edit, replace, or classify unmarked comments - as this workflow's own output. -- Focus on actionable review findings: correctness bugs, missing edge cases, regressions, - dangerous migrations, broken tests, mismatches between the issue and the implementation, - backward-compatibility risks, failure-mode gaps, security problems, and operational risk. -- Check both the intended behavior and the safety of the change: - - verify the implementation matches the issue, PR description, and any explicit design intent - - look for regressions in existing APIs, configuration, data flows, and operator workflows - - inspect boundary conditions and failure paths, not just the happy path - - call out risky permissions, secret exposure, destructive commands, or unsafe automation -- Prefer fewer, high-confidence findings over many low-signal comments. Avoid speculative, - style-only, naming-preference, or minor-refactor comments unless they hide a real defect. -- When leaving a finding, explain the concrete risk: what breaks, when it breaks, and why it - matters. -- When you can see a likely repair direction, add a brief implementation hint that helps the - implementer understand how to resolve the problem. Keep it short and non-binding: clarify the - shape of a fix without dictating the only acceptable implementation. -- Limit agent-only review loops. Track how many times this workflow has sent the issue back from - `Agent Review` to `In Progress` in the reviewer summary comment's `handoff_count` field. - - before applying any increment logic, check the existing reviewer summary comment's `status` - field: - - if `status=pass`: the prior agent review cycle ended with approval, and the issue was - subsequently rejected by a human reviewer and re-entered `Agent Review`. This is a fresh - cycle, not a continuation of the previous agent-only loop, so reset `handoff_count` to `0`. - (Without this reset, normal agent → human → agent round-trips would consume the loop budget - even though no infinite agent-only loop occurred.) - - if `status=needs_changes`: the issue is still inside the same agent-only cycle; carry the - existing `handoff_count` forward without resetting. - - if no existing summary comment is found: start `handoff_count` at `0` as normal. - - increment `handoff_count` each time this workflow returns the issue to `In Progress` - - once `handoff_count` reaches `3`, stop sending the issue back to `In Progress` - - after the third send-back, escalate by updating the reviewer summary and progress comment to - say `Human attention required: agent review loop limit reached.` and move the issue to - `In Review` for human review - - if the summary marker is missing, malformed, or cannot be parsed confidently, do not guess; - update the reviewer summary and progress comment to say `Human attention required: reviewer - state could not be read safely.` and move the issue to `In Review` -- If you find actionable issues: - - post marker-tagged PR comments with concrete guidance; use inline comments when a code location - matters - - choose a stable `id` for each finding (for example, path + short slug) and search existing - `` comments first; if the same still-applicable finding - is already present, do not post it again - - create or update exactly one reviewer summary comment: search existing PR comments for - `` - and a visible `[Baton Reviewer]` prefix plus `Managed by Baton; do not edit the marker line - manually.`. Each finding comment should normally use `[must]`; use `[ask]` instead when you - need clarification before deciding whether the change is wrong, and use `[imo]` sparingly for - non-blocking advice. - - update the issue progress comment with `Role: Baton Reviewer` plus a concise summary of what - the implementation workflow should address next - - if `handoff_count` is still below `3`, move the issue status back to "In Progress" so the - implementation workflow can resume - - if this finding set would make `handoff_count` exceed `3`, do not send the issue back again; - instead, keep `status=needs_changes`, say `Human attention required: agent review loop limit - reached.`, and move the issue to "In Review" -- If you do not find actionable issues: - - create or update exactly one reviewer summary comment using the same search-then-edit-or-create - pattern: `` and a visible - `[Baton Reviewer]` prefix plus `Managed by Baton; do not edit the marker line manually.` - - update the issue progress comment with `Role: Baton Reviewer` and say the PR is ready for - human review - - move the issue status to "In Review" -- Do not resolve review threads on behalf of humans. Leave the discussion state visible unless a - human reviewer resolves it later. -- Report progress by editing a single persistent comment on the issue. The comment must begin - with the marker ``. On each run, search existing comments for that - marker first; if found, edit it in place; if not found, create it. Do not post multiple - separate comments. The comment body has two sections: - 1. **Summary section** (overwrite on every run): `Role:` and `Status:` lines immediately after - the marker, giving the current state at a glance. `Role:` must be `Baton Reviewer`. - 2. **Log section** (append-only): a `` block within the same comment. On - every run, prepend one new line in the format ` | | ` - so the full round-trip history is preserved. If the existing comment has no - `` block (e.g. it predates this format), append the block rather than - failing. - - Example comment format: - ``` - - Role: Baton Reviewer - Status: needs_changes — missing edge case in retry logic - - - 2026-06-20T10:30Z | Baton Reviewer | needs_changes — missing edge case in retry logic - 2026-06-20T09:10Z | Baton Implementer | pr-opened — PR #42 - ``` -- Only stop early for a true blocker (missing required auth, permissions, or secrets that cannot - be resolved in-session). If blocked, record what is missing and what action is needed to - unblock in the progress comment, include `Role: Baton Reviewer`, create or update the reviewer - summary comment with `status=needs_changes`, say `Human attention required:` in both places, then - move the issue status to "In Review" and stop. diff --git a/package.json b/package.json index b6b3925..66dc514 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "lint": "biome check .", "test": "vitest run", "triage": "tsx scripts/triage/index.ts", - "review": "tsx scripts/review/index.ts", "version-packages": "changeset version && npm install --package-lock-only" }, "dependencies": { diff --git a/scripts/review/claude-run.ts b/scripts/review/claude-run.ts deleted file mode 100644 index e89c67a..0000000 --- a/scripts/review/claude-run.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { isRecord, shellQuote } from "../../src/util.js"; -import { runOnce } from "../lib/subprocess.js"; -import type { ReviewConfig } from "./config.js"; -import type { ReviewRunner } from "./runner.js"; - -function checkResult(stdout: string): void { - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - let msg: unknown; - try { - msg = JSON.parse(trimmed); - } catch { - continue; - } - if (isRecord(msg) && msg.type === "result") { - if (msg.is_error === true) { - throw new Error( - `claude returned an error: ${typeof msg.result === "string" ? msg.result : "(no message)"}`, - ); - } - return; - } - } - throw new Error( - `claude stream-json output contained no {"type":"result"} line`, - ); -} - -export function createClaudeReviewRunner(config: ReviewConfig): ReviewRunner { - return { - async run(workspaceDir: string, prompt: string): Promise { - const { permissionMode, denyTools } = config.claudeCode; - const effectiveDenyTools = denyTools.length > 0 ? denyTools : []; - - let command = `claude -p --output-format stream-json --verbose --permission-mode ${shellQuote(permissionMode)}`; - if (effectiveDenyTools.length > 0) { - command += ` --disallowedTools ${shellQuote(effectiveDenyTools.join(","))}`; - } - if (config.agent.model) { - command += ` --model ${shellQuote(config.agent.model)}`; - } - - const stdout = await runOnce(command, prompt, { - timeoutMs: config.agent.timeoutMs, - cwd: workspaceDir, - }); - checkResult(stdout); - }, - }; -} diff --git a/scripts/review/config.ts b/scripts/review/config.ts deleted file mode 100644 index df34b64..0000000 --- a/scripts/review/config.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { expandPath, resolveEnvValue } from "../../src/config/schema.js"; -import { isRecord, parseFrontMatter } from "../../src/util.js"; - -export interface ReviewTrackerConfig { - token: string | null; - owner: string; - ownerType: "organization" | "user"; - projectNumber: number; - statusField: string; - activeStates: string[]; - repos: string[] | null; -} - -export interface WorkspaceConfig { - root: string; - hookTimeoutMs: number; -} - -export interface HooksConfig { - afterCreate: string | null; - beforeRun: string | null; -} - -export interface AgentConfig { - kind: "claude_code" | "copilot"; - timeoutMs: number; - model: string | null; - maxConcurrent: number; -} - -export interface CopilotConfig { - allowAllTools: boolean; - allowTools: string[]; - denyTools: string[]; -} - -export interface ClaudeCodeConfig { - permissionMode: string; - denyTools: string[]; -} - -export interface ReviewConfig { - tracker: ReviewTrackerConfig; - workspace: WorkspaceConfig; - hooks: HooksConfig; - agent: AgentConfig; - copilot: CopilotConfig; - claudeCode: ClaudeCodeConfig; -} - -type Env = Record; - -function str(v: unknown): string | null { - return typeof v === "string" && v.length > 0 ? v : null; -} - -function resolveStr(v: unknown, env: Env): string | null { - const s = str(v); - return s === null ? null : resolveEnvValue(s, env); -} - -function section( - raw: Record, - key: string, -): Record { - const v = raw[key]; - if (v !== undefined && !isRecord(v)) { - throw new Error(`"${key}" must be a map in REVIEW.md, got: ${typeof v}`); - } - return isRecord(v) ? v : {}; -} - -export function parseReviewConfig( - raw: Record, - env: Env = process.env, - baseDir = process.cwd(), -): ReviewConfig { - const t = section(raw, "tracker"); - - const owner = resolveStr(t.owner, env); - if (!owner) { - throw new Error("tracker.owner is required in REVIEW.md"); - } - - const projectNumberRaw = t.project_number; - let projectNumber: number; - if (typeof projectNumberRaw === "number") { - projectNumber = projectNumberRaw; - } else if (typeof projectNumberRaw === "string") { - const resolved = resolveEnvValue(projectNumberRaw, env); - const parsed = resolved !== null ? Number(resolved) : NaN; - projectNumber = parsed; - } else { - projectNumber = NaN; - } - if (!Number.isInteger(projectNumber) || projectNumber <= 0) { - throw new Error( - "tracker.project_number is required and must be a positive integer in REVIEW.md", - ); - } - - const ownerTypeRaw = resolveStr(t.owner_type, env); - let ownerType: "organization" | "user"; - if ( - ownerTypeRaw === undefined || - ownerTypeRaw === null || - ownerTypeRaw === "" - ) { - ownerType = "organization"; - } else if (ownerTypeRaw === "organization" || ownerTypeRaw === "user") { - ownerType = ownerTypeRaw; - } else { - throw new Error( - `tracker.owner_type must be "organization" or "user" in REVIEW.md, got: ${String(ownerTypeRaw)}`, - ); - } - - const activeStatesRaw = t.active_states; - const activeStates: string[] = - Array.isArray(activeStatesRaw) && - activeStatesRaw.every((x) => typeof x === "string") - ? (activeStatesRaw as string[]) - : []; - if (activeStates.length === 0) { - throw new Error( - "tracker.active_states is required and must be a non-empty string array in REVIEW.md", - ); - } - - const reposRaw = t.repos; - const repos: string[] | null = - Array.isArray(reposRaw) && reposRaw.every((x) => typeof x === "string") - ? (reposRaw as string[]) - : null; - - const tokenRaw = str(t.token) ?? "$GITHUB_TOKEN"; // literal string passed to resolveEnvValue below, which expands $VAR — same lazy pattern as src/config/schema.ts - const tracker: ReviewTrackerConfig = { - token: resolveEnvValue(tokenRaw, env), - owner, - ownerType, - projectNumber, - statusField: resolveStr(t.status_field, env) ?? "Status", - activeStates, - repos, - }; - - const w = section(raw, "workspace"); - const rootRaw = str(w.root); - if (!rootRaw) { - throw new Error("workspace.root is required in REVIEW.md"); - } - const hookTimeoutMsRaw = w.hook_timeout_ms; - let hookTimeoutMs = 120000; - if (hookTimeoutMsRaw !== undefined && hookTimeoutMsRaw !== null) { - if ( - typeof hookTimeoutMsRaw !== "number" || - !Number.isInteger(hookTimeoutMsRaw) || - hookTimeoutMsRaw <= 0 - ) { - throw new Error( - `workspace.hook_timeout_ms must be a positive integer, got ${String(hookTimeoutMsRaw)}`, - ); - } - hookTimeoutMs = hookTimeoutMsRaw; - } - const workspace: WorkspaceConfig = { - root: expandPath(rootRaw, baseDir, env), - hookTimeoutMs, - }; - - const h = section(raw, "hooks"); - const hooks: HooksConfig = { - afterCreate: str(h.after_create), - beforeRun: str(h.before_run), - }; - - const a = section(raw, "agent"); - const kind = resolveStr(a.kind, env); - if (kind !== "claude_code" && kind !== "copilot") { - throw new Error( - `agent.kind must be "claude_code" or "copilot" in REVIEW.md, got: ${String(kind)}`, - ); - } - - const timeoutMsRaw = a.timeout_ms; - let timeoutMs = 300000; - if (timeoutMsRaw !== undefined && timeoutMsRaw !== null) { - if ( - typeof timeoutMsRaw !== "number" || - !Number.isInteger(timeoutMsRaw) || - timeoutMsRaw <= 0 - ) { - throw new Error( - `agent.timeout_ms must be a positive integer, got ${String(timeoutMsRaw)}`, - ); - } - timeoutMs = timeoutMsRaw; - } - - const maxConcurrentRaw = a.max_concurrent; - let maxConcurrent = 1; - if (maxConcurrentRaw !== undefined && maxConcurrentRaw !== null) { - if ( - typeof maxConcurrentRaw !== "number" || - !Number.isInteger(maxConcurrentRaw) || - maxConcurrentRaw <= 0 - ) { - throw new Error( - `agent.max_concurrent must be a positive integer, got ${String(maxConcurrentRaw)}`, - ); - } - maxConcurrent = maxConcurrentRaw; - } - - const agent: AgentConfig = { - kind, - timeoutMs, - model: resolveStr(a.model, env), - maxConcurrent, - }; - - const cp = section(raw, "copilot"); - const copilot: CopilotConfig = { - allowAllTools: cp.allow_all_tools === true, - allowTools: - Array.isArray(cp.allow_tools) && - cp.allow_tools.every((x) => typeof x === "string") - ? (cp.allow_tools as string[]) - : [], - denyTools: - Array.isArray(cp.deny_tools) && - cp.deny_tools.every((x) => typeof x === "string") - ? (cp.deny_tools as string[]) - : [], - }; - - const cc = section(raw, "claude_code"); - const claudeCode: ClaudeCodeConfig = { - permissionMode: resolveStr(cc.permission_mode, env) ?? "bypassPermissions", - denyTools: - Array.isArray(cc.deny_tools) && - cc.deny_tools.every((x) => typeof x === "string") - ? (cc.deny_tools as string[]) - : [], - }; - - return { tracker, workspace, hooks, agent, copilot, claudeCode }; -} - -export async function loadReviewConfig( - filePath: string, - env: Env = process.env, -): Promise<{ config: ReviewConfig; promptTemplate: string }> { - let text: string; - try { - text = await readFile(filePath, "utf8"); - } catch (e) { - throw new Error(`cannot read review file: ${filePath}`, { cause: e }); - } - - const { raw, body: promptTemplate } = parseFrontMatter(text); - const config = parseReviewConfig(raw, env, path.dirname(filePath)); - - return { config, promptTemplate }; -} diff --git a/scripts/review/copilot-run.ts b/scripts/review/copilot-run.ts deleted file mode 100644 index 392a847..0000000 --- a/scripts/review/copilot-run.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { shellQuote } from "../../src/util.js"; -import { runOnce } from "../lib/subprocess.js"; -import type { ReviewConfig } from "./config.js"; -import type { ReviewRunner } from "./runner.js"; - -function checkCopilotResult(stdout: string): void { - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - let msg: unknown; - try { - msg = JSON.parse(trimmed); - } catch { - continue; - } - if ( - typeof msg === "object" && - msg !== null && - "type" in msg && - msg.type === "result" - ) { - const exitCode = - "exitCode" in msg && typeof msg.exitCode === "number" - ? msg.exitCode - : 1; - if (exitCode !== 0) { - throw new Error(`copilot returned a non-zero exitCode: ${exitCode}`); - } - return; - } - } - throw new Error(`copilot json output contained no {"type":"result"} line`); -} - -export function createCopilotReviewRunner(config: ReviewConfig): ReviewRunner { - return { - async run(workspaceDir: string, prompt: string): Promise { - const promptBytes = Buffer.byteLength(prompt, "utf8"); - if (promptBytes > 128 * 1024) { - throw new Error( - `copilot review: prompt too large for argv (${promptBytes} bytes > 131072)`, - ); - } - - const sessionId = randomUUID(); - const { allowAllTools, allowTools, denyTools } = config.copilot; - - let command = `copilot -p ${shellQuote(prompt)} --output-format json --no-ask-user --log-level none --session-id ${sessionId}`; - if (config.agent.model) { - command += ` --model ${shellQuote(config.agent.model)}`; - } - if (allowAllTools) { - command += " --allow-all-tools"; - } else { - for (const tool of allowTools) { - command += ` --allow-tool=${shellQuote(tool)}`; - } - } - for (const tool of denyTools) { - command += ` --deny-tool=${shellQuote(tool)}`; - } - - const stdout = await runOnce(command, undefined, { - timeoutMs: config.agent.timeoutMs, - cwd: workspaceDir, - }); - checkCopilotResult(stdout); - }, - }; -} diff --git a/scripts/review/index.ts b/scripts/review/index.ts deleted file mode 100644 index 9df1f98..0000000 --- a/scripts/review/index.ts +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env node -import path from "node:path"; -import { Liquid } from "liquidjs"; -import { ClaudeCodeRunner } from "../../src/agent/claude-code.js"; -import { CopilotRunner } from "../../src/agent/copilot.js"; -import type { - AgentEvent, - AgentRunner, - AgentSession, -} from "../../src/agent/runner.js"; -import type { BatonConfig, TrackerConfig } from "../../src/config/schema.js"; -import { Logger } from "../../src/observability/logger.js"; -import { ensureGitBashOnWindowsPath } from "../../src/platform/git-bash.js"; -import { GitHubProjectsClient } from "../../src/tracker/github-projects.js"; -import type { Issue } from "../../src/tracker/types.js"; -import { WorkspaceManager } from "../../src/workspace/manager.js"; -import type { ReviewConfig, ReviewTrackerConfig } from "./config.js"; -import { loadReviewConfig } from "./config.js"; - -ensureGitBashOnWindowsPath(); - -const logger = new Logger({ service: "review" }); -const liquid = new Liquid({ strictVariables: true }); - -function usage(): string { - return [ - "Usage: tsx scripts/review/index.ts [REVIEW.md]", - "", - "Runs a one-shot review pass for issues in the configured active states.", - ].join("\n"); -} - -function isHelpFlag(arg: string | undefined): boolean { - return arg === "-h" || arg === "--help"; -} - -function toTrackerConfig(config: ReviewTrackerConfig): TrackerConfig { - return { - kind: "github_projects", - endpoint: "https://api.github.com/graphql", - token: config.token, - owner: config.owner, - ownerType: config.ownerType, - projectNumber: config.projectNumber, - statusField: config.statusField, - priorityField: null, - repos: config.repos, - requiredLabels: [], - activeStates: config.activeStates, - terminalStates: [], - }; -} - -function toWorkspaceManagerConfig(config: ReviewConfig): BatonConfig { - return { - tracker: { - kind: "github_projects", - endpoint: "https://api.github.com/graphql", - token: config.tracker.token, - owner: config.tracker.owner, - ownerType: config.tracker.ownerType, - projectNumber: config.tracker.projectNumber, - statusField: config.tracker.statusField, - priorityField: null, - repos: config.tracker.repos, - requiredLabels: [], - activeStates: config.tracker.activeStates, - terminalStates: [], - }, - polling: { intervalMs: 30000 }, - workspace: { root: config.workspace.root }, - hooks: { - afterCreate: config.hooks.afterCreate, - beforeRun: config.hooks.beforeRun, - afterRun: null, - beforeRemove: null, - timeoutMs: config.workspace.hookTimeoutMs, - }, - agent: { - kind: config.agent.kind, - maxConcurrentAgents: config.agent.maxConcurrent, - maxTurns: 1, - maxRetryBackoffMs: 300000, - maxConcurrentAgentsByState: {}, - }, - claudeCode: { - command: "claude", - model: config.claudeCode.model, - permissionMode: config.claudeCode.permissionMode, - allowedTools: [], - disallowedTools: config.claudeCode.denyTools, - appendSystemPrompt: null, - extraArgs: [], - turnTimeoutMs: config.agent.timeoutMs, - stallTimeoutMs: 300000, - }, - copilot: { - command: "copilot", - model: config.copilot.model, - allowAllTools: config.copilot.allowAllTools, - allowTools: config.copilot.allowTools, - denyTools: [], - extraArgs: [], - turnTimeoutMs: config.agent.timeoutMs, - stallTimeoutMs: 300000, - }, - server: { host: "127.0.0.1", port: null }, - }; -} - -function createRunner(config: ReviewConfig, runLogger: Logger): AgentRunner { - if (config.agent.kind === "claude_code") { - return new ClaudeCodeRunner( - config.claudeCode, - runLogger.child({ agent: "claude_code" }), - ); - } - return new CopilotRunner( - config.copilot, - runLogger.child({ agent: "copilot" }), - ); -} - -function logAgentEvent(issueLogger: Logger, event: AgentEvent): void { - switch (event.event) { - case "notification": - if (event.message) { - issueLogger.info("agent notification", { message: event.message }); - } - break; - case "tool_use": - issueLogger.debug("agent tool use", { tool: event.message }); - break; - case "turn_completed": - issueLogger.info("agent turn completed", { usage: event.usage }); - break; - case "turn_failed": - issueLogger.warn("agent turn failed", { - message: event.message ?? null, - }); - break; - default: - issueLogger.debug("agent event", { - event: event.event, - message: event.message ?? null, - }); - break; - } -} - -async function runIssue( - issue: Issue, - workspaceManager: WorkspaceManager, - runner: AgentRunner, - promptTemplate: string, - issueLogger: Logger, -): Promise { - const workspace = await workspaceManager.createForIssue(issue); - issueLogger.info("workspace ready", { workspace: workspace.path }); - - await workspaceManager.runBeforeRun(issue, workspace.path); - - const prompt = await liquid.parseAndRender(promptTemplate, { - issue, - attempt: null, - }); - const session = await runner.startSession(workspace.path); - - try { - const result = await runner.runTurn(session, prompt, (event) => { - logAgentEvent(issueLogger, event); - }); - if (!result.ok) { - throw new Error(result.error ?? "agent run failed"); - } - } finally { - await stopSessionQuietly(runner, session, issueLogger); - } -} - -async function stopSessionQuietly( - runner: AgentRunner, - session: AgentSession, - issueLogger: Logger, -): Promise { - try { - await runner.stopSession(session); - } catch (err) { - issueLogger.error("failed to stop agent session", { error: String(err) }); - } -} - -async function main(): Promise { - const configArg = process.argv[2]; - if (isHelpFlag(configArg)) { - process.stdout.write(`${usage()}\n`); - return; - } - - const configPath = configArg ?? path.join(process.cwd(), "REVIEW.md"); - - logger.info("loading review config", { path: configPath }); - const { config, promptTemplate } = await loadReviewConfig(configPath); - - if (!config.tracker.token) { - throw new Error( - "tracker.token is required — set GITHUB_TOKEN or provide it in REVIEW.md", - ); - } - - const tracker = new GitHubProjectsClient(toTrackerConfig(config.tracker)); - const workspaceManager = new WorkspaceManager( - toWorkspaceManagerConfig(config), - logger, - ); - const runner = createRunner(config, logger); - - logger.info("fetching issues", { - owner: config.tracker.owner, - project: config.tracker.projectNumber, - active_states: config.tracker.activeStates, - }); - - const issues = await tracker.fetchIssuesByStates(config.tracker.activeStates); - const runnableIssues = issues.filter((issue) => !issue.closed); - logger.info("fetched issues", { - issue_count: issues.length, - runnable_count: runnableIssues.length, - closed_skipped: issues.length - runnableIssues.length, - }); - - let successCount = 0; - let failureCount = 0; - - for (const issue of runnableIssues) { - const issueLogger = logger.child({ issue: issue.identifier }); - try { - await runIssue( - issue, - workspaceManager, - runner, - promptTemplate, - issueLogger, - ); - successCount++; - } catch (err) { - failureCount++; - issueLogger.error("review failed", { error: String(err) }); - } - } - - logger.info("review complete", { - success: successCount, - failed: failureCount, - total: runnableIssues.length, - }); - - if (failureCount > 0) { - process.exitCode = 1; - } -} - -main().catch((err) => { - logger.error("review failed", { error: String(err) }); - process.exit(1); -}); diff --git a/scripts/review/runner.ts b/scripts/review/runner.ts deleted file mode 100644 index ef03f8f..0000000 --- a/scripts/review/runner.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createClaudeReviewRunner } from "./claude-run.js"; -import type { ReviewConfig } from "./config.js"; -import { createCopilotReviewRunner } from "./copilot-run.js"; - -export interface ReviewRunner { - run(workspaceDir: string, prompt: string): Promise; -} - -export function createRunner(config: ReviewConfig): ReviewRunner { - if (config.agent.kind === "copilot") { - return createCopilotReviewRunner(config); - } - return createClaudeReviewRunner(config); -} diff --git a/scripts/review/workspace.ts b/scripts/review/workspace.ts deleted file mode 100644 index a23b9e9..0000000 --- a/scripts/review/workspace.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { mkdir, stat } from "node:fs/promises"; -import path from "node:path"; -import type { Logger } from "../../src/observability/logger.js"; -import { makePlatform } from "../../src/platform/platform.js"; -import type { Issue } from "../../src/tracker/types.js"; -import { runHookScript } from "../../src/workspace/hooks.js"; -import { sanitizeWorkspaceKey } from "../../src/workspace/manager.js"; -import type { ReviewConfig } from "./config.js"; - -function hookEnv( - issue: Issue, - workspacePath: string, - platform: ReturnType, -): Record { - const native = platform.nativePath(workspacePath); - return { - BATON_ISSUE_ID: issue.id, - BATON_ISSUE_IDENTIFIER: issue.identifier, - BATON_ISSUE_NUMBER: String(issue.number), - BATON_ISSUE_REPO: issue.repository, - BATON_ISSUE_URL: issue.url ?? "", - BATON_ISSUE_STATUS: issue.state, - BATON_WORKSPACE: platform.toBashPath(workspacePath), - ...(native ? { BATON_WORKSPACE_NATIVE: native } : {}), - }; -} - -export async function setupWorkspace( - issue: Issue, - config: ReviewConfig, - logger: Logger, -): Promise { - const platform = makePlatform(); - const root = path.resolve(config.workspace.root); - const key = sanitizeWorkspaceKey(issue.identifier); - const workspacePath = path.resolve(root, key); - - if (workspacePath === root || !workspacePath.startsWith(root + path.sep)) { - throw new Error( - `workspace path for ${issue.identifier} escapes workspace root`, - ); - } - - let existing: { isDirectory(): boolean } | null = null; - try { - existing = await stat(workspacePath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - throw err; - } - existing = null; - } - if (existing !== null && !existing.isDirectory()) { - throw new Error( - `workspace path exists and is not a directory: ${workspacePath}`, - ); - } - const exists = existing !== null; - - if (!exists) { - await mkdir(workspacePath, { recursive: true }); - - if (config.hooks.afterCreate) { - logger.info("running after_create hook", { workspace: workspacePath }); - const result = await runHookScript(config.hooks.afterCreate, { - cwd: workspacePath, - env: hookEnv(issue, workspacePath, platform), - timeoutMs: config.workspace.hookTimeoutMs, - treeKiller: platform.treeKiller, - }); - if (!result.ok) { - logger.error("after_create hook failed", { - workspace: workspacePath, - timedOut: result.timedOut, - code: result.code, - }); - try { - await platform.removeDir(workspacePath); - } catch (err) { - throw new Error( - `after_create hook failed and workspace cleanup failed: ${String(err)}`, - ); - } - throw new Error( - `after_create hook failed (timedOut=${result.timedOut} code=${result.code}): ${result.output.slice(0, 500)}`, - ); - } - } - } - - if (config.hooks.beforeRun) { - logger.info("running before_run hook", { workspace: workspacePath }); - const result = await runHookScript(config.hooks.beforeRun, { - cwd: workspacePath, - env: hookEnv(issue, workspacePath, platform), - timeoutMs: config.workspace.hookTimeoutMs, - treeKiller: platform.treeKiller, - }); - if (!result.ok) { - logger.error("before_run hook failed", { - workspace: workspacePath, - timedOut: result.timedOut, - code: result.code, - }); - throw new Error( - `before_run hook failed (timedOut=${result.timedOut} code=${result.code}): ${result.output.slice(0, 500)}`, - ); - } - } - - return workspacePath; -} diff --git a/test/review-config.test.ts b/test/review-config.test.ts deleted file mode 100644 index d819fc7..0000000 --- a/test/review-config.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { parseReviewConfig } from "../scripts/review/config.js"; - -function baseRaw(): Record { - return { - tracker: { - owner: "acme", - project_number: 1, - active_states: ["Todo"], - }, - workspace: { - root: "workspaces/review", - }, - agent: { - kind: "copilot", - }, - }; -} - -describe("parseReviewConfig workspace root expansion", () => { - it("resolves workspace.root relative to the REVIEW.md directory", () => { - const reviewDir = path.join(path.sep, "tmp", "review-root"); - const config = parseReviewConfig(baseRaw(), {}, reviewDir); - expect(config.workspace.root).toBe( - path.resolve(reviewDir, "workspaces/review"), - ); - }); - - it("expands home marker with the shared expandPath behavior", () => { - const raw = baseRaw(); - (raw.workspace as Record).root = "~/review-workspaces"; - const config = parseReviewConfig(raw, {}, "/ignored"); - expect(config.workspace.root).toBe( - path.join(os.homedir(), "review-workspaces"), - ); - }); -}); diff --git a/test/review-runners.test.ts b/test/review-runners.test.ts deleted file mode 100644 index 65836d4..0000000 --- a/test/review-runners.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createClaudeReviewRunner } from "../scripts/review/claude-run.js"; -import type { ReviewConfig } from "../scripts/review/config.js"; -import { createCopilotReviewRunner } from "../scripts/review/copilot-run.js"; - -vi.mock("../scripts/lib/subprocess.js", () => ({ - runOnce: vi.fn(), -})); - -import { runOnce } from "../scripts/lib/subprocess.js"; - -const mockRunOnce = vi.mocked(runOnce); - -function makeConfig(overrides: Partial = {}): ReviewConfig { - return { - tracker: { - token: "token", - owner: "acme", - ownerType: "organization", - projectNumber: 1, - statusField: "Status", - activeStates: ["Todo"], - repos: null, - }, - workspace: { - root: "/tmp/review", - hookTimeoutMs: 120000, - }, - hooks: { - afterCreate: null, - beforeRun: null, - }, - agent: { - kind: "claude_code", - timeoutMs: 30000, - model: null, - maxConcurrent: 1, - }, - copilot: { - allowAllTools: false, - allowTools: [], - denyTools: [], - }, - claudeCode: { - permissionMode: "bypassPermissions", - denyTools: [], - }, - ...overrides, - }; -} - -beforeEach(() => { - mockRunOnce.mockReset(); -}); - -describe("createClaudeReviewRunner", () => { - it("builds command with --verbose and optional flags, then validates result line", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "result", is_error: false, result: "ok" }), - ); - const config = makeConfig({ - agent: { - kind: "claude_code", - timeoutMs: 12345, - model: "claude-opus-4.7", - maxConcurrent: 1, - }, - claudeCode: { - permissionMode: "acceptEdits", - denyTools: ["Bash(rm:*)", "Edit"], - }, - }); - - await createClaudeReviewRunner(config).run("/tmp/ws", "review this"); - - const [command, stdin, opts] = mockRunOnce.mock.calls.at(0) ?? []; - expect(command).toContain("--verbose"); - expect(command).toContain("--permission-mode 'acceptEdits'"); - expect(command).toContain("--disallowedTools 'Bash(rm:*),Edit'"); - expect(command).toContain("--model 'claude-opus-4.7'"); - expect(stdin).toBe("review this"); - expect(opts).toEqual({ timeoutMs: 12345, cwd: "/tmp/ws" }); - }); - - it("throws when stream-json output has no result line", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "assistant", text: "partial" }), - ); - await expect( - createClaudeReviewRunner(makeConfig()).run("/tmp/ws", "p"), - ).rejects.toThrow('{"type":"result"}'); - }); - - it("throws when result line marks is_error=true", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ - type: "result", - is_error: true, - result: "Permission denied", - }), - ); - await expect( - createClaudeReviewRunner(makeConfig()).run("/tmp/ws", "p"), - ).rejects.toThrow("claude returned an error: Permission denied"); - }); -}); - -describe("createCopilotReviewRunner", () => { - it("uses --allow-all-tools for allowAllTools mode", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "result", exitCode: 0 }), - ); - const config = makeConfig({ - agent: { - kind: "copilot", - timeoutMs: 22222, - model: "gpt-5", - maxConcurrent: 1, - }, - copilot: { allowAllTools: true, allowTools: [], denyTools: [] }, - }); - - await createCopilotReviewRunner(config).run("/tmp/ws", "run review"); - - const [command, stdin, opts] = mockRunOnce.mock.calls.at(0) ?? []; - expect(command).toContain("--allow-all-tools"); - expect(command).not.toContain("--allow-tool=*"); - expect(command).toContain("--model 'gpt-5'"); - expect(stdin).toBeUndefined(); - expect(opts).toEqual({ timeoutMs: 22222, cwd: "/tmp/ws" }); - }); - - it("includes allow/deny tool flags when configured", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "result", exitCode: 0 }), - ); - const config = makeConfig({ - agent: { - kind: "copilot", - timeoutMs: 30000, - model: null, - maxConcurrent: 1, - }, - copilot: { - allowAllTools: false, - allowTools: ["Bash", "Read"], - denyTools: ["shell(git:*)", "Edit"], - }, - }); - - await createCopilotReviewRunner(config).run("/tmp/ws", "run review"); - - const [command] = mockRunOnce.mock.calls.at(0) ?? []; - expect(command).toContain("--allow-tool='Bash'"); - expect(command).toContain("--allow-tool='Read'"); - expect(command).toContain("--deny-tool='shell(git:*)'"); - expect(command).toContain("--deny-tool='Edit'"); - }); - - it("throws when prompt exceeds argv limit", async () => { - const oversizedPrompt = "a".repeat(128 * 1024 + 1); - await expect( - createCopilotReviewRunner( - makeConfig({ - agent: { - kind: "copilot", - timeoutMs: 30000, - model: null, - maxConcurrent: 1, - }, - }), - ).run("/tmp/ws", oversizedPrompt), - ).rejects.toThrow("prompt too large"); - expect(mockRunOnce).not.toHaveBeenCalled(); - }); - - it("throws when output has no result line", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "assistant.message", data: { content: "ok" } }), - ); - await expect( - createCopilotReviewRunner( - makeConfig({ - agent: { - kind: "copilot", - timeoutMs: 30000, - model: null, - maxConcurrent: 1, - }, - }), - ).run("/tmp/ws", "p"), - ).rejects.toThrow('{"type":"result"}'); - }); - - it("throws when copilot result exitCode is non-zero", async () => { - mockRunOnce.mockResolvedValue( - JSON.stringify({ type: "result", exitCode: 1 }), - ); - await expect( - createCopilotReviewRunner( - makeConfig({ - agent: { - kind: "copilot", - timeoutMs: 30000, - model: null, - maxConcurrent: 1, - }, - }), - ).run("/tmp/ws", "p"), - ).rejects.toThrow("non-zero exitCode: 1"); - }); -}); diff --git a/test/review-workspace.test.ts b/test/review-workspace.test.ts deleted file mode 100644 index d1ecd33..0000000 --- a/test/review-workspace.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ReviewConfig } from "../scripts/review/config.js"; -import { setupWorkspace } from "../scripts/review/workspace.js"; -import type { Platform } from "../src/platform/platform.js"; -import * as platformModule from "../src/platform/platform.js"; -import { makeIssue, silentLogger } from "./helpers.js"; - -async function tempRoot(): Promise { - return mkdtemp(join(tmpdir(), "baton-review-ws-")); -} - -async function exists(path: string): Promise { - try { - await stat(path); - return true; - } catch { - return false; - } -} - -function makeReviewConfig( - root: string, - hooks: { afterCreate?: string | null; beforeRun?: string | null } = {}, -): ReviewConfig { - return { - tracker: { - token: "test-token", - owner: "acme", - ownerType: "organization", - projectNumber: 1, - statusField: "Status", - activeStates: ["Todo"], - repos: null, - }, - workspace: { - root, - hookTimeoutMs: 2000, - }, - hooks: { - afterCreate: hooks.afterCreate ?? null, - beforeRun: hooks.beforeRun ?? null, - }, - agent: { - kind: "copilot", - timeoutMs: 300000, - model: null, - maxConcurrent: 1, - }, - copilot: { - allowAllTools: false, - allowTools: [], - }, - claudeCode: { - permissionMode: "bypassPermissions", - denyTools: [], - }, - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("setupWorkspace", () => { - it("creates a missing workspace, runs after_create, and returns the workspace path", async () => { - const root = await tempRoot(); - const issue = makeIssue(); - const config = makeReviewConfig(root, { - afterCreate: 'echo "created" > created.txt', - }); - - const workspacePath = await setupWorkspace(issue, config, silentLogger); - - expect(workspacePath).toBe(join(root, issue.identifier)); - expect( - await readFile(join(workspacePath, "created.txt"), "utf8"), - ).toContain("created"); - }); - - it("skips after_create on revisit and runs before_run each time", async () => { - const root = await tempRoot(); - const issue = makeIssue(); - const config = makeReviewConfig(root, { - afterCreate: 'echo "create" >> create.log', - beforeRun: 'echo "before" >> before.log', - }); - - const workspacePath = await setupWorkspace(issue, config, silentLogger); - await setupWorkspace(issue, config, silentLogger); - - const created = await readFile(join(workspacePath, "create.log"), "utf8"); - const before = await readFile(join(workspacePath, "before.log"), "utf8"); - expect(created.trim().split("\n")).toHaveLength(1); - expect(before.trim().split("\n")).toHaveLength(2); - }); - - it("fails with a clear error when the workspace path exists as a file", async () => { - const root = await tempRoot(); - const issue = makeIssue(); - await writeFile(join(root, issue.identifier), "occupied"); - const config = makeReviewConfig(root); - - await expect(setupWorkspace(issue, config, silentLogger)).rejects.toThrow( - /workspace path exists and is not a directory/, - ); - }); - - it("removes workspace when after_create fails", async () => { - const root = await tempRoot(); - const issue = makeIssue(); - const config = makeReviewConfig(root, { - afterCreate: 'echo "stale" > stale.txt; exit 1', - }); - - await expect(setupWorkspace(issue, config, silentLogger)).rejects.toThrow( - /after_create hook failed/, - ); - expect(await exists(join(root, issue.identifier))).toBe(false); - }); - - it("includes cleanup failure details when after_create and cleanup both fail", async () => { - const root = await tempRoot(); - const config = makeReviewConfig(root, { - afterCreate: "exit 1", - }); - const real = platformModule.makePlatform(); - const fakePlatform: Platform = { - ...real, - removeDir: vi.fn().mockRejectedValueOnce(new Error("cleanup failed")), - }; - vi.spyOn(platformModule, "makePlatform").mockReturnValue(fakePlatform); - - await expect( - setupWorkspace(makeIssue(), config, silentLogger), - ).rejects.toThrow(/workspace cleanup failed: Error: cleanup failed/); - }); - - it("rejects workspace paths that escape the root", async () => { - const root = await tempRoot(); - const config = makeReviewConfig(root); - - await expect( - setupWorkspace(makeIssue({ identifier: ".." }), config, silentLogger), - ).rejects.toThrow(/escapes workspace root/); - }); - - it("fails when before_run hook exits non-zero", async () => { - const root = await tempRoot(); - const config = makeReviewConfig(root, { - beforeRun: "exit 2", - }); - - await expect( - setupWorkspace(makeIssue(), config, silentLogger), - ).rejects.toThrow(/before_run hook failed/); - }); - - it("passes bash and native workspace paths to hooks", async () => { - const root = await tempRoot(); - const config = makeReviewConfig(root, { - afterCreate: - 'printf "%s|%s" "$BATON_WORKSPACE" "$BATON_WORKSPACE_NATIVE" > env.txt', - }); - const real = platformModule.makePlatform(); - const fakePlatform: Platform = { - ...real, - toBashPath: (p: string) => `bash:${p}`, - nativePath: (p: string) => `native:${p}`, - }; - vi.spyOn(platformModule, "makePlatform").mockReturnValue(fakePlatform); - - const workspacePath = await setupWorkspace( - makeIssue(), - config, - silentLogger, - ); - const env = await readFile(join(workspacePath, "env.txt"), "utf8"); - expect(env).toBe(`bash:${workspacePath}|native:${workspacePath}`); - }); -});