diff --git a/vscode/src/panel/KanbanPanel.cache.test.ts b/vscode/src/panel/KanbanPanel.cache.test.ts index 2d49fcd..ef5350a 100644 --- a/vscode/src/panel/KanbanPanel.cache.test.ts +++ b/vscode/src/panel/KanbanPanel.cache.test.ts @@ -334,7 +334,7 @@ describe('kanban panel cache integration', () => { stopCoordinator = () => webhookCoordinator.dispose() const first = f.open() await first.board.loadAndPush() - mocks.receiveEvent!({ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} }) + mocks.receiveEvent!({ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', repository: { host: 'host', owner: 'owner', repo: 'repo' }, raw: {} }) await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce()) first.panel.dispose() const reopened = f.open() diff --git a/vscode/src/webhook/coordinator.cache.test.ts b/vscode/src/webhook/coordinator.cache.test.ts index 54bf6b9..7a68402 100644 --- a/vscode/src/webhook/coordinator.cache.test.ts +++ b/vscode/src/webhook/coordinator.cache.test.ts @@ -48,10 +48,11 @@ vi.mock('./server', () => ({ })) const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false } +const repository = { host: 'host', owner: 'owner', repo: 'repo' } const events: WebhookEvent[] = [ - { kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} }, - { kind: 'issue_comment', action: 'created', issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} }, - { kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', raw: {} }, + { kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', repository, raw: {} }, + { kind: 'issue_comment', action: 'created', issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', repository, raw: {} }, + { kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', merged: true, mergedAt: undefined, repository, raw: {} }, ] function fixture() { @@ -99,7 +100,7 @@ describe('coordinator cache invalidation', () => { const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false }) await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce()) mocks.receive!(events[0]) - expect(getKanbanState(f.context.workspaceState).snapshot).toBeUndefined() + await vi.waitFor(() => expect(getKanbanState(f.context.workspaceState).snapshot).toBeUndefined()) expect(f.update).toHaveBeenCalledOnce() release() await saving @@ -110,8 +111,8 @@ describe('coordinator cache invalidation', () => { it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => { const f = fixture() const state = getKanbanState(f.context.workspaceState) - mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} }) - expect(state.snapshot).toBeUndefined() + mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', repository, raw: {} }) + await vi.waitFor(() => expect(state.snapshot).toBeUndefined()) await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)) }) @@ -119,7 +120,7 @@ describe('coordinator cache invalidation', () => { const f = fixture() const state = getKanbanState(f.context.workspaceState) const snapshot = state.snapshot - mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '谢谢', commentAuthor: 'other', commentHtmlUrl: '', raw: {} }) + mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '谢谢', commentAuthor: 'other', commentHtmlUrl: '', repository, raw: {} }) await Promise.resolve() expect(state.snapshot).toBe(snapshot) expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached) @@ -149,7 +150,7 @@ describe('coordinator cache invalidation', () => { mocks.token.mockResolvedValue('token') const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) } webhookCoordinator.setActivePanel(panel as unknown as KanbanWebviewPanel) - mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} }) + mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', merged: false, mergedAt: undefined, repository, raw: {} }) await vi.waitFor(() => expect(panel.triggerAutoReviewTab).toHaveBeenCalledOnce()) }) @@ -172,7 +173,7 @@ describe('coordinator cache invalidation', () => { const first = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) } const second = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined), reloadAfterInvalidation: vi.fn().mockResolvedValue(undefined) } webhookCoordinator.setActivePanel(first as unknown as KanbanWebviewPanel) - mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} }) + mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', merged: false, mergedAt: undefined, repository, raw: {} }) await vi.waitFor(() => expect(mocks.readState).toHaveBeenCalledTimes(pendingRead)) webhookCoordinator.setActivePanel(undefined) if (reopen) @@ -185,7 +186,7 @@ describe('coordinator cache invalidation', () => { it('普通 push 不清看板', async () => { const f = fixture() - mocks.receive!({ kind: 'push', branch: 'main', raw: {} }) + mocks.receive!({ kind: 'push', branch: 'main', repository, raw: {} }) await Promise.resolve() expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached) expect(f.update).not.toHaveBeenCalled() diff --git a/vscode/src/webhook/coordinator.issueCreation.test.ts b/vscode/src/webhook/coordinator.issueCreation.test.ts index 3b9a545..b6509ce 100644 --- a/vscode/src/webhook/coordinator.issueCreation.test.ts +++ b/vscode/src/webhook/coordinator.issueCreation.test.ts @@ -397,7 +397,7 @@ describe('issue creation lifecycle boundaries', () => { const f = fixture() f.register() await dispatch(event('label_updated')) - await dispatch({ ...event(), raw: { issue: {} } }) + await dispatch({ ...event(), repository: undefined, raw: { issue: {} } }) expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({}) await dispatch(event()) expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID) diff --git a/vscode/src/webhook/coordinator.routing.test.ts b/vscode/src/webhook/coordinator.routing.test.ts new file mode 100644 index 0000000..6bc1b9b --- /dev/null +++ b/vscode/src/webhook/coordinator.routing.test.ts @@ -0,0 +1,160 @@ +import type { ExtensionContext } from 'vscode' +import type { KanbanWebviewPanel } from '../panel/KanbanPanel' +import type { WebhookEvent } from './eventParser' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { KANBAN_SNAPSHOT_KEY } from '../panel/kanbanSnapshot' +import { webhookCoordinator } from './coordinator' +import { parseEvent } from './eventParser' + +const mocks = vi.hoisted(() => ({ + origin: { host: 'gitea.example.cn', owner: 'team', repo: 'superwork' }, + openForge: vi.fn(), + getIssue: vi.fn(), + getPullRequest: vi.fn(), + mergeState: vi.fn(), + readState: vi.fn(), + log: vi.fn(), +})) +vi.mock('vscode', () => ({ env: {}, Uri: {}, window: { showInformationMessage: async () => undefined }, workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] } })) +vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) +vi.mock('../auth/identity', () => ({ loginFor: async () => 'me' })) +vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) +vi.mock('../git/remote', async importOriginal => ({ + ...(await importOriginal()), + detectRepo: async () => mocks.origin, +})) +vi.mock('../forge/context', () => ({ openForge: mocks.openForge })) +vi.mock('../forge/issueLoader', () => ({ loadIssues: async () => ({ issues: [] }), loadSingleIssue: async () => null })) +vi.mock('../forge/state', () => ({ + readForgeState: mocks.readState, + mergeForgeState: mocks.mergeState, + mergeForgeStateGuarded: async () => ({ posted: false }), +})) +vi.mock('../issues/localState', () => ({ overlayLocalIssueState: (_ctx: unknown, issues: unknown[]) => issues, getLocalIssueState: () => ({}) })) +vi.mock('../panel/handlers/issueChat', () => ({ handleIssueComments: async () => undefined })) +vi.mock('../panel/handlers/terminals', () => ({ hasLiveIssueSessionTerminal: () => false })) +vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues })) +vi.mock('../settings/store', () => ({ getSettings: () => ({ webhookSecret: '', webhookPort: 12345, devBranch: 'main', releaseBranchPrefix: 'release/', autoReview: false }) })) +vi.mock('./server', () => ({ + WebhookServer: class { + setSecret() {} + start() { return Promise.resolve() } + stop() { return Promise.resolve() } + onEvent() { return { dispose() {} } } + }, +})) + +const SUPERWORK = 'https://gitea.example.cn/team/superwork' +const ARAMHEX = 'https://github.com/team/aramhex' + +function dispatch(event: WebhookEvent | null): Promise { + return (webhookCoordinator as unknown as { handleEvent: (event: WebhookEvent) => Promise }).handleEvent(event!) +} + +function issueEdited(repository: string | undefined): WebhookEvent | null { + return parseEvent('issues', undefined, { + action: 'edited', + issue: { number: 12, title: 't', body: '', html_url: `${repository}/issues/12`, user: { login: 'me' }, assignees: [{ login: 'me' }] }, + ...(repository ? { repository: { html_url: repository } } : {}), + }) +} + +function prEvent(repository: string, action: string, extra: Record = {}): WebhookEvent | null { + return parseEvent('pull_request', undefined, { + action, + pull_request: { number: 30, html_url: `${repository}/pull/30`, body: 'Closes #12', head: { ref: 'feature/a' }, ...extra }, + repository: { html_url: repository }, + }) +} + +function fixture() { + const disk = new Map([[KANBAN_SNAPSHOT_KEY, { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false }]]) + const update = vi.fn(async (key: string, value: unknown) => { + if (value === undefined) + disk.delete(key) + else + disk.set(key, value) + }) + const context = { workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext + webhookCoordinator.init(context) + const panel = { postMessage: vi.fn(), handleReleaseStatus: vi.fn(), noteIssueOwnership: vi.fn(), withLiveTerminalTabState: (issues: unknown[]) => issues } + return { context, disk, update, panel: panel as unknown as KanbanWebviewPanel & typeof panel } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.origin = { host: 'gitea.example.cn', owner: 'team', repo: 'superwork' } + mocks.getIssue.mockResolvedValue({ assignees: [{ login: 'me' }] }) + mocks.getPullRequest.mockResolvedValue({ body: 'Closes #12' }) + mocks.readState.mockResolvedValue({}) + mocks.mergeState.mockResolvedValue(undefined) + mocks.openForge.mockImplementation(async (_ctx: unknown, workspaceRoot: string, remote: { host: string, owner: string, repo: string }) => ({ + ok: true, + forge: { workspaceRoot, kind: remote.host === 'github.com' ? 'github' : 'gitea', ...remote, provider: { getIssue: mocks.getIssue, getPullRequest: mocks.getPullRequest } }, + })) +}) +afterEach(async () => { + await webhookCoordinator.dispose() +}) + +describe('事件按仓库路由', () => { + it('别的仓库的事件不碰本窗口的看板、缓存和工单状态', async () => { + const f = fixture() + await dispatch(issueEdited(ARAMHEX)) + await dispatch(prEvent(ARAMHEX, 'opened')) + expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(true) + webhookCoordinator.setActivePanel(f.panel) + await dispatch(issueEdited(ARAMHEX)) + await dispatch(prEvent(ARAMHEX, 'closed', { merged: true })) + await dispatch(parseEvent('push', undefined, { ref: 'refs/heads/main', repository: { html_url: ARAMHEX } })) + expect(mocks.openForge).not.toHaveBeenCalled() + expect(mocks.mergeState).not.toHaveBeenCalled() + expect(f.panel.postMessage).not.toHaveBeenCalled() + expect(f.panel.handleReleaseStatus).not.toHaveBeenCalled() + expect(f.update).not.toHaveBeenCalled() + }) + + it('缺仓库信息的事件忽略', async () => { + const f = fixture() + webhookCoordinator.setActivePanel(f.panel) + await dispatch(issueEdited(undefined)) + expect(mocks.openForge).not.toHaveBeenCalled() + expect(f.panel.postMessage).not.toHaveBeenCalled() + }) + + it('本窗口仓库的事件用 payload 里的仓库打开 provider', async () => { + const f = fixture() + webhookCoordinator.setActivePanel(f.panel) + await dispatch(issueEdited(SUPERWORK)) + expect(mocks.openForge).toHaveBeenCalledWith(f.context, '/repo', { host: 'gitea.example.cn', owner: 'team', repo: 'superwork' }) + expect(mocks.mergeState).toHaveBeenCalledWith(expect.objectContaining({ getIssue: mocks.getIssue }), 12, { specFile: '.claude/specs/a.md' }) + }) + + it('github.com 仓库拿到 GitHub 的 provider,仓库名大小写不同也认', async () => { + mocks.origin = { host: 'github.com', owner: 'team', repo: 'aramhex' } + const f = fixture() + webhookCoordinator.setActivePanel(f.panel) + await dispatch(prEvent('https://github.com/Team/Aramhex', 'opened')) + expect(mocks.openForge).toHaveBeenCalledWith(f.context, '/repo', { host: 'github.com', owner: 'Team', repo: 'Aramhex' }) + expect(await mocks.openForge.mock.results[0].value).toMatchObject({ forge: { kind: 'github' } }) + expect(mocks.mergeState).toHaveBeenCalledWith(expect.anything(), 12, { pr: '30', implementStatus: 'done' }) + }) + + it('合并状态直接取 payload 里的 PR,不再回查', async () => { + const f = fixture() + webhookCoordinator.setActivePanel(f.panel) + await dispatch(prEvent(SUPERWORK, 'closed', { merged: true, merged_at: '2026-10-19T08:00:00Z' })) + expect(mocks.getPullRequest).not.toHaveBeenCalled() + expect(mocks.mergeState).toHaveBeenCalledWith(expect.anything(), 12, { prMerged: true, prMergedAt: '2026-10-19T08:00:00Z' }) + mocks.mergeState.mockClear() + await dispatch(prEvent(SUPERWORK, 'closed', { merged: false })) + expect(mocks.mergeState).not.toHaveBeenCalled() + }) + + it('本仓库的 push 才刷新发布按钮', async () => { + const f = fixture() + webhookCoordinator.setActivePanel(f.panel) + await dispatch(parseEvent('push', undefined, { ref: 'refs/heads/main', repository: { html_url: SUPERWORK } })) + expect(f.panel.handleReleaseStatus).toHaveBeenCalledOnce() + }) +}) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index eef797d..cea38c3 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -21,12 +21,12 @@ import { promises as fsp } from 'node:fs' import { env, Uri, window, workspace } from 'vscode' import { loginFor } from '../auth/identity' import { renderReviewPrompt } from '../cc/prompts' -import { openForge, resolveForge } from '../forge/context' +import { openForge } from '../forge/context' import { cleanCommentSummary, systemCommentText } from '../forge/issueComment' import { loadIssues, loadSingleIssue } from '../forge/issueLoader' import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../forge/markers' import { mergeForgeState, mergeForgeStateGuarded, readForgeState } from '../forge/state' -import { parseRemoteUrl } from '../git/remote' +import { detectRepo } from '../git/remote' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { hasStateColumn, parseStateComment } from '../issues/stateComment' import { logger } from '../logging/logger' @@ -41,21 +41,6 @@ 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 -} - export interface PendingIssueCreation { sessionId: string remote: Remote @@ -80,6 +65,12 @@ interface IssueCreationEntry { operation?: Promise } +/** 事件所属仓库,以及本窗口打开它的工作区目录。 */ +interface EventRepository { + workspaceRoot: string + remote: Remote +} + const ISSUE_ACTIONS = ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'] function issueCreationNonce(body: string): string | undefined { @@ -87,11 +78,6 @@ function issueCreationNonce(body: string): string | undefined { return matches.length === 1 ? matches[0][1] : undefined } -function issueRepository(event: IssueWebhookEvent): Remote | undefined { - const raw = event.raw as { repository?: { html_url?: unknown } } | null - return typeof raw?.repository?.html_url === 'string' ? parseRemoteUrl(raw.repository.html_url) : undefined -} - function sameRepository(left: Remote, right: Remote): boolean { return left.host.toLowerCase() === right.host.toLowerCase() && left.owner.toLowerCase() === right.owner.toLowerCase() @@ -160,8 +146,6 @@ class WebhookCoordinator { private ctx?: ExtensionContext private readonly issueCreations = new Map() private server?: WebhookServer - // kept for legacy cleanup; new flow uses one shared webhook - private pendingHooks = new Map() private livePanel: KanbanWebviewPanel | undefined private readonly eventPanels = new AsyncLocalStorage<{ panel: KanbanWebviewPanel | undefined }>() private boardEventsInFlight = 0 @@ -190,14 +174,6 @@ class WebhookCoordinator { message: 'coordinator 初始化中', }) - // Restore pending webhook registrations from workspaceState. - const stored = ctx.workspaceState.get>(PENDING_HOOKS_KEY) ?? {} - for (const [k, v] of Object.entries(stored)) { - const n = Number(k) - if (Number.isFinite(n) && v && typeof v === 'object') - this.pendingHooks.set(n, v) - } - this.server = new WebhookServer() this.server.setSecret(getSettings(ctx).webhookSecret) this.eventSubscription = this.server.onEvent((event: WebhookEvent) => { @@ -271,7 +247,7 @@ class WebhookCoordinator { source: 'webhook', message: `coordinator 切换端口 ${srv.currentPort ?? '?'} → ${port}`, }) - await srv.stop() + // start 自己会关掉旧端口;和激活时还没完成的那次 start 排队执行,不会自己和自己抢端口。 await srv.start(port) } @@ -280,36 +256,6 @@ class WebhookCoordinator { this.server?.setSecret(secret) } - /** Register a pending hook after createWebhook on gitea succeeded. */ - async addPending(issueNumber: number, info: PendingHook): Promise { - this.pendingHooks.set(issueNumber, info) - await this.persist() - logger.add({ - level: 'info', - source: 'webhook', - message: `addPending #${issueNumber} hookId=${info.hookId}`, - details: `${info.host}/${info.owner}/${info.repo} feature=${info.feature}`, - }) - } - - /** Remove a pending hook (no-op if absent). Persists. */ - async removePending(issueNumber: number): Promise { - if (!this.pendingHooks.has(issueNumber)) - return - this.pendingHooks.delete(issueNumber) - await this.persist() - logger.add({ - level: 'info', - source: 'webhook', - message: `removePending #${issueNumber}`, - }) - } - - /** Read current map (defensive copy). */ - snapshot(): Map { - return new Map(this.pendingHooks) - } - /** 保存建单关联信息,避免关闭看板时丢失会话归属。 */ registerIssueCreation(nonce: string, pending: PendingIssueCreation): void { if (this.issueCreations.has(nonce)) @@ -346,7 +292,7 @@ class WebhookCoordinator { return undefined const entry = this.issueCreations.get(nonce) const record = entry?.pending ?? entry?.linked - const remote = issueRepository(event) + const remote = event.repository if (!entry || !record || !remote || !sameRepository(record.remote, remote)) return undefined if (entry.targetIssueNumber !== undefined && entry.targetIssueNumber !== event.issueNumber) { @@ -422,39 +368,29 @@ class WebhookCoordinator { return this.livePanel } - private async persist(): Promise { - if (!this.ctx) - return - const obj: Record = {} - for (const [k, v] of this.pendingHooks) - obj[String(k)] = v - await this.ctx.workspaceState.update(PENDING_HOOKS_KEY, obj) - } - /** - * 给 webhook 事件找对应的代码托管仓库。pendingHooks 只是缓存:webhook 地址本身就说明这个工单归我们管, - * 缓存没有时按当前工作区的 origin 恢复。没工作区、没 origin、没凭据时返回 null。 + * 事件所属仓库就是本窗口工作区的 origin 时,返回工作区目录和这个仓库;别的仓库、缺仓库信息时返回 `undefined`。 + * + * 本机所有窗口都会收到所有仓库的事件(见 [WebhookServer]),工单号在不同仓库里会重复,不筛掉就会把别的仓库的事件作用到本窗口的看板上。 */ - private async forgeForEvent(issueNumber: number): Promise { - if (!this.ctx) - return null - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) - return null - const pending = this.pendingHooks.get(issueNumber) - if (pending) { - const resolved = await openForge(this.ctx, ws, pending) - return resolved.ok ? resolved.forge : null + private async homeOf(event: WebhookEvent): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + const remote = event.repository + if (!remote) { + logger.add({ level: 'warn', source: 'webhook', message: `事件缺少 repository,忽略 (${event.kind})` }) + return undefined } - const resolved = await resolveForge(this.ctx) - if (!resolved.ok) - return null - logger.add({ - level: 'info', - source: 'webhook', - message: `未在 pendingHooks 中找到 #${issueNumber},已按工作区 origin 恢复仓库上下文`, - }) - return resolved.forge + const origin = workspaceRoot ? await detectRepo(workspaceRoot) : undefined + if (!workspaceRoot || !origin || !sameRepository(origin, remote)) { + logger.add({ + level: 'info', + source: 'webhook', + message: `不是本窗口的仓库,忽略 ${remote.host}/${remote.owner}/${remote.repo} 的 ${event.kind} 事件`, + details: origin ? `本窗口: ${origin.host}/${origin.owner}/${origin.repo}` : '本窗口没有可识别的 origin', + }) + return undefined + } + return { workspaceRoot, remote } } /** @@ -497,13 +433,6 @@ class WebhookCoordinator { 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. * @@ -518,7 +447,7 @@ class WebhookCoordinator { * Returns `null` when none of the strategies produced a match — the * caller drops the event with a warning. */ - private async resolveIssueNumber(event: PrWebhookEvent): Promise { + private async resolveIssueNumber(event: PrWebhookEvent, forge: ForgeContext): Promise { if (typeof event.issueNumber === 'number') return event.issueNumber @@ -535,10 +464,7 @@ class WebhookCoordinator { if (this.activePanel && event.branch) { try { - const resolved = await resolveForge(this.ctx!) - if (!resolved.ok) - return null - const { issues } = await loadIssues(resolved.forge) + const { issues } = await loadIssues(forge) const found = issues.find(i => i.branch === event.branch) if (found) { logger.add({ @@ -580,6 +506,10 @@ class WebhookCoordinator { } const ctx = this.ctx + // 先认仓库再动看板缓存:别的仓库的事件连缓存都不该清。 + const home = await this.homeOf(event) + if (!home) + return const panel = this.livePanel const affectsBoard = changesKanban(event) if (affectsBoard) { @@ -588,7 +518,7 @@ class WebhookCoordinator { void getKanbanState(ctx.workspaceState).invalidate() } try { - await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx)) + await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx, home)) } finally { if (affectsBoard) { @@ -601,7 +531,7 @@ class WebhookCoordinator { } } - private async dispatchEvent(event: WebhookEvent, ctx: ExtensionContext): Promise { + private async dispatchEvent(event: WebhookEvent, ctx: ExtensionContext, home: EventRepository): Promise { if (event.kind === 'push') { const s = getSettings(ctx) logger.add({ @@ -615,26 +545,38 @@ class WebhookCoordinator { return } + // 新建工单的会话关联只写本机状态,不依赖仓库凭据,先于打开仓库做。 + const creation = event.kind === 'issue' && ISSUE_ACTIONS.includes(event.action) ? await this.reconcileIssueCreation(event) : undefined + const opened = await openForge(ctx, home.workspaceRoot, home.remote) + if (!opened.ok) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `${event.kind} 事件忽略:${opened.message}`, + }) + return + } + const forge = opened.forge + if (event.kind === 'issue') { logger.add({ level: 'info', source: 'webhook', message: `收到 issue 事件 action=${event.action} issue=#${event.issueNumber}`, }) - const creation = ISSUE_ACTIONS.includes(event.action) ? await this.reconcileIssueCreation(event) : undefined if (event.action === 'opened' || event.action === 'reopened') { - await this.handleIssueOpened(event, creation) + await this.handleIssueOpened(event, forge, creation) } else if (event.action === 'edited') { - await this.handleIssueEdited(event) + await this.handleIssueEdited(event, forge) if (creation) - await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '编辑', creation }) + await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '编辑', forge, creation }) } else if (event.action === 'closed') { - await this.handleIssueStateChanged(event, creation) + await this.handleIssueStateChanged(event, forge, creation) } else if (event.action === 'assigned' || event.action === 'unassigned') { - await this.handleIssueAssigned(event, creation) + await this.handleIssueAssigned(event, forge, creation) } else { logger.add({ @@ -653,7 +595,7 @@ class WebhookCoordinator { source: 'webhook', message: `收到 issue_comment 事件 action=${event.action} issue=#${event.issueNumber}`, }) - await this.handleIssueCommentCreated(event) + await this.handleIssueCommentCreated(event, forge) return } @@ -663,7 +605,7 @@ class WebhookCoordinator { message: `收到 webhook 事件 action=${event.action} issue=${typeof event.issueNumber === 'number' ? `#${event.issueNumber}` : '<未定>'} pr=#${event.pr}`, }) - const issueNumber = await this.resolveIssueNumber(event) + const issueNumber = await this.resolveIssueNumber(event, forge) if (issueNumber === null) return // Narrow `issueNumber` for downstream handlers. We build a fresh @@ -674,23 +616,22 @@ class WebhookCoordinator { switch (resolved.action) { case 'opened': case 'reopened': { - await this.handlePrOpened(resolved) + await this.handlePrOpened(resolved, forge) break } + // GitHub 发 synchronize,Gitea 发 synchronized。 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) + await this.handlePrSynchronize(resolved, forge) break } case 'closed': { - await this.handlePrClosed(resolved) + await this.handlePrClosed(resolved, forge) break } + // 只有 Gitea 会发;GitHub 的 PR 删不掉,没有这个动作。 case 'deleted': { - await this.handlePrDeleted(resolved) + await this.handlePrDeleted(resolved, forge) break } default: { @@ -706,8 +647,8 @@ class WebhookCoordinator { } /** 刷新新建工单卡片,并为本机创建的工单显示成功提示。 */ - private async handleIssueOpened(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { - await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '创建', creation, select: creation ? true : undefined }) + private async handleIssueOpened(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise { + await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '创建', forge, creation, select: creation ? true : undefined }) if (creation) { this.activePanel?.postMessage({ type: 'toast/show', @@ -732,27 +673,17 @@ class WebhookCoordinator { raw: unknown /** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */ reason: string + forge: ForgeContext creation?: LinkedIssueCreation select?: boolean }): Promise { - const { issueNumber, raw, reason, creation } = opts + const { issueNumber, raw, reason, forge, creation } = opts if (!this.ctx) return // 没面板就没什么可更新,下次打开看板的批量加载会兜底。 if (!this.activePanel) return - const resolved = await resolveForge(this.ctx) - if (!resolved.ok) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `${reason} 事件忽略:${resolved.message}`, - }) - return - } - const { forge } = resolved - // 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, // 省一次身份请求。 @@ -805,24 +736,26 @@ class WebhookCoordinator { * issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板 * 要实时上卡/撤卡,不能等下次手动刷新。 */ - private async handleIssueAssigned(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { + private async handleIssueAssigned(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise { await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: event.action === 'unassigned' ? '取消指派' : '指派', + forge, creation, }) } /** - * issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着 + * issue closed/reopened:工单直接在仓库网页上开关时,状态评论不一定跟着 * 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。 */ - private async handleIssueStateChanged(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { + private async handleIssueStateChanged(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise { await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: event.action === 'closed' ? '工单关闭' : '工单重开', + forge, creation, }) } @@ -833,21 +766,7 @@ class WebhookCoordinator { * 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对, * 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。 */ - private async handleIssueEdited(event: IssueWebhookEvent): Promise { - if (!this.ctx) - return - - const resolved = await resolveForge(this.ctx) - if (!resolved.ok) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `issue edited 事件忽略:${resolved.message}`, - }) - return - } - const { forge } = resolved - + private async handleIssueEdited(event: IssueWebhookEvent, forge: ForgeContext): Promise { const found = parseBodyMarkers(event.body) if (Object.keys(found).length === 0) { logger.add({ @@ -947,23 +866,11 @@ class WebhookCoordinator { } /** - * `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`. + * PR `opened` / `reopened`:把 PR 编号和「实施完成」记进工单状态、刷新看板、弹提示,开着自动审查时接着起审查。 */ - private async handlePrOpened(event: ResolvedWebhookEvent): Promise { + private async handlePrOpened(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise { if (!this.ctx) return - const ctx = await this.forgeForEvent(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、不触发审查。 @@ -1012,7 +919,7 @@ class WebhookCoordinator { // indefinitely. const action = 'Open PR' void window.showInformationMessage( - `#${event.issueNumber} 已关联 PR !${event.pr}`, + `#${event.issueNumber} 已关联 PR #${event.pr}`, action, ).then((pick) => { if (pick === action) @@ -1070,19 +977,9 @@ class WebhookCoordinator { * around through {@link handleIssueCommentCreated} to inject into the * implementation terminal. */ - private async handlePrSynchronize(event: ResolvedWebhookEvent): Promise { + private async handlePrSynchronize(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise { if (!this.ctx) return - const ctx = await this.forgeForEvent(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 @@ -1141,49 +1038,18 @@ class WebhookCoordinator { } /** - * `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. + * PR `closed`:合并关掉的把 `prMerged` 和合并时间记进工单状态,没合并就关掉的什么也不做。 */ - private async handlePrClosed(event: ResolvedWebhookEvent): Promise { + private async handlePrClosed(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise { logger.add({ level: 'info', source: 'webhook', - message: `PR #${event.pr} closed (保留 webhook 以备 reopen/重建)`, + message: `PR #${event.pr} closed merged=${event.merged}`, details: `issue=#${event.issueNumber}`, }) - // 判断是否为合并关闭 —— gitea PR payload 里 merged 字段不在 ResolvedWebhookEvent - // 类型上,主动查一次 API 取 merged 状态(成本低,避免 server.ts 解析变动)。 - if (!this.ctx) - return - const ctx = await this.forgeForEvent(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 ctx.provider.getPullRequest(prIndex) - merged = pr.merged - mergedAt = pr.mergedAt ?? 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) + // 合并关掉的 PR 在 payload 里 merged 为 true,只是关掉的不记。 + if (!event.merged) return if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') { @@ -1197,7 +1063,7 @@ class WebhookCoordinator { } try { - await mergeForgeState(ctx.provider, event.issueNumber, { prMerged: true, prMergedAt: mergedAt ?? new Date().toISOString() }) + await mergeForgeState(ctx.provider, event.issueNumber, { prMerged: true, prMergedAt: event.mergedAt ?? new Date().toISOString() }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1230,27 +1096,15 @@ class WebhookCoordinator { } /** - * `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. + * PR `deleted`(只有 Gitea 会发):PR 被删掉了,把工单状态里的 `pr` 清成空串,看板上随之不再显示 PR。 */ - private async handlePrDeleted(event: ResolvedWebhookEvent): Promise { + private async handlePrDeleted(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise { logger.add({ level: 'info', source: 'webhook', message: `PR #${event.pr} deleted, 清空 state JSON 中的 pr`, details: `issue=#${event.issueNumber}`, }) - const ctx = await this.forgeForEvent(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({ @@ -1293,36 +1147,17 @@ class WebhookCoordinator { } } - /** - * 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 - /** - * Inbound `issue_comment created` handler — picks up the marker comment that - * the codex review run posts back, strips the marker line, and injects the - * remaining markdown into the issue's implementation cc terminal. - * - * Comments without the `` marker are ignored. Edited / - * deleted comments are also ignored (the dispatcher only routes 'created'). - * - */ /** 申请移交时给负责人发系统通知。看板关着也发。 */ - private async notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise { + private async notifyHandoffRequest(event: IssueCommentWebhookEvent, forge: ForgeContext): Promise { 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) + if (from === seen) return - const resolved = await resolveForge(this.ctx) - if (!resolved.ok) - return - const me = await loginFor(resolved.forge) + const me = await loginFor(forge) 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) @@ -1352,7 +1187,12 @@ class WebhookCoordinator { }) } - private async handleIssueCommentCreated(event: IssueCommentWebhookEvent): Promise { + /** + * 新评论:刷新讨论列表;是状态评论就重拉卡片;别人在工单上发言时弹系统通知;带 `` 的审查意见注入实施终端。 + * + * 编辑、删除的评论不处理。 + */ + private async handleIssueCommentCreated(event: IssueCommentWebhookEvent, ctx: ForgeContext): Promise { if (event.action !== 'created') { logger.add({ level: 'info', @@ -1362,8 +1202,7 @@ class WebhookCoordinator { 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. + // 审查标记没认出来时,看日志里的原文前 200 字排查。 logger.add({ level: 'info', source: 'webhook', @@ -1371,17 +1210,7 @@ class WebhookCoordinator { details: body.slice(0, 200), }) - const ctx = await this.forgeForEvent(event.issueNumber) - if (!ctx) { - logger.add({ - level: 'error', - source: 'webhook', - message: `无法解析 #${event.issueNumber} 的仓库上下文(issue_comment),跳过`, - }) - return - } - - // gitea 的 issue_comment payload 里,PR 上的评论同样以 issue.number 形式 + // PR 上的评论同样以 issue.number 形式 // 携带 PR 编号。这里如果 prNumber 已置位,就拉 PR body 反查 `Closes #N` // 找到真正的工单编号;找不到则保持原 event.issueNumber 兜底。 let realIssueNumber = event.issueNumber @@ -1429,11 +1258,12 @@ class WebhookCoordinator { source: 'webhook', message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`, }) - await this.notifyHandoffRequest(event) + await this.notifyHandoffRequest(event, ctx) await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '列变化', + forge: ctx, }) return } @@ -1597,15 +1427,7 @@ class WebhookCoordinator { return } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - logger.add({ - level: 'error', - source: 'webhook', - message: `triggerReview 中止 #${issueNumber}:没有工作区`, - }) - return - } + const { workspaceRoot } = ctx // state 读一次,既当审查提示词的模板上下文,又给 worktree 兜底。读失败按空 // 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。 diff --git a/vscode/src/webhook/eventOwnership.ts b/vscode/src/webhook/eventOwnership.ts index 4e5327e..7e07cdd 100644 --- a/vscode/src/webhook/eventOwnership.ts +++ b/vscode/src/webhook/eventOwnership.ts @@ -1,7 +1,7 @@ /** - * 多人协作事件门禁:Gitea webhook 广播到每位开发者的机器,写状态、触发 + * 多人协作事件门禁:仓库 webhook 广播到每位开发者的机器,写状态、触发 * codex 审查、注入终端这类副作用只允许"责任人机器"执行,其余机器只做 - * UI 刷新。归属以 Gitea 原生 assignee 为准;存量无 assignee 的工单退回 + * UI 刷新。归属以仓库原生 assignee 为准;存量无 assignee 的工单退回 * 本地证据(本机是否开着该工单的实施/测试终端)。 */ @@ -10,7 +10,7 @@ export type EventOwnership = 'own' | 'observe' export function decideEventOwnership(opts: { /** 工单 assignee 的 login 列表,空数组表示未指派。 */ assignees: string[] - /** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */ + /** 本机凭据对应的仓库 login;身份解析失败时为 undefined。 */ me: string | undefined /** 本机是否存在该工单的实施/测试终端(存量工单的兜底证据)。 */ hasLocalSessionEvidence: boolean diff --git a/vscode/src/webhook/eventParser.test.ts b/vscode/src/webhook/eventParser.test.ts index 041069e..0758235 100644 --- a/vscode/src/webhook/eventParser.test.ts +++ b/vscode/src/webhook/eventParser.test.ts @@ -21,6 +21,7 @@ describe('parseEvent', () => { commentAuthor: 'alice', commentBody: 'hello world', commentHtmlUrl: 'https://gitea.example.com/repo/issues/42#issuecomment-1', + repository: undefined, raw, }) }) @@ -70,6 +71,99 @@ describe('issue creation correlation payload', () => { it.each(['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'])('preserves the exact creation marker and repository for %s', (action) => { const body = '' const raw = { action, issue: { number: 483, body, title: 'created', html_url: 'https://host/owner/repo/issues/483' }, repository: { html_url: 'https://host/owner/repo' } } - expect(parseEvent('issues', undefined, raw)).toEqual({ kind: 'issue', action, issueNumber: 483, body, title: 'created', htmlUrl: raw.issue.html_url, raw }) + expect(parseEvent('issues', undefined, raw)).toEqual({ kind: 'issue', action, issueNumber: 483, body, title: 'created', htmlUrl: raw.issue.html_url, repository: { host: 'host', owner: 'owner', repo: 'repo' }, raw }) + }) +}) + +const GITHUB_REPOSITORY = { full_name: 'octocat/Hello-World', html_url: 'https://github.com/octocat/Hello-World', clone_url: 'https://github.com/octocat/Hello-World.git' } +const GITHUB_REMOTE = { host: 'github.com', owner: 'octocat', repo: 'Hello-World' } + +describe('github.com 的 payload', () => { + it('issues opened: body 为 null 时按空串,仓库取自 repository.html_url', () => { + const raw = { + action: 'opened', + issue: { number: 1347, title: 'Found a bug', body: null, html_url: 'https://github.com/octocat/Hello-World/issues/1347', user: { login: 'octocat' }, assignees: [] }, + repository: GITHUB_REPOSITORY, + sender: { login: 'octocat' }, + } + expect(parseEvent('issues', undefined, raw)).toEqual({ + kind: 'issue', + action: 'opened', + issueNumber: 1347, + title: 'Found a bug', + body: '', + htmlUrl: 'https://github.com/octocat/Hello-World/issues/1347', + repository: GITHUB_REMOTE, + raw, + }) + }) + + it('issue_comment 在 PR 上:issue.pull_request 是带链接的对象', () => { + const raw = { + action: 'created', + issue: { number: 7, pull_request: { url: 'https://api.github.com/repos/octocat/Hello-World/pulls/7', html_url: 'https://github.com/octocat/Hello-World/pull/7', merged_at: null } }, + comment: { body: '\nLGTM', html_url: 'https://github.com/octocat/Hello-World/pull/7#issuecomment-1', user: { login: 'reviewer' } }, + repository: GITHUB_REPOSITORY, + sender: { login: 'reviewer' }, + } + expect(parseEvent('issue_comment', undefined, raw)).toMatchObject({ + kind: 'issue_comment', + action: 'created', + issueNumber: 7, + prNumber: 7, + commentAuthor: 'reviewer', + repository: GITHUB_REMOTE, + }) + }) + + it('评论人只认 login,不读 Gitea 兼容字段 username', () => { + const raw = { action: 'created', issue: { number: 3 }, comment: { body: 'x', user: { username: 'legacy' } }, sender: { username: 'legacy' } } + expect(parseEvent('issue_comment', undefined, raw)).toMatchObject({ commentAuthor: '' }) + }) + + it.each(['opened', 'reopened', 'synchronize'])('pull_request %s', (action) => { + const raw = { + action, + number: 7, + pull_request: { number: 7, html_url: 'https://github.com/octocat/Hello-World/pull/7', title: 'feat', body: 'Closes #3', head: { ref: 'feature/x', sha: 'abc' }, base: { ref: 'main', sha: 'def' }, merged: false, merged_at: null }, + repository: GITHUB_REPOSITORY, + } + expect(parseEvent('pull_request', undefined, raw)).toEqual({ + kind: 'pr', + issueNumber: undefined, + action, + pr: '7', + branch: 'feature/x', + htmlUrl: 'https://github.com/octocat/Hello-World/pull/7', + title: 'feat', + body: 'Closes #3', + merged: false, + mergedAt: undefined, + repository: GITHUB_REMOTE, + raw, + }) + }) + + it('pull_request closed 时从 payload 读合并状态与时间', () => { + const raw = { + action: 'closed', + pull_request: { number: 7, html_url: 'https://github.com/octocat/Hello-World/pull/7', body: null, head: { ref: 'feature/x' }, merged: true, merged_at: '2026-10-19T08:00:00Z' }, + repository: GITHUB_REPOSITORY, + } + expect(parseEvent('pull_request', undefined, raw)).toMatchObject({ kind: 'pr', action: 'closed', body: '', merged: true, mergedAt: '2026-10-19T08:00:00Z' }) + }) + + it.each(['pull_request_review_comment', 'pull_request_review', 'ping', ''])('只有 pull_request 事件才当 PR 事件:%s', (header) => { + const raw = { + action: 'deleted', + pull_request: { number: 7, html_url: 'https://github.com/octocat/Hello-World/pull/7', head: { ref: 'feature/x' } }, + repository: GITHUB_REPOSITORY, + } + expect(parseEvent(header, undefined, raw)).toBeNull() + }) + + it('push 带上分支和仓库', () => { + const raw = { ref: 'refs/heads/main', repository: GITHUB_REPOSITORY } + expect(parseEvent('push', undefined, raw)).toEqual({ kind: 'push', branch: 'main', repository: GITHUB_REMOTE, raw }) }) }) diff --git a/vscode/src/webhook/eventParser.ts b/vscode/src/webhook/eventParser.ts index ed3ba24..3883793 100644 --- a/vscode/src/webhook/eventParser.ts +++ b/vscode/src/webhook/eventParser.ts @@ -1,3 +1,13 @@ +/** + * webhook 事件解析:GitHub 和 Gitea 的 payload 在这里用到的字段同名同形,一份解析两家通用。 + * + * 每个事件都带上 `repository`(取自 payload 的 `repository.html_url`),看板据此只处理本窗口打开的仓库; + * payload 里没有仓库信息时为 `undefined`。 + */ + +import type { Remote } from '../git/remote' +import { parseRemoteUrl } from '../git/remote' + export interface PrWebhookEvent { kind: 'pr' issueNumber: number | undefined @@ -7,6 +17,11 @@ export interface PrWebhookEvent { htmlUrl: string title: string body: string + /** 关闭时是不是合并关掉的;`closed` 以外的动作恒为 `false`。 */ + merged: boolean + /** 合并时间(ISO 8601),没合并时没有。 */ + mergedAt: string | undefined + repository: Remote | undefined raw: unknown } @@ -17,6 +32,7 @@ export interface IssueWebhookEvent { title: string body: string htmlUrl: string + repository: Remote | undefined raw: unknown } @@ -28,17 +44,25 @@ export interface IssueCommentWebhookEvent { commentBody: string commentAuthor: string commentHtmlUrl: string + repository: Remote | undefined raw: unknown } export interface PushWebhookEvent { kind: 'push' branch: string + repository: Remote | undefined raw: unknown } export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent +/** payload 里 `repository.html_url` 指向的仓库。 */ +function repositoryOf(repository: unknown): Remote | undefined { + const url = (repository as { html_url?: unknown } | null | undefined)?.html_url + return typeof url === 'string' ? parseRemoteUrl(url) : undefined +} + export function parseEvent( eventHeader: string, issueNumber: number | undefined, @@ -53,16 +77,18 @@ export function parseEvent( comment?: unknown ref?: unknown sender?: unknown + repository?: unknown } + const repository = repositoryOf(obj.repository) if (eventHeader === 'push') { - // gitea push 事件无 `action`,顶层带 `ref: "refs/heads/"`。 + // push 事件没有 `action`,顶层带 `ref: "refs/heads/"`。 if (typeof obj.ref !== 'string') return null const branch = obj.ref.replace(/^refs\/heads\//, '') if (!branch) return null - return { kind: 'push', branch, raw } + return { kind: 'push', branch, repository, raw } } if (typeof obj.action !== 'string') @@ -87,18 +113,16 @@ export function parseEvent( const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number) if (!Number.isFinite(num)) return null - // gitea sets issue.pull_request (non-null object) when the issue is a PR. + // 评论在 PR 上时 issue.pull_request 是个对象,issue.number 就是 PR 编号。 const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object' ? num : undefined const commentBody = typeof commentObj.body === 'string' ? commentObj.body : '' const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : '' - const commentUser = commentObj.user as { login?: unknown, username?: unknown } | undefined - const sender = obj.sender as { login?: unknown, username?: unknown } | undefined + const commentUser = commentObj.user as { login?: unknown } | undefined + const sender = obj.sender as { login?: unknown } | undefined const commentAuthor = (typeof commentUser?.login === 'string' && commentUser.login) - || (typeof commentUser?.username === 'string' && commentUser.username) || (typeof sender?.login === 'string' && sender.login) - || (typeof sender?.username === 'string' && sender.username) || '' return { kind: 'issue_comment', @@ -108,6 +132,7 @@ export function parseEvent( commentAuthor, commentBody, commentHtmlUrl, + repository, raw, } } @@ -135,12 +160,14 @@ export function parseEvent( title, body, htmlUrl, + repository, raw, } } - // Default path: pull_request payload (preserves legacy behaviour for any - // event header value, since gitea historically may not set the header). + // PR 的评审、评审评论等事件也带 pull_request 对象,只认 pull_request 事件,免得把评审评论的 deleted 当成 PR 被删。 + if (eventHeader !== 'pull_request') + return null const pr = obj.pull_request if (!pr || typeof pr !== 'object') return null @@ -150,6 +177,8 @@ export function parseEvent( head?: unknown title?: unknown body?: unknown + merged?: unknown + merged_at?: unknown } const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number) if (!Number.isFinite(num)) @@ -172,6 +201,9 @@ export function parseEvent( htmlUrl, title, body, + merged: prObj.merged === true, + mergedAt: typeof prObj.merged_at === 'string' ? prObj.merged_at : undefined, + repository, raw, } } diff --git a/vscode/src/webhook/issueAppend.ts b/vscode/src/webhook/issueAppend.ts index 9b49e9e..899c6ee 100644 --- a/vscode/src/webhook/issueAppend.ts +++ b/vscode/src/webhook/issueAppend.ts @@ -15,7 +15,7 @@ export function decideIssueAppend(opts: { poster: string | undefined /** 工单 assignee 的 login 列表,空数组表示未指派。 */ assignees: string[] - /** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */ + /** 本机凭据对应的仓库 login;身份解析失败时为 undefined。 */ me: string | undefined }): boolean { // ① 本机发起的创建 → 恒显,范围和身份都不作数。 diff --git a/vscode/src/webhook/relay.ts b/vscode/src/webhook/relay.ts new file mode 100644 index 0000000..38c7f33 --- /dev/null +++ b/vscode/src/webhook/relay.ts @@ -0,0 +1,103 @@ +/** + * 本机开着好几个 VS Code 窗口时只有一个窗口占得到 webhook 端口,它把验过签的请求原样转给其余窗口,每个窗口再按事件里的仓库决定管不管。 + * + * 没占到端口的窗口在 `127.0.0.1` 上另开一个随机端口,登记到 [PeerRegistry] 的目录里:一个端口一个文件,文件内容是该窗口扩展进程的 pid。 + */ + +import type { Buffer } from 'node:buffer' +import { promises as fsp } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { logger } from '../logging/logger' + +/** 转发时带上的请求头:解析事件和校验签名只看这几个。 */ +export const RELAYED_HEADERS = ['content-type', 'x-github-event', 'x-hub-signature-256'] as const + +/** 各公网端口登记目录的上级目录,放在系统临时目录里,换了 VS Code profile 的窗口也找得到彼此。 */ +export function defaultPeerRoot(): string { + return path.join(tmpdir(), 'superpowers-webhook') +} + +/** 进程还在就算活着;没权限探测(`EPERM`)说明进程存在,只是属于别的用户。 */ +function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) + return false + try { + process.kill(pid, 0) + return true + } + catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM' + } +} + +/** 没占到公网端口的窗口在这里登记自己的本地端口,占到端口的窗口从这里找转发对象。 */ +export class PeerRegistry { + constructor(private readonly dir: string) {} + + async add(port: number): Promise { + await fsp.mkdir(this.dir, { recursive: true }) + await fsp.writeFile(path.join(this.dir, String(port)), String(process.pid)) + } + + async remove(port: number): Promise { + await fsp.rm(path.join(this.dir, String(port)), { force: true }) + } + + /** 还活着的窗口的本地端口;进程已经退出的登记顺手删掉,窗口崩溃时没来得及注销的就是这么清掉的。 */ + async ports(): Promise { + let names: string[] + try { + names = await fsp.readdir(this.dir) + } + catch { + return [] + } + const out: number[] = [] + for (const name of names) { + const port = Number(name) + if (!Number.isInteger(port) || port <= 0) + continue + const pid = Number(await fsp.readFile(path.join(this.dir, name), 'utf8').catch(() => '')) + if (processAlive(pid)) + out.push(port) + else + await this.remove(port) + } + return out + } +} + +export interface RelayedRequest { + /** 原请求的路径,如 `/webhook`。 */ + url: string + headers: Record + body: Buffer +} + +/** 把请求转给每个登记过的窗口;连不上的登记删掉,其余失败只记日志,一个窗口出错不影响别的窗口。 */ +export async function forwardToPeers(registry: PeerRegistry, request: RelayedRequest): Promise { + const ports = await registry.ports() + await Promise.all(ports.map(async (port) => { + try { + await fetch(`http://127.0.0.1:${port}${request.url}`, { + method: 'POST', + headers: request.headers, + body: request.body, + signal: AbortSignal.timeout(5000), + }) + } + catch (err) { + const cause = (err as { cause?: { code?: unknown } }).cause + if (cause?.code === 'ECONNREFUSED') + await registry.remove(port) + logger.add({ + level: 'warn', + source: 'webhook', + message: `转发给本机其它窗口失败 127.0.0.1:${port}`, + details: err instanceof Error ? err.message : String(err), + }) + } + })) +} diff --git a/vscode/src/webhook/server.test.ts b/vscode/src/webhook/server.test.ts new file mode 100644 index 0000000..4507546 --- /dev/null +++ b/vscode/src/webhook/server.test.ts @@ -0,0 +1,217 @@ +import type { IncomingHttpHeaders } from 'node:http' +import type { WebhookEvent } from './eventParser' +import { Buffer } from 'node:buffer' +import { createHmac } from 'node:crypto' +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { handleWebhookRequest, WebhookServer } from './server' + +const mocks = vi.hoisted(() => ({ log: vi.fn() })) +vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) +vi.mock('vscode', () => ({ + EventEmitter: class { + private listeners: Array<(event: T) => void> = [] + event = (listener: (event: T) => void) => { + this.listeners.push(listener) + return { dispose: () => {} } + } + + fire(event: T) { + for (const listener of this.listeners) + listener(event) + } + }, +})) + +const SECRET = 'top-secret' +const PAYLOAD = JSON.stringify({ + action: 'opened', + issue: { number: 5, title: 't', body: null, html_url: 'https://github.com/octocat/hello/issues/5' }, + repository: { html_url: 'https://github.com/octocat/hello' }, +}) + +function sign(body: string, secret = SECRET): string { + return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}` +} + +function request(opts: { method?: string, url?: string, headers?: IncomingHttpHeaders, body?: string } = {}) { + const body = opts.body ?? PAYLOAD + return { + method: opts.method ?? 'POST', + url: opts.url ?? '/webhook', + headers: { 'content-type': 'application/json', 'x-github-event': 'issues', 'x-hub-signature-256': sign(body), ...opts.headers }, + body: Buffer.from(body), + } +} + +function logged(text: string): boolean { + return mocks.log.mock.calls.some(([entry]) => `${entry.message} ${entry.details ?? ''}`.includes(text)) +} + +beforeEach(() => { + mocks.log.mockClear() +}) + +describe('handleWebhookRequest', () => { + it('签名是 sha256= 加 HMAC-SHA256 十六进制时接受并解析事件', () => { + const reply = handleWebhookRequest(request(), SECRET) + expect(reply.status).toBe(200) + expect(reply.event).toMatchObject({ kind: 'issue', issueNumber: 5, repository: { host: 'github.com', owner: 'octocat', repo: 'hello' } }) + }) + + it.each([ + ['签名错', { 'x-hub-signature-256': sign(PAYLOAD, 'other') }], + ['缺 sha256= 前缀', { 'x-hub-signature-256': sign(PAYLOAD).slice('sha256='.length) }], + ['不是十六进制', { 'x-hub-signature-256': 'sha256=zz' }], + ['只带旧的 X-Gitea-Signature', { 'x-hub-signature-256': undefined, 'x-gitea-signature': sign(PAYLOAD).slice('sha256='.length) }], + ])('%s → 401', (_name, headers) => { + const reply = handleWebhookRequest(request({ headers }), SECRET) + expect(reply).toEqual({ status: 401, body: { ok: false, error: 'invalid_signature' } }) + }) + + it('没设 secret 时不校验签名', () => { + const reply = handleWebhookRequest(request({ headers: { 'x-hub-signature-256': undefined } }), '') + expect(reply.status).toBe(200) + expect(reply.event?.kind).toBe('issue') + }) + + it('ping 正常应答且不产生事件', () => { + const body = JSON.stringify({ zen: 'Keep it logically awesome.', hook_id: 1 }) + const reply = handleWebhookRequest(request({ body, headers: { 'x-github-event': 'ping' } }), SECRET) + expect(reply).toEqual({ status: 200, body: { ok: true } }) + expect(logged('ping')).toBe(true) + }) + + it.each(['issues', 'ping'])('form 编码的 %s 拒收并提示改成 JSON', (event) => { + const body = `payload=${encodeURIComponent(PAYLOAD)}` + const reply = handleWebhookRequest(request({ body, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-github-event': event } }), SECRET) + expect(reply).toEqual({ status: 415, body: { ok: false, error: 'unsupported_content_type' } }) + expect(reply.event).toBeUndefined() + expect(logged('application/json')).toBe(true) + }) + + it('application/json 带 charset 也接受', () => { + const reply = handleWebhookRequest(request({ headers: { 'content-type': 'application/json; charset=utf-8' } }), SECRET) + expect(reply.status).toBe(200) + }) + + it('缺 X-GitHub-Event → 400', () => { + const reply = handleWebhookRequest(request({ headers: { 'x-github-event': undefined, 'x-gitea-event': 'issues' } }), SECRET) + expect(reply).toEqual({ status: 400, body: { ok: false, error: 'missing_event' } }) + }) + + it('非 POST → 405,路径不对 → 404,请求体不是 JSON → 400', () => { + expect(handleWebhookRequest(request({ method: 'GET' }), SECRET).status).toBe(405) + expect(handleWebhookRequest(request({ url: '/hooks' }), SECRET).status).toBe(404) + expect(handleWebhookRequest(request({ body: '{oops' }), SECRET)).toEqual({ status: 400, body: { ok: false, error: 'invalid_json' } }) + }) + + it('旧的 /webhook/<工单号> 路径把工单号带进 PR 事件', () => { + const body = JSON.stringify({ action: 'opened', pull_request: { number: 9, html_url: 'https://h/o/r/pulls/9', head: { ref: 'feature/a' } } }) + const reply = handleWebhookRequest(request({ url: '/webhook/12', body, headers: { 'x-github-event': 'pull_request' } }), SECRET) + expect(reply.event).toMatchObject({ kind: 'pr', issueNumber: 12, pr: '9' }) + }) + + it('认不出的事件应答 200 但不产生事件', () => { + const reply = handleWebhookRequest(request({ headers: { 'x-github-event': 'star' } }), SECRET) + expect(reply).toEqual({ status: 200, body: { ok: true } }) + }) +}) + +describe('同一台机器多个窗口共用端口', () => { + let peerRoot: string + const servers: WebhookServer[] = [] + + function open(): { server: WebhookServer, events: WebhookEvent[] } { + const server = new WebhookServer({ peerRoot, takeoverIntervalMs: 20 }) + server.setSecret(SECRET) + servers.push(server) + const events: WebhookEvent[] = [] + server.onEvent(event => events.push(event)) + return { server, events } + } + + async function deliver(port: number, body = PAYLOAD): Promise { + const res = await fetch(`http://127.0.0.1:${port}/webhook`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-github-event': 'issues', 'x-hub-signature-256': sign(body) }, + body, + }) + return res.status + } + + beforeEach(() => { + peerRoot = mkdtempSync(path.join(tmpdir(), 'webhook-peers-')) + }) + + afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.stop())) + rmSync(peerRoot, { recursive: true, force: true }) + }) + + it('抢到端口的窗口把事件转给其余窗口', async () => { + const hub = open() + await hub.server.start(0) + const port = hub.server.currentPort! + const peer = open() + await peer.server.start(port) + expect(peer.server.currentPort).toBe(port) + + expect(await deliver(port)).toBe(200) + expect(hub.events).toHaveLength(1) + await vi.waitFor(() => expect(peer.events).toHaveLength(1)) + expect(peer.events[0]).toMatchObject({ kind: 'issue', issueNumber: 5 }) + }) + + it('签名不对的请求不转发', async () => { + const hub = open() + await hub.server.start(0) + const port = hub.server.currentPort! + const peer = open() + await peer.server.start(port) + const res = await fetch(`http://127.0.0.1:${port}/webhook`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-github-event': 'issues', 'x-hub-signature-256': sign(PAYLOAD, 'other') }, + body: PAYLOAD, + }) + expect(res.status).toBe(401) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(peer.events).toHaveLength(0) + }) + + it('抢到端口的窗口关掉后,其余窗口接手端口', async () => { + const hub = open() + await hub.server.start(0) + const port = hub.server.currentPort! + const peer = open() + await peer.server.start(port) + await hub.server.stop() + + await vi.waitFor(async () => expect(await deliver(port)).toBe(200)) + await vi.waitFor(() => expect(peer.events).toHaveLength(1)) + expect(readdirSync(path.join(peerRoot, String(port)))).toEqual([]) + }) + + it('进程已经不在的登记不转发并被清掉', async () => { + const hub = open() + await hub.server.start(0) + const port = hub.server.currentPort! + const dir = path.join(peerRoot, String(port)) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, '1'), '999999999') + + expect(await deliver(port)).toBe(200) + await vi.waitFor(() => expect(readdirSync(dir)).toEqual([])) + }) + + it('同一端口重复 start 不会自己挤掉自己', async () => { + const hub = open() + await hub.server.start(0) + const port = hub.server.currentPort! + await Promise.all([hub.server.start(port), hub.server.start(port)]) + expect(await deliver(port)).toBe(200) + expect(hub.events).toHaveLength(1) + }) +}) diff --git a/vscode/src/webhook/server.ts b/vscode/src/webhook/server.ts index 2ad61d9..c0cdb59 100644 --- a/vscode/src/webhook/server.ts +++ b/vscode/src/webhook/server.ts @@ -1,37 +1,28 @@ /** - * Minimal HTTP server that listens for Gitea webhooks and fires a VS Code - * `Event` for each accepted payload. Two payload shapes are - * supported, discriminated by the `X-Gitea-Event` header: - * - `pull_request` → {@link PrWebhookEvent} - * - `issues` → {@link IssueWebhookEvent} (used by the new-issue - * flow to round-trip a `` marker - * back into the panel) + * 接收代码托管仓库 webhook 的本机 HTTP 服务,每个验过签、认得出的请求在 [WebhookServer.onEvent] 上发一个事件。 * - * Routes accepted: - * - `POST /webhook/:issueNumber` (legacy) — the issue number is taken from - * the path and round-tripped on the event. Kept so per-issue webhooks - * that the extension previously created on gitea keep working. - * - `POST /webhook` (canonical) — the user configures a single shared - * webhook in gitea pointing here; the event leaves `issueNumber` - * undefined (PR case) and the coordinator resolves it from the PR body - * (`Closes #N` / branch fallback). Issue events carry the number - * directly in the payload, so the path doesn't matter for them. + * GitHub 和 Gitea(v28 源码 `services/webhook/deliver.go` 的 `addDefaultHeaders`)都发 `X-GitHub-Event` 和 + * `X-Hub-Signature-256: sha256=`,所以只认这一套头;请求体只收 `application/json`。 * - * Other paths/methods get a 404/405. Malformed JSON or missing fields → 400. - * Non-matching actions still respond 200 but skip the emit (gitea retries - * less aggressively that way). + * 路径:`POST /webhook` 是现在的用法,PR 事件的工单号之后由 PR 描述里的 `Closes #N` 反查; + * `POST /webhook/<工单号>` 是旧版按工单建的 webhook 留下的地址,工单号直接取自路径。 + * + * 本机开着几个窗口时只有一个窗口占得到端口,其余窗口经 [forwardToPeers] 收到转发,见 [PeerRegistry]。 */ -import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' import type { Event } from 'vscode' import type { WebhookEvent } from './eventParser' import { Buffer } from 'node:buffer' import { createHmac, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' +import path from 'node:path' import { EventEmitter } from 'vscode' import { logger } from '../logging/logger' import { parseEvent } from './eventParser' +import { defaultPeerRoot, forwardToPeers, PeerRegistry, RELAYED_HEADERS } from './relay' export type { IssueCommentWebhookEvent, @@ -42,238 +33,341 @@ export type { } from './eventParser' export { parseEvent } from './eventParser' +/** 交给 [handleWebhookRequest] 的一次请求,`body` 是原始字节,验签要用。 */ +export interface WebhookRequest { + method: string | undefined + url: string | undefined + headers: IncomingHttpHeaders + body: Buffer +} + +/** 回给发送方的状态码和正文;`event` 是这次请求要交给看板处理的事件,没有就说明不用处理。 */ +export interface WebhookReply { + status: number + body: { ok: boolean, error?: string } + event?: WebhookEvent +} + +function fail(status: number, error: string): WebhookReply { + return { status, body: { ok: false, error } } +} + +function headerOf(headers: IncomingHttpHeaders, name: string): string { + const value = headers[name] + return (Array.isArray(value) ? value[0] : value) ?? '' +} + +/** `X-Hub-Signature-256` 必须是 `sha256=` 加 64 位十六进制的 HMAC-SHA256,按时序安全的方式比对。 */ +function signatureMatches(body: Buffer, header: string, secret: string): boolean { + const match = /^sha256=([0-9a-f]{64})$/i.exec(header) + if (!match) + return false + const expected = createHmac('sha256', secret).update(body).digest() + return timingSafeEqual(Buffer.from(match[1], 'hex'), expected) +} + +/** 日志里的一行事件摘要。 */ +function describeEvent(event: WebhookEvent): string { + const repo = event.repository ? `${event.repository.owner}/${event.repository.repo}` : '<缺仓库>' + switch (event.kind) { + case 'pr': + return `PR action=${event.action} #${event.pr} 分支 ${event.branch} ${typeof event.issueNumber === 'number' ? `issue=#${event.issueNumber}` : 'path=/webhook'} repo=${repo}` + case 'issue_comment': + return `issue_comment action=${event.action} issue=#${event.issueNumber} pr=${event.prNumber ? `#${event.prNumber}` : ''} repo=${repo}` + case 'push': + return `推送分支 ${event.branch} repo=${repo}` + case 'issue': + return `issue action=${event.action} issue=#${event.issueNumber} repo=${repo}` + } +} + +/** + * 校验并解析一次 webhook 请求,不碰网络,便于单测。 + * + * `secret` 不是空串时先验签,不通过一律 401:端口经 frp 暴露在公网,不验签谁都能伪造事件去触发审查。 + * `ping` 是 GitHub 建好 webhook 时发的连通测试,应答 200 但不产生事件。 + */ +export function handleWebhookRequest(request: WebhookRequest, secret: string): WebhookReply { + const url = request.url ?? '' + if (request.method !== 'POST') + return fail(405, 'method_not_allowed') + + const route = /^\/webhook(?:\/(\d+))?(?:\?.*)?$/.exec(url) + if (!route) { + logger.add({ level: 'warn', source: 'webhook', message: '路径不匹配', details: url }) + return fail(404, 'not_found') + } + const issueNumber = route[1] ? Number(route[1]) : undefined + + if (secret) { + const signature = headerOf(request.headers, 'x-hub-signature-256') + if (!signatureMatches(request.body, signature, secret)) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `签名校验失败,拒绝请求 (X-Hub-Signature-256=${signature ? '不匹配' : '<缺失>'})`, + }) + return fail(401, 'invalid_signature') + } + } + + const eventName = headerOf(request.headers, 'x-github-event') + if (!eventName) { + logger.add({ level: 'warn', source: 'webhook', message: '缺少 X-GitHub-Event 头,拒绝请求' }) + return fail(400, 'missing_event') + } + + const contentType = headerOf(request.headers, 'content-type') + if (!/^application\/json\s*(?:;|$)/i.test(contentType)) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `请求体是 ${contentType || '<未知类型>'},只收 application/json:把仓库 webhook 设置里的 Content type 改成 application/json`, + details: `X-GitHub-Event=${eventName}`, + }) + return fail(415, 'unsupported_content_type') + } + + if (eventName === 'ping') { + logger.add({ level: 'info', source: 'webhook', message: '收到 ping,webhook 已连通' }) + return { status: 200, body: { ok: true } } + } + + const body = request.body.toString('utf-8') + let parsed: unknown + try { + parsed = JSON.parse(body) as unknown + } + catch { + logger.add({ level: 'warn', source: 'webhook', message: '请求体解析失败', details: body.slice(0, 500) }) + return fail(400, 'invalid_json') + } + + const event = parseEvent(eventName, issueNumber, parsed) + if (!event) { + logger.add({ level: 'info', source: 'webhook', message: `未处理事件,已忽略 (X-GitHub-Event=${eventName})` }) + return { status: 200, body: { ok: true } } + } + logger.add({ level: 'info', source: 'webhook', message: `匹配 ${describeEvent(event)} (X-GitHub-Event=${eventName})` }) + return { status: 200, body: { ok: true }, event } +} + +/** 在 `port` 上开始监听;`host` 不给时监听所有网卡。 */ +function listen(server: Server, port: number, host?: string): Promise { + return new Promise((resolve, reject) => { + function onError(err: Error): void { + server.off('listening', onListening) + reject(err) + } + function onListening(): void { + server.off('error', onError) + resolve(server) + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()) + }) +} + +export interface WebhookServerOptions { + /** 各窗口登记本地端口的目录的上级,默认 [defaultPeerRoot]。 */ + peerRoot?: string + /** 没占到端口的窗口每隔多少毫秒试一次接手端口。 */ + takeoverIntervalMs?: number +} + export class WebhookServer { private server?: Server + /** 配置的公网端口;本窗口没占到时仍记着它,接手时用。 */ private port?: number + /** `hub` 占着公网端口并负责转发;`peer` 没占到,在 `localPort` 上等转发。 */ + private role?: 'hub' | 'peer' + private localPort?: number + private registry?: PeerRegistry + private takeoverTimer?: ReturnType private secret = '' + /** 让 start / stop / 接手端口依次执行,并发调用时不会自己和自己抢端口。 */ + private queue: Promise = Promise.resolve() private readonly emitter = new EventEmitter() + private readonly peerRoot: string + private readonly takeoverIntervalMs: number readonly onEvent: Event = this.emitter.event - /** - * 设置(或清空)Gitea webhook secret。非空时对每个请求校验 - * `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401 - * ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。 - */ + constructor(options: WebhookServerOptions = {}) { + this.peerRoot = options.peerRoot ?? defaultPeerRoot() + this.takeoverIntervalMs = options.takeoverIntervalMs ?? 3000 + } + + /** 设置或清空 webhook Secret,空串表示不验签。 */ setSecret(secret: string): void { this.secret = secret } - /** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */ - private verifySignature(rawBody: Buffer, signatureHeader: string): boolean { - const expected = createHmac('sha256', this.secret).update(rawBody).digest() - let received: Buffer - try { - received = Buffer.from(signatureHeader, 'hex') - } - catch { - return false - } - return received.length === expected.length && timingSafeEqual(received, expected) - } - - /** The port currently bound, or undefined if the server is not listening. */ + /** 本窗口正在服务的公网端口,不论是否亲自占着;没启动时为 `undefined`。 */ get currentPort(): number | undefined { - return this.port + return this.server ? this.port : undefined } /** - * Start (or move) the server on `port`. If already listening on the same - * port this is a no-op. If listening on a different port, the old server - * is closed and a new one started. Resolves once the listen succeeds. + * 在 `port` 上开始接收;已经在这个端口上就什么也不做,换了端口就先停掉旧的。 + * + * 端口被本机别的窗口占着时不报错,改为在 `127.0.0.1` 的随机端口上等它转发,并定时试着接手。 + * + * @throws [Error] 端口被占用以外的监听失败 */ - async start(port: number): Promise { + start(port: number): Promise { + return this.serial(() => this.open(port)) + } + + stop(): Promise { + return this.serial(() => this.close()) + } + + private serial(task: () => Promise): Promise { + const run = this.queue.then(task, task) + this.queue = run.catch(() => undefined) + return run + } + + private createHttpServer(): Server { + return createServer((req, res) => this.receive(req, res)) + } + + private async open(port: number): Promise { if (this.server && this.port === port) return - if (this.server) - await this.stop() - - const server = createServer((req, res) => this.handle(req, res)) - await new Promise((resolve, reject) => { - function onError(err: Error): void { - server.off('listening', onListening) - logger.add({ - level: 'error', - source: 'webhook', - message: 'bind 失败', - details: err.message, - }) - reject(err) - } - function onListening(): void { - server.off('error', onError) - resolve() - } - server.once('error', onError) - server.once('listening', onListening) - server.listen(port) - }) - this.server = server - this.port = port - logger.add({ - level: 'info', - source: 'webhook', - message: `HTTP server listening on :${port}`, - }) - } - - async stop(): Promise { - const srv = this.server - if (!srv) - return - this.server = undefined - this.port = undefined - await new Promise((resolve) => { - srv.close(() => resolve()) - }) - logger.add({ - level: 'info', - source: 'webhook', - message: 'HTTP server 已停止', - }) - } - - private handle(req: IncomingMessage, res: ServerResponse): void { + await this.close() try { - const url = req.url ?? '' - logger.add({ - level: 'info', - source: 'webhook', - message: `收到 ${req.method ?? '?'} ${url}`, - }) - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'method_not_allowed' })) - return - } - - // Two accepted shapes: - // /webhook → issueNumber resolved later from PR body - // /webhook/ → legacy per-issue, issueNumber set from path - let issueNumber: number | undefined - let routeMatched = false - const legacyMatch = /^\/webhook\/(\d+)(?:\?.*)?$/.exec(url) - if (legacyMatch) { - issueNumber = Number(legacyMatch[1]) - routeMatched = true - } - else if (/^\/webhook(?:\?.*)?$/.test(url)) { - issueNumber = undefined - routeMatched = true - } - if (!routeMatched) { - logger.add({ - level: 'warn', - source: 'webhook', - message: '路径不匹配', - details: url, - }) - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'not_found' })) - return - } - - const chunks: Buffer[] = [] - req.on('data', (c: Buffer) => chunks.push(c)) - req.on('end', () => { - try { - const rawBody = Buffer.concat(chunks) - if (this.secret) { - const signatureHeader = (req.headers['x-gitea-signature'] || '').toString() - if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`, - }) - res.writeHead(401, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'invalid_signature' })) - return - } - } - const body = rawBody.toString('utf-8') - const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '' - logger.add({ - level: 'info', - source: 'webhook', - message: `收到请求 X-Gitea-Event=${eventHeader} bodyLen=${body.length}`, - }) - let parsed: unknown - try { - parsed = JSON.parse(body) as unknown - } - catch { - logger.add({ - level: 'warn', - source: 'webhook', - message: '请求体解析失败', - details: body.slice(0, 500), - }) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'invalid_json' })) - return - } - - const event = parseEvent(eventHeader, issueNumber, parsed) - if (event) { - if (event.kind === 'pr') { - const issuePart = typeof event.issueNumber === 'number' - ? `issue=#${event.issueNumber}` - : 'path=/webhook' - logger.add({ - level: 'info', - source: 'webhook', - message: `匹配 action=${event.action} PR #${event.pr} 分支 ${event.branch} ${issuePart} (event=${eventHeader})`, - }) - } - else if (event.kind === 'issue_comment') { - logger.add({ - level: 'info', - source: 'webhook', - message: `匹配 issue_comment action=${event.action} issue=#${event.issueNumber} pr=${event.prNumber ? `#${event.prNumber}` : ''} (event=${eventHeader})`, - }) - } - else if (event.kind === 'push') { - logger.add({ - level: 'info', - source: 'webhook', - message: `推送分支 ${event.branch} (event=${eventHeader})`, - }) - } - else { - logger.add({ - level: 'info', - source: 'webhook', - message: `匹配 issue action=${event.action} issue=#${event.issueNumber} (event=${eventHeader})`, - }) - } - this.emitter.fire(event) - } - else { - logger.add({ - level: 'info', - source: 'webhook', - message: `未处理事件,已忽略 (X-Gitea-Event=${eventHeader})`, - }) - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: true })) - } - catch (err) { - console.error('[superpowers/webhook] handler error:', err) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'internal_error' })) - } - }) - req.on('error', (err) => { - console.error('[superpowers/webhook] request stream error:', err) - }) + const server = await listen(this.createHttpServer(), port) + this.server = server + this.port = (server.address() as AddressInfo).port + this.role = 'hub' + this.registry = new PeerRegistry(path.join(this.peerRoot, String(this.port))) + logger.add({ level: 'info', source: 'webhook', message: `HTTP server listening on :${this.port}` }) + return } catch (err) { - console.error('[superpowers/webhook] top-level error:', err) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ok: false, error: 'internal_error' })) + if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') { + logger.add({ level: 'error', source: 'webhook', message: 'bind 失败', details: (err as Error).message }) + throw err + } } + const server = await listen(this.createHttpServer(), 0, '127.0.0.1') + const localPort = (server.address() as AddressInfo).port + const registry = new PeerRegistry(path.join(this.peerRoot, String(port))) + try { + await registry.add(localPort) + } + catch (err) { + await closeServer(server) + throw err + } + this.server = server + this.port = port + this.role = 'peer' + this.localPort = localPort + this.registry = registry + this.takeoverTimer = setInterval(() => { + void this.serial(() => this.takeOver()) + }, this.takeoverIntervalMs) + this.takeoverTimer.unref?.() + logger.add({ + level: 'info', + source: 'webhook', + message: `端口 :${port} 已被本机其它窗口占用,改由它转发事件(本窗口在 127.0.0.1:${localPort} 接收)`, + }) + } + + /** 占着端口的窗口关掉后,没占到的窗口接手公网端口;仍被占着就等下一轮。 */ + private async takeOver(): Promise { + if (this.role !== 'peer' || this.port === undefined) + return + let server: Server + try { + server = await listen(this.createHttpServer(), this.port) + } + catch { + return + } + clearInterval(this.takeoverTimer) + this.takeoverTimer = undefined + const previous = this.server + const localPort = this.localPort + this.server = server + this.role = 'hub' + this.localPort = undefined + if (localPort !== undefined) + await this.registry?.remove(localPort).catch(() => undefined) + if (previous) + await closeServer(previous) + logger.add({ level: 'info', source: 'webhook', message: `已接手端口 :${this.port},HTTP server listening` }) + } + + private async close(): Promise { + clearInterval(this.takeoverTimer) + this.takeoverTimer = undefined + const server = this.server + if (!server) + return + const { registry, localPort } = this + this.server = undefined + this.port = undefined + this.role = undefined + this.localPort = undefined + this.registry = undefined + if (localPort !== undefined) + await registry?.remove(localPort).catch(() => undefined) + await closeServer(server) + logger.add({ level: 'info', source: 'webhook', message: 'HTTP server 已停止' }) + } + + private receive(req: IncomingMessage, res: ServerResponse): void { + logger.add({ level: 'info', source: 'webhook', message: `收到 ${req.method ?? '?'} ${req.url ?? ''}` }) + const chunks: Buffer[] = [] + req.on('data', (c: Buffer) => chunks.push(c)) + req.on('error', (err) => { + console.error('[superpowers/webhook] request stream error:', err) + }) + req.on('end', () => { + const body = Buffer.concat(chunks) + let reply: WebhookReply + try { + reply = handleWebhookRequest({ method: req.method, url: req.url, headers: req.headers, body }, this.secret) + } + catch (err) { + console.error('[superpowers/webhook] handler error:', err) + reply = fail(500, 'internal_error') + } + res.writeHead(reply.status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(reply.body)) + if (!reply.event) + return + if (this.role === 'hub') + void this.relay(req, body) + this.emitter.fire(reply.event) + }) + } + + /** 把验过签的请求原样转给本机其余窗口,它们各自验签、各自按仓库筛选。 */ + private async relay(req: IncomingMessage, body: Buffer): Promise { + const registry = this.registry + if (!registry) + return + const headers: Record = {} + for (const name of RELAYED_HEADERS) { + const value = headerOf(req.headers, name) + if (value) + headers[name] = value + } + await forwardToPeers(registry, { url: req.url ?? '/webhook', headers, body }).catch((err: unknown) => { + logger.add({ level: 'warn', source: 'webhook', message: '转发给本机其它窗口失败', details: err instanceof Error ? err.message : String(err) }) + }) } }