✨ 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
+1031
-508
No files matched your search
@@ -334,7 +334,7 @@ describe('kanban panel cache integration', () => {
|
|||||||
stopCoordinator = () => webhookCoordinator.dispose()
|
stopCoordinator = () => webhookCoordinator.dispose()
|
||||||
const first = f.open()
|
const first = f.open()
|
||||||
await first.board.loadAndPush()
|
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())
|
await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce())
|
||||||
first.panel.dispose()
|
first.panel.dispose()
|
||||||
const reopened = f.open()
|
const reopened = f.open()
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ vi.mock('./server', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false }
|
const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false }
|
||||||
|
const repository = { host: 'host', owner: 'owner', repo: 'repo' }
|
||||||
const events: WebhookEvent[] = [
|
const events: WebhookEvent[] = [
|
||||||
{ kind: 'issue', action: 'closed', issueNumber: 462, 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: '', 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: '', raw: {} },
|
{ kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', merged: true, mergedAt: undefined, repository, raw: {} },
|
||||||
]
|
]
|
||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
@@ -99,7 +100,7 @@ describe('coordinator cache invalidation', () => {
|
|||||||
const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false })
|
const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false })
|
||||||
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
|
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
|
||||||
mocks.receive!(events[0])
|
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()
|
expect(f.update).toHaveBeenCalledOnce()
|
||||||
release()
|
release()
|
||||||
await saving
|
await saving
|
||||||
@@ -110,8 +111,8 @@ describe('coordinator cache invalidation', () => {
|
|||||||
it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => {
|
it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => {
|
||||||
const f = fixture()
|
const f = fixture()
|
||||||
const state = getKanbanState(f.context.workspaceState)
|
const state = getKanbanState(f.context.workspaceState)
|
||||||
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} })
|
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', repository, raw: {} })
|
||||||
expect(state.snapshot).toBeUndefined()
|
await vi.waitFor(() => expect(state.snapshot).toBeUndefined())
|
||||||
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
|
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ describe('coordinator cache invalidation', () => {
|
|||||||
const f = fixture()
|
const f = fixture()
|
||||||
const state = getKanbanState(f.context.workspaceState)
|
const state = getKanbanState(f.context.workspaceState)
|
||||||
const snapshot = state.snapshot
|
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()
|
await Promise.resolve()
|
||||||
expect(state.snapshot).toBe(snapshot)
|
expect(state.snapshot).toBe(snapshot)
|
||||||
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
|
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
|
||||||
@@ -149,7 +150,7 @@ describe('coordinator cache invalidation', () => {
|
|||||||
mocks.token.mockResolvedValue('token')
|
mocks.token.mockResolvedValue('token')
|
||||||
const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
|
const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
|
||||||
webhookCoordinator.setActivePanel(panel as unknown as KanbanWebviewPanel)
|
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())
|
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 first = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
|
||||||
const second = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined), reloadAfterInvalidation: 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)
|
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))
|
await vi.waitFor(() => expect(mocks.readState).toHaveBeenCalledTimes(pendingRead))
|
||||||
webhookCoordinator.setActivePanel(undefined)
|
webhookCoordinator.setActivePanel(undefined)
|
||||||
if (reopen)
|
if (reopen)
|
||||||
@@ -185,7 +186,7 @@ describe('coordinator cache invalidation', () => {
|
|||||||
|
|
||||||
it('普通 push 不清看板', async () => {
|
it('普通 push 不清看板', async () => {
|
||||||
const f = fixture()
|
const f = fixture()
|
||||||
mocks.receive!({ kind: 'push', branch: 'main', raw: {} })
|
mocks.receive!({ kind: 'push', branch: 'main', repository, raw: {} })
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
|
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
|
||||||
expect(f.update).not.toHaveBeenCalled()
|
expect(f.update).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ describe('issue creation lifecycle boundaries', () => {
|
|||||||
const f = fixture()
|
const f = fixture()
|
||||||
f.register()
|
f.register()
|
||||||
await dispatch(event('label_updated'))
|
await dispatch(event('label_updated'))
|
||||||
await dispatch({ ...event(), raw: { issue: {} } })
|
await dispatch({ ...event(), repository: undefined, raw: { issue: {} } })
|
||||||
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
|
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
|
||||||
await dispatch(event())
|
await dispatch(event())
|
||||||
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,12 +21,12 @@ import { promises as fsp } from 'node:fs'
|
|||||||
import { env, Uri, window, workspace } from 'vscode'
|
import { env, Uri, window, workspace } from 'vscode'
|
||||||
import { loginFor } from '../auth/identity'
|
import { loginFor } from '../auth/identity'
|
||||||
import { renderReviewPrompt } from '../cc/prompts'
|
import { renderReviewPrompt } from '../cc/prompts'
|
||||||
import { openForge, resolveForge } from '../forge/context'
|
import { openForge } from '../forge/context'
|
||||||
import { cleanCommentSummary, systemCommentText } from '../forge/issueComment'
|
import { cleanCommentSummary, systemCommentText } from '../forge/issueComment'
|
||||||
import { loadIssues, loadSingleIssue } from '../forge/issueLoader'
|
import { loadIssues, loadSingleIssue } from '../forge/issueLoader'
|
||||||
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../forge/markers'
|
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../forge/markers'
|
||||||
import { mergeForgeState, mergeForgeStateGuarded, readForgeState } from '../forge/state'
|
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 { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
|
||||||
import { hasStateColumn, parseStateComment } from '../issues/stateComment'
|
import { hasStateColumn, parseStateComment } from '../issues/stateComment'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
@@ -41,21 +41,6 @@ import { decideEventOwnership } from './eventOwnership'
|
|||||||
import { decideIssueAppend } from './issueAppend'
|
import { decideIssueAppend } from './issueAppend'
|
||||||
import { WebhookServer } from './server'
|
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 {
|
export interface PendingIssueCreation {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
remote: Remote
|
remote: Remote
|
||||||
@@ -80,6 +65,12 @@ interface IssueCreationEntry {
|
|||||||
operation?: Promise<LinkedIssueCreation | undefined>
|
operation?: Promise<LinkedIssueCreation | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 事件所属仓库,以及本窗口打开它的工作区目录。 */
|
||||||
|
interface EventRepository {
|
||||||
|
workspaceRoot: string
|
||||||
|
remote: Remote
|
||||||
|
}
|
||||||
|
|
||||||
const ISSUE_ACTIONS = ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned']
|
const ISSUE_ACTIONS = ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned']
|
||||||
|
|
||||||
function issueCreationNonce(body: string): string | undefined {
|
function issueCreationNonce(body: string): string | undefined {
|
||||||
@@ -87,11 +78,6 @@ function issueCreationNonce(body: string): string | undefined {
|
|||||||
return matches.length === 1 ? matches[0][1] : 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 {
|
function sameRepository(left: Remote, right: Remote): boolean {
|
||||||
return left.host.toLowerCase() === right.host.toLowerCase()
|
return left.host.toLowerCase() === right.host.toLowerCase()
|
||||||
&& left.owner.toLowerCase() === right.owner.toLowerCase()
|
&& left.owner.toLowerCase() === right.owner.toLowerCase()
|
||||||
@@ -160,8 +146,6 @@ class WebhookCoordinator {
|
|||||||
private ctx?: ExtensionContext
|
private ctx?: ExtensionContext
|
||||||
private readonly issueCreations = new Map<string, IssueCreationEntry>()
|
private readonly issueCreations = new Map<string, IssueCreationEntry>()
|
||||||
private server?: WebhookServer
|
private server?: WebhookServer
|
||||||
// kept for legacy cleanup; new flow uses one shared webhook
|
|
||||||
private pendingHooks = new Map<number, PendingHook>()
|
|
||||||
private livePanel: KanbanWebviewPanel | undefined
|
private livePanel: KanbanWebviewPanel | undefined
|
||||||
private readonly eventPanels = new AsyncLocalStorage<{ panel: KanbanWebviewPanel | undefined }>()
|
private readonly eventPanels = new AsyncLocalStorage<{ panel: KanbanWebviewPanel | undefined }>()
|
||||||
private boardEventsInFlight = 0
|
private boardEventsInFlight = 0
|
||||||
@@ -190,14 +174,6 @@ class WebhookCoordinator {
|
|||||||
message: 'coordinator 初始化中',
|
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 = new WebhookServer()
|
||||||
this.server.setSecret(getSettings(ctx).webhookSecret)
|
this.server.setSecret(getSettings(ctx).webhookSecret)
|
||||||
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
|
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
|
||||||
@@ -271,7 +247,7 @@ class WebhookCoordinator {
|
|||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `coordinator 切换端口 ${srv.currentPort ?? '?'} → ${port}`,
|
message: `coordinator 切换端口 ${srv.currentPort ?? '?'} → ${port}`,
|
||||||
})
|
})
|
||||||
await srv.stop()
|
// start 自己会关掉旧端口;和激活时还没完成的那次 start 排队执行,不会自己和自己抢端口。
|
||||||
await srv.start(port)
|
await srv.start(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,36 +256,6 @@ class WebhookCoordinator {
|
|||||||
this.server?.setSecret(secret)
|
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 {
|
registerIssueCreation(nonce: string, pending: PendingIssueCreation): void {
|
||||||
if (this.issueCreations.has(nonce))
|
if (this.issueCreations.has(nonce))
|
||||||
@@ -346,7 +292,7 @@ class WebhookCoordinator {
|
|||||||
return undefined
|
return undefined
|
||||||
const entry = this.issueCreations.get(nonce)
|
const entry = this.issueCreations.get(nonce)
|
||||||
const record = entry?.pending ?? entry?.linked
|
const record = entry?.pending ?? entry?.linked
|
||||||
const remote = issueRepository(event)
|
const remote = event.repository
|
||||||
if (!entry || !record || !remote || !sameRepository(record.remote, remote))
|
if (!entry || !record || !remote || !sameRepository(record.remote, remote))
|
||||||
return undefined
|
return undefined
|
||||||
if (entry.targetIssueNumber !== undefined && entry.targetIssueNumber !== event.issueNumber) {
|
if (entry.targetIssueNumber !== undefined && entry.targetIssueNumber !== event.issueNumber) {
|
||||||
@@ -422,39 +368,29 @@ class WebhookCoordinator {
|
|||||||
return this.livePanel
|
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 时,返回工作区目录和这个仓库;别的仓库、缺仓库信息时返回 `undefined`。
|
||||||
* 缓存没有时按当前工作区的 origin 恢复。没工作区、没 origin、没凭据时返回 null。
|
*
|
||||||
|
* 本机所有窗口都会收到所有仓库的事件(见 [WebhookServer]),工单号在不同仓库里会重复,不筛掉就会把别的仓库的事件作用到本窗口的看板上。
|
||||||
*/
|
*/
|
||||||
private async forgeForEvent(issueNumber: number): Promise<ForgeContext | null> {
|
private async homeOf(event: WebhookEvent): Promise<EventRepository | undefined> {
|
||||||
if (!this.ctx)
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
return null
|
const remote = event.repository
|
||||||
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
|
if (!remote) {
|
||||||
if (!ws)
|
logger.add({ level: 'warn', source: 'webhook', message: `事件缺少 repository,忽略 (${event.kind})` })
|
||||||
return null
|
return undefined
|
||||||
const pending = this.pendingHooks.get(issueNumber)
|
|
||||||
if (pending) {
|
|
||||||
const resolved = await openForge(this.ctx, ws, pending)
|
|
||||||
return resolved.ok ? resolved.forge : null
|
|
||||||
}
|
}
|
||||||
const resolved = await resolveForge(this.ctx)
|
const origin = workspaceRoot ? await detectRepo(workspaceRoot) : undefined
|
||||||
if (!resolved.ok)
|
if (!workspaceRoot || !origin || !sameRepository(origin, remote)) {
|
||||||
return null
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `未在 pendingHooks 中找到 #${issueNumber},已按工作区 origin 恢复仓库上下文`,
|
message: `不是本窗口的仓库,忽略 ${remote.host}/${remote.owner}/${remote.repo} 的 ${event.kind} 事件`,
|
||||||
|
details: origin ? `本窗口: ${origin.host}/${origin.owner}/${origin.repo}` : '本窗口没有可识别的 origin',
|
||||||
})
|
})
|
||||||
return resolved.forge
|
return undefined
|
||||||
|
}
|
||||||
|
return { workspaceRoot, remote }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -497,13 +433,6 @@ class WebhookCoordinator {
|
|||||||
return decision
|
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.
|
* 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
|
* Returns `null` when none of the strategies produced a match — the
|
||||||
* caller drops the event with a warning.
|
* 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')
|
if (typeof event.issueNumber === 'number')
|
||||||
return event.issueNumber
|
return event.issueNumber
|
||||||
|
|
||||||
@@ -535,10 +464,7 @@ class WebhookCoordinator {
|
|||||||
|
|
||||||
if (this.activePanel && event.branch) {
|
if (this.activePanel && event.branch) {
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveForge(this.ctx!)
|
const { issues } = await loadIssues(forge)
|
||||||
if (!resolved.ok)
|
|
||||||
return null
|
|
||||||
const { issues } = await loadIssues(resolved.forge)
|
|
||||||
const found = issues.find(i => i.branch === event.branch)
|
const found = issues.find(i => i.branch === event.branch)
|
||||||
if (found) {
|
if (found) {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -580,6 +506,10 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctx = this.ctx
|
const ctx = this.ctx
|
||||||
|
// 先认仓库再动看板缓存:别的仓库的事件连缓存都不该清。
|
||||||
|
const home = await this.homeOf(event)
|
||||||
|
if (!home)
|
||||||
|
return
|
||||||
const panel = this.livePanel
|
const panel = this.livePanel
|
||||||
const affectsBoard = changesKanban(event)
|
const affectsBoard = changesKanban(event)
|
||||||
if (affectsBoard) {
|
if (affectsBoard) {
|
||||||
@@ -588,7 +518,7 @@ class WebhookCoordinator {
|
|||||||
void getKanbanState(ctx.workspaceState).invalidate()
|
void getKanbanState(ctx.workspaceState).invalidate()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx))
|
await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx, home))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if (affectsBoard) {
|
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') {
|
if (event.kind === 'push') {
|
||||||
const s = getSettings(ctx)
|
const s = getSettings(ctx)
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -615,26 +545,38 @@ class WebhookCoordinator {
|
|||||||
return
|
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') {
|
if (event.kind === 'issue') {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `收到 issue 事件 action=${event.action} issue=#${event.issueNumber}`,
|
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') {
|
if (event.action === 'opened' || event.action === 'reopened') {
|
||||||
await this.handleIssueOpened(event, creation)
|
await this.handleIssueOpened(event, forge, creation)
|
||||||
}
|
}
|
||||||
else if (event.action === 'edited') {
|
else if (event.action === 'edited') {
|
||||||
await this.handleIssueEdited(event)
|
await this.handleIssueEdited(event, forge)
|
||||||
if (creation)
|
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') {
|
else if (event.action === 'closed') {
|
||||||
await this.handleIssueStateChanged(event, creation)
|
await this.handleIssueStateChanged(event, forge, creation)
|
||||||
}
|
}
|
||||||
else if (event.action === 'assigned' || event.action === 'unassigned') {
|
else if (event.action === 'assigned' || event.action === 'unassigned') {
|
||||||
await this.handleIssueAssigned(event, creation)
|
await this.handleIssueAssigned(event, forge, creation)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -653,7 +595,7 @@ class WebhookCoordinator {
|
|||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `收到 issue_comment 事件 action=${event.action} issue=#${event.issueNumber}`,
|
message: `收到 issue_comment 事件 action=${event.action} issue=#${event.issueNumber}`,
|
||||||
})
|
})
|
||||||
await this.handleIssueCommentCreated(event)
|
await this.handleIssueCommentCreated(event, forge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +605,7 @@ class WebhookCoordinator {
|
|||||||
message: `收到 webhook 事件 action=${event.action} issue=${typeof event.issueNumber === 'number' ? `#${event.issueNumber}` : '<未定>'} pr=#${event.pr}`,
|
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)
|
if (issueNumber === null)
|
||||||
return
|
return
|
||||||
// Narrow `issueNumber` for downstream handlers. We build a fresh
|
// Narrow `issueNumber` for downstream handlers. We build a fresh
|
||||||
@@ -674,23 +616,22 @@ class WebhookCoordinator {
|
|||||||
switch (resolved.action) {
|
switch (resolved.action) {
|
||||||
case 'opened':
|
case 'opened':
|
||||||
case 'reopened': {
|
case 'reopened': {
|
||||||
await this.handlePrOpened(resolved)
|
await this.handlePrOpened(resolved, forge)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// GitHub 发 synchronize,Gitea 发 synchronized。
|
||||||
case 'synchronize':
|
case 'synchronize':
|
||||||
case 'synchronized': {
|
case 'synchronized': {
|
||||||
// Gitea uses 'synchronized' (with d) per source; some examples
|
await this.handlePrSynchronize(resolved, forge)
|
||||||
// floating around use 'synchronize' without the d. Accept both
|
|
||||||
// defensively.
|
|
||||||
await this.handlePrSynchronize(resolved)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'closed': {
|
case 'closed': {
|
||||||
await this.handlePrClosed(resolved)
|
await this.handlePrClosed(resolved, forge)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// 只有 Gitea 会发;GitHub 的 PR 删不掉,没有这个动作。
|
||||||
case 'deleted': {
|
case 'deleted': {
|
||||||
await this.handlePrDeleted(resolved)
|
await this.handlePrDeleted(resolved, forge)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -706,8 +647,8 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新新建工单卡片,并为本机创建的工单显示成功提示。 */
|
/** 刷新新建工单卡片,并为本机创建的工单显示成功提示。 */
|
||||||
private async handleIssueOpened(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise<void> {
|
private async handleIssueOpened(event: IssueWebhookEvent, forge: ForgeContext, creation?: LinkedIssueCreation): Promise<void> {
|
||||||
await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '创建', creation, select: creation ? true : undefined })
|
await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '创建', forge, creation, select: creation ? true : undefined })
|
||||||
if (creation) {
|
if (creation) {
|
||||||
this.activePanel?.postMessage({
|
this.activePanel?.postMessage({
|
||||||
type: 'toast/show',
|
type: 'toast/show',
|
||||||
@@ -732,27 +673,17 @@ class WebhookCoordinator {
|
|||||||
raw: unknown
|
raw: unknown
|
||||||
/** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */
|
/** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */
|
||||||
reason: string
|
reason: string
|
||||||
|
forge: ForgeContext
|
||||||
creation?: LinkedIssueCreation
|
creation?: LinkedIssueCreation
|
||||||
select?: boolean
|
select?: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { issueNumber, raw, reason, creation } = opts
|
const { issueNumber, raw, reason, forge, creation } = opts
|
||||||
if (!this.ctx)
|
if (!this.ctx)
|
||||||
return
|
return
|
||||||
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
|
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
|
||||||
if (!this.activePanel)
|
if (!this.activePanel)
|
||||||
return
|
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' 可见性门禁:与我有关 → 上卡,
|
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡,
|
||||||
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
|
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
|
||||||
// 省一次身份请求。
|
// 省一次身份请求。
|
||||||
@@ -805,24 +736,26 @@ class WebhookCoordinator {
|
|||||||
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
|
* 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({
|
await this.refreshIssueCard({
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
raw: event.raw,
|
raw: event.raw,
|
||||||
reason: event.action === 'unassigned' ? '取消指派' : '指派',
|
reason: event.action === 'unassigned' ? '取消指派' : '指派',
|
||||||
|
forge,
|
||||||
creation,
|
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({
|
await this.refreshIssueCard({
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
raw: event.raw,
|
raw: event.raw,
|
||||||
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
|
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
|
||||||
|
forge,
|
||||||
creation,
|
creation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -833,21 +766,7 @@ class WebhookCoordinator {
|
|||||||
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
|
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
|
||||||
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
|
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
|
||||||
*/
|
*/
|
||||||
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
|
private async handleIssueEdited(event: IssueWebhookEvent, forge: ForgeContext): 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
|
|
||||||
|
|
||||||
const found = parseBodyMarkers(event.body)
|
const found = parseBodyMarkers(event.body)
|
||||||
if (Object.keys(found).length === 0) {
|
if (Object.keys(found).length === 0) {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -947,23 +866,11 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `opened` / `reopened`: persist `pr` + `implementStatus=done` into the
|
* PR `opened` / `reopened`:把 PR 编号和「实施完成」记进工单状态、刷新看板、弹提示,开着自动审查时接着起审查。
|
||||||
* state JSON, refresh the kanban, toast. The webhook is *not* deleted
|
|
||||||
* here — Phase A keeps it alive for `synchronize` / `closed`.
|
|
||||||
*/
|
*/
|
||||||
private async handlePrOpened(event: ResolvedWebhookEvent): Promise<void> {
|
private async handlePrOpened(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
|
||||||
if (!this.ctx)
|
if (!this.ctx)
|
||||||
return
|
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') {
|
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
|
||||||
// 别人的工单:只刷本机看板,不写共享状态、不弹 toast、不触发审查。
|
// 别人的工单:只刷本机看板,不写共享状态、不弹 toast、不触发审查。
|
||||||
@@ -1012,7 +919,7 @@ class WebhookCoordinator {
|
|||||||
// indefinitely.
|
// indefinitely.
|
||||||
const action = 'Open PR'
|
const action = 'Open PR'
|
||||||
void window.showInformationMessage(
|
void window.showInformationMessage(
|
||||||
`#${event.issueNumber} 已关联 PR !${event.pr}`,
|
`#${event.issueNumber} 已关联 PR #${event.pr}`,
|
||||||
action,
|
action,
|
||||||
).then((pick) => {
|
).then((pick) => {
|
||||||
if (pick === action)
|
if (pick === action)
|
||||||
@@ -1070,19 +977,9 @@ class WebhookCoordinator {
|
|||||||
* around through {@link handleIssueCommentCreated} to inject into the
|
* around through {@link handleIssueCommentCreated} to inject into the
|
||||||
* implementation terminal.
|
* implementation terminal.
|
||||||
*/
|
*/
|
||||||
private async handlePrSynchronize(event: ResolvedWebhookEvent): Promise<void> {
|
private async handlePrSynchronize(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
|
||||||
if (!this.ctx)
|
if (!this.ctx)
|
||||||
return
|
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')
|
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe')
|
||||||
return
|
return
|
||||||
@@ -1141,49 +1038,18 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `closed`: log only. The webhook stays alive on gitea so a later
|
* PR `closed`:合并关掉的把 `prMerged` 和合并时间记进工单状态,没合并就关掉的什么也不做。
|
||||||
* `reopened` or a "delete + re-push" cycle (which fires `deleted` followed
|
|
||||||
* by a fresh `opened`) keeps flowing through the same registration.
|
|
||||||
* Cleanup of the webhook is deferred to the future "完成" column trigger.
|
|
||||||
*/
|
*/
|
||||||
private async handlePrClosed(event: ResolvedWebhookEvent): Promise<void> {
|
private async handlePrClosed(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `PR #${event.pr} closed (保留 webhook 以备 reopen/重建)`,
|
message: `PR #${event.pr} closed merged=${event.merged}`,
|
||||||
details: `issue=#${event.issueNumber}`,
|
details: `issue=#${event.issueNumber}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否为合并关闭 —— gitea PR payload 里 merged 字段不在 ResolvedWebhookEvent
|
// 合并关掉的 PR 在 payload 里 merged 为 true,只是关掉的不记。
|
||||||
// 类型上,主动查一次 API 取 merged 状态(成本低,避免 server.ts 解析变动)。
|
if (!event.merged)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
|
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
|
||||||
@@ -1197,7 +1063,7 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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
|
* PR `deleted`(只有 Gitea 会发):PR 被删掉了,把工单状态里的 `pr` 清成空串,看板上随之不再显示 PR。
|
||||||
* state JSON (using the empty string so the loader treats it as unset) and
|
|
||||||
* refresh the kanban. The webhook stays alive for the next push.
|
|
||||||
*/
|
*/
|
||||||
private async handlePrDeleted(event: ResolvedWebhookEvent): Promise<void> {
|
private async handlePrDeleted(event: ResolvedWebhookEvent, ctx: ForgeContext): Promise<void> {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `PR #${event.pr} deleted, 清空 state JSON 中的 pr`,
|
message: `PR #${event.pr} deleted, 清空 state JSON 中的 pr`,
|
||||||
details: `issue=#${event.issueNumber}`,
|
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') {
|
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
|
||||||
// pr 字段清空由责任人机器落库,本机只刷看板。
|
// pr 字段清空由责任人机器落库,本机只刷看板。
|
||||||
this.activePanel?.postMessage({
|
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)
|
const from = handoffRequestFromOf(event.commentBody)
|
||||||
if (!from) {
|
if (!from) {
|
||||||
this.handoffRequestSeen.delete(event.issueNumber)
|
this.handoffRequestSeen.delete(event.issueNumber)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const seen = this.handoffRequestSeen.get(event.issueNumber) ?? ''
|
const seen = this.handoffRequestSeen.get(event.issueNumber) ?? ''
|
||||||
if (from === seen || !this.ctx)
|
if (from === seen)
|
||||||
return
|
return
|
||||||
const resolved = await resolveForge(this.ctx)
|
const me = await loginFor(forge)
|
||||||
if (!resolved.ok)
|
|
||||||
return
|
|
||||||
const me = await loginFor(resolved.forge)
|
|
||||||
const payload = event.raw as { issue?: { assignees?: Array<{ login?: string } | null> | null } | null } | null
|
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] : []))
|
const assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
|
||||||
if (!me || assignees.length === 0)
|
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') {
|
if (event.action !== 'created') {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -1362,8 +1202,7 @@ class WebhookCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body = event.commentBody
|
const body = event.commentBody
|
||||||
// Diagnostic: dump body length + first 200 chars so we can see exactly
|
// 审查标记没认出来时,看日志里的原文前 200 字排查。
|
||||||
// what gitea is sending us when marker detection misses.
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
@@ -1371,17 +1210,7 @@ class WebhookCoordinator {
|
|||||||
details: body.slice(0, 200),
|
details: body.slice(0, 200),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ctx = await this.forgeForEvent(event.issueNumber)
|
// PR 上的评论同样以 issue.number 形式
|
||||||
if (!ctx) {
|
|
||||||
logger.add({
|
|
||||||
level: 'error',
|
|
||||||
source: 'webhook',
|
|
||||||
message: `无法解析 #${event.issueNumber} 的仓库上下文(issue_comment),跳过`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// gitea 的 issue_comment payload 里,PR 上的评论同样以 issue.number 形式
|
|
||||||
// 携带 PR 编号。这里如果 prNumber 已置位,就拉 PR body 反查 `Closes #N`
|
// 携带 PR 编号。这里如果 prNumber 已置位,就拉 PR body 反查 `Closes #N`
|
||||||
// 找到真正的工单编号;找不到则保持原 event.issueNumber 兜底。
|
// 找到真正的工单编号;找不到则保持原 event.issueNumber 兜底。
|
||||||
let realIssueNumber = event.issueNumber
|
let realIssueNumber = event.issueNumber
|
||||||
@@ -1429,11 +1258,12 @@ class WebhookCoordinator {
|
|||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
|
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
|
||||||
})
|
})
|
||||||
await this.notifyHandoffRequest(event)
|
await this.notifyHandoffRequest(event, ctx)
|
||||||
await this.refreshIssueCard({
|
await this.refreshIssueCard({
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
raw: event.raw,
|
raw: event.raw,
|
||||||
reason: '列变化',
|
reason: '列变化',
|
||||||
|
forge: ctx,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1597,15 +1427,7 @@ class WebhookCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const { workspaceRoot } = ctx
|
||||||
if (!workspaceRoot) {
|
|
||||||
logger.add({
|
|
||||||
level: 'error',
|
|
||||||
source: 'webhook',
|
|
||||||
message: `triggerReview 中止 #${issueNumber}:没有工作区`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// state 读一次,既当审查提示词的模板上下文,又给 worktree 兜底。读失败按空
|
// state 读一次,既当审查提示词的模板上下文,又给 worktree 兜底。读失败按空
|
||||||
// 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
// 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 多人协作事件门禁:Gitea webhook 广播到每位开发者的机器,写状态、触发
|
* 多人协作事件门禁:仓库 webhook 广播到每位开发者的机器,写状态、触发
|
||||||
* codex 审查、注入终端这类副作用只允许"责任人机器"执行,其余机器只做
|
* codex 审查、注入终端这类副作用只允许"责任人机器"执行,其余机器只做
|
||||||
* UI 刷新。归属以 Gitea 原生 assignee 为准;存量无 assignee 的工单退回
|
* UI 刷新。归属以仓库原生 assignee 为准;存量无 assignee 的工单退回
|
||||||
* 本地证据(本机是否开着该工单的实施/测试终端)。
|
* 本地证据(本机是否开着该工单的实施/测试终端)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export type EventOwnership = 'own' | 'observe'
|
|||||||
export function decideEventOwnership(opts: {
|
export function decideEventOwnership(opts: {
|
||||||
/** 工单 assignee 的 login 列表,空数组表示未指派。 */
|
/** 工单 assignee 的 login 列表,空数组表示未指派。 */
|
||||||
assignees: string[]
|
assignees: string[]
|
||||||
/** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */
|
/** 本机凭据对应的仓库 login;身份解析失败时为 undefined。 */
|
||||||
me: string | undefined
|
me: string | undefined
|
||||||
/** 本机是否存在该工单的实施/测试终端(存量工单的兜底证据)。 */
|
/** 本机是否存在该工单的实施/测试终端(存量工单的兜底证据)。 */
|
||||||
hasLocalSessionEvidence: boolean
|
hasLocalSessionEvidence: boolean
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe('parseEvent', () => {
|
|||||||
commentAuthor: 'alice',
|
commentAuthor: 'alice',
|
||||||
commentBody: 'hello world',
|
commentBody: 'hello world',
|
||||||
commentHtmlUrl: 'https://gitea.example.com/repo/issues/42#issuecomment-1',
|
commentHtmlUrl: 'https://gitea.example.com/repo/issues/42#issuecomment-1',
|
||||||
|
repository: undefined,
|
||||||
raw,
|
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) => {
|
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 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' } }
|
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 {
|
export interface PrWebhookEvent {
|
||||||
kind: 'pr'
|
kind: 'pr'
|
||||||
issueNumber: number | undefined
|
issueNumber: number | undefined
|
||||||
@@ -7,6 +17,11 @@ export interface PrWebhookEvent {
|
|||||||
htmlUrl: string
|
htmlUrl: string
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
|
/** 关闭时是不是合并关掉的;`closed` 以外的动作恒为 `false`。 */
|
||||||
|
merged: boolean
|
||||||
|
/** 合并时间(ISO 8601),没合并时没有。 */
|
||||||
|
mergedAt: string | undefined
|
||||||
|
repository: Remote | undefined
|
||||||
raw: unknown
|
raw: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +32,7 @@ export interface IssueWebhookEvent {
|
|||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
htmlUrl: string
|
htmlUrl: string
|
||||||
|
repository: Remote | undefined
|
||||||
raw: unknown
|
raw: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,17 +44,25 @@ export interface IssueCommentWebhookEvent {
|
|||||||
commentBody: string
|
commentBody: string
|
||||||
commentAuthor: string
|
commentAuthor: string
|
||||||
commentHtmlUrl: string
|
commentHtmlUrl: string
|
||||||
|
repository: Remote | undefined
|
||||||
raw: unknown
|
raw: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PushWebhookEvent {
|
export interface PushWebhookEvent {
|
||||||
kind: 'push'
|
kind: 'push'
|
||||||
branch: string
|
branch: string
|
||||||
|
repository: Remote | undefined
|
||||||
raw: unknown
|
raw: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent
|
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(
|
export function parseEvent(
|
||||||
eventHeader: string,
|
eventHeader: string,
|
||||||
issueNumber: number | undefined,
|
issueNumber: number | undefined,
|
||||||
@@ -53,16 +77,18 @@ export function parseEvent(
|
|||||||
comment?: unknown
|
comment?: unknown
|
||||||
ref?: unknown
|
ref?: unknown
|
||||||
sender?: unknown
|
sender?: unknown
|
||||||
|
repository?: unknown
|
||||||
}
|
}
|
||||||
|
const repository = repositoryOf(obj.repository)
|
||||||
|
|
||||||
if (eventHeader === 'push') {
|
if (eventHeader === 'push') {
|
||||||
// gitea push 事件无 `action`,顶层带 `ref: "refs/heads/<branch>"`。
|
// push 事件没有 `action`,顶层带 `ref: "refs/heads/<branch>"`。
|
||||||
if (typeof obj.ref !== 'string')
|
if (typeof obj.ref !== 'string')
|
||||||
return null
|
return null
|
||||||
const branch = obj.ref.replace(/^refs\/heads\//, '')
|
const branch = obj.ref.replace(/^refs\/heads\//, '')
|
||||||
if (!branch)
|
if (!branch)
|
||||||
return null
|
return null
|
||||||
return { kind: 'push', branch, raw }
|
return { kind: 'push', branch, repository, raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj.action !== 'string')
|
if (typeof obj.action !== 'string')
|
||||||
@@ -87,18 +113,16 @@ export function parseEvent(
|
|||||||
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
|
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
|
||||||
if (!Number.isFinite(num))
|
if (!Number.isFinite(num))
|
||||||
return null
|
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'
|
const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object'
|
||||||
? num
|
? num
|
||||||
: undefined
|
: undefined
|
||||||
const commentBody = typeof commentObj.body === 'string' ? commentObj.body : ''
|
const commentBody = typeof commentObj.body === 'string' ? commentObj.body : ''
|
||||||
const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : ''
|
const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : ''
|
||||||
const commentUser = commentObj.user as { login?: unknown, username?: unknown } | undefined
|
const commentUser = commentObj.user as { login?: unknown } | undefined
|
||||||
const sender = obj.sender as { login?: unknown, username?: unknown } | undefined
|
const sender = obj.sender as { login?: unknown } | undefined
|
||||||
const commentAuthor = (typeof commentUser?.login === 'string' && commentUser.login)
|
const commentAuthor = (typeof commentUser?.login === 'string' && commentUser.login)
|
||||||
|| (typeof commentUser?.username === 'string' && commentUser.username)
|
|
||||||
|| (typeof sender?.login === 'string' && sender.login)
|
|| (typeof sender?.login === 'string' && sender.login)
|
||||||
|| (typeof sender?.username === 'string' && sender.username)
|
|
||||||
|| ''
|
|| ''
|
||||||
return {
|
return {
|
||||||
kind: 'issue_comment',
|
kind: 'issue_comment',
|
||||||
@@ -108,6 +132,7 @@ export function parseEvent(
|
|||||||
commentAuthor,
|
commentAuthor,
|
||||||
commentBody,
|
commentBody,
|
||||||
commentHtmlUrl,
|
commentHtmlUrl,
|
||||||
|
repository,
|
||||||
raw,
|
raw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,12 +160,14 @@ export function parseEvent(
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
htmlUrl,
|
htmlUrl,
|
||||||
|
repository,
|
||||||
raw,
|
raw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default path: pull_request payload (preserves legacy behaviour for any
|
// PR 的评审、评审评论等事件也带 pull_request 对象,只认 pull_request 事件,免得把评审评论的 deleted 当成 PR 被删。
|
||||||
// event header value, since gitea historically may not set the header).
|
if (eventHeader !== 'pull_request')
|
||||||
|
return null
|
||||||
const pr = obj.pull_request
|
const pr = obj.pull_request
|
||||||
if (!pr || typeof pr !== 'object')
|
if (!pr || typeof pr !== 'object')
|
||||||
return null
|
return null
|
||||||
@@ -150,6 +177,8 @@ export function parseEvent(
|
|||||||
head?: unknown
|
head?: unknown
|
||||||
title?: unknown
|
title?: unknown
|
||||||
body?: unknown
|
body?: unknown
|
||||||
|
merged?: unknown
|
||||||
|
merged_at?: unknown
|
||||||
}
|
}
|
||||||
const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number)
|
const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number)
|
||||||
if (!Number.isFinite(num))
|
if (!Number.isFinite(num))
|
||||||
@@ -172,6 +201,9 @@ export function parseEvent(
|
|||||||
htmlUrl,
|
htmlUrl,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
|
merged: prObj.merged === true,
|
||||||
|
mergedAt: typeof prObj.merged_at === 'string' ? prObj.merged_at : undefined,
|
||||||
|
repository,
|
||||||
raw,
|
raw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ export function decideIssueAppend(opts: {
|
|||||||
poster: string | undefined
|
poster: string | undefined
|
||||||
/** 工单 assignee 的 login 列表,空数组表示未指派。 */
|
/** 工单 assignee 的 login 列表,空数组表示未指派。 */
|
||||||
assignees: string[]
|
assignees: string[]
|
||||||
/** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */
|
/** 本机凭据对应的仓库 login;身份解析失败时为 undefined。 */
|
||||||
me: string | undefined
|
me: string | undefined
|
||||||
}): boolean {
|
}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+302
-208
@@ -1,37 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Minimal HTTP server that listens for Gitea webhooks and fires a VS Code
|
* 接收代码托管仓库 webhook 的本机 HTTP 服务,每个验过签、认得出的请求在 [WebhookServer.onEvent] 上发一个事件。
|
||||||
* `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)
|
|
||||||
*
|
*
|
||||||
* Routes accepted:
|
* GitHub 和 Gitea(v28 源码 `services/webhook/deliver.go` 的 `addDefaultHeaders`)都发 `X-GitHub-Event` 和
|
||||||
* - `POST /webhook/:issueNumber` (legacy) — the issue number is taken from
|
* `X-Hub-Signature-256: sha256=<hex>`,所以只认这一套头;请求体只收 `application/json`。
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Other paths/methods get a 404/405. Malformed JSON or missing fields → 400.
|
* 路径:`POST /webhook` 是现在的用法,PR 事件的工单号之后由 PR 描述里的 `Closes #N` 反查;
|
||||||
* Non-matching actions still respond 200 but skip the emit (gitea retries
|
* `POST /webhook/<工单号>` 是旧版按工单建的 webhook 留下的地址,工单号直接取自路径。
|
||||||
* less aggressively that way).
|
*
|
||||||
|
* 本机开着几个窗口时只有一个窗口占得到端口,其余窗口经 [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 { Event } from 'vscode'
|
||||||
import type { WebhookEvent } from './eventParser'
|
import type { WebhookEvent } from './eventParser'
|
||||||
import { Buffer } from 'node:buffer'
|
import { Buffer } from 'node:buffer'
|
||||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
import path from 'node:path'
|
||||||
import { EventEmitter } from 'vscode'
|
import { EventEmitter } from 'vscode'
|
||||||
|
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
import { parseEvent } from './eventParser'
|
import { parseEvent } from './eventParser'
|
||||||
|
import { defaultPeerRoot, forwardToPeers, PeerRegistry, RELAYED_HEADERS } from './relay'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
IssueCommentWebhookEvent,
|
IssueCommentWebhookEvent,
|
||||||
@@ -42,238 +33,341 @@ export type {
|
|||||||
} from './eventParser'
|
} from './eventParser'
|
||||||
export { parseEvent } from './eventParser'
|
export { parseEvent } from './eventParser'
|
||||||
|
|
||||||
export class WebhookServer {
|
/** 交给 [handleWebhookRequest] 的一次请求,`body` 是原始字节,验签要用。 */
|
||||||
private server?: Server
|
export interface WebhookRequest {
|
||||||
private port?: number
|
method: string | undefined
|
||||||
private secret = ''
|
url: string | undefined
|
||||||
private readonly emitter = new EventEmitter<WebhookEvent>()
|
headers: IncomingHttpHeaders
|
||||||
|
body: Buffer
|
||||||
readonly onEvent: Event<WebhookEvent> = this.emitter.event
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置(或清空)Gitea webhook secret。非空时对每个请求校验
|
|
||||||
* `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401
|
|
||||||
* ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。
|
|
||||||
*/
|
|
||||||
setSecret(secret: string): void {
|
|
||||||
this.secret = secret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */
|
/** 回给发送方的状态码和正文;`event` 是这次请求要交给看板处理的事件,没有就说明不用处理。 */
|
||||||
private verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
|
export interface WebhookReply {
|
||||||
const expected = createHmac('sha256', this.secret).update(rawBody).digest()
|
status: number
|
||||||
let received: Buffer
|
body: { ok: boolean, error?: string }
|
||||||
try {
|
event?: WebhookEvent
|
||||||
received = Buffer.from(signatureHeader, 'hex')
|
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
|
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
|
return false
|
||||||
}
|
const expected = createHmac('sha256', secret).update(body).digest()
|
||||||
return received.length === expected.length && timingSafeEqual(received, expected)
|
return timingSafeEqual(Buffer.from(match[1], 'hex'), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The port currently bound, or undefined if the server is not listening. */
|
/** 日志里的一行事件摘要。 */
|
||||||
get currentPort(): number | undefined {
|
function describeEvent(event: WebhookEvent): string {
|
||||||
return this.port
|
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}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start (or move) the server on `port`. If already listening on the same
|
* 校验并解析一次 webhook 请求,不碰网络,便于单测。
|
||||||
* 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.
|
* `secret` 不是空串时先验签,不通过一律 401:端口经 frp 暴露在公网,不验签谁都能伪造事件去触发审查。
|
||||||
|
* `ping` 是 GitHub 建好 webhook 时发的连通测试,应答 200 但不产生事件。
|
||||||
*/
|
*/
|
||||||
async start(port: number): Promise<void> {
|
export function handleWebhookRequest(request: WebhookRequest, secret: string): WebhookReply {
|
||||||
if (this.server && this.port === port)
|
const url = request.url ?? ''
|
||||||
return
|
if (request.method !== 'POST')
|
||||||
if (this.server)
|
return fail(405, 'method_not_allowed')
|
||||||
await this.stop()
|
|
||||||
|
|
||||||
const server = createServer((req, res) => this.handle(req, res))
|
const route = /^\/webhook(?:\/(\d+))?(?:\?.*)?$/.exec(url)
|
||||||
await new Promise<void>((resolve, reject) => {
|
if (!route) {
|
||||||
function onError(err: Error): void {
|
logger.add({ level: 'warn', source: 'webhook', message: '路径不匹配', details: url })
|
||||||
server.off('listening', onListening)
|
return fail(404, 'not_found')
|
||||||
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}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
const issueNumber = route[1] ? Number(route[1]) : undefined
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
if (secret) {
|
||||||
const srv = this.server
|
const signature = headerOf(request.headers, 'x-hub-signature-256')
|
||||||
if (!srv)
|
if (!signatureMatches(request.body, signature, secret)) {
|
||||||
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 {
|
|
||||||
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({
|
logger.add({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: '路径不匹配',
|
message: `签名校验失败,拒绝请求 (X-Hub-Signature-256=${signature ? '不匹配' : '<缺失>'})`,
|
||||||
details: url,
|
|
||||||
})
|
})
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
return fail(401, 'invalid_signature')
|
||||||
res.end(JSON.stringify({ ok: false, error: 'not_found' }))
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks: Buffer[] = []
|
const eventName = headerOf(request.headers, 'x-github-event')
|
||||||
req.on('data', (c: Buffer) => chunks.push(c))
|
if (!eventName) {
|
||||||
req.on('end', () => {
|
logger.add({ level: 'warn', source: 'webhook', message: '缺少 X-GitHub-Event 头,拒绝请求' })
|
||||||
try {
|
return fail(400, 'missing_event')
|
||||||
const rawBody = Buffer.concat(chunks)
|
}
|
||||||
if (this.secret) {
|
|
||||||
const signatureHeader = (req.headers['x-gitea-signature'] || '').toString()
|
const contentType = headerOf(request.headers, 'content-type')
|
||||||
if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) {
|
if (!/^application\/json\s*(?:;|$)/i.test(contentType)) {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`,
|
message: `请求体是 ${contentType || '<未知类型>'},只收 application/json:把仓库 webhook 设置里的 Content type 改成 application/json`,
|
||||||
|
details: `X-GitHub-Event=${eventName}`,
|
||||||
})
|
})
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' })
|
return fail(415, 'unsupported_content_type')
|
||||||
res.end(JSON.stringify({ ok: false, error: 'invalid_signature' }))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventName === 'ping') {
|
||||||
|
logger.add({ level: 'info', source: 'webhook', message: '收到 ping,webhook 已连通' })
|
||||||
|
return { status: 200, body: { ok: true } }
|
||||||
}
|
}
|
||||||
const body = rawBody.toString('utf-8')
|
|
||||||
const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '<missing>'
|
const body = request.body.toString('utf-8')
|
||||||
logger.add({
|
|
||||||
level: 'info',
|
|
||||||
source: 'webhook',
|
|
||||||
message: `收到请求 X-Gitea-Event=${eventHeader} bodyLen=${body.length}`,
|
|
||||||
})
|
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(body) as unknown
|
parsed = JSON.parse(body) as unknown
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
logger.add({
|
logger.add({ level: 'warn', source: 'webhook', message: '请求体解析失败', details: body.slice(0, 500) })
|
||||||
level: 'warn',
|
return fail(400, 'invalid_json')
|
||||||
source: 'webhook',
|
}
|
||||||
message: '请求体解析失败',
|
|
||||||
details: body.slice(0, 500),
|
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)
|
||||||
})
|
})
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
}
|
||||||
res.end(JSON.stringify({ ok: false, error: 'invalid_json' }))
|
|
||||||
|
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
|
||||||
|
|
||||||
|
constructor(options: WebhookServerOptions = {}) {
|
||||||
|
this.peerRoot = options.peerRoot ?? defaultPeerRoot()
|
||||||
|
this.takeoverIntervalMs = options.takeoverIntervalMs ?? 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置或清空 webhook Secret,空串表示不验签。 */
|
||||||
|
setSecret(secret: string): void {
|
||||||
|
this.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本窗口正在服务的公网端口,不论是否亲自占着;没启动时为 `undefined`。 */
|
||||||
|
get currentPort(): number | undefined {
|
||||||
|
return this.server ? this.port : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 `port` 上开始接收;已经在这个端口上就什么也不做,换了端口就先停掉旧的。
|
||||||
|
*
|
||||||
|
* 端口被本机别的窗口占着时不报错,改为在 `127.0.0.1` 的随机端口上等它转发,并定时试着接手。
|
||||||
|
*
|
||||||
|
* @throws [Error] 端口被占用以外的监听失败
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
await this.close()
|
||||||
|
try {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
catch (err) {
|
||||||
const event = parseEvent(eventHeader, issueNumber, parsed)
|
if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
|
||||||
if (event) {
|
logger.add({ level: 'error', source: 'webhook', message: 'bind 失败', details: (err as Error).message })
|
||||||
if (event.kind === 'pr') {
|
throw err
|
||||||
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') {
|
const server = await listen(this.createHttpServer(), 0, '127.0.0.1')
|
||||||
logger.add({
|
const localPort = (server.address() as AddressInfo).port
|
||||||
level: 'info',
|
const registry = new PeerRegistry(path.join(this.peerRoot, String(port)))
|
||||||
source: 'webhook',
|
try {
|
||||||
message: `推送分支 ${event.branch} (event=${eventHeader})`,
|
await registry.add(localPort)
|
||||||
})
|
|
||||||
}
|
|
||||||
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) {
|
catch (err) {
|
||||||
console.error('[superpowers/webhook] handler error:', err)
|
await closeServer(server)
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
throw err
|
||||||
res.end(JSON.stringify({ ok: false, error: 'internal_error' }))
|
|
||||||
}
|
}
|
||||||
|
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) => {
|
req.on('error', (err) => {
|
||||||
console.error('[superpowers/webhook] request stream 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) {
|
catch (err) {
|
||||||
console.error('[superpowers/webhook] top-level error:', err)
|
console.error('[superpowers/webhook] handler error:', err)
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
reply = fail(500, 'internal_error')
|
||||||
res.end(JSON.stringify({ ok: false, error: '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