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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .opencode-review/agents/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ You are an expert code reviewer specializing in identifying bugs, security issue
Delivery is enforced by the runtime. When the run says `Delivery mode: draft`,
`gitea-review` only saves a PENDING review for human editing and exports its
summary locally. A verified saved/reused draft or a draft already submitted by
the reviewer completes the task. Stop then; never try to publish it through any
the reviewer completes the model's task. The trusted runner waits for human
submission and then automatically attaches the local summary as a PR comment.
Stop then; never try to publish it through any
other tool. Draft conflicts or edits require the reviewer to finish in the UI.
The mandatory tool call below applies to both delivery modes; "submit" means
deliver to the configured mode, not permission to publish a draft.
Expand Down
20 changes: 20 additions & 0 deletions .opencode-review/draft-watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { configuredReviewTarget, GiteaClient } from "./lib/gitea"
import { watchDraftSummary } from "./lib/draft-summary"

export async function runDraftWatcher(key: string) {
const controller = new AbortController()
const stop = () => controller.abort(new Error("DRAFT_WAIT_INTERRUPTED: Waiting stopped; resume with the same output mount and draft key"))
process.once("SIGINT", stop)
process.once("SIGTERM", stop)
try {
return await watchDraftSummary(new GiteaClient(), configuredReviewTarget(true)!, key, { signal: controller.signal })
} finally {
process.removeListener("SIGINT", stop)
process.removeListener("SIGTERM", stop)
}
}

if (import.meta.main) {
try { await runDraftWatcher(process.argv[2] ?? "") }
catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
}
9 changes: 5 additions & 4 deletions .opencode-review/lib/draft-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { commentsDigest } from "./review-confirmation"
import { digest, readJSON, stateDirectory, writeJSON, writeReceipt, type Snapshot } from "./review-state"

