mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
720492d632
* fix(opencode): support OpenCode 2.x via native tools and commands
The single-file plugin (open-code-review.ts) cannot load on OpenCode 2.x:
the 2.x loader requires a default-exported { id, effect | setup } and has
no custom-tool registration API. It also fails on 1.x for most users
because nothing installs the @opencode-ai/plugin dependency the file
imports.
Ship the same ocr_review / ocr_health features as 2.x-native custom
tools plus /ocr-review /ocr-health commands, and document the
@opencode-ai/plugin dependency step for both versions.
* fix(opencode): dual V1+V2 plugin entrypoint instead of separate files
Replace the tools/+commands/ split with the documented dual plugin form:
default-export { ...Plugin.define({ id, setup }), server }. V2 registers
ocr_review/ocr_health via ctx.tool.transform and /ocr-review//ocr-health
via ctx.command.transform, reusing the same OCR logic as V1. Add
@opencode/plugin beta devDependency and a test for the default export.
* fix(opencode): review feedback: typeless V2 import, strict schemas, tests
- Import @opencode/plugin as types only and export a plain dual object,
so OpenCode 1.x never needs the V2 beta package at runtime (verified
in the built output: only node:*, @opencode-ai/plugin imports remain).
- V2 numeric inputs now require positive integers
({ type: integer, minimum: 1 }), matching the V1 zod schema.
- V2 commands keep the V1 sentence break, skip user-defined names like
the V1 ??= guards, and resolveSessionCwd falls back to the plugin
location when the session lookup fails.
- README: single download block, corrected 30-minute tool timeout,
deduped project section.
- Track package-lock.json (drop the local ignore) so npm ci works.
- Move the V2 stub harness into the test suite (+6 tests, 29 passing).
- Verified live on OpenCode 1.18.30 sandbox: plugin loads with no
errors, single init across sessions, both commands registered once.
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
import { spawn } from "node:child_process"
|
|
import { join } from "node:path"
|
|
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
import type { Plugin as PluginV2 } from "@opencode/plugin"
|
|
|
|
interface ReviewInput {
|
|
commit?: string
|
|
from?: string
|
|
to?: string
|
|
resume?: string
|
|
background?: string
|
|
exclude?: string
|
|
model?: string
|
|
concurrency?: number
|
|
timeoutMinutes?: number
|
|
overallTimeoutMinutes?: number
|
|
maxTools?: number
|
|
maxGitProcesses?: number
|
|
preview?: boolean
|
|
}
|
|
|
|
interface OcrInvocation {
|
|
command: string
|
|
prefixArgs: string[]
|
|
}
|
|
|
|
interface RunOptions {
|
|
cwd: string
|
|
timeoutMs?: number | null
|
|
maxOutputBytes?: number
|
|
invocation?: OcrInvocation
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
interface RunResult {
|
|
stdout: string
|
|
stderr: string
|
|
exitCode: number
|
|
}
|
|
|
|
class OcrExecutionError extends Error {
|
|
readonly exitCode: number | null
|
|
readonly stderr: string
|
|
readonly stdout: string
|
|
|
|
constructor(message: string, result: {
|
|
exitCode: number | null
|
|
stderr?: string
|
|
stdout?: string
|
|
}) {
|
|
super(message)
|
|
this.name = "OcrExecutionError"
|
|
this.exitCode = result.exitCode
|
|
this.stderr = result.stderr ?? ""
|
|
this.stdout = result.stdout ?? ""
|
|
}
|
|
}
|
|
|
|
function pushValue(args: string[], flag: string, value: string | number | undefined): void {
|
|
if (value !== undefined && value !== "") {
|
|
args.push(flag, String(value))
|
|
}
|
|
}
|
|
|
|
function buildReviewArgs(input: ReviewInput, repo: string): string[] {
|
|
const hasRange = input.from !== undefined || input.to !== undefined
|
|
if (hasRange && (!input.from || !input.to)) {
|
|
throw new Error("Both 'from' and 'to' are required for a branch comparison.")
|
|
}
|
|
if (input.commit && hasRange) {
|
|
throw new Error("Use either 'commit' or a 'from'/'to' range, not both.")
|
|
}
|
|
if (input.resume && (input.commit || hasRange)) {
|
|
throw new Error("'resume' cannot be combined with 'commit' or a 'from'/'to' range.")
|
|
}
|
|
if (input.preview && input.resume) {
|
|
throw new Error("'preview' and 'resume' cannot be used together.")
|
|
}
|
|
|
|
const args = ["review", "--audience", "agent"]
|
|
if (!input.preview) {
|
|
args.push("--format", "json")
|
|
}
|
|
args.push("--repo", repo)
|
|
|
|
pushValue(args, "--commit", input.commit)
|
|
pushValue(args, "--from", input.from)
|
|
pushValue(args, "--to", input.to)
|
|
pushValue(args, "--resume", input.resume)
|
|
pushValue(args, "--background", input.background)
|
|
pushValue(args, "--exclude", input.exclude)
|
|
pushValue(args, "--model", input.model)
|
|
pushValue(args, "--concurrency", input.concurrency)
|
|
pushValue(args, "--timeout", input.timeoutMinutes)
|
|
pushValue(args, "--max-tools", input.maxTools)
|
|
pushValue(args, "--max-git-procs", input.maxGitProcesses)
|
|
|
|
if (input.preview) {
|
|
args.push("--preview")
|
|
}
|
|
return args
|
|
}
|
|
|
|
function appendChunk(
|
|
chunks: Uint8Array[],
|
|
currentBytes: number,
|
|
chunk: Uint8Array,
|
|
maxBytes: number,
|
|
): number {
|
|
const nextBytes = currentBytes + chunk.byteLength
|
|
if (nextBytes > maxBytes) {
|
|
throw new Error(`OCR output exceeded the ${maxBytes}-byte safety limit.`)
|
|
}
|
|
chunks.push(chunk)
|
|
return nextBytes
|
|
}
|
|
|
|
async function runOcr(args: string[], options: RunOptions): Promise<RunResult> {
|
|
const invocation = options.invocation ?? { command: "ocr", prefixArgs: [] }
|
|
const timeoutMs = options.timeoutMs === undefined ? 15 * 60 * 1000 : options.timeoutMs
|
|
const maxOutputBytes = options.maxOutputBytes ?? 10 * 1024 * 1024
|
|
|
|
return await new Promise<RunResult>((resolve, reject) => {
|
|
const child = spawn(
|
|
invocation.command,
|
|
[...invocation.prefixArgs, ...args],
|
|
{
|
|
cwd: options.cwd,
|
|
env: process.env,
|
|
shell: false,
|
|
detached: true,
|
|
},
|
|
)
|
|
child.stdin.end()
|
|
|
|
const stdoutChunks: Uint8Array[] = []
|
|
const stderrChunks: Uint8Array[] = []
|
|
let outputBytes = 0
|
|
let settled = false
|
|
let closed = false
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
|
|
let abort: (() => void) | undefined
|
|
|
|
const finish = (callback: () => void): void => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timer)
|
|
if (abort) {
|
|
options.signal?.removeEventListener("abort", abort)
|
|
}
|
|
callback()
|
|
}
|
|
|
|
const killProcessGroup = (signal: NodeJS.Signals): void => {
|
|
if (closed || child.pid === undefined) return
|
|
if (process.platform === "win32") {
|
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" })
|
|
return
|
|
}
|
|
try {
|
|
process.kill(-child.pid, signal)
|
|
} catch {
|
|
child.kill(signal)
|
|
}
|
|
}
|
|
|
|
const terminateChild = (): void => {
|
|
if (closed) return
|
|
killProcessGroup("SIGTERM")
|
|
forceKillTimer ??= setTimeout(() => {
|
|
if (!closed) {
|
|
killProcessGroup("SIGKILL")
|
|
}
|
|
}, 3_000)
|
|
}
|
|
|
|
const failForOutputLimit = (error: Error): void => {
|
|
terminateChild()
|
|
finish(() => reject(error))
|
|
}
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
try {
|
|
outputBytes = appendChunk(stdoutChunks, outputBytes, chunk, maxOutputBytes)
|
|
} catch (error) {
|
|
failForOutputLimit(error as Error)
|
|
}
|
|
})
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
try {
|
|
outputBytes = appendChunk(stderrChunks, outputBytes, chunk, maxOutputBytes)
|
|
} catch (error) {
|
|
failForOutputLimit(error as Error)
|
|
}
|
|
})
|
|
|
|
child.on("error", (error) => {
|
|
const message = error.message.includes("ENOENT")
|
|
? "OpenCodeReview is not installed or 'ocr' is not on PATH. Install it with: npm install -g @alibaba-group/open-code-review"
|
|
: `Failed to start OpenCodeReview: ${error.message}`
|
|
finish(() => reject(new OcrExecutionError(message, { exitCode: null })))
|
|
})
|
|
|
|
child.on("close", (exitCode) => {
|
|
closed = true
|
|
clearTimeout(forceKillTimer)
|
|
finish(() => {
|
|
const result = {
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8").trim(),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf8").trim(),
|
|
exitCode: exitCode ?? 1,
|
|
}
|
|
if (exitCode !== 0) {
|
|
reject(new OcrExecutionError(
|
|
result.stderr || result.stdout || `OpenCodeReview exited with code ${result.exitCode}.`,
|
|
result,
|
|
))
|
|
return
|
|
}
|
|
resolve(result)
|
|
})
|
|
})
|
|
|
|
abort = (): void => {
|
|
terminateChild()
|
|
finish(() => reject(new OcrExecutionError(
|
|
"OpenCodeReview was cancelled by OpenCode.",
|
|
{
|
|
exitCode: null,
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
},
|
|
)))
|
|
}
|
|
options.signal?.addEventListener("abort", abort, { once: true })
|
|
|
|
if (timeoutMs !== null) {
|
|
timer = setTimeout(() => {
|
|
terminateChild()
|
|
finish(() => reject(new OcrExecutionError(
|
|
`OpenCodeReview timed out after ${Math.round(timeoutMs / 1000)} seconds.`,
|
|
{
|
|
exitCode: null,
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
},
|
|
)))
|
|
}, timeoutMs)
|
|
}
|
|
|
|
if (options.signal?.aborted) {
|
|
abort()
|
|
}
|
|
})
|
|
}
|
|
|
|
function formatReviewResult(result: RunResult, preview: boolean): string {
|
|
if (preview) {
|
|
return result.stdout || "No files changed."
|
|
}
|
|
if (result.stdout === "") {
|
|
return "No changes detected; OCR produced no output."
|
|
}
|
|
try {
|
|
JSON.parse(result.stdout)
|
|
return result.stdout
|
|
} catch {
|
|
throw new OcrExecutionError("OpenCodeReview returned invalid JSON.", result)
|
|
}
|
|
}
|
|
|
|
const optionalString = (description: string) =>
|
|
tool.schema.string().optional().describe(description)
|
|
|
|
const optionalPositiveInt = (description: string) =>
|
|
tool.schema.number().int().positive().optional().describe(description)
|
|
|
|
const reviewArgs = {
|
|
commit: optionalString("Review one commit against its parent."),
|
|
from: optionalString("Base ref for a branch/range comparison. Must be paired with 'to'."),
|
|
to: optionalString("Target ref for a branch/range comparison. Must be paired with 'from'."),
|
|
resume: optionalString("Resume a previous OCR review session by ID."),
|
|
background: optionalString("Business or requirement context that the implementation should satisfy."),
|
|
exclude: optionalString("Comma-separated gitignore-style exclusion patterns."),
|
|
model: optionalString("Override the model configured in OpenCodeReview."),
|
|
concurrency: optionalPositiveInt("Maximum concurrent file reviews."),
|
|
timeoutMinutes: optionalPositiveInt("Per-file OCR timeout in minutes."),
|
|
overallTimeoutMinutes: optionalPositiveInt(
|
|
"Optional wall-clock timeout for the complete OCR process in minutes.",
|
|
),
|
|
maxTools: optionalPositiveInt("Maximum tool-call rounds per subtask; OCR enforces a minimum of 50."),
|
|
maxGitProcesses: optionalPositiveInt("Maximum concurrent Git subprocesses."),
|
|
preview: tool.schema.boolean().optional().describe(
|
|
"List the files that would be reviewed without calling an LLM.",
|
|
),
|
|
}
|
|
|
|
export const OpenCodeReviewPlugin: Plugin = async ({ client, worktree }) => {
|
|
try {
|
|
await client.app.log({
|
|
body: {
|
|
service: "open-code-review",
|
|
level: "info",
|
|
message: "OpenCodeReview tools registered",
|
|
},
|
|
})
|
|
} catch {
|
|
// Best-effort telemetry; a failed log call must not block tool registration.
|
|
}
|
|
|
|
return {
|
|
config: async (config) => {
|
|
config.command ??= {}
|
|
config.command["ocr-review"] ??= {
|
|
description: "Review code changes with OpenCodeReview",
|
|
template:
|
|
"Use the ocr_review tool to review the requested target. " +
|
|
"Treat the following text as review intent, target details, and business context: $ARGUMENTS. " +
|
|
"If no target is specified, review the current workspace changes. " +
|
|
"Report findings by severity with exact file and line references.",
|
|
}
|
|
config.command["ocr-health"] ??= {
|
|
description: "Check OpenCodeReview and its LLM connection",
|
|
template:
|
|
"Use the ocr_health tool and explain any configuration problem concisely.",
|
|
}
|
|
},
|
|
tool: {
|
|
ocr_review: tool({
|
|
description:
|
|
"Run OpenCodeReview on workspace changes, one commit, or a ref range. " +
|
|
"Returns structured line-level findings as JSON. Use preview=true to inspect scope without LLM usage.",
|
|
args: reviewArgs,
|
|
async execute(args, context) {
|
|
const input = args as ReviewInput
|
|
const cwd = context.worktree || context.directory || worktree
|
|
const defaultOverallMs = 30 * 60 * 1000
|
|
const options: RunOptions = {
|
|
cwd,
|
|
signal: context.abort,
|
|
timeoutMs: input.overallTimeoutMinutes !== undefined
|
|
? input.overallTimeoutMinutes * 60 * 1000
|
|
: defaultOverallMs,
|
|
}
|
|
const result = await runOcr(buildReviewArgs(input, cwd), options)
|
|
return formatReviewResult(result, input.preview === true)
|
|
},
|
|
}),
|
|
ocr_health: tool({
|
|
description:
|
|
"Check the installed OpenCodeReview version and verify its configured LLM connection.",
|
|
args: {},
|
|
async execute(_args, context) {
|
|
const cwd = context.worktree || context.directory || worktree
|
|
const [version, llm] = await Promise.allSettled([
|
|
runOcr(["version"], {
|
|
cwd,
|
|
timeoutMs: 30_000,
|
|
signal: context.abort,
|
|
}),
|
|
runOcr(["llm", "test"], {
|
|
cwd,
|
|
timeoutMs: 60_000,
|
|
signal: context.abort,
|
|
}),
|
|
])
|
|
const rejected = [version, llm].find(
|
|
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
)
|
|
if (context.abort?.aborted && rejected) {
|
|
throw rejected.reason
|
|
}
|
|
|
|
const parts: string[] = []
|
|
if (version.status === "fulfilled") {
|
|
parts.push(version.value.stdout)
|
|
} else {
|
|
parts.push(`Version check failed: ${version.reason?.message ?? "unknown error"}`)
|
|
}
|
|
if (llm.status === "fulfilled") {
|
|
parts.push(llm.value.stdout, llm.value.stderr)
|
|
} else {
|
|
parts.push(`LLM connection check failed: ${llm.reason?.message ?? "unknown error"}`)
|
|
}
|
|
return parts.filter(Boolean).join("\n")
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenCode 2.x entrypoint (https://opencode.ai/v2/docs/build/plugins).
|
|
// The default export at the bottom of this file serves both versions: V2
|
|
// reads `id` + `setup`, V1 reads `server`. All OCR logic above is shared.
|
|
//
|
|
// The V2 API is imported as types only (`import type`), and the definition
|
|
// below is a plain object literal: `Plugin.define` is an identity function,
|
|
// so V1 never needs the `@opencode/plugin` package at runtime.
|
|
//
|
|
// V2 limitations (no equivalent in the V2 tool API): tool execution has no
|
|
// abort signal, so cancellation relies on the overall timeout, and the
|
|
// per-session working directory is resolved from the session location.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OCR_REVIEW_DESCRIPTION =
|
|
"Run OpenCodeReview on workspace changes, one commit, or a ref range. " +
|
|
"Returns structured line-level findings as JSON. Use preview=true to inspect scope without LLM usage."
|
|
|
|
const OCR_HEALTH_DESCRIPTION =
|
|
"Check the installed OpenCodeReview version and verify its configured LLM connection."
|
|
|
|
const OCR_REVIEW_COMMAND_TEMPLATE =
|
|
"Use the ocr_review tool to review the requested target. " +
|
|
"Treat the following text as review intent, target details, and business context:"
|
|
|
|
const OCR_REVIEW_COMMAND_SUFFIX =
|
|
". If no target is specified, review the current workspace changes. " +
|
|
"Report findings by severity with exact file and line references."
|
|
|
|
const OCR_HEALTH_COMMAND_TEMPLATE =
|
|
"Use the ocr_health tool and explain any configuration problem concisely."
|
|
|
|
const reviewInputSchema = {
|
|
type: "object",
|
|
properties: {
|
|
commit: { type: "string", description: "Review one commit against its parent." },
|
|
from: { type: "string", description: "Base ref for a branch/range comparison. Must be paired with 'to'." },
|
|
to: { type: "string", description: "Target ref for a branch/range comparison. Must be paired with 'from'." },
|
|
resume: { type: "string", description: "Resume a previous OCR review session by ID." },
|
|
background: { type: "string", description: "Business or requirement context that the implementation should satisfy." },
|
|
exclude: { type: "string", description: "Comma-separated gitignore-style exclusion patterns." },
|
|
model: { type: "string", description: "Override the model configured in OpenCodeReview." },
|
|
concurrency: { type: "integer", minimum: 1, description: "Maximum concurrent file reviews." },
|
|
timeoutMinutes: { type: "integer", minimum: 1, description: "Per-file OCR timeout in minutes." },
|
|
overallTimeoutMinutes: { type: "integer", minimum: 1, description: "Optional wall-clock timeout for the complete OCR process in minutes." },
|
|
maxTools: { type: "integer", minimum: 1, description: "Maximum tool-call rounds per subtask; OCR enforces a minimum of 50." },
|
|
maxGitProcesses: { type: "integer", minimum: 1, description: "Maximum concurrent Git subprocesses." },
|
|
preview: { type: "boolean", description: "List the files that would be reviewed without calling an LLM." },
|
|
},
|
|
required: [],
|
|
additionalProperties: false,
|
|
}
|
|
|
|
async function resolveSessionCwd(ctx: PluginV2.Context, sessionID: string): Promise<string> {
|
|
try {
|
|
const session = await ctx.session.get({ sessionID })
|
|
const directory = session.location.directory
|
|
const subpath = session.subpath
|
|
return subpath === undefined || subpath === "" ? directory : join(directory, subpath)
|
|
} catch {
|
|
return ctx.location.directory
|
|
}
|
|
}
|
|
|
|
async function setupV2(ctx: PluginV2.Context): Promise<void> {
|
|
await ctx.tool.transform((editor) => {
|
|
editor.add({
|
|
name: "ocr_review",
|
|
description: OCR_REVIEW_DESCRIPTION,
|
|
input: reviewInputSchema,
|
|
execute: async (input, toolCtx) => {
|
|
const review = input as ReviewInput
|
|
const cwd = await resolveSessionCwd(ctx, toolCtx.sessionID)
|
|
const result = await runOcr(buildReviewArgs(review, cwd), {
|
|
cwd,
|
|
timeoutMs: review.overallTimeoutMinutes !== undefined
|
|
? review.overallTimeoutMinutes * 60 * 1000
|
|
: 30 * 60 * 1000,
|
|
})
|
|
return { content: formatReviewResult(result, review.preview === true) }
|
|
},
|
|
})
|
|
editor.add({
|
|
name: "ocr_health",
|
|
description: OCR_HEALTH_DESCRIPTION,
|
|
input: { type: "object", properties: {}, additionalProperties: false },
|
|
execute: async (_input, toolCtx) => {
|
|
const cwd = await resolveSessionCwd(ctx, toolCtx.sessionID)
|
|
const [version, llm] = await Promise.allSettled([
|
|
runOcr(["version"], { cwd, timeoutMs: 30_000 }),
|
|
runOcr(["llm", "test"], { cwd, timeoutMs: 60_000 }),
|
|
])
|
|
const parts: string[] = []
|
|
if (version.status === "fulfilled") {
|
|
parts.push(version.value.stdout)
|
|
} else {
|
|
parts.push(`Version check failed: ${version.reason?.message ?? "unknown error"}`)
|
|
}
|
|
if (llm.status === "fulfilled") {
|
|
parts.push(llm.value.stdout, llm.value.stderr)
|
|
} else {
|
|
parts.push(`LLM connection check failed: ${llm.reason?.message ?? "unknown error"}`)
|
|
}
|
|
return { content: parts.filter(Boolean).join("\n") }
|
|
},
|
|
})
|
|
})
|
|
|
|
// Like the V1 `??=` guards above, never override commands the user
|
|
// already defined under the same names.
|
|
const registeredCommands = await ctx.command.list()
|
|
const commandNames = new Set(registeredCommands.data.map((command) => command.name))
|
|
|
|
await ctx.command.transform((editor) => {
|
|
// Text-only prompts, matching the V1 $ARGUMENTS templates: spreading the
|
|
// incoming prompt attachments would violate exactOptionalPropertyTypes
|
|
// and risk stale attachment offsets after the text rewrite.
|
|
if (!commandNames.has("ocr-review")) {
|
|
editor.add({
|
|
name: "ocr-review",
|
|
description: "Review code changes with OpenCodeReview",
|
|
execute: async ({ sessionID, prompt, delivery }) => {
|
|
await ctx.session.prompt({
|
|
sessionID,
|
|
text: `${OCR_REVIEW_COMMAND_TEMPLATE}${prompt.text ?? ""}${OCR_REVIEW_COMMAND_SUFFIX}`,
|
|
delivery,
|
|
})
|
|
},
|
|
})
|
|
}
|
|
if (!commandNames.has("ocr-health")) {
|
|
editor.add({
|
|
name: "ocr-health",
|
|
description: "Check OpenCodeReview and its LLM connection",
|
|
execute: async ({ sessionID, delivery }) => {
|
|
await ctx.session.prompt({
|
|
sessionID,
|
|
text: OCR_HEALTH_COMMAND_TEMPLATE,
|
|
delivery,
|
|
})
|
|
},
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
export default {
|
|
id: "open-code-review",
|
|
setup: setupV2,
|
|
server: OpenCodeReviewPlugin,
|
|
}
|