✨ feat(vscode): webhook 改认 GitHub 风格头并支持 GitHub,事件按仓库分给对应窗口

- 事件头只认 X-GitHub-Event、签名只认 X-Hub-Signature-256: sha256=<hex>(Gitea v28 也发这两个头);ping 正常应答,form 编码返回 415 并提示改成 JSON
- 只有 pull_request 事件才当 PR 事件,PR 合并状态直接取 payload;评论人只认 login
- 每个事件带上 payload 里的仓库,不是本窗口 origin 的事件整条忽略,provider 按事件里的仓库打开
- 本机多个窗口时由占到端口的窗口把验过签的请求转给其余窗口,它关掉后其余窗口自动接手端口
- 删掉不再使用的 pendingHooks 遗留代码
This commit is contained in:
cruldra committed 2026-10-19 08:23:42 +11:00
1 parent 5a1c0e20a2
commit ac3c882672
12 files changed
+1057 -534

No files matched your search

+1 -1
View File
@@ -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()
+11 -10
View File
@@ -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()
@@ -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)
@@ -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<typeof import('../git/remote')>()),
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<void> {
return (webhookCoordinator as unknown as { handleEvent: (event: WebhookEvent) => Promise<void> }).handleEvent(event!)
}
function issueEdited(repository: string | undefined): WebhookEvent | null {
return parseEvent('issues', undefined, {
action: 'edited',
issue: { number: 12, title: 't', body: '<!-- spx:spec=.claude/specs/a.md -->', html_url: `${repository}/issues/12`, user: { login: 'me' }, assignees: [{ login: 'me' }] },
...(repository ? { repository: { html_url: repository } } : {}),
})
}
function prEvent(repository: string, action: string, extra: Record<string, unknown> = {}): 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<string, unknown>([[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()
})
})
+100 -278
View File
@@ -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<LinkedIssueCreation | undefined>
}
/** 事件所属仓库,以及本窗口打开它的工作区目录。 */
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<string, IssueCreationEntry>()
private server?: WebhookServer
// kept for legacy cleanup; new flow uses one shared webhook
private pendingHooks = new Map<number, PendingHook>()
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<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) => {
@@ -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<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)
}
/** 保存建单关联信息,避免关闭看板时丢失会话归属。 */
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<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)
}
/**
* 给 webhook 事件找对应的代码托管仓库。pendingHooks 只是缓存:webhook 地址本身就说明这个工单归我们管,
* 缓存没有时按当前工作区的 origin 恢复。没工作区、没 origin、没凭据时返回 null。
* 事件所属仓库就是本窗口工作区的 origin 时,返回工作区目录和这个仓库;别的仓库、缺仓库信息时返回 `undefined`。
*
* 本机所有窗口都会收到所有仓库的事件(见 [WebhookServer]),工单号在不同仓库里会重复,不筛掉就会把别的仓库的事件作用到本窗口的看板上。
*/
private async forgeForEvent(issueNumber: number): Promise<ForgeContext | null> {
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<EventRepository | undefined> {
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<number | null> {
private async resolveIssueNumber(event: PrWebhookEvent, forge: ForgeContext): Promise<number | null> {
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<void> {
private async dispatchEvent(event: WebhookEvent, ctx: ExtensionContext, home: EventRepository): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
private async handleIssueAssigned(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise<void> {
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<void> {
private async handleIssueStateChanged(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise<void> {
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<void> {
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<void> {
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<void> {
private async handlePrOpened(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
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<void> {
private async handlePrSynchronize(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
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<void> {
private async handlePrClosed(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
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<void> {
private async handlePrDeleted(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
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 `<!-- spx:review=1 -->` marker are ignored. Edited /
* deleted comments are also ignored (the dispatcher only routes 'created').
*
*/
/** 申请移交时给负责人发系统通知。看板关着也发。 */
private async notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise<void> {
private async notifyHandoffRequest(event: IssueCommentWebhookEvent, forge: ForgeContext): 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)
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<void> {
/**
* 新评论:刷新讨论列表;是状态评论就重拉卡片;别人在工单上发言时弹系统通知;带 `<!-- spx:review=1 -->` 的审查意见注入实施终端。
*
* 编辑、删除的评论不处理。
*/
private async handleIssueCommentCreated(event: IssueCommentWebhookEvent, ctx: ForgeContext): Promise<void> {
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。
+3 -3
View File
@@ -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
+95 -1
View File
@@ -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 = '<!-- spx:nonce=f961e23b-f841-48f5-869e-2f59473b53d4 -->'
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: '<!-- spx:review=1 -->\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 })
})
})
+41 -9
View File
@@ -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/<branch>"`。
// push 事件没有 `action`,顶层带 `ref: "refs/heads/<branch>"`。
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,
}
}
+1 -1
View File
@@ -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 {
// ① 本机发起的创建 → 恒显,范围和身份都不作数。
+103
View File
@@ -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<void> {
await fsp.mkdir(this.dir, { recursive: true })
await fsp.writeFile(path.join(this.dir, String(port)), String(process.pid))
}
async remove(port: number): Promise<void> {
await fsp.rm(path.join(this.dir, String(port)), { force: true })
}
/** 还活着的窗口的本地端口;进程已经退出的登记顺手删掉,窗口崩溃时没来得及注销的就是这么清掉的。 */
async ports(): Promise<number[]> {
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<string, string>
body: Buffer
}
/** 把请求转给每个登记过的窗口;连不上的登记删掉,其余失败只记日志,一个窗口出错不影响别的窗口。 */
export async function forwardToPeers(registry: PeerRegistry, request: RelayedRequest): Promise<void> {
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),
})
}
}))
}
+217
View File
@@ -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 <T> {
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<number> {
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)
})
})
+324 -230
View File
@@ -1,37 +1,28 @@
/**
* Minimal HTTP server that listens for Gitea webhooks and fires a VS Code
* `Event<WebhookEvent>` 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 `<!-- spx:nonce=... -->` 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=<hex>`,所以只认这一套头;请求体只收 `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}` : '<no>'} 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<Server> {
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<void> {
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<typeof setInterval>
private secret = ''
/** 让 start / stop / 接手端口依次执行,并发调用时不会自己和自己抢端口。 */
private queue: Promise<unknown> = Promise.resolve()
private readonly emitter = new EventEmitter<WebhookEvent>()
private readonly peerRoot: string
private readonly takeoverIntervalMs: number
readonly onEvent: Event<WebhookEvent> = 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<void> {
start(port: number): Promise<void> {
return this.serial(() => this.open(port))
}
stop(): Promise<void> {
return this.serial(() => this.close())
}
private serial<T>(task: () => Promise<T>): Promise<T> {
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<void> {
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<void>((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<void> {
const srv = this.server
if (!srv)
return
this.server = undefined
this.port = undefined
await new Promise<void>((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/<digits> → 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() || '<missing>'
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}` : '<no>'} (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<void> {
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<void> {
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<void> {
const registry = this.registry
if (!registry)
return
const headers: Record<string, string> = {}
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) })
})
}
}