export interface DraftComment { path: string; body: string; new_position: number; old_position: number }
interface DraftRecord {
export interface DraftRecord {
key: string
target: ReturnType<typeof receiptTarget>
userId: number
Expand All @@ -18,6 +18,7 @@ interface DraftRecord {
recommendation: string
nativeBody: string
reviewId?: number
summaryPublication?: { body: string; commentId?: number }
}
interface TimelineEntry { id: number; type: string; review_id?: number; user?: { id: number }; body?: string }

Expand Down Expand Up @@ -90,7 +91,7 @@ export async function saveDraft(api: GiteaClient, args: RepositoryPR, snapshot:
if (!record) {
record = { key, target, userId, head: snapshot.head, base: snapshot.base, context: snapshot.context,
summary, comments, commentsDigest: commentsDigest(comments), recommendation,
nativeBody: `OpenCode draft: copy the locally exported Markdown summary into the review form before submitting.\n\n<!-- opencode-review-draft:v1 ${key} -->` }
nativeBody: `OpenCode draft: edit the pending comments and submit in the native UI. Keep the review container running; it will attach the AI summary after submission.\n\n<!-- opencode-review-draft:v1 ${key} -->` }
await exportDraft(record)
// Persist before POST: an ambiguous response must never cause another POST.
await writeJSON(recordPath, record)
Expand Down Expand Up @@ -143,7 +144,7 @@ export async function saveDraft(api: GiteaClient, args: RepositoryPR, snapshot:
await checkCurrent(api, args, snapshot)
const artifacts = await exportDraft(record)
const url = `${api.baseURL}/${args.owner}/${args.repo}/pulls/${args.pull_number}/files`
await writeReceipt({ mode: "draft", status, target, head: record.head, reviewId: current.id, url, artifacts, warnings })
return `${status === "draft_submitted" ? "Draft already submitted by reviewer; no new review created" : "Draft saved and verified; not published"}: ${url}\nCommit: ${record.head}\nSummary: ${artifacts.markdown}\nData: ${artifacts.json}\n${warnings.join("\n")}\nUse the same account in the native UI. Copy the Markdown summary, edit the pending comments, then manually submit.`
await writeReceipt({ mode: "draft", status, target, head: record.head, reviewId: current.id, url, artifacts, warnings, draftKey: key })
return `${status === "draft_submitted" ? "Draft already submitted by reviewer; no new review created" : "Draft saved and verified; not published"}: ${url}\nCommit: ${record.head}\nSummary: ${artifacts.markdown}\nData: ${artifacts.json}\n${warnings.join("\n")}\nUse the same account in the native UI. Edit the pending comments and manually submit. The runner will attach the summary automatically after submission; keep the container running.`
} finally { await rm(lock, { recursive: true, force: true }) }
}
107 changes: 107 additions & 0 deletions .opencode-review/lib/draft-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { mkdir, readFile, rm } from "node:fs/promises"
import { join, resolve } from "node:path"
import { GiteaClient, matchesReviewTarget, PRChangedError, prPath, pullRefHead, receiptTarget, requireOpen, type PullRequest, type RepositoryPR, type Review } from "./gitea"
import { digest, readJSON, stateDirectory, writeJSON } from "./review-state"
import type { DraftRecord } from "./draft-review"

interface Comment { id: number; body: string; user: { id: number } }
export function draftWaitSeconds() {
const seconds = Number(process.env.DRAFT_WAIT_TIMEOUT_SECONDS ?? 3600)
if (!Number.isSafeInteger(seconds) || seconds < 0) throw new Error("DRAFT_WAIT_TIMEOUT_SECONDS must be a non-negative integer")
return seconds
}

// This runs outside the model. It never submits a review or edits human content.
export async function watchDraftSummary(api: GiteaClient, args: RepositoryPR, key: string,
options: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}) {
if (!process.env.REVIEW_OUTPUT_DIR) throw new Error("DRAFT_OUTPUT_REQUIRED: Mount the original REVIEW_OUTPUT_DIR to resume")
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error("DRAFT_STATE_REQUIRED: A valid persisted draft key is required")
const timeout = options.timeoutMs ?? draftWaitSeconds() * 1000
const recordPath = join(stateDirectory(), `draft-${key}.json`)
const target = receiptTarget(api.baseURL, args)
const requestOptions = { signal: options.signal }
const user = await api.json<{ id: number }>("/user", requestOptions)
const lock = join(stateDirectory(), `draft-lock-${digest(JSON.stringify([target, user.id]))}`)
await mkdir(stateDirectory(), { recursive: true, mode: 0o700 })
try { await mkdir(lock, { mode: 0o700 }) }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("DRAFT_BUSY: Another run owns this account/PR lock")
throw error
}
try {
const record = await readJSON<DraftRecord>(recordPath)
if (!record || record.key !== key || !matchesReviewTarget(record.target, target) || record.userId !== user.id ||
!Number.isSafeInteger(record.reviewId) || record.reviewId! <= 0) {
throw new Error("DRAFT_STATE_REQUIRED: Draft state does not match this account and target; no summary published")
}
const path = prPath(args)
const commentsPath = `${path.slice(0, path.lastIndexOf("/pulls/"))}/issues/${args.pull_number}/comments`
const marker = `<!-- opencode-review-draft-summary:v1 ${key} review=${record.reviewId} -->`
async function currentReview() {
const review = await api.json<Review>(`${path}/reviews/${record!.reviewId}`, requestOptions)
if (review.user.id !== user.id || review.commit_id !== record!.head || review.dismissed ||
!["PENDING", "COMMENT", "APPROVED", "REQUEST_CHANGES"].includes(review.state)) {
throw new Error("DRAFT_CHANGED: Review owner, commit, dismissal or state changed; no summary published")
}
const pr = await api.json<PullRequest>(path, requestOptions)
requireOpen(pr)
if (pr.head.sha !== record!.head || pr.base.sha !== record!.base || await pullRefHead(api, args, requestOptions) !== record!.head) {
throw new PRChangedError("The draft snapshot is stale; no summary published")
}
return review
}
const deadline = Date.now() + timeout
console.log(`Waiting for reviewer to submit: ${api.baseURL}/${args.owner}/${args.repo}/pulls/${args.pull_number}/files`)
console.log(`Keep this container running. The summary will appear automatically after submission. Resume key: ${key}`)
while (true) {
options.signal?.throwIfAborted()
const review = await currentReview()
if (review.state !== "PENDING") break
if (Date.now() >= deadline) throw new Error(`DRAFT_WAIT_TIMEOUT: Review is still pending. Resume with the same output mount and target: draft-watch ${key}`)
await new Promise<void>((resolve, reject) => {
const signal = options.signal
const abort = () => { clearTimeout(timer); reject(signal!.reason) }
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve() }, Math.min(options.pollMs ?? 3000, Math.max(0, deadline - Date.now())))
signal?.addEventListener("abort", abort, { once: true })
if (signal?.aborted) { signal.removeEventListener("abort", abort); abort() }
})
}
const url = (id: number) => `${api.baseURL}/${args.owner}/${args.repo}/pulls/${args.pull_number}#issuecomment-${id}`
if (record.summaryPublication?.commentId) {
console.log(`AI summary already attached: ${url(record.summaryPublication.commentId)}`)
return url(record.summaryPublication.commentId)
}
const findPublished = async () => {
const matches = (await api.list<Comment>(commentsPath, requestOptions)).filter(comment => comment.user.id === user.id && comment.body.includes(marker))
if (matches.length > 1) throw new Error("DRAFT_SUMMARY_AMBIGUOUS: Multiple matching summary comments; no further write")
return matches[0]
}
let published = await findPublished()
if (!published && !record.summaryPublication) {
// Read the editable local export only after the human submits. Never put
// confidential findings in a PENDING body, which some timeline APIs leak.
const markdown = await readFile(join(resolve(process.env.REVIEW_OUTPUT_DIR!), `review-${key}.md`), "utf8")
if (!markdown.trim()) throw new Error("DRAFT_SUMMARY_EMPTY: Local summary is empty; no comment published")
record.summaryPublication = { body: `### AI review summary\n\nAutomatically attached after the reviewer submitted review \`${record.reviewId}\`. Reviewed commit: \`${record.head}\`.\n\n${markdown.trim()}\n\n${marker}` }
const confirmed = await currentReview()
if (confirmed.state === "PENDING") throw new Error("DRAFT_CHANGED: The review is pending again; no summary published")
options.signal?.throwIfAborted()
// Record intent before POST. An uncertain response or restart only reads
// back this marker; it must never repeat a potentially successful write.
await writeJSON(recordPath, record)
try {
await api.request(commentsPath, { ...requestOptions, method: "POST", body: JSON.stringify({ body: record.summaryPublication.body }) })
} catch { /* Reconcile an accepted POST even if its response was lost. */ }
published = await findPublished()
}
if (!published) throw new Error("DRAFT_SUMMARY_UNKNOWN: Publication could not be confirmed; preserve state. No automatic POST retry")
// A new publication must read back exactly. Later human edits are preserved.
if (record.summaryPublication && published.body !== record.summaryPublication.body) {
throw new Error("DRAFT_SUMMARY_CHANGED: Published summary differs from the saved intent; no content overwritten")
}
record.summaryPublication = { body: published.body, commentId: published.id }
await writeJSON(recordPath, record)
console.log(`AI summary attached: ${url(published.id)}`)
return url(published.id)
} finally { await rm(lock, { recursive: true, force: true }) }
}
11 changes: 6 additions & 5 deletions .opencode-review/lib/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export class GiteaClient {
if (options.body) headers.set("Content-Type", "application/json")
// Never automatically retry a write: a lost response does not imply a failed write.
const response = await fetch(`${this.baseURL}/api/v1${endpoint}`, {
...options, headers, signal: options.signal ?? AbortSignal.timeout(timeoutMs()),
...options, headers, signal: options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs())]) : AbortSignal.timeout(timeoutMs()),
})
if (!response.ok) {
const detail = (await response.text()).slice(0, 1000)
Expand All @@ -106,10 +107,10 @@ export class GiteaClient {
return response.text()
}

async list<T>(endpoint: string): Promise<T[]> {
async list<T>(endpoint: string, options?: RequestInit): Promise<T[]> {
const result: T[] = []
for (let page = 1; page <= 1000; page++) {
const response = await this.request(`${endpoint}${endpoint.includes("?") ? "&" : "?"}limit=50&page=${page}`)
const response = await this.request(`${endpoint}${endpoint.includes("?") ? "&" : "?"}limit=50&page=${page}`, options)
const rows = await response.json() as T[]
if (!Array.isArray(rows)) throw new Error(`Expected a list from ${endpoint}`)
result.push(...rows)
Expand Down Expand Up @@ -143,10 +144,10 @@ export function prPath(pr: RepositoryPR) {
return `/repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/pulls/${pr.pull_number}`
}

export async function pullRefHead(api: GiteaClient, args: RepositoryPR): Promise<string | undefined> {
export async function pullRefHead(api: GiteaClient, args: RepositoryPR, options?: RequestInit): Promise<string | undefined> {
const path = prPath(args)
const refs = await api.json<{ ref: string; object: { sha: string } }[]>(
`${path.slice(0, path.lastIndexOf("/pulls/"))}/git/refs/pull/${args.pull_number}/head`)
`${path.slice(0, path.lastIndexOf("/pulls/"))}/git/refs/pull/${args.pull_number}/head`, options)
return refs.find(ref => ref.ref === `refs/pull/${args.pull_number}/head`)?.object.sha
}

Expand Down
1 change: 1 addition & 0 deletions .opencode-review/lib/review-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Receipt {
reviewId: number
url: string
artifacts?: { json: string; markdown: string }
draftKey?: string
warnings?: string[]
}

Expand Down
7 changes: 6 additions & 1 deletion .opencode-review/run-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { tmpdir } from "node:os"
import { readJSON, stateDirectory, type Receipt } from "./lib/review-state"
import { configuredReviewTarget, matchesReviewTarget, reviewURL } from "./lib/gitea"
import { reviewMode } from "./lib/review-mode"
import { draftWaitSeconds } from "./lib/draft-summary"
import { runDraftWatcher } from "./draft-watch"

export async function runReview(prompt: string) {
const mode = reviewMode()
const wait = mode === "draft" ? draftWaitSeconds() : 0
const target = configuredReviewTarget(true)!
const runID = crypto.randomUUID()
const state = resolve(stateDirectory())
Expand Down Expand Up @@ -46,8 +49,10 @@ export async function runReview(prompt: string) {
}
console.log(`Verified ${mode === "draft" ? "draft" : "review"}: ${receipt.url} (${receipt.head}, ${receipt.status})`)
if (mode === "draft") {
console.log(`Copy the summary from: ${receipt.artifacts!.markdown}`)
console.log(`Local summary preview: ${receipt.artifacts!.markdown}`)
for (const warning of receipt.warnings ?? []) console.warn(warning)
if (wait > 0) await runDraftWatcher(receipt.draftKey ?? "")
else console.log(`Draft saved without waiting (DRAFT_WAIT_TIMEOUT_SECONDS=0). To attach the summary after submission, resume: draft-watch ${receipt.draftKey ?? "<draft-key>"}`)
}
await rm(receiptPath, { force: true })
return 0
Expand Down
2 changes: 1 addition & 1 deletion .opencode-review/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["lib/**/*.ts", "run-review.ts", "tools/*.ts", "env.d.ts"]
"include": ["lib/**/*.ts", "run-review.ts", "draft-watch.ts", "tools/*.ts", "env.d.ts"]
}
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,11 +392,23 @@ docker run --rm \
"${REVIEW_IMAGE:-ghcr.io/ccsert/opencode-review:latest}"
```

Use the actual human reviewer's token. Open the returned **Files** page with that
same account, edit its pending line comments, copy the exported Markdown summary
into the review form, and manually choose Comment, Approve or Request Changes.
The native editor does not prefill summaries. AI summaries stay local: some
server versions expose pending review bodies through their timeline API.
Use the actual human reviewer's token. When the runner starts waiting, keep the
container running, open the returned **Files** page with that account, edit the
pending line comments, and choose Comment, Approve or Request Changes. After
submission the container automatically attaches the summary as a separate PR
comment identifying the review and commit. No copy/paste is needed. Any human
text in the native review form is preserved. Before submission the AI summary
stays local, because some server versions expose pending review bodies through
their timeline API. You may preview or edit the exported Markdown locally.

The default wait is one hour (`DRAFT_WAIT_TIMEOUT_SECONDS=3600`). After a timeout
or interruption, use the same output mount, account and target with
`draft-watch <draft-key>` to resume without calling the model again. Setting the
timeout to `0` saves without waiting and requires that separate watcher command.

Editing/deleting line comments does not regenerate the summary. Preview or
edit the local Markdown when your changes affect its conclusions; that file
is what the watcher publishes after submission.

Repeated draft runs reuse the original review. Manual edits and existing pending
reviews are preserved; conflicts stop the run. A manually submitted draft is
Expand Down
8 changes: 6 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,13 @@ docker run --rm \
"${REVIEW_IMAGE:-ghcr.io/ccsert/opencode-review:latest}"
```

使用**实际审查者账号的 Token**。运行结束后,以同一账号打开返回的 Files 页面,修改待定行评论,把导出的 Markdown 总结复制到评审框,再手动选择“评论 / 通过 / 请求变更”。原生页面不会自动预填总结。
使用**实际审查者账号的 Token**。出现等待提示后,保持 Docker 容器运行,以同一账号打开返回的 Files 页面,修改待定行评论,再手动选择“评论 / 通过 / 请求变更”。提交后,容器自动把总结作为一条关联该评审和代码版本的 PR 评论发布,无需复制粘贴。你在原生评审框填写的补充意见会保留。

AI 总结仅保存在本地,因为部分服务器版本的时间线 API 会返回待定 review 的正文。重复运行复用原草稿;已有人工草稿或被人工修改的内容会触发停止,避免覆盖。手动提交后可识别原 review,且不会把它算成自动增量审查基准。
提交前 AI 总结仅保存在本地,因为部分服务器版本的时间线 API 会返回待定 review 的正文;可查看或编辑导出的 Markdown,提交后会自动读取它。默认等待一小时,可用 `DRAFT_WAIT_TIMEOUT_SECONDS` 调整。超时或中断后,用相同挂载、账号和目标运行 `draft-watch <草稿 key>` 即可继续等待,无需再次调用模型。设为 `0` 时只保存草稿,需要另行启动等待命令。

重复运行复用原草稿;已有人工草稿或被人工修改的内容会触发停止,避免覆盖。等待程序允许正常的人工编辑,且不会把人工提交算成自动增量审查基准。

修改或删除行评论不会自动重写总结。总结的预览、编辑目前通过本地 Markdown 完成;如果人工修改影响总结中的结论,请同步修改该文件,提交后会自动发布它。

生命周期、并发限制、恢复方法及 Forgejo 时间线显示限制见 [草稿模式说明](docs/draft-review.md)。

Expand Down
Loading
Loading