Gitea 的 issue_assign 事件 X-Gitea-Event 值为 issues、action 为 assigned/unassigned,coordinator 此前只处理 opened/reopened/edited, 指派事件被丢弃。现复用 decideIssueAppend 门禁:指派给我 → issue/append 实时上卡(已在板上则原位刷新),与我无关 → issue/remove 撤卡。
1674 lines
55 KiB
TypeScript
1674 lines
55 KiB
TypeScript
/**
|
||
* 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<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
|
||
}
|
||
|
||
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 才能过 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<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()
|