/** * 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 { 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 & { issueNumber: number } class WebhookCoordinator { private ctx?: ExtensionContext private server?: WebhookServer // kept for legacy cleanup; new flow uses one shared webhook private pendingHooks = new Map() private activePanel: KanbanWebviewPanel | undefined private initialized = false private eventSubscription?: { dispose: () => void } /** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */ private reviewInFlight = new Set() /** 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>(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 { 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 { 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 { 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 { 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 { 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 { if (!this.ctx) return const obj: Record = {} 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 { 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 { 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 `` 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 { 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 } const nonceMatch = event.body.match(//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 = {} 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(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 { 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(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 `` * / `` 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 { 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 写的 `` 占位被当真。 const specMatch = event.body.match(//) const planMatch = event.body.match(//) 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 = {} 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 = {} 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 { 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 { 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 { 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 { 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 才能过 postMessage;undefined 会被序列化丢掉 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 { 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 `` 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 { 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 (!//i.test(body)) { logger.add({ level: 'info', source: 'webhook', message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`, }) return } const text = body.replace(/\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 => //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 `` 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 { 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 { 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()