Files
superwork/vscode/src/webhook/coordinator.ts
T

1684 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Module-level singleton that owns the webhook HTTP server lifecycle for the
* whole extension. Lives from `activate()` to window close, so PR callbacks
* keep arriving even when the Kanban panel is closed.
*
* Ownership moved here from `KanbanWebviewPanel` so that closing the panel
* (or switching focus away from it) no longer cancels in-flight implement
* flows. The panel only registers itself as the "active panel" for refresh
* callbacks while it's open.
*/
import type { ExtensionContext } from 'vscode'
import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { KanbanScope } from '../panel/kanbanScope'
import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server'
import { promises as fsp } from 'node:fs'
import { env, Uri, window, workspace } from 'vscode'
import { loginForToken } from '../auth/identity'
import { getToken } from '../auth/secrets'
import { getReviewPrompt } from '../cc/prompts'
import { isMailboxIssue } from '../cc/sessionHandoff'
import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
import { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership'
import { decideIssueAppend } from './issueAppend'
import { WebhookServer } from './server'
/**
* workspaceState key for persisting in-flight webhook registrations across
* VS Code reloads. Kept on workspaceState (not globalState) because the
* gitea hooks are repo-scoped, which is workspace-scoped.
*/
const PENDING_HOOKS_KEY = 'superpowers.pendingHooks'
export interface PendingHook {
hookId: number
host: string
owner: string
repo: string
feature: string
}
/**
* A {@link PrWebhookEvent} whose `issueNumber` has been resolved (either from
* the legacy `/webhook/:n` path or via PR-body / branch heuristics).
*/
type ResolvedWebhookEvent = Omit<PrWebhookEvent, 'issueNumber'> & { issueNumber: number }
class WebhookCoordinator {
private ctx?: ExtensionContext
private server?: WebhookServer
// kept for legacy cleanup; new flow uses one shared webhook
private pendingHooks = new Map<number, PendingHook>()
private activePanel: KanbanWebviewPanel | undefined
private initialized = false
private eventSubscription?: { dispose: () => void }
/** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */
private reviewInFlight = new Set<number>()
/** Called once from extension.ts activate(). Idempotent. */
init(ctx: ExtensionContext): void {
if (this.initialized) {
logger.add({
level: 'info',
source: 'webhook',
message: 'coordinator.init 重复调用,忽略',
})
return
}
this.initialized = true
this.ctx = ctx
logger.add({
level: 'info',
source: 'webhook',
message: 'coordinator 初始化中',
})
// Restore pending webhook registrations from workspaceState.
const stored = ctx.workspaceState.get<Record<string, PendingHook>>(PENDING_HOOKS_KEY) ?? {}
for (const [k, v] of Object.entries(stored)) {
const n = Number(k)
if (Number.isFinite(n) && v && typeof v === 'object')
this.pendingHooks.set(n, v)
}
this.server = new WebhookServer()
this.server.setSecret(getSettings(ctx).webhookSecret)
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
void this.handleEvent(event)
})
const port = getSettings(ctx).webhookPort
this.server.start(port).then(() => {
logger.add({
level: 'info',
source: 'webhook',
message: `coordinator 启动成功 port=${port}`,
})
}).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'error',
source: 'webhook',
message: 'coordinator 启动失败',
details: message,
})
})
}
/** Stops the HTTP server. Called via ctx.subscriptions when the window closes. */
async dispose(): Promise<void> {
if (!this.initialized)
return
logger.add({
level: 'info',
source: 'webhook',
message: 'coordinator 停止中',
})
this.eventSubscription?.dispose()
this.eventSubscription = undefined
const srv = this.server
this.server = undefined
if (srv) {
try {
await srv.stop()
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'coordinator 停止失败',
details: message,
})
}
}
this.activePanel = undefined
this.initialized = false
logger.add({
level: 'info',
source: 'webhook',
message: 'coordinator 已停止',
})
}
/** Restart the server only if `port` differs from the currently-bound one. */
async ensurePort(port: number): Promise<void> {
const srv = this.server
if (!srv)
return
if (srv.currentPort === port)
return
logger.add({
level: 'info',
source: 'webhook',
message: `coordinator 切换端口 ${srv.currentPort ?? '?'}${port}`,
})
await srv.stop()
await srv.start(port)
}
/** 设置面板保存后热更新签名 secret,无需重启监听。 */
ensureSecret(secret: string): void {
this.server?.setSecret(secret)
}
/** Register a pending hook after createWebhook on gitea succeeded. */
async addPending(issueNumber: number, info: PendingHook): Promise<void> {
this.pendingHooks.set(issueNumber, info)
await this.persist()
logger.add({
level: 'info',
source: 'webhook',
message: `addPending #${issueNumber} hookId=${info.hookId}`,
details: `${info.host}/${info.owner}/${info.repo} feature=${info.feature}`,
})
}
/** Remove a pending hook (no-op if absent). Persists. */
async removePending(issueNumber: number): Promise<void> {
if (!this.pendingHooks.has(issueNumber))
return
this.pendingHooks.delete(issueNumber)
await this.persist()
logger.add({
level: 'info',
source: 'webhook',
message: `removePending #${issueNumber}`,
})
}
/** Read current map (defensive copy). */
snapshot(): Map<number, PendingHook> {
return new Map(this.pendingHooks)
}
/** Webview panel binding for refresh + toast. Pass undefined when closing. */
setActivePanel(p: KanbanWebviewPanel | undefined): void {
this.activePanel = p
}
private async persist(): Promise<void> {
if (!this.ctx)
return
const obj: Record<string, PendingHook> = {}
for (const [k, v] of this.pendingHooks)
obj[String(k)] = v
await this.ctx.workspaceState.update(PENDING_HOOKS_KEY, obj)
}
/**
* Resolve the repo context for an incoming webhook event. The pendingHooks
* map is only a convenience cache — the webhook URL path itself proves the
* issue belongs to us, so a cache miss is recoverable via detectRepo. Returns
* null only when there's no workspace, no origin remote, or no saved token.
*/
private async resolveRepoContext(issueNumber: number): Promise<{
hookId: number | undefined
host: string
owner: string
repo: string
token: string
} | null> {
if (!this.ctx)
return null
const pending = this.pendingHooks.get(issueNumber)
if (pending) {
const tok = await getToken(this.ctx, pending.host)
if (!tok)
return null
return { hookId: pending.hookId, host: pending.host, owner: pending.owner, repo: pending.repo, token: tok }
}
// Recover via detectRepo + getToken when the in-memory map is empty.
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!ws)
return null
const remote = await detectRepo(ws)
if (!remote)
return null
const tok = await getToken(this.ctx, remote.host)
if (!tok)
return null
logger.add({
level: 'info',
source: 'webhook',
message: `未在 pendingHooks 中找到 #${issueNumber},已通过 detectRepo 恢复仓库上下文`,
})
return { hookId: undefined, host: remote.host, owner: remote.owner, repo: remote.repo, token: tok }
}
/**
* 多人协作门禁:webhook 广播到每位开发者的机器,只有"责任人机器"执行
* 副作用(写 state JSON / 触发 codex 审查 / 注入终端),其余机器只刷
* 本机 UI。判定规则见 {@link decideEventOwnership};任何一步失败都退
* 化为 observe——宁可漏做(可人工补)也不能多机重复做。
*
* `evidenceKinds` 是无 assignee 存量工单的兜底证据(本机终端类型);
* PR 事件默认只认实施/测试终端,issue edited 额外认规划终端。
*/
private async resolveOwnership(
issueNumber: number,
ctx: { host: string, owner: string, repo: string, token: string },
evidenceKinds?: readonly string[],
): Promise<'own' | 'observe'> {
let assignees: string[] = []
try {
const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber })
assignees = (issue?.assignees ?? []).map(a => a.login)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 #${issueNumber} assignee 失败,按无 assignee 兜底`,
details: message,
})
}
const me = await loginForToken(ctx.host, ctx.token)
const hasLocalSessionEvidence = hasLiveIssueSessionTerminal(issueNumber, evidenceKinds)
const decision = decideEventOwnership({ assignees, me, hasLocalSessionEvidence })
logger.add({
level: 'info',
source: 'webhook',
message: `归属判定 #${issueNumber}: ${decision}`,
details: `assignees=[${assignees.join(',')}] me=${me ?? '<未知>'} localTab=${hasLocalSessionEvidence}`,
})
return decision
}
/**
* Process a single `pull_request` webhook delivery: merge the PR number
* into the issue's state-JSON comment, delete the gitea webhook, and (if
* a panel is open) refresh the kanban so the new fields surface
* immediately. Always shows a toast — `window.showInformationMessage`
* works regardless of panel state.
*/
/**
* Resolve which issue an incoming webhook event belongs to.
*
* Order of attempts:
* 1. `event.issueNumber` (set by the legacy `/webhook/:n` path).
* 2. Parse PR body for `Closes #N` / `Fixes #N` / `Resolves #N` (and
* the short forms `close`/`fix`/`resolve`). First match wins,
* case-insensitive.
* 3. Branch-name fallback: scan kanban issues for one whose `branch`
* matches the event's head branch (strict equality).
*
* Returns `null` when none of the strategies produced a match — the
* caller drops the event with a warning.
*/
private async resolveIssueNumber(event: PrWebhookEvent): Promise<number | null> {
if (typeof event.issueNumber === 'number')
return event.issueNumber
const body = event.body || ''
const m = body.match(/\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i)
if (m) {
const n = Number.parseInt(m[1], 10)
logger.add({
level: 'info',
source: 'webhook',
message: `通过 PR body 关键词解析到 issue=#${n}`,
})
return n
}
if (this.activePanel && event.branch) {
try {
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!ws)
return null
const remote = await detectRepo(ws)
if (!remote)
return null
const tok = await getToken(this.ctx!, remote.host)
if (!tok)
return null
const issues = await loadIssues({ host: remote.host, token: tok, owner: remote.owner, repo: remote.repo })
const found = issues.find(i => i.branch === event.branch)
if (found) {
logger.add({
level: 'info',
source: 'webhook',
message: `通过分支名 ${event.branch} 反查到 issue=#${found.number}`,
})
return found.number
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '分支兜底反查失败',
details: msg,
})
}
}
logger.add({
level: 'warn',
source: 'webhook',
message: '无法定位工单(既无 path 也无 body 关键词也无 branch 匹配)',
details: `branch=${event.branch} bodyLen=${body.length}`,
})
return null
}
private async handleEvent(event: WebhookEvent): Promise<void> {
if (!this.ctx) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'handleEvent 调用时 ctx 未初始化',
})
return
}
if (event.kind === 'push') {
const s = getSettings(this.ctx)
const autoBuild = s.autoBuildBranch.trim() || s.devBranch
logger.add({
level: 'info',
source: 'webhook',
message: `收到 push 事件 branch=${event.branch}`,
})
if (event.branch === s.devBranch || event.branch === autoBuild)
void this.activePanel?.handleBranchSyncCheck()
return
}
if (event.kind === 'issue') {
logger.add({
level: 'info',
source: 'webhook',
message: `收到 issue 事件 action=${event.action} issue=#${event.issueNumber}`,
})
if (event.action === 'opened' || event.action === 'reopened') {
await this.handleIssueOpened(event)
}
else if (event.action === 'edited') {
await this.handleIssueEdited(event)
}
else if (event.action === 'assigned' || event.action === 'unassigned') {
// Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠
// action 区分,不会走到独立的事件分支。
await this.handleIssueAssigned(event)
}
else {
logger.add({
level: 'info',
source: 'webhook',
message: `未处理 issue action=${event.action}`,
details: `issue=#${event.issueNumber}`,
})
}
return
}
if (event.kind === 'issue_comment') {
logger.add({
level: 'info',
source: 'webhook',
message: `收到 issue_comment 事件 action=${event.action} issue=#${event.issueNumber}`,
})
await this.handleIssueCommentCreated(event)
return
}
logger.add({
level: 'info',
source: 'webhook',
message: `收到 webhook 事件 action=${event.action} issue=${typeof event.issueNumber === 'number' ? `#${event.issueNumber}` : '<未定>'} pr=#${event.pr}`,
})
const issueNumber = await this.resolveIssueNumber(event)
if (issueNumber === null)
return
// Narrow `issueNumber` for downstream handlers. We build a fresh
// object instead of mutating `event` so TypeScript can see the
// non-undefined type without casts at every call site.
const resolved: ResolvedWebhookEvent = { ...event, issueNumber }
switch (resolved.action) {
case 'opened':
case 'reopened': {
await this.handlePrOpened(resolved)
break
}
case 'synchronize':
case 'synchronized': {
// Gitea uses 'synchronized' (with d) per source; some examples
// floating around use 'synchronize' without the d. Accept both
// defensively.
await this.handlePrSynchronize(resolved)
break
}
case 'closed': {
await this.handlePrClosed(resolved)
break
}
case 'deleted': {
await this.handlePrDeleted(resolved)
break
}
default: {
logger.add({
level: 'info',
source: 'webhook',
message: `未处理 action=${resolved.action}`,
details: `issue=#${resolved.issueNumber} pr=#${resolved.pr}`,
})
break
}
}
}
/**
* Handles a freshly opened gitea issue. Two paths:
* - The issue body carries `<!-- spx:nonce=... -->` and matches a
* pending creation tracked by the panel → merge column / sessionId /
* brainstormProfilePath / color into the state-JSON comment, append the card
* incrementally, then clean up the inbox tmpdir.
* - No nonce / no match (external creation, e.g. manual `tea issues
* create`) → just append the card so the kanban stays in sync; do
* NOT touch the state-JSON comment.
*/
private async handleIssueOpened(event: IssueWebhookEvent): Promise<void> {
if (!this.ctx)
return
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!ws) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue 事件忽略:未打开工作区',
})
return
}
const remote = await detectRepo(ws)
if (!remote) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue 事件忽略:工作区未关联 gitea',
})
return
}
const token = await getToken(this.ctx, remote.host)
if (!token) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue 事件忽略:未配置 token',
})
return
}
if (isMailboxIssue({ title: event.title, body: event.body })) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`,
})
return
}
const nonceMatch = event.body.match(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
const nonce = nonceMatch ? nonceMatch[1] : null
// Promote the in-flight brainstorm terminal into the panel's
// issueNumber-keyed map BEFORE we drain the pending entry. The terminal
// tab was created with name `issue-new-{nonce}-规划` (we didn't know
// issueNumber yet) and VS Code can't rename it, so this side-map is what
// lets card↔terminal selection round-trip after the webhook arrives.
if (nonce && this.activePanel)
this.activePanel.linkPendingTerminalToIssue(nonce, event.issueNumber)
const pending = nonce && this.activePanel
? this.activePanel.takePendingIssueCreation(nonce)
: undefined
if (pending) {
logger.add({
level: 'info',
source: 'webhook',
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
})
// 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写
// column / color 这类团队可见字段。
const localPatch: Record<string, unknown> = {}
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
localPatch.sessionId = pending.sessionId
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
localPatch.brainstormProfilePath = pending.brainstormProfilePath
if (Object.keys(localPatch).length > 0)
await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch)
try {
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber: event.issueNumber,
extra: {
column: 'todo',
color: pending.color,
},
})
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'mergeStateJsonComment 失败(继续 append',
details: msg,
})
}
// Best-effort cleanup of the inbox tmpdir.
try {
await fsp.rm(pending.inboxDir, { recursive: true, force: true })
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'inbox 清理失败',
details: msg,
})
}
}
else if (nonce) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue body 含 nonce=${nonce} 但未匹配到 pending → 当作外部创建`,
})
}
else {
logger.add({
level: 'info',
source: 'webhook',
message: `issue body 无 spx:nonce → 当作外部创建`,
})
}
if (this.activePanel) {
// 看板 'mine' 范围的批量加载只拉"我创建/指派给我"的工单,而 webhook
// 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡
// 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时
// 才解析,省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
const pendingCreatedLocally = pending !== undefined
let poster: string | undefined
let assignees: string[] = []
let me: string | undefined
if (!pendingCreatedLocally && scope === 'mine') {
const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
poster = raw?.issue?.user?.login
assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
me = await loginForToken(remote.host, token)
}
if (!decideIssueAppend({ scope, pendingCreatedLocally, poster, assignees, me })) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue #${event.issueNumber} 与我无关,scope=mine 过滤跳过 append`,
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
})
return
}
try {
const loaded = await loadSingleIssue({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
workspaceRoot: ws,
issueNumber: event.issueNumber,
})
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
if (issue) {
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
if (pending) {
this.activePanel.postMessage({
type: 'toast/show',
id: `issue-created-${event.issueNumber}`,
level: 'success',
message: `#${event.issueNumber} 已创建`,
link: { label: '查看', url: event.htmlUrl },
dismissOnTimer: 8000,
})
// L462 的 linkPendingTerminalToIssue 也会推 brainstormTabOpen
// 但那时 #N 还不在前端 issues 列表里、patch 会被丢弃;
// issue/append 之后再补发一次,让 X 关闭按钮立即出现。
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { brainstormTabOpen: true },
})
}
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'loadSingleIssue 失败',
details: msg,
})
}
}
}
/**
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
* 要实时上卡/撤卡,不能等下次手动刷新。
*/
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> {
if (!this.ctx)
return
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
if (!this.activePanel)
return
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!ws) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:未打开工作区',
})
return
}
const remote = await detectRepo(ws)
if (!remote) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:工作区未关联 gitea',
})
return
}
const token = await getToken(this.ctx, remote.host)
if (!token) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:未配置 token',
})
return
}
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡,
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
// 省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
let poster: string | undefined
let assignees: string[] = []
let me: string | undefined
if (scope === 'mine') {
const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
poster = raw?.issue?.user?.login
assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
me = await loginForToken(remote.host, token)
}
if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue #${event.issueNumber} ${event.action === 'unassigned' ? '取消指派' : '指派'}后与我无关,scope=mine 撤卡`,
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
})
// 卡本来不在板上时前端 remove 是无害 no-op。
this.activePanel.postMessage({ type: 'issue/remove', issueNumber: event.issueNumber })
return
}
try {
const loaded = await loadSingleIssue({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
workspaceRoot: ws,
issueNumber: event.issueNumber,
})
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新
// assignee,不在板上的新增——指派给我的工单借此实时上卡。
if (issue)
this.activePanel.postMessage({ type: 'issue/append', issue })
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'loadSingleIssue 失败(issue assigned',
details: msg,
})
}
}
/**
* Issue `edited` payload: cc updates the issue body with `<!-- spx:spec=... -->`
* / `<!-- spx:plan=... -->` annotation lines as it discovers spec/plan files.
* We scan the body for those markers, diff against the state-JSON comment,
* and (if anything changed) merge the new paths in + patch the open panel
* so the detail view refreshes without a full kanban reload.
*/
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
if (!this.ctx)
return
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!ws) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue edited 事件忽略:未打开工作区',
})
return
}
const remote = await detectRepo(ws)
if (!remote) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue edited 事件忽略:工作区未关联 gitea',
})
return
}
const token = await getToken(this.ctx, remote.host)
if (!token) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue edited 事件忽略:未配置 token',
})
return
}
// 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `<!-- spx:spec=... -->` 占位被当真。
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const specFile = specMatch ? specMatch[1] : undefined
const planFile = planMatch ? planMatch[1] : undefined
if (!specFile && !planFile) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited body 无 spx:spec/spx:plan 注释,跳过`,
})
return
}
let currentState: Record<string, unknown> = {}
try {
currentState = await readStateJsonComment({
host: remote.host,
token,
owner: remote.owner,
repo: remote.repo,
issueNumber: event.issueNumber,
})
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'readStateJsonComment 失败(继续尝试合并)',
details: msg,
})
}
const currentSpec = typeof currentState.specFile === 'string' ? currentState.specFile : undefined
const currentPlan = typeof currentState.planFile === 'string' ? currentState.planFile : undefined
// cc only adds or updates; it never deletes. Treat an absent marker
// (undefined) as "no signal", not "clear" — that way deleting a spec
// line by mistake won't lose the existing pointer.
const nextSpec = specFile ?? currentSpec
const nextPlan = planFile ?? currentPlan
if (currentSpec === nextSpec && currentPlan === nextPlan) {
// Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment
// 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠
// coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去,
// 否则底部 property grid 不刷新。
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited spec/plan 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (spec=${nextSpec ?? '<空>'} plan=${nextPlan ?? '<空>'})`,
})
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (nextSpec)
patch.specFile = nextSpec
if (nextPlan)
patch.planFile = nextPlan
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
return
}
// spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
// 额外认规划终端。observe 机器只把新值推给本机 webview。
const ownership = await this.resolveOwnership(
event.issueNumber,
{ host: remote.host, owner: remote.owner, repo: remote.repo, token },
['实施', '测试', '规划'],
)
if (ownership === 'observe') {
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (specFile)
patch.specFile = specFile
if (planFile)
patch.planFile = planFile
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
return
}
const extra: Record<string, unknown> = {}
if (specFile)
extra.specFile = specFile
if (planFile)
extra.planFile = planFile
try {
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber: event.issueNumber,
extra,
})
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'mergeStateJsonComment 失败(issue edited',
details: msg,
})
return
}
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited spec/plan 已同步`,
details: `spec=${specFile ?? '<未变>'} plan=${planFile ?? '<未变>'}`,
})
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (specFile)
patch.specFile = specFile
if (planFile)
patch.planFile = planFile
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
}
/**
* `opened` / `reopened`: persist `pr` + `implementStatus=done` into the
* state JSON, refresh the kanban, toast. The webhook is *not* deleted
* here — Phase A keeps it alive for `synchronize` / `closed`.
*/
private async handlePrOpened(event: ResolvedWebhookEvent): Promise<void> {
if (!this.ctx)
return
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx) {
logger.add({
level: 'error',
source: 'webhook',
message: `无法解析 #${event.issueNumber} 的仓库上下文,跳过`,
details: `action=${event.action} pr=#${event.pr}`,
})
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// 别人的工单:只刷本机看板,不写共享状态、不弹 toast、不触发审查。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { pr: event.pr, implementStatus: 'done' },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber: event.issueNumber,
extra: {
pr: event.pr,
implementStatus: 'done',
},
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'error',
source: 'webhook',
message: '更新 state JSON 失败',
details: message,
})
}
if (this.activePanel) {
try {
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { pr: event.pr, implementStatus: 'done' },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'panel.postMessage 失败',
details: message,
})
}
}
// Fire-and-forget the toast — `showInformationMessage` is sticky until the
// user clicks, so awaiting it would block the auto-review trigger below
// indefinitely.
const action = 'Open PR'
void window.showInformationMessage(
`#${event.issueNumber} 已关联 PR !${event.pr}`,
action,
).then((pick) => {
if (pick === action)
void env.openExternal(Uri.parse(event.htmlUrl))
}, (err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '展示 toast 失败',
details: message,
})
})
// Per-issue override takes precedence over the global setting. The
// state JSON's `autoReview` field is set the first time the user toggles
// the checkbox in the detail panel; absent ⇒ follow global.
let effectiveAutoReview = getSettings(this.ctx).autoReview
let autoReviewSource: 'issue' | 'global' = 'global'
try {
const stateJson = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber: event.issueNumber,
})
if (typeof stateJson.autoReview === 'boolean') {
effectiveAutoReview = stateJson.autoReview
autoReviewSource = 'issue'
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '读取 state JSON 失败(autoReview override',
details: message,
})
}
if (effectiveAutoReview) {
// Fire-and-forget; the review can take minutes and we don't want to
// block the webhook response or the panel refresh.
void this.triggerReview(event.issueNumber, event.pr, ctx)
}
else {
logger.add({
level: 'info',
source: 'webhook',
message: `跳过自动审查 #${event.issueNumber}`,
details: `autoReview=off (source=${autoReviewSource})`,
})
}
}
/**
* `synchronize`: kick off a fresh `codex exec review` run when autoReview
* is on. Codex itself posts the review back as a PR comment, which loops
* around through {@link handleIssueCommentCreated} to inject into the
* implementation terminal.
*/
private async handlePrSynchronize(event: ResolvedWebhookEvent): Promise<void> {
if (!this.ctx)
return
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx) {
logger.add({
level: 'error',
source: 'webhook',
message: `无法解析 #${event.issueNumber} 的仓库上下文,跳过`,
details: `action=${event.action} pr=#${event.pr}`,
})
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe')
return
// Per-issue override takes precedence over the global setting.
let effectiveAutoReview = getSettings(this.ctx).autoReview
let autoReviewSource: 'issue' | 'global' = 'global'
try {
const stateJson = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber: event.issueNumber,
})
if (typeof stateJson.autoReview === 'boolean') {
effectiveAutoReview = stateJson.autoReview
autoReviewSource = 'issue'
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '读取 state JSON 失败(autoReview override, synchronize',
details: message,
})
}
if (!effectiveAutoReview) {
logger.add({
level: 'info',
source: 'webhook',
message: `跳过 synchronize 自动审查 #${event.issueNumber}`,
details: `autoReview=off (source=${autoReviewSource})`,
})
return
}
void this.triggerReview(event.issueNumber, event.pr, ctx)
}
/**
* `closed`: log only. The webhook stays alive on gitea so a later
* `reopened` or a "delete + re-push" cycle (which fires `deleted` followed
* by a fresh `opened`) keeps flowing through the same registration.
* Cleanup of the webhook is deferred to the future "完成" column trigger.
*/
private async handlePrClosed(event: ResolvedWebhookEvent): Promise<void> {
logger.add({
level: 'info',
source: 'webhook',
message: `PR #${event.pr} closed (保留 webhook 以备 reopen/重建)`,
details: `issue=#${event.issueNumber}`,
})
// 判断是否为合并关闭 —— gitea PR payload 里 merged 字段不在 ResolvedWebhookEvent
// 类型上,主动查一次 API 取 merged 状态(成本低,避免 server.ts 解析变动)。
if (!this.ctx)
return
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx)
return
const prIndex = Number.parseInt(event.pr, 10)
if (!Number.isFinite(prIndex))
return
let merged = false
let mergedAt: string | null = null
try {
const pr = await getPullRequest({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: prIndex,
})
merged = pr.merged
mergedAt = pr.merged_at ?? null
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `查询 PR #${event.pr} merged 状态失败`,
details: message,
})
return
}
if (!merged)
return
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// 合并状态由责任人机器落库,本机只刷看板。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { prMerged: true },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber: event.issueNumber,
extra: { prMerged: true, prMergedAt: mergedAt ?? new Date().toISOString() },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '写入 prMerged=true 到 state JSON 失败',
details: message,
})
}
if (this.activePanel) {
try {
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { prMerged: true },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'panel.postMessage (prMerged) 失败',
details: message,
})
}
}
}
/**
* `deleted`: the user hard-deleted the PR on gitea. Clear `pr` from the
* state JSON (using the empty string so the loader treats it as unset) and
* refresh the kanban. The webhook stays alive for the next push.
*/
private async handlePrDeleted(event: ResolvedWebhookEvent): Promise<void> {
logger.add({
level: 'info',
source: 'webhook',
message: `PR #${event.pr} deleted, 清空 state JSON 中的 pr`,
details: `issue=#${event.issueNumber}`,
})
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx) {
logger.add({
level: 'error',
source: 'webhook',
message: `无法解析 #${event.issueNumber} 的仓库上下文,跳过`,
details: `action=${event.action} pr=#${event.pr}`,
})
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// pr 字段清空由责任人机器落库,本机只刷看板。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { pr: null },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber: event.issueNumber,
extra: { pr: '' },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '清空 state JSON 失败(deleted',
details: message,
})
}
if (this.activePanel) {
try {
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
// null 才能过 postMessageundefined 会被序列化丢掉
patch: { pr: null },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'panel.postMessage 失败(deleted',
details: message,
})
}
}
}
/**
* Kept for future "完成" column trigger: deletes the gitea webhook and
* removes the pending entry. Currently unused — closed PRs no longer
* trigger cleanup; the user clears them manually via gitea or via a future
* column transition.
*/
// kept for future "完成" column trigger
private async deleteWebhookAndForget(issueNumber: number, pending: PendingHook, token: string): Promise<void> {
try {
await deleteWebhook({
host: pending.host,
token,
owner: pending.owner,
repo: pending.repo,
hookId: pending.hookId,
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: '删除 gitea hook 失败',
details: message,
})
}
await this.removePending(issueNumber)
}
/**
* Inbound `issue_comment created` handler — picks up the marker comment that
* the codex review run posts back, strips the marker line, and injects the
* remaining markdown into the issue's implementation cc terminal.
*
* Comments without the `<!-- spx:review=1 -->` marker are ignored. Edited /
* deleted comments are also ignored (the dispatcher only routes 'created').
*
* `isFirstReview` is derived by counting the marker across the issue's
* existing comments — when this is the only one (count <= 1), it's the
* first review and `injectIntoImplTerminal` appends the merge-to-main
* suffix.
*/
private async handleIssueCommentCreated(event: IssueCommentWebhookEvent): Promise<void> {
if (event.action !== 'created') {
logger.add({
level: 'info',
source: 'webhook',
message: `跳过 issue_comment action=${event.action} #${event.issueNumber}`,
})
return
}
const body = event.commentBody
// Diagnostic: dump body length + first 200 chars so we can see exactly
// what gitea is sending us when marker detection misses.
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
details: body.slice(0, 200),
})
if (!/<!--\s*spx:review=1\s*-->/i.test(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`,
})
return
}
const text = body.replace(/<!--\s*spx:review=1\s*-->\s*/i, '').trim()
if (!text) {
logger.add({
level: 'warn',
source: 'webhook',
message: `审查评论正文为空 #${event.issueNumber}`,
})
return
}
// resolveRepoContext is keyed by pendingHooks/workspace, not by issue
// number — it just needs *some* number to log against. We pass the
// gitea-reported number here (which is the PR number when this comment
// is on a PR) and reverse-resolve to the real issue number below.
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx) {
logger.add({
level: 'error',
source: 'webhook',
message: `无法解析 #${event.issueNumber} 的仓库上下文(issue_comment),跳过`,
})
return
}
// gitea 的 issue_comment payload 里,PR 上的评论同样以 issue.number 形式
// 携带 PR 编号。这里如果 prNumber 已置位,就拉 PR body 反查 `Closes #N`
// 找到真正的工单编号;找不到则保持原 event.issueNumber 兜底。
let realIssueNumber = event.issueNumber
if (event.prNumber !== undefined) {
try {
const pr = await getPullRequest({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: event.prNumber,
})
const m = (pr.body ?? '').match(/\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i)
if (m) {
realIssueNumber = Number.parseInt(m[1], 10)
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`,
})
}
else {
logger.add({
level: 'warn',
source: 'webhook',
message: `PR #${event.prNumber} body 未找到 Closes #N,按原 issueNumber=${event.issueNumber} 处理`,
})
}
}
catch (err) {
logger.add({
level: 'warn',
source: 'webhook',
message: `反查 PR #${event.prNumber} 失败`,
details: err instanceof Error ? err.message : String(err),
})
}
}
if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') {
// 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到
// 该终端,提前拦截省掉后面的评论计数 API 往返。
return
}
// Count marker comments to determine isFirstReview. This call also
// includes the just-posted comment, so a first review yields count=1.
let reviewCount = 1
try {
// 审查评论 post 在 PR 上,不是工单 issue 上。如果当前 comment 来自 PR
// (event.prNumber !== undefined),就去 PR 评论流数;否则才走工单号。
const commentsIndex = event.prNumber ?? realIssueNumber
const comments = await listIssueComments({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: commentsIndex,
})
reviewCount = comments.filter(c => /<!--\s*spx:review=1\s*-->/i.test(c.body ?? '')).length
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `统计审查评论数失败 #${realIssueNumber}(按首次处理)`,
details: message,
})
}
const isFirstReview = reviewCount <= 1
const injected = this.activePanel
? this.activePanel.injectIntoImplTerminal(realIssueNumber, text, isFirstReview)
: false
if (!injected) {
logger.add({
level: 'warn',
source: 'webhook',
message: `审查注入失败(实施 tab 未找到)#${realIssueNumber}`,
})
return
}
logger.add({
level: 'info',
source: 'webhook',
message: `审查反馈已注入实施终端 #${realIssueNumber} isFirstReview=${isFirstReview} length=${text.length}`,
})
}
/**
* Spawn `codex exec review` in the issue's worktree. Codex itself is
* instructed (via the review prompt) to post the review back as a PR
* comment with the `<!-- spx:review=1 -->` marker; the comment hits
* the webhook again as an `issue_comment created` event and loops
* around through {@link handleIssueCommentCreated}. Fire-and-forget.
*/
private async triggerReview(
issueNumber: number,
prNumber: string,
ctx: { host: string, owner: string, repo: string, token: string },
): Promise<void> {
if (!this.ctx)
return
if (this.reviewInFlight.has(issueNumber)) {
logger.add({
level: 'info',
source: 'webhook',
message: `自动审查 #${issueNumber} 已在进行中,忽略并发触发`,
})
return
}
this.reviewInFlight.add(issueNumber)
try {
await this.triggerReviewBody(issueNumber, prNumber, ctx)
}
finally {
this.reviewInFlight.delete(issueNumber)
}
}
private async triggerReviewBody(
issueNumber: number,
prNumber: string,
ctx: { host: string, owner: string, repo: string, token: string },
): Promise<void> {
if (!this.ctx)
return
// Read current column from state JSON; if it's still 'in-progress',
// auto-advance to 'review' so the kanban reflects "审查中" status. Skip if
// already in review/done to avoid bouncing the card around.
try {
const stateJson = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber,
})
if (stateJson.column === 'in-progress') {
const result = await mergeStateJsonCommentGuarded({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber,
protectDoneColumn: true,
extra: { column: 'review' },
})
if (result.protectedDoneColumn) {
logger.add({
level: 'info',
source: 'webhook',
message: `跳过自动推进 #${issueNumber}: 当前已是 done,不回退到 review`,
})
}
else if (result.posted) {
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber,
patch: { column: 'review' },
})
logger.add({
level: 'info',
source: 'webhook',
message: `自动推进 #${issueNumber}: in-progress → review`,
})
}
}
}
catch (err) {
// Non-fatal: log and keep going with the review.
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取/更新 column 失败 (auto-advance review) #${issueNumber}`,
details: message,
})
}
if (!this.activePanel) {
logger.add({
level: 'warn',
source: 'webhook',
message: `无 active panel,跳过审查 #${issueNumber}`,
})
return
}
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
logger.add({
level: 'error',
source: 'webhook',
message: `triggerReview 中止 #${issueNumber}:没有工作区`,
})
return
}
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
// 不回落);本地无记录时读共享评论兜底(存量数据)。缺失不硬失败——
// triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
let worktreePath = ''
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
if (local.worktreePath !== undefined) {
worktreePath = local.worktreePath
}
else {
try {
const state = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber,
})
if (typeof state.worktreePath === 'string')
worktreePath = state.worktreePath
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 state JSON 失败(triggerReview#${issueNumber}`,
details: message,
})
}
}
logger.add({
level: 'info',
source: 'webhook',
message: `开始审查 #${issueNumber} worktreePath=${worktreePath || '(none)'}`,
})
const prompt = getReviewPrompt(this.ctx, { prNumber })
const ok = await this.activePanel.triggerAutoReviewTab({
issueNumber,
prNumber,
prompt,
worktreePath,
workspaceRoot,
})
if (!ok) {
logger.add({
level: 'warn',
source: 'webhook',
message: `triggerAutoReviewTab 失败 #${issueNumber}`,
})
return
}
logger.add({
level: 'info',
source: 'webhook',
message: `审查 tab 已就绪 #${issueNumber},等待 codex 通过 PR 评论回流`,
})
}
}
export const webhookCoordinator = new WebhookCoordinator()