✨ 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:
1 parent
5a1c0e20a2
commit
ac3c882672
12 files changed
+1057
-534
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
// ① 本机发起的创建 → 恒显,范围和身份都不作数。
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -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
@@ -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) })
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user