1855 lines
62 KiB
TypeScript
1855 lines
62 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 { execFile } from 'node:child_process'
|
||
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 { renderReviewPrompt } from '../cc/prompts'
|
||
import { isMailboxIssue } from '../cc/sessionHandoff'
|
||
import { detectRepo } from '../git/remote'
|
||
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
|
||
import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment'
|
||
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
||
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers'
|
||
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
|
||
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
|
||
import { logger } from '../logging/logger'
|
||
import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest'
|
||
import * as issueChat from '../panel/handlers/issueChat'
|
||
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
||
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
|
||
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
|
||
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
|
||
}
|
||
|
||
/**
|
||
* issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步
|
||
* 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。
|
||
*
|
||
* 路径必须含 `/` 且以对应后缀结尾:cc 在头脑风暴阶段会往 body 里写
|
||
* `<!-- spx:spec=... -->` 这种占位说明文,不卡死就会被当成真路径存进去。
|
||
*/
|
||
const BODY_MARKERS = [
|
||
{ type: 'spec', field: 'specFile', ext: 'md' },
|
||
{ type: 'plan', field: 'planFile', ext: 'md' },
|
||
{ type: 'prototype', field: 'prototypeFile', ext: 'json' },
|
||
] as const
|
||
|
||
/** 本次从 body 里扫出来的 marker 值,按 state JSON 字段名索引。 */
|
||
type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string }
|
||
|
||
function handoffRequestFromOf(body: string): string {
|
||
try {
|
||
const obj = JSON.parse(body) as { handoffRequestFrom?: unknown }
|
||
return typeof obj.handoffRequestFrom === 'string' ? obj.handoffRequestFrom.trim() : ''
|
||
}
|
||
catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function parseBodyMarkers(body: string): MarkerPatch {
|
||
const out: MarkerPatch = {}
|
||
for (const { type, field, ext } of BODY_MARKERS) {
|
||
const re = new RegExp(String.raw`<!--\s*spx:${type}=([^\s/>]*\/[^\s>]*\.${ext})\s*-->`)
|
||
const m = body.match(re)
|
||
if (m)
|
||
out[field] = m[1]
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 日志用的一行摘要,例如 `spec=a.md plan=<空> prototype=<空>`。 */
|
||
function describeMarkers(patch: MarkerPatch): string {
|
||
return BODY_MARKERS.map(({ type, field }) => `${type}=${patch[field] ?? '<空>'}`).join(' ')
|
||
}
|
||
|
||
/**
|
||
* 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>()
|
||
/** 已经为这条移交申请通知过负责人。申请取消后清掉。 */
|
||
private handoffRequestSeen = new Map<number, string>()
|
||
|
||
/** 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 n = parseClosingIssueNumber(body)
|
||
if (n !== undefined) {
|
||
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)
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `收到 push 事件 branch=${event.branch}`,
|
||
})
|
||
// 推到开发分支或任一发布分支都会改变「未发布提交数」,刷新发布按钮。
|
||
if (event.branch === s.devBranch || event.branch.startsWith(s.releaseBranchPrefix))
|
||
void this.activePanel?.handleReleaseStatus()
|
||
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 === 'closed' || event.action === 'reopened') {
|
||
await this.handleIssueStateChanged(event)
|
||
}
|
||
else if (event.action === 'assigned' || event.action === 'unassigned') {
|
||
// Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠
|
||
// action 区分,不会走到独立的事件分支。
|
||
await this.handleIssueAssigned(event)
|
||
}
|
||
else {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `未处理 issue action=${event.action}`,
|
||
details: `issue=#${event.issueNumber}`,
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
if (event.kind === 'issue_comment') {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `收到 issue_comment 事件 action=${event.action} issue=#${event.issueNumber}`,
|
||
})
|
||
await this.handleIssueCommentCreated(event)
|
||
return
|
||
}
|
||
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `收到 webhook 事件 action=${event.action} issue=${typeof event.issueNumber === 'number' ? `#${event.issueNumber}` : '<未定>'} pr=#${event.pr}`,
|
||
})
|
||
|
||
const issueNumber = await this.resolveIssueNumber(event)
|
||
if (issueNumber === null)
|
||
return
|
||
// Narrow `issueNumber` for downstream handlers. We build a fresh
|
||
// object instead of mutating `event` so TypeScript can see the
|
||
// non-undefined type without casts at every call site.
|
||
const resolved: ResolvedWebhookEvent = { ...event, issueNumber }
|
||
|
||
switch (resolved.action) {
|
||
case 'opened':
|
||
case 'reopened': {
|
||
await this.handlePrOpened(resolved)
|
||
break
|
||
}
|
||
case 'synchronize':
|
||
case 'synchronized': {
|
||
// Gitea uses 'synchronized' (with d) per source; some examples
|
||
// floating around use 'synchronize' without the d. Accept both
|
||
// defensively.
|
||
await this.handlePrSynchronize(resolved)
|
||
break
|
||
}
|
||
case 'closed': {
|
||
await this.handlePrClosed(resolved)
|
||
break
|
||
}
|
||
case 'deleted': {
|
||
await this.handlePrDeleted(resolved)
|
||
break
|
||
}
|
||
default: {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `未处理 action=${resolved.action}`,
|
||
details: `issue=#${resolved.issueNumber} pr=#${resolved.pr}`,
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handles a freshly opened gitea issue. Two paths:
|
||
* - The issue body carries `<!-- spx:nonce=... -->` and matches a
|
||
* pending creation tracked by the panel → merge column / sessionId /
|
||
* brainstormProfilePath / color into the state-JSON comment, append the card
|
||
* incrementally, then clean up the inbox tmpdir.
|
||
* - No nonce / no match (external creation, e.g. manual `tea issues
|
||
* create`) → just append the card so the kanban stays in sync; do
|
||
* NOT touch the state-JSON comment.
|
||
*/
|
||
private async handleIssueOpened(event: IssueWebhookEvent): Promise<void> {
|
||
if (!this.ctx)
|
||
return
|
||
|
||
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
if (!ws) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: 'issue 事件忽略:未打开工作区',
|
||
})
|
||
return
|
||
}
|
||
const remote = await detectRepo(ws)
|
||
if (!remote) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: 'issue 事件忽略:工作区未关联 gitea',
|
||
})
|
||
return
|
||
}
|
||
const token = await getToken(this.ctx, remote.host)
|
||
if (!token) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: 'issue 事件忽略:未配置 token',
|
||
})
|
||
return
|
||
}
|
||
|
||
if (isMailboxIssue({ title: event.title, body: event.body })) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`,
|
||
})
|
||
return
|
||
}
|
||
|
||
const nonceMatch = event.body.match(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
|
||
const nonce = nonceMatch ? nonceMatch[1] : null
|
||
// Promote the in-flight brainstorm terminal into the panel's
|
||
// issueNumber-keyed map BEFORE we drain the pending entry. The terminal
|
||
// tab was created with name `issue-new-{nonce}-规划` (we didn't know
|
||
// issueNumber yet) and VS Code can't rename it, so this side-map is what
|
||
// lets card↔terminal selection round-trip after the webhook arrives.
|
||
if (nonce && this.activePanel)
|
||
this.activePanel.linkPendingTerminalToIssue(nonce, event.issueNumber)
|
||
const pending = nonce && this.activePanel
|
||
? this.activePanel.takePendingIssueCreation(nonce)
|
||
: undefined
|
||
|
||
if (pending) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
|
||
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
|
||
})
|
||
// 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写
|
||
// column / color 这类团队可见字段。
|
||
const localPatch: Record<string, unknown> = {}
|
||
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
|
||
localPatch.sessionId = pending.sessionId
|
||
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
|
||
localPatch.brainstormProfilePath = pending.brainstormProfilePath
|
||
if (Object.keys(localPatch).length > 0)
|
||
await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch)
|
||
|
||
try {
|
||
await mergeStateJsonComment({
|
||
host: remote.host,
|
||
owner: remote.owner,
|
||
repo: remote.repo,
|
||
token,
|
||
issueNumber: event.issueNumber,
|
||
extra: {
|
||
column: 'todo',
|
||
color: pending.color,
|
||
},
|
||
})
|
||
}
|
||
catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: 'mergeStateJsonComment 失败(继续 append)',
|
||
details: msg,
|
||
})
|
||
}
|
||
|
||
// Best-effort cleanup of the inbox tmpdir.
|
||
try {
|
||
await fsp.rm(pending.inboxDir, { recursive: true, force: true })
|
||
}
|
||
catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: 'inbox 清理失败',
|
||
details: msg,
|
||
})
|
||
}
|
||
}
|
||
else if (nonce) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue body 含 nonce=${nonce} 但未匹配到 pending → 当作外部创建`,
|
||
})
|
||
}
|
||
else {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue body 无 spx:nonce → 当作外部创建`,
|
||
})
|
||
}
|
||
|
||
if (this.activePanel) {
|
||
// 看板 'mine' 范围的批量加载只拉"我创建/指派给我"的工单,而 webhook
|
||
// 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡
|
||
// 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时
|
||
// 才解析,省一次身份请求。
|
||
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
|
||
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 annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
|
||
if (issue) {
|
||
this.activePanel.noteIssueOwnership(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,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把一张工单的最新状态推给看板:重拉这张工单、叠加本机运行态,再按
|
||
* 'mine' 可见性门禁决定上卡还是撤卡。指派变化、列变化、开关工单都走
|
||
* 这里。它只刷 UI——不写远端状态、不起会话,所以在"观察者"机器上执行
|
||
* 同样安全,同事把卡拖到哪一列,这边就跟着动到哪一列。
|
||
*/
|
||
private async refreshIssueCard(opts: {
|
||
issueNumber: number
|
||
/** 原始 webhook payload,'mine' 门禁从里面取 poster / assignees。 */
|
||
raw: unknown
|
||
/** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */
|
||
reason: string
|
||
}): Promise<void> {
|
||
const { issueNumber, raw, reason } = opts
|
||
if (!this.ctx)
|
||
return
|
||
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
|
||
if (!this.activePanel)
|
||
return
|
||
|
||
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
if (!ws) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `${reason} 事件忽略:未打开工作区`,
|
||
})
|
||
return
|
||
}
|
||
const remote = await detectRepo(ws)
|
||
if (!remote) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `${reason} 事件忽略:工作区未关联 gitea`,
|
||
})
|
||
return
|
||
}
|
||
const token = await getToken(this.ctx, remote.host)
|
||
if (!token) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `${reason} 事件忽略:未配置 token`,
|
||
})
|
||
return
|
||
}
|
||
|
||
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡,
|
||
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
|
||
// 省一次身份请求。
|
||
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
|
||
let poster: string | undefined
|
||
let assignees: string[] = []
|
||
let me: string | undefined
|
||
if (scope === 'mine') {
|
||
const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
|
||
poster = payload?.issue?.user?.login
|
||
assignees = (payload?.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 #${issueNumber} ${reason} 后与我无关,scope=mine 撤卡`,
|
||
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
|
||
})
|
||
// 卡本来不在板上时前端 remove 是无害 no-op。
|
||
this.activePanel.postMessage({ type: 'issue/remove', issueNumber })
|
||
return
|
||
}
|
||
try {
|
||
const loaded = await loadSingleIssue({
|
||
host: remote.host,
|
||
owner: remote.owner,
|
||
repo: remote.repo,
|
||
token,
|
||
workspaceRoot: ws,
|
||
issueNumber,
|
||
})
|
||
const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
|
||
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
|
||
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
|
||
if (issue) {
|
||
this.activePanel.noteIssueOwnership(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 失败(${reason})`,
|
||
details: msg,
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
|
||
* 要实时上卡/撤卡,不能等下次手动刷新。
|
||
*/
|
||
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> {
|
||
await this.refreshIssueCard({
|
||
issueNumber: event.issueNumber,
|
||
raw: event.raw,
|
||
reason: event.action === 'unassigned' ? '取消指派' : '指派',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着
|
||
* 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。
|
||
*/
|
||
private async handleIssueStateChanged(event: IssueWebhookEvent): Promise<void> {
|
||
await this.refreshIssueCard({
|
||
issueNumber: event.issueNumber,
|
||
raw: event.raw,
|
||
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
|
||
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
|
||
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
|
||
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
|
||
*/
|
||
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
|
||
}
|
||
|
||
const found = parseBodyMarkers(event.body)
|
||
if (Object.keys(found).length === 0) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue=#${event.issueNumber} edited body 无 spx marker 注释,跳过`,
|
||
})
|
||
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,
|
||
})
|
||
}
|
||
|
||
// cc 只增不删:body 里没有某个 marker 当「没信号」,不当「清空」,
|
||
// 免得手滑删掉一行就把已有指针丢了。
|
||
const next: MarkerPatch = {}
|
||
let changed = false
|
||
for (const { field } of BODY_MARKERS) {
|
||
const current = typeof currentState[field] === 'string' ? currentState[field] as string : undefined
|
||
const value = found[field] ?? current
|
||
if (value)
|
||
next[field] = value
|
||
if (value !== current)
|
||
changed = true
|
||
}
|
||
if (!changed) {
|
||
// 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 marker 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (${describeMarkers(next)})`,
|
||
})
|
||
this.activePanel?.postMessage({
|
||
type: 'issue/patch',
|
||
issueNumber: event.issueNumber,
|
||
patch: next,
|
||
})
|
||
return
|
||
}
|
||
|
||
// marker 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
|
||
// 额外认规划终端。observe 机器只把新值推给本机 webview。
|
||
const ownership = await this.resolveOwnership(
|
||
event.issueNumber,
|
||
{ host: remote.host, owner: remote.owner, repo: remote.repo, token },
|
||
['实施', '测试', '规划'],
|
||
)
|
||
if (ownership === 'observe') {
|
||
this.activePanel?.postMessage({
|
||
type: 'issue/patch',
|
||
issueNumber: event.issueNumber,
|
||
patch: found,
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
await mergeStateJsonComment({
|
||
host: remote.host,
|
||
owner: remote.owner,
|
||
repo: remote.repo,
|
||
token,
|
||
issueNumber: event.issueNumber,
|
||
extra: found,
|
||
})
|
||
}
|
||
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 marker 已同步`,
|
||
details: describeMarkers(found),
|
||
})
|
||
|
||
this.activePanel?.postMessage({
|
||
type: 'issue/patch',
|
||
issueNumber: event.issueNumber,
|
||
patch: found,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* `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'
|
||
let stateJson: Record<string, unknown> | undefined
|
||
try {
|
||
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,
|
||
})
|
||
}
|
||
// 新提交让上一次「已通过」作废。已经是 false 或没写过就不再发一条状态评论。
|
||
// 读失败时不知道旧值,也清一次,避免卡片继续挂着已通过。
|
||
if (stateJson === undefined || stateJson.reviewPassed === true) {
|
||
try {
|
||
await mergeStateJsonComment({
|
||
host: ctx.host,
|
||
token: ctx.token,
|
||
owner: ctx.owner,
|
||
repo: ctx.repo,
|
||
issueNumber: event.issueNumber,
|
||
extra: { reviewPassed: false },
|
||
})
|
||
this.activePanel?.postMessage({
|
||
type: 'issue/patch',
|
||
issueNumber: event.issueNumber,
|
||
patch: { reviewPassed: false },
|
||
})
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `新提交未能清掉审查通过 #${event.issueNumber}`,
|
||
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 notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise<void> {
|
||
const from = handoffRequestFromOf(event.commentBody)
|
||
if (!from) {
|
||
this.handoffRequestSeen.delete(event.issueNumber)
|
||
return
|
||
}
|
||
const seen = this.handoffRequestSeen.get(event.issueNumber) ?? ''
|
||
if (from === seen || !this.ctx)
|
||
return
|
||
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
if (!ws)
|
||
return
|
||
const remote = await detectRepo(ws)
|
||
if (!remote)
|
||
return
|
||
const token = await getToken(this.ctx, remote.host)
|
||
if (!token)
|
||
return
|
||
const me = await loginForToken(remote.host, token)
|
||
const payload = event.raw as { issue?: { assignees?: Array<{ login?: string } | null> | null } | null } | null
|
||
const assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
|
||
if (!me || assignees.length === 0)
|
||
return
|
||
const notice = handoffRequestSystemNotice({
|
||
issueNumber: event.issueNumber,
|
||
me,
|
||
assignees,
|
||
from,
|
||
seenFrom: seen,
|
||
})
|
||
this.handoffRequestSeen.set(event.issueNumber, from)
|
||
if (!notice)
|
||
return
|
||
execFile('osascript', [
|
||
'-e',
|
||
`display notification ${JSON.stringify('打开看板同意或拒绝')} with title ${JSON.stringify(notice)}`,
|
||
], (err) => {
|
||
if (err) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `移交申请通知发送失败 #${event.issueNumber}`,
|
||
details: err.message,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
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),
|
||
})
|
||
|
||
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 closing = parseClosingIssueNumber(pr.body ?? '')
|
||
if (closing !== undefined) {
|
||
realIssueNumber = closing
|
||
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 (this.activePanel) {
|
||
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
|
||
}
|
||
|
||
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
|
||
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
|
||
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
|
||
if (event.prNumber === undefined && hasStateColumn(body)) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
|
||
})
|
||
await this.notifyHandoffRequest(event)
|
||
await this.refreshIssueCard({
|
||
issueNumber: event.issueNumber,
|
||
raw: event.raw,
|
||
reason: '列变化',
|
||
})
|
||
return
|
||
}
|
||
|
||
// 判定发言人:只有纯工单(event.prNumber === undefined)上的真人评论才提醒;
|
||
// PR 评论(代码审查、PR 讨论、Review 机器人等)绝对不发通知。
|
||
const isPureIssueComment = event.prNumber === undefined
|
||
const author = event.commentAuthor
|
||
const me = await loginForToken(ctx.host, ctx.token)
|
||
const isSystemComment = !!systemCommentText(body)
|
||
const isMine = !!(me && author === me)
|
||
|
||
if (isPureIssueComment && author && !isMine && !isSystemComment) {
|
||
const summary = cleanCommentSummary(body)
|
||
// 触发 macOS 系统原生右上角横幅通知
|
||
execFile('osascript', [
|
||
'-e',
|
||
`display notification ${JSON.stringify(summary)} with title ${JSON.stringify(`工单 #${realIssueNumber} 新讨论`)} subtitle ${JSON.stringify(author)}`,
|
||
], (err) => {
|
||
if (err) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `macOS 原生通知发送失败 #${realIssueNumber}`,
|
||
details: err.message,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
if (!hasReviewMarker(body)) {
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`,
|
||
})
|
||
return
|
||
}
|
||
const text = stripReviewMarker(body)
|
||
if (!text) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `审查评论正文为空 #${event.issueNumber}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
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 => hasReviewMarker(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
|
||
}
|
||
|
||
// state 读一次,既当审查提示词的模板上下文,又给 worktree 兜底。读失败按空
|
||
// 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
||
let state: Record<string, unknown> = {}
|
||
try {
|
||
state = await readStateJsonComment({
|
||
host: ctx.host,
|
||
token: ctx.token,
|
||
owner: ctx.owner,
|
||
repo: ctx.repo,
|
||
issueNumber,
|
||
})
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'webhook',
|
||
message: `读取 state JSON 失败(triggerReview)#${issueNumber}`,
|
||
details: message,
|
||
})
|
||
}
|
||
|
||
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
|
||
// 不回落);本地无记录时才用共享评论里的值(存量数据)。
|
||
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
|
||
const worktreePath = local.worktreePath
|
||
?? (typeof state.worktreePath === 'string' ? state.worktreePath : '')
|
||
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `开始审查 #${issueNumber} worktreePath=${worktreePath || '(none)'}`,
|
||
})
|
||
|
||
const prompt = renderReviewPrompt(this.ctx, { state, issueNumber: String(issueNumber), prNumber })
|
||
|
||
await this.activePanel.triggerAutoReviewTab({
|
||
issueNumber,
|
||
prNumber,
|
||
prompt,
|
||
worktreePath,
|
||
workspaceRoot,
|
||
})
|
||
|
||
logger.add({
|
||
level: 'info',
|
||
source: 'webhook',
|
||
message: `审查 tab 已就绪 #${issueNumber},等待 codex 通过 PR 评论回流`,
|
||
})
|
||
}
|
||
}
|
||
|
||
export const webhookCoordinator = new WebhookCoordinator()
|