diff --git a/vscode/src/cc/ccCommand.ts b/vscode/src/cc/ccCommand.ts index f0fc645..a5a9a9e 100644 --- a/vscode/src/cc/ccCommand.ts +++ b/vscode/src/cc/ccCommand.ts @@ -18,6 +18,7 @@ export interface CcCommandOpts { effort?: string /** When set, append `--resume ` so cc restores that conversation. */ resumeSessionId?: string + sessionId?: string /** * When set, append the prompt as a positional arg. Combined with * `resumeSessionId` it is submitted as the next message of the restored @@ -27,12 +28,16 @@ export interface CcCommandOpts { } export function buildCcArgv(opts: CcCommandOpts): string[] { + if (opts.sessionId && opts.resumeSessionId) + throw new Error('sessionId 与 resumeSessionId 不能同时指定') const argv = [resolveClaudeBin() ?? 'claude'] if (opts.effort) argv.push('--effort', opts.effort) argv.push('--dangerously-skip-permissions', '--settings', opts.profilePath) if (opts.resumeSessionId) argv.push('--resume', opts.resumeSessionId) + if (opts.sessionId) + argv.push('--session-id', opts.sessionId) if (opts.prompt) argv.push(opts.prompt) return argv diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 77a20f6..2fc6c5b 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -72,6 +72,7 @@ export interface MergeStateJsonCommentOpts { token: string issueNumber: number extra: Record + defaults?: Record } /** @@ -152,6 +153,7 @@ async function postMergedStateJsonComment( extra: Record, ): Promise { const merged: Record = { + ...opts.defaults, ...currentState, ...extra, } diff --git a/vscode/src/panel/KanbanPanel.cache.test.ts b/vscode/src/panel/KanbanPanel.cache.test.ts index 5bbb227..566ceff 100644 --- a/vscode/src/panel/KanbanPanel.cache.test.ts +++ b/vscode/src/panel/KanbanPanel.cache.test.ts @@ -54,7 +54,7 @@ vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: mocks.an vi.mock('../settings/store', () => ({ getSettings: () => ({ autoReview: true, youtrackBaseUrl: '', youtrackProjectShortName: '' }), })) -vi.mock('../webhook/coordinator', () => ({ webhookCoordinator: { setActivePanel: mocks.setActivePanel } })) +vi.mock('../webhook/coordinator', () => ({ webhookCoordinator: { setActivePanel: mocks.setActivePanel, restoreIssueCreationTerminals: vi.fn() } })) vi.mock('../webhook/server', () => ({ WebhookServer: class { setSecret() {} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index c0496ee..f70baf3 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -117,45 +117,7 @@ export class KanbanWebviewPanel { // internal: handler 模块访问 readonly managedTerminals = new Map() - /** - * Tracks in-flight "create issue" runs keyed by the nonce embedded in the - * cc prompt. Populated synchronously in `handleIssueCreate` before the - * terminal is shown; the session watcher fills in `sessionId` once cc - * starts writing its jsonl. The webhook coordinator drains entries via - * `takePendingIssueCreation` when the corresponding `issues opened` - * payload arrives, then merges the column / sessionId / brainstormProfilePath / - * color into the state-JSON comment and cleans up the inbox tmpdir. - */ - // internal: handler 模块访问 - readonly pendingIssueCreations = new Map() - - /** - * issueNumber → the brainstorm terminal originally created via the new-issue - * flow (its name is `issue-new-{shortNonce}-规划`, NOT `issue-${N}-规划`). - * Populated by `linkPendingTerminalToIssue` after the webhook coordinator - * matches the nonce in `issue.body` to an entry in `pendingIssueCreations`. - * - * VS Code's Terminal API has no rename method, so we can't fix the tab name - * after the fact. Instead we keep this side-map so `handleSessionFocus` - * (card → terminal) and `handleActiveTerminalChanged` (terminal → card) can - * still find the tab and round-trip selection. - */ - // internal: handler 模块访问 + /** 保存新建工单对应的终端,让卡片与规划会话能够互相定位。 */ readonly newIssueTerminals = new Map() /** @@ -1040,6 +1002,7 @@ export class KanbanWebviewPanel { this.activeLoad = undefined this.loadInFlight = undefined this.postMessage({ type: 'issues/loaded' }) + webhookCoordinator.restoreIssueCreationTerminals(this) }) return this.loadInFlight } @@ -1380,43 +1343,6 @@ export class KanbanWebviewPanel { terminals.untrackClosedTerminal(this, closed) } - /** - * Removes and returns the {@link pendingIssueCreations} entry for the - * given nonce, if any. Called by the webhook coordinator when a matching - * `issues opened` payload arrives. Returning `undefined` (and leaving the - * map untouched) signals "no match — treat as external issue creation". - */ - // internal: handler 模块访问 - takePendingIssueCreation(nonce: string): { - sessionId?: string - brainstormProfilePath?: string - color: string - workspaceRoot: string - inboxDir: string - terminalName: string - terminal: Terminal - createdAt: number - } | undefined { - return terminals.takePendingIssueCreation(this, nonce) - } - - /** - * Called by the webhook coordinator after it parses a `spx:nonce=...` token - * out of `issue.body` and matches it to an entry in `pendingIssueCreations`. - * Promotes the in-flight brainstorm terminal into the issueNumber-keyed - * `newIssueTerminals` map so subsequent `handleSessionFocus(issueNumber)` - * and `handleActiveTerminalChanged` calls can find it. - * - * Also drops the terminal into `terminals` keyed by sessionId (when known) - * so `handleResumeSession` reuses the same tab instead of spawning a new - * one. Does NOT remove the pending entry — `takePendingIssueCreation` is - * still responsible for cleanup on its own path. - */ - // internal: handler 模块访问 - public linkPendingTerminalToIssue(nonce: string, issueNumber: number): void { - terminals.linkPendingTerminalToIssue(this, nonce, issueNumber) - } - private dispose(): void { if (this.disposed) return diff --git a/vscode/src/panel/handlers/issues.create.test.ts b/vscode/src/panel/handlers/issues.create.test.ts new file mode 100644 index 0000000..0fdc828 --- /dev/null +++ b/vscode/src/panel/handlers/issues.create.test.ts @@ -0,0 +1,109 @@ +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { handleIssueCreate } from './issues' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + rm: vi.fn(), + createTerminal: vi.fn(), + register: vi.fn(), + attach: vi.fn(), + cancel: vi.fn(), + watch: vi.fn(), + log: vi.fn(), + prompt: vi.fn(), +})) +vi.mock('vscode', () => ({ + commands: {}, + env: { shell: '/bin/zsh' }, + Uri: {}, + ThemeColor: class { constructor(public id: string) {} }, + window: { createTerminal: mocks.createTerminal }, + workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] }, +})) +vi.mock('node:fs', async importOriginal => ({ + ...(await importOriginal()), + promises: { ...(await importOriginal()).promises, mkdir: mocks.mkdir, rm: mocks.rm }, +})) +vi.mock('../../auth/secrets', () => ({ getToken: async () => 'token' })) +vi.mock('../../cc/claudeBin', () => ({ resolveClaudeBin: () => '/bin/claude' })) +vi.mock('../../cc/prompts', () => ({ renderBrainstormPrompt: mocks.prompt })) +vi.mock('../../cc/sessionWatcher', () => ({ projectsDirFor: () => '/sessions', watchForNewSession: mocks.watch })) +vi.mock('../../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) })) +vi.mock('../../logging/logger', () => ({ logger: { add: mocks.log } })) +vi.mock('../../settings/store', () => ({ getSettings: () => ({ webhookPort: 12345 }) })) +vi.mock('../../webhook/coordinator', () => ({ webhookCoordinator: { + ensurePort: async () => undefined, + registerIssueCreation: mocks.register, + attachIssueCreationTerminal: mocks.attach, + cancelIssueCreation: mocks.cancel, +} })) +vi.mock('../../webview/html', () => ({ makeNonce: () => 'toast' })) +vi.mock('../issueColor', () => ({ pickRandomIssueColor: () => 'terminal.ansiBlue', themeColorIdToIconUri: () => 'icon' })) +vi.mock('./sessions', () => ({ resolveBrainstormProfilePath: () => '/profiles/brainstorm.json' })) + +function fixture() { + const panel = { context: {}, resolveTerminalLocation: () => ({ viewColumn: 1 }), postMessage: vi.fn() } as unknown as KanbanWebviewPanel + const terminal = { name: 'new issue', show: vi.fn(), dispose: vi.fn() } + mocks.createTerminal.mockReturnValue(terminal) + return { panel, terminal } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.mkdir.mockResolvedValue(undefined) + mocks.rm.mockResolvedValue(undefined) + mocks.register.mockReset() + mocks.attach.mockReset() + mocks.cancel.mockReset() + mocks.createTerminal.mockReset() + mocks.watch.mockReturnValue(new Promise(() => {})) + mocks.prompt.mockReturnValue('create issue') +}) + +describe('create issue with a predetermined session', () => { + it('registers an independent UUID before terminal creation and passes exactly that identity', async () => { + const f = fixture() + await handleIssueCreate(f.panel, 'create this', undefined, '/profiles/chosen.json') + expect(mocks.register).toHaveBeenCalledOnce() + const [nonce, record] = mocks.register.mock.calls[0] + expect(record.sessionId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/) + expect(record.sessionId).not.toBe(nonce) + expect(record).toMatchObject({ remote: { host: 'host', owner: 'owner', repo: 'repo' }, workspaceRoot: '/repo', brainstormProfilePath: '/profiles/chosen.json' }) + expect(mocks.register.mock.invocationCallOrder[0]).toBeLessThan(mocks.createTerminal.mock.invocationCallOrder[0]) + const args = mocks.createTerminal.mock.calls[0][0].shellArgs as string[] + expect(args[args.indexOf('--session-id') + 1]).toBe(record.sessionId) + expect(mocks.attach).toHaveBeenCalledWith(nonce, f.terminal) + expect(mocks.prompt.mock.calls[0][1].nonce).toBe(nonce) + expect(mocks.watch).not.toHaveBeenCalled() + }) + + it('separate creations never share a session identity', async () => { + const f = fixture() + await handleIssueCreate(f.panel, 'first') + await handleIssueCreate(f.panel, 'second') + expect(mocks.register).toHaveBeenCalledTimes(2) + const ids = mocks.register.mock.calls.map(([, record]) => record.sessionId) + expect(new Set(ids).size).toBe(2) + }) + + it.each(['create', 'show'])('cancels and reports a terminal %s failure', async (failure) => { + const f = fixture() + if (failure === 'create') { + mocks.createTerminal.mockImplementation(() => { + throw new Error('terminal unavailable') + }) + } + else { + f.terminal.show.mockImplementation(() => { + throw new Error('terminal unavailable') + }) + } + await handleIssueCreate(f.panel, 'create this') + expect(mocks.cancel).toHaveBeenCalledWith(mocks.register.mock.calls[0][0]) + expect(f.panel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'toast/show', level: 'error', message: expect.stringContaining('terminal unavailable') })) + expect(mocks.rm).toHaveBeenCalledWith(expect.any(String), { recursive: true, force: true }) + if (failure === 'show') + expect(f.terminal.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index babb7b8..a57edcc 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -16,7 +16,7 @@ import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt } from '../../cc/prompts' import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle' import { sessionLaunch } from '../../cc/sessionTerminal' -import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' +import { projectsDirFor } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' import { gitFetch, resolveFeatureBranch } from '../../git/branches' import { detectRepo } from '../../git/remote' @@ -34,7 +34,6 @@ import { } from '../../gitea/api' import { isValidSpxFilePath, loadIssues } from '../../gitea/issueLoader' import { mergeStateJsonComment } from '../../gitea/stateJson' -import { mergeLocalIssueState } from '../../issues/localState' import { logger } from '../../logging/logger' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' @@ -44,7 +43,6 @@ import { evaluateLockedReason } from '../issueLock' import { cleanupFeatureBranch } from './branchCleanup' import { clearSessionConfirmText, clearSessionDoneText, SESSION_KINDS } from './sessionKinds' import * as sessions from './sessions' -import { issueNumberForNewIssueTerminal } from './terminals' import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset' export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise { @@ -1366,8 +1364,7 @@ export async function handleIssueCreate( return } - // Ensure the always-on webhook listener is up so we can receive the - // `issues opened` callback that drives state-JSON fill-in. + // 先启动工单通知接收器,确保新工单能关联到本次会话。 const settings = getSettings(panel.context) try { await webhookCoordinator.ensurePort(settings.webhookPort) @@ -1385,6 +1382,7 @@ export async function handleIssueCreate( } const nonce = randomUUID() + const sessionId = randomUUID() const shortNonce = nonce.slice(0, 8) const inboxDir = path.join(os.tmpdir(), 'spx-inbox', nonce) try { @@ -1445,54 +1443,54 @@ export async function handleIssueCreate( // 新建工单弹窗里选的 profile > 设置里的「头脑风暴默认配置文件」> DEFAULT。 const effectiveProfilePath = sessions.resolveBrainstormProfilePath(panel, brainstormProfilePath) - // Ensure the claude projects subdir exists *before* spawning the - // terminal so the watcher can't miss the create event. - const projDir = projectsDirFor(workspaceRoot) - try { - await fsp.mkdir(projDir, { recursive: true }) - } - catch (err) { - console.warn('[superpowers] failed to mkdir claude projects dir:', err) - } - const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) - - const terminalName = `issue-new-${shortNonce}-规划` - const themeColor = new ThemeColor(color) - const iconUri = themeColorIdToIconUri(color) - const terminal = window.createTerminal({ - name: terminalName, - cwd: workspaceRoot, - location: panel.resolveTerminalLocation(false), - iconPath: iconUri, - color: themeColor, - ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt })), - }) - terminal.show(false) - logger.add({ - level: 'info', - source: 'terminal', - message: `已创建终端 "${terminal.name}"`, - }) - - // 新建工单会话是头脑风暴;只把用户选中的 brainstorm profile 写入 pending, - // 空串不落盘(webhook 侧也会跳过空值),实施/测试 profile 各自独立配置。 - const storedBrainstormProfile - = brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath.trim() : undefined - panel.pendingIssueCreations.set(nonce, { + const storedBrainstormProfile = brainstormProfilePath?.trim() || undefined + webhookCoordinator.registerIssueCreation(nonce, { + sessionId, + remote, brainstormProfilePath: storedBrainstormProfile, color, workspaceRoot, inboxDir, - terminalName, - terminal, - createdAt: Date.now(), }) + + let terminal: ReturnType | undefined + try { + terminal = window.createTerminal({ + name: `issue-new-${shortNonce}-规划`, + cwd: workspaceRoot, + location: panel.resolveTerminalLocation(false), + iconPath: themeColorIdToIconUri(color), + color: new ThemeColor(color), + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, sessionId, prompt })), + }) + webhookCoordinator.attachIssueCreationTerminal(nonce, terminal) + terminal.show(false) + } + catch (err) { + webhookCoordinator.cancelIssueCreation(nonce) + terminal?.dispose() + try { + await fsp.rm(inboxDir, { recursive: true, force: true }) + } + catch (cleanupError) { + logger.add({ level: 'warn', source: 'panel', message: '新建工单临时目录清理失败', details: String(cleanupError) }) + } + panel.postMessage({ + type: 'toast/show', + id: `issue-new-${shortNonce}`, + level: 'error', + message: `新建工单会话启动失败: ${err instanceof Error ? err.message : String(err)}`, + dismissOnTimer: 8000, + }) + return + } + logger.add({ level: 'info', source: 'panel', message: `已启动新建工单会话 nonce=${shortNonce}`, + details: `sessionId=${sessionId} terminal=${terminal.name}`, }) - panel.postMessage({ type: 'toast/show', id: `issue-new-${shortNonce}`, @@ -1501,56 +1499,6 @@ export async function handleIssueCreate( spinner: true, dismissOnTimer: 4000, }) - - // 工单往往比会话文件先出现。webhook 把 pending 取走时 sessionId 还空, - // 这里在文件落地后补写本机会话 id。 - watchPromise.then(async (sid) => { - if (!sid) { - logger.add({ - level: 'warn', - source: 'panel', - message: `新建工单会话监听超时 nonce=${shortNonce}`, - }) - return - } - const pending = panel.pendingIssueCreations.get(nonce) - if (pending) { - pending.sessionId = sid - logger.add({ - level: 'info', - source: 'panel', - message: `已捕获新建工单会话 ${sid} nonce=${shortNonce}`, - }) - return - } - const issueNumber = issueNumberForNewIssueTerminal(panel, terminal) - if (issueNumber === undefined) { - logger.add({ - level: 'warn', - source: 'panel', - message: `新建工单会话 ${sid} 已捕获,但 nonce=${shortNonce} 还没对上工单`, - }) - return - } - try { - const state = await panel.readIssueState(issueNumber) - if (typeof state.sessionId === 'string' && state.sessionId.length > 0) - return - } - catch { - // 本机状态还没有,照写 - } - await mergeLocalIssueState(panel.context, 'gitea', issueNumber, { sessionId: sid }) - panel.terminals.set(sid, terminal) - panel.postMessage({ type: 'issue/patch', issueNumber, patch: { sessionId: sid } }) - logger.add({ - level: 'info', - source: 'panel', - message: `补写工单 #${issueNumber} 头脑风暴会话 ${sid}`, - }) - }).catch((err) => { - console.warn('[superpowers] new-issue session watch failed:', err) - }) } /** diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 96a4402..6f33589 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -137,11 +137,7 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe ) { return } - // Priority 0: new-issue flow terminal whose name is `issue-new-{nonce}-规划`, - // not `issue-${N}-规划`. The webhook coordinator stitches issueNumber → - // terminal into `newIssueTerminals` via `linkPendingTerminalToIssue`. - // We can't rename the terminal tab (VS Code API limitation), so this - // side-map is the only way to find it by issueNumber. + // 新建会话的终端名不含工单号,必须通过已保存的映射定位。 const newIssueTerm = panel.newIssueTerminals.get(issueNumber) if (newIssueTerm && newIssueTerm.exitStatus === undefined) { if (newIssueTerm !== window.activeTerminal) @@ -190,9 +186,7 @@ export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: } return } - // Fallback: `issue-new-${shortNonce}-规划` — created via the new-issue flow - // before we knew the issueNumber. Reverse-scan `newIssueTerminals` (which - // was populated by the webhook coordinator via `linkPendingTerminalToIssue`). + // 从新建终端反查所属工单,让切换终端时看板也选中相应卡片。 if (terminal.name.startsWith('issue-new-')) { for (const [num, term] of panel.newIssueTerminals) { if (term === terminal) { @@ -355,54 +349,16 @@ export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: nu terminal.dispose() } -/** - * Removes and returns the {@link pendingIssueCreations} entry for the - * given nonce, if any. Called by the webhook coordinator when a matching - * `issues opened` payload arrives. Returning `undefined` (and leaving the - * map untouched) signals "no match — treat as external issue creation". - */ -export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): { - sessionId?: string - brainstormProfilePath?: string - color: string - workspaceRoot: string - inboxDir: string - terminalName: string - terminal: Terminal - createdAt: number -} | undefined { - const entry = panel.pendingIssueCreations.get(nonce) - if (entry) - panel.pendingIssueCreations.delete(nonce) - return entry -} - -/** - * Called by the webhook coordinator after it parses a `spx:nonce=...` token - * out of `issue.body` and matches it to an entry in `pendingIssueCreations`. - * Promotes the in-flight brainstorm terminal into the issueNumber-keyed - * `newIssueTerminals` map so subsequent `handleSessionFocus(issueNumber)` - * and `handleActiveTerminalChanged` calls can find it. - * - * Also drops the terminal into `terminals` keyed by sessionId (when known) - * so `handleResumeSession` reuses the same tab instead of spawning a new - * one. Does NOT remove the pending entry — `takePendingIssueCreation` is - * still responsible for cleanup on its own path. - */ -export function linkPendingTerminalToIssue(panel: KanbanWebviewPanel, nonce: string, issueNumber: number): void { - const pending = panel.pendingIssueCreations.get(nonce) - if (!pending) +/** 将已知会话绑定到工单,让卡片和仍打开的终端能够互相定位。 */ +export function linkIssueCreationTerminal( + panel: KanbanWebviewPanel, + creation: { sessionId: string, terminal?: Terminal }, + issueNumber: number, +): void { + const terminal = creation.terminal + if (!terminal || terminal.exitStatus !== undefined || !window.terminals.includes(terminal)) return - panel.newIssueTerminals.set(issueNumber, pending.terminal) - if (pending.sessionId && pending.sessionId.length > 0) - panel.terminals.set(pending.sessionId, pending.terminal) - trackSessionTerminal(panel, pending.terminal, issueNumber, 'brainstorm') -} - -export function issueNumberForNewIssueTerminal(panel: KanbanWebviewPanel, terminal: Terminal): number | undefined { - for (const [issueNumber, linked] of panel.newIssueTerminals) { - if (linked === terminal) - return issueNumber - } - return undefined + panel.newIssueTerminals.set(issueNumber, terminal) + panel.terminals.set(creation.sessionId, terminal) + trackSessionTerminal(panel, terminal, issueNumber, 'brainstorm') } diff --git a/vscode/src/webhook/coordinator.issueCreation.test.ts b/vscode/src/webhook/coordinator.issueCreation.test.ts new file mode 100644 index 0000000..572db51 --- /dev/null +++ b/vscode/src/webhook/coordinator.issueCreation.test.ts @@ -0,0 +1,421 @@ +import type { ExtensionContext, Terminal } from 'vscode' +import type { Issue } from '../gitea/types' +import type { KanbanWebviewPanel } from '../panel/KanbanPanel' +import type { IssueWebhookEvent } from './eventParser' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalIssueState, overlayLocalIssueState } from '../issues/localState' +import { trackSessionTerminal, withLiveTerminalTabState } from '../panel/handlers/terminals' +import { applyIssueUpdate } from '../shared/kanbanUpdates' +import { webhookCoordinator } from './coordinator' +import { parseEvent } from './eventParser' + +const mocks = vi.hoisted(() => ({ + token: vi.fn(), + load: vi.fn(), + getIssue: vi.fn(), + mergeShared: vi.fn(), + readShared: vi.fn(), + log: vi.fn(), + rm: vi.fn(), + terminals: [] as Terminal[], +})) +vi.mock('vscode', () => ({ + env: {}, + Uri: {}, + TabInputTerminal: class {}, + window: { terminals: mocks.terminals, showInformationMessage: async () => undefined }, + workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] }, +})) +vi.mock('node:fs', async importOriginal => ({ + ...(await importOriginal()), + promises: { ...(await importOriginal()).promises, rm: mocks.rm }, +})) +vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) +vi.mock('../auth/secrets', () => ({ getToken: mocks.token })) +vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' })) +vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) +vi.mock('../git/remote', async importOriginal => ({ + ...(await importOriginal()), + detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }), +})) +vi.mock('../gitea/api', () => ({ getIssue: mocks.getIssue })) +vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.load })) +vi.mock('../gitea/stateJson', async importOriginal => ({ + hasStateColumn: (await importOriginal()).hasStateColumn, + readStateJsonComment: mocks.readShared, + mergeStateJsonComment: mocks.mergeShared, +})) +vi.mock('../panel/handlers/issueChat', () => ({})) +vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues })) +vi.mock('../settings/store', () => ({ getSettings: () => ({ webhookSecret: '', webhookPort: 12345, devBranch: 'main', releaseBranchPrefix: 'release/' }) })) +vi.mock('./server', () => ({ + WebhookServer: class { + setSecret() {} + start() { return Promise.resolve() } + stop() { return Promise.resolve() } + onEvent() { return { dispose() {} } } + }, +})) + +const NONCE = 'f961e23b-f841-48f5-869e-2f59473b53d4' +const SID = '5a749776-d1c8-4cf0-8f85-3b8d7b804498' +const REMOTE = { host: 'host', owner: 'owner', repo: 'repo' } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function event(action = 'assigned', issueNumber = 483, repository = 'https://host/owner/repo', nonce = NONCE): IssueWebhookEvent { + const raw = { + action, + issue: { number: issueNumber, title: 'issue', body: `description\n`, html_url: `https://host/owner/repo/issues/${issueNumber}`, user: { login: 'me' }, assignees: [{ login: 'me' }] }, + repository: { html_url: repository }, + } + return parseEvent('issues', undefined, raw) as IssueWebhookEvent +} + +function dispatch(value: IssueWebhookEvent) { + return (webhookCoordinator as unknown as { handleEvent: (event: IssueWebhookEvent) => Promise }).handleEvent(value) +} + +function fixture() { + const disk = new Map() + 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 + const terminal = { name: 'issue-new-f961e23b-规划', exitStatus: undefined } as Terminal + mocks.terminals.push(terminal) + webhookCoordinator.init(context) + const makePanel = () => ({ + context, + newIssueTerminals: new Map(), + terminals: new Map(), + terminalOrigin: new Map(), + noteIssueOwnership: vi.fn(), + postMessage: vi.fn(), + reloadAfterInvalidation: vi.fn(), + withLiveTerminalTabState(this: KanbanWebviewPanel, issues: Issue[]) { + return withLiveTerminalTabState(this, issues) + }, + } as unknown as KanbanWebviewPanel) + const panel = makePanel() + webhookCoordinator.setActivePanel(panel) + const register = () => { + webhookCoordinator.registerIssueCreation(NONCE, { sessionId: SID, remote: REMOTE, color: 'terminal.ansiBlue', workspaceRoot: '/repo', inboxDir: '/inbox', brainstormProfilePath: '/profiles/brainstorm.json' }) + webhookCoordinator.attachIssueCreationTerminal(NONCE, terminal) + } + return { context, disk, update, terminal, panel, makePanel, register } +} + +describe('edited issue keeps live terminal state', () => { + it.each([ + ['brainstorm', '规划', 'brainstormTabOpen'], + ['implement', '实施', 'implementTabOpen'], + ['review', '审查', 'reviewTabOpen'], + ['test', '测试', 'testTabOpen'], + ['prototype', '原型', 'prototypeTabOpen'], + ] as const)('preserves the %s session flag when replacing the issue card', async (kind, label, field) => { + const f = fixture() + let cards: Issue[] = [] + vi.mocked(f.panel.postMessage).mockImplementation((message) => { + if (message.type === 'issue/append' || message.type === 'issue/patch' || message.type === 'issue/remove') + cards = applyIssueUpdate(cards, message) + }) + f.register() + await dispatch(event()) + const terminal = kind === 'brainstorm' + ? f.terminal + : { name: `issue-483-${label}`, exitStatus: undefined } as Terminal + if (kind !== 'brainstorm') + mocks.terminals.push(terminal) + trackSessionTerminal(f.panel, terminal, 483, kind) + expect(cards[0][field]).toBe(true) + vi.mocked(f.panel.postMessage).mockClear() + await dispatch({ ...event('edited'), title: 'updated title' }) + expect(mocks.terminals).toContain(terminal) + expect(cards[0][field]).toBe(true) + expect(f.panel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'issue/append', + issue: expect.objectContaining({ number: 483, sessionId: SID, [field]: true }), + })) + }) +}) + +beforeEach(() => { + vi.clearAllMocks() + mocks.terminals.splice(0) + mocks.token.mockReset().mockResolvedValue('token') + mocks.load.mockReset().mockResolvedValue({ number: 483, source: 'gitea', title: 'issue' }) + mocks.getIssue.mockReset().mockResolvedValue({ state: 'open', assignees: [{ login: 'me' }] }) + mocks.mergeShared.mockReset().mockResolvedValue(undefined) + mocks.readShared.mockReset().mockResolvedValue({}) + mocks.rm.mockReset().mockResolvedValue(undefined) +}) +afterEach(async () => { + await webhookCoordinator.dispose() +}) + +describe('issue creation reconciliation', () => { + it('only assigned is enough to save and display the original session', async () => { + const f = fixture() + f.register() + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483)).toMatchObject({ sessionId: SID, brainstormProfilePath: '/profiles/brainstorm.json' }) + expect(f.panel.newIssueTerminals.get(483)).toBe(f.terminal) + expect(f.panel.terminals.get(SID)).toBe(f.terminal) + expect(f.panel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'issue/append', issue: expect.objectContaining({ sessionId: SID }) })) + expect(mocks.mergeShared).toHaveBeenCalledWith(expect.objectContaining({ defaults: { column: 'todo', color: 'terminal.ansiBlue' }, extra: {} })) + }) + + it.each(['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'])('reconciles before handling %s', async (action) => { + const f = fixture() + f.register() + await dispatch(event(action)) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + expect(f.panel.terminals.get(SID)).toBe(f.terminal) + }) + + it.each([['assigned', 'opened'], ['opened', 'assigned']])('initializes once for %s followed by %s and duplicates', async (first, second) => { + const f = fixture() + f.register() + await dispatch(event(first)) + await dispatch(event(second)) + await dispatch(event(first)) + expect(f.update.mock.calls.filter(([key]) => key === 'superpowers.localIssueState')).toHaveLength(1) + expect(mocks.mergeShared).toHaveBeenCalledOnce() + expect(mocks.rm).toHaveBeenCalledOnce() + }) + + it('concurrent notifications share one write and wait for it before loading', async () => { + const f = fixture() + f.register() + const saving = deferred() + const entered = deferred() + const persist = f.update.getMockImplementation()! + f.update.mockImplementation(async (key, value) => { + if (key === 'superpowers.localIssueState') { + entered.resolve() + await saving.promise + } + await persist(key, value) + }) + const first = dispatch(event('assigned')) + await entered.promise + const second = dispatch(event('opened')) + expect(mocks.load).not.toHaveBeenCalled() + saving.resolve() + await Promise.all([first, second]) + expect(f.update.mock.calls.filter(([key]) => key === 'superpowers.localIssueState')).toHaveLength(1) + expect(mocks.mergeShared).toHaveBeenCalledOnce() + }) + + it.each(['https://other/owner/repo', 'https://host/other/repo', 'https://host/owner/other'])('does not bind a matching nonce from another repository: %s', async (repository) => { + const f = fixture() + f.register() + await dispatch(event('assigned', 483, repository)) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + expect(mocks.mergeShared).not.toHaveBeenCalled() + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + }) + + it('ignores unknown, partial and ambiguous markers', async () => { + const f = fixture() + f.register() + for (const body of ['', '', '', `${event().body}\n${event().body}`]) + await dispatch({ ...event(), body }) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + expect(mocks.mergeShared).not.toHaveBeenCalled() + }) + + it('rejects reusing one nonce for a different issue during and after linking', async () => { + const f = fixture() + f.register() + const entered = deferred() + const saving = deferred() + const persist = f.update.getMockImplementation()! + f.update.mockImplementation(async (key, value) => { + if (key === 'superpowers.localIssueState') { + entered.resolve() + await saving.promise + } + await persist(key, value) + }) + const first = dispatch(event()) + await entered.promise + await dispatch(event('assigned', 484)) + saving.resolve() + await first + await dispatch(event('assigned', 484)) + expect(getLocalIssueState(f.context, 'gitea', 484)).toEqual({}) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + }) + + it('retains pending metadata on local save failure and retries on the next event', async () => { + const f = fixture() + f.register() + f.update.mockRejectedValueOnce(new Error('local disk unavailable')) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + expect(mocks.mergeShared).not.toHaveBeenCalled() + expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ level: 'error', details: expect.stringContaining('local disk unavailable') })) + await dispatch(event('edited')) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + }) + + it.each(['shared', 'cleanup'])('%s failure cannot undo local success or repeat initialization', async (failure) => { + const f = fixture() + f.register() + if (failure === 'shared') + mocks.mergeShared.mockRejectedValueOnce(new Error('remote unavailable')) + else + mocks.rm.mockRejectedValueOnce(new Error('cleanup unavailable')) + await dispatch(event()) + await dispatch(event('opened')) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + expect(f.update.mock.calls.filter(([key]) => key === 'superpowers.localIssueState')).toHaveLength(1) + expect(mocks.mergeShared).toHaveBeenCalledOnce() + }) + + it('derives the missing default column from the current remote issue state', async () => { + const f = fixture() + f.register() + mocks.getIssue.mockResolvedValue({ state: 'closed' }) + await dispatch(event('assigned')) + expect(mocks.mergeShared).toHaveBeenCalledWith(expect.objectContaining({ defaults: { column: 'done', color: 'terminal.ansiBlue' }, extra: {} })) + }) + + it('saves while the board is closed and restores terminal links when reopened', async () => { + const f = fixture() + f.register() + webhookCoordinator.setActivePanel(undefined) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + const reopened = f.makePanel() + webhookCoordinator.setActivePanel(reopened) + expect(reopened.terminals.get(SID)).toBe(f.terminal) + expect(overlayLocalIssueState(f.context, [{ number: 483, source: 'gitea' } as never], '/repo')[0]).toMatchObject({ sessionId: SID }) + }) + + it('does not deliver an old event to the board opened during its save', async () => { + const f = fixture() + f.register() + const saving = deferred() + const entered = deferred() + const persist = f.update.getMockImplementation()! + f.update.mockImplementation(async (key, value) => { + if (key === 'superpowers.localIssueState') { + entered.resolve() + await saving.promise + } + await persist(key, value) + }) + const handling = dispatch(event()) + await entered.promise + webhookCoordinator.setActivePanel(undefined) + const reopened = f.makePanel() + webhookCoordinator.setActivePanel(reopened) + saving.resolve() + await handling + expect(f.panel.postMessage).not.toHaveBeenCalled() + expect(reopened.postMessage).not.toHaveBeenCalled() + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + expect(reopened.reloadAfterInvalidation).toHaveBeenCalled() + await dispatch(event()) + expect(reopened.terminals.get(SID)).toBe(f.terminal) + }) + + it('keeps the session identity without resurrecting a closed terminal', async () => { + const f = fixture() + f.register() + mocks.terminals.splice(0) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + expect(f.panel.terminals.size).toBe(0) + const reopened = f.makePanel() + webhookCoordinator.setActivePanel(reopened) + expect(reopened.terminals.size).toBe(0) + }) + + it('cancelled launches never associate and extension disposal forgets registrations', async () => { + const f = fixture() + f.register() + webhookCoordinator.cancelIssueCreation(NONCE) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + f.register() + await webhookCoordinator.dispose() + webhookCoordinator.init(f.context) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + }) +}) + +describe('issue creation lifecycle boundaries', () => { + it('does not recreate the old terminal association after the user clears the session', async () => { + const f = fixture() + f.register() + await dispatch(event()) + await f.update('superpowers.localIssueState', { 'gitea:483': { sessionId: '' } }) + const reopened = f.makePanel() + webhookCoordinator.setActivePanel(reopened) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe('') + expect(reopened.terminals.size).toBe(0) + }) + + it('a current board load can restore links that completed during a board replacement', async () => { + const f = fixture() + f.register() + webhookCoordinator.setActivePanel(undefined) + await dispatch(event()) + const reopened = f.makePanel() + webhookCoordinator.setActivePanel(reopened) + reopened.terminals.clear() + reopened.newIssueTerminals.clear() + webhookCoordinator.restoreIssueCreationTerminals(f.panel) + expect(reopened.terminals.size).toBe(0) + webhookCoordinator.restoreIssueCreationTerminals(reopened) + expect(reopened.terminals.get(SID)).toBe(f.terminal) + }) + + it('unknown actions and absent repository identity cannot claim pending creation', async () => { + const f = fixture() + f.register() + await dispatch(event('label_updated')) + await dispatch({ ...event(), raw: { issue: {} } }) + expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({}) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + }) + + it('a terminal with an exit status is not restored even while its tab remains visible', async () => { + const f = fixture() + f.register() + Object.assign(f.terminal, { exitStatus: { code: 1 } }) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + expect(f.panel.terminals.size).toBe(0) + }) + + it('keeps the claimed target after a failed local save', async () => { + const f = fixture() + f.register() + f.update.mockRejectedValueOnce(new Error('local unavailable')) + await dispatch(event()) + await dispatch(event('assigned', 484)) + expect(getLocalIssueState(f.context, 'gitea', 484)).toEqual({}) + await dispatch(event()) + expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID) + }) +}) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 3c246db..b13b82f 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -9,7 +9,8 @@ * callbacks while it's open. */ -import type { ExtensionContext } from 'vscode' +import type { ExtensionContext, Terminal } from 'vscode' +import type { Remote } from '../git/remote' import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanScope } from '../panel/kanbanScope' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' @@ -20,7 +21,7 @@ import { env, Uri, window, workspace } from 'vscode' import { loginForToken } from '../auth/identity' import { getToken } from '../auth/secrets' import { renderReviewPrompt } from '../cc/prompts' -import { detectRepo } from '../git/remote' +import { detectRepo, parseRemoteUrl } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api' import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' @@ -30,7 +31,7 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from import { logger } from '../logging/logger' import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest' import * as issueChat from '../panel/handlers/issueChat' -import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' +import { hasLiveIssueSessionTerminal, linkIssueCreationTerminal } from '../panel/handlers/terminals' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { getKanbanState } from '../panel/kanbanState' import { annotateMissingSessionIds } from '../sessions/sessionFiles' @@ -54,6 +55,48 @@ export interface PendingHook { feature: string } +export interface PendingIssueCreation { + sessionId: string + remote: Remote + workspaceRoot: string + inboxDir: string + color: string + brainstormProfilePath?: string +} + +interface LinkedIssueCreation { + sessionId: string + remote: Remote + workspaceRoot: string + issueNumber: number +} + +interface IssueCreationEntry { + pending?: PendingIssueCreation + linked?: LinkedIssueCreation + targetIssueNumber?: number + terminal?: Terminal + operation?: Promise +} + +const ISSUE_ACTIONS = ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'] + +function issueCreationNonce(body: string): string | undefined { + const matches = [...body.matchAll(//gi)] + return matches.length === 1 ? matches[0][1] : undefined +} + +function issueRepository(event: IssueWebhookEvent): Remote | undefined { + const raw = event.raw as { repository?: { html_url?: unknown } } | null + return typeof raw?.repository?.html_url === 'string' ? parseRemoteUrl(raw.repository.html_url) : undefined +} + +function sameRepository(left: Remote, right: Remote): boolean { + return left.host.toLowerCase() === right.host.toLowerCase() + && left.owner.toLowerCase() === right.owner.toLowerCase() + && left.repo.toLowerCase() === right.repo.toLowerCase() +} + /** * issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步 * 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。 @@ -107,7 +150,7 @@ function changesKanban(event: WebhookEvent): boolean { case 'push': return false case 'issue': - return ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'].includes(event.action) + return ISSUE_ACTIONS.includes(event.action) case 'issue_comment': return ['created', 'edited', 'deleted'].includes(event.action) && event.prNumber === undefined && hasStateColumn(event.commentBody) @@ -119,6 +162,7 @@ function changesKanban(event: WebhookEvent): boolean { class WebhookCoordinator { private ctx?: ExtensionContext + private readonly issueCreations = new Map() private server?: WebhookServer // kept for legacy cleanup; new flow uses one shared webhook private pendingHooks = new Map() @@ -210,6 +254,7 @@ class WebhookCoordinator { } } this.livePanel = undefined + this.issueCreations.clear() this.initialized = false logger.add({ level: 'info', @@ -269,11 +314,114 @@ class WebhookCoordinator { return new Map(this.pendingHooks) } - /** Webview panel binding for refresh + toast. Pass undefined when closing. */ + /** 保存建单关联信息,避免关闭看板时丢失会话归属。 */ + registerIssueCreation(nonce: string, pending: PendingIssueCreation): void { + if (this.issueCreations.has(nonce)) + throw new Error(`新建工单标记已注册: ${nonce}`) + this.issueCreations.set(nonce, { pending }) + } + + attachIssueCreationTerminal(nonce: string, terminal: Terminal): void { + const entry = this.issueCreations.get(nonce) + if (!entry) + throw new Error(`新建工单标记未注册: ${nonce}`) + entry.terminal = terminal + } + + cancelIssueCreation(nonce: string): void { + const entry = this.issueCreations.get(nonce) + if (entry && !entry.operation && !entry.linked) + this.issueCreations.delete(nonce) + } + + restoreIssueCreationTerminals(panel: KanbanWebviewPanel): void { + if (this.livePanel !== panel || !this.ctx) + return + for (const entry of this.issueCreations.values()) { + const linked = entry.linked + if (linked && getLocalIssueState(this.ctx, 'gitea', linked.issueNumber).sessionId === linked.sessionId) + linkIssueCreationTerminal(panel, { sessionId: linked.sessionId, terminal: entry.terminal }, linked.issueNumber) + } + } + + private async reconcileIssueCreation(event: IssueWebhookEvent): Promise { + const nonce = issueCreationNonce(event.body) + if (!nonce) + return undefined + const entry = this.issueCreations.get(nonce) + const record = entry?.pending ?? entry?.linked + const remote = issueRepository(event) + if (!entry || !record || !remote || !sameRepository(record.remote, remote)) + return undefined + if (entry.targetIssueNumber !== undefined && entry.targetIssueNumber !== event.issueNumber) { + logger.add({ level: 'error', source: 'webhook', message: '新建工单标记指向不同工单,拒绝关联', details: `nonce=${nonce} original=#${entry.targetIssueNumber} received=#${event.issueNumber}` }) + return undefined + } + if (entry.operation) + return entry.operation + if (entry.linked) + return entry.linked + entry.targetIssueNumber = event.issueNumber + entry.operation = this.completeIssueCreation(nonce, entry, event.issueNumber).finally(() => { + entry.operation = undefined + }) + return entry.operation + } + + private async completeIssueCreation(nonce: string, entry: IssueCreationEntry, issueNumber: number): Promise { + const pending = entry.pending + const ctx = this.ctx + if (!pending || !ctx) + return undefined + try { + await mergeLocalIssueState(ctx, 'gitea', issueNumber, { + sessionId: pending.sessionId, + ...(pending.brainstormProfilePath ? { brainstormProfilePath: pending.brainstormProfilePath } : {}), + }) + } + catch (err) { + logger.add({ level: 'error', source: 'webhook', message: `工单 #${issueNumber} 会话关联保存失败,保留待关联记录`, details: String(err) }) + return undefined + } + + const linked: LinkedIssueCreation = { sessionId: pending.sessionId, remote: pending.remote, workspaceRoot: pending.workspaceRoot, issueNumber } + entry.linked = linked + entry.pending = undefined + logger.add({ level: 'info', source: 'webhook', message: `已关联工单 #${issueNumber} 与新建会话`, details: `nonce=${nonce} sessionId=${linked.sessionId}` }) + + try { + const token = await getToken(ctx, pending.remote.host) + if (!token) + throw new Error('未配置 Gitea token') + const issue = await getIssue({ ...pending.remote, token, index: issueNumber }) + if (!issue) + throw new Error('工单不存在') + await mergeStateJsonComment({ + ...pending.remote, + token, + issueNumber, + defaults: { column: issue.state === 'closed' ? 'done' : 'todo', color: pending.color }, + extra: {}, + }) + } + catch (err) { + logger.add({ level: 'warn', source: 'webhook', message: `工单 #${issueNumber} 默认状态保存失败,本地会话关联已保留`, details: String(err) }) + } + try { + await fsp.rm(pending.inboxDir, { recursive: true, force: true }) + } + catch (err) { + logger.add({ level: 'warn', source: 'webhook', message: '新建工单临时目录清理失败', details: String(err) }) + } + return linked + } + setActivePanel(p: KanbanWebviewPanel | undefined): void { if (this.livePanel && this.livePanel !== p && this.boardEventsInFlight > 0 && this.ctx) void getKanbanState(this.ctx.workspaceState).invalidate() this.livePanel = p + if (p) + this.restoreIssueCreationTerminals(p) } private get activePanel(): KanbanWebviewPanel | undefined { @@ -502,19 +650,20 @@ class WebhookCoordinator { source: 'webhook', message: `收到 issue 事件 action=${event.action} issue=#${event.issueNumber}`, }) + const creation = ISSUE_ACTIONS.includes(event.action) ? await this.reconcileIssueCreation(event) : undefined if (event.action === 'opened' || event.action === 'reopened') { - await this.handleIssueOpened(event) + await this.handleIssueOpened(event, creation) } else if (event.action === 'edited') { await this.handleIssueEdited(event) + if (creation) + await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '编辑', creation }) } - else if (event.action === 'closed' || event.action === 'reopened') { - await this.handleIssueStateChanged(event) + else if (event.action === 'closed') { + await this.handleIssueStateChanged(event, creation) } else if (event.action === 'assigned' || event.action === 'unassigned') { - // Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠 - // action 区分,不会走到独立的事件分支。 - await this.handleIssueAssigned(event) + await this.handleIssueAssigned(event, creation) } else { logger.add({ @@ -585,198 +734,18 @@ class WebhookCoordinator { } } - /** - * Handles a freshly opened gitea issue. Two paths: - * - The issue body carries `` and matches a - * pending creation tracked by the panel → merge column / sessionId / - * brainstormProfilePath / color into the state-JSON comment, append the card - * incrementally, then clean up the inbox tmpdir. - * - No nonce / no match (external creation, e.g. manual `tea issues - * create`) → just append the card so the kanban stays in sync; do - * NOT touch the state-JSON comment. - */ - private async handleIssueOpened(event: IssueWebhookEvent): Promise { - if (!this.ctx) - return - - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'issue 事件忽略:未打开工作区', + /** 刷新新建工单卡片,并为本机创建的工单显示成功提示。 */ + private async handleIssueOpened(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { + await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: '创建', creation, select: creation ? true : undefined }) + if (creation) { + this.activePanel?.postMessage({ + type: 'toast/show', + id: `issue-created-${event.issueNumber}`, + level: 'success', + message: `#${event.issueNumber} 已创建`, + link: { label: '查看', url: event.htmlUrl }, + dismissOnTimer: 8000, }) - return - } - const remote = await detectRepo(ws) - if (!remote) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'issue 事件忽略:工作区未关联 gitea', - }) - return - } - const token = await getToken(this.ctx, remote.host) - if (!token) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'issue 事件忽略:未配置 token', - }) - return - } - - const nonceMatch = event.body.match(//i) - const nonce = nonceMatch ? nonceMatch[1] : null - // Promote the in-flight brainstorm terminal into the panel's - // issueNumber-keyed map BEFORE we drain the pending entry. The terminal - // tab was created with name `issue-new-{nonce}-规划` (we didn't know - // issueNumber yet) and VS Code can't rename it, so this side-map is what - // lets card↔terminal selection round-trip after the webhook arrives. - if (nonce && this.activePanel) - this.activePanel.linkPendingTerminalToIssue(nonce, event.issueNumber) - const pending = nonce && this.activePanel - ? this.activePanel.takePendingIssueCreation(nonce) - : undefined - - if (pending) { - logger.add({ - level: 'info', - source: 'webhook', - message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`, - details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`, - }) - // 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写 - // column / color 这类团队可见字段。 - const localPatch: Record = {} - if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0) - localPatch.sessionId = pending.sessionId - if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0) - localPatch.brainstormProfilePath = pending.brainstormProfilePath - if (Object.keys(localPatch).length > 0) - await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch) - - try { - await mergeStateJsonComment({ - host: remote.host, - owner: remote.owner, - repo: remote.repo, - token, - issueNumber: event.issueNumber, - extra: { - column: 'todo', - color: pending.color, - }, - }) - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - logger.add({ - level: 'warn', - source: 'webhook', - message: 'mergeStateJsonComment 失败(继续 append)', - details: msg, - }) - } - - // Best-effort cleanup of the inbox tmpdir. - try { - await fsp.rm(pending.inboxDir, { recursive: true, force: true }) - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - logger.add({ - level: 'warn', - source: 'webhook', - message: 'inbox 清理失败', - details: msg, - }) - } - } - else if (nonce) { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue body 含 nonce=${nonce} 但未匹配到 pending → 当作外部创建`, - }) - } - else { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue body 无 spx:nonce → 当作外部创建`, - }) - } - - if (this.activePanel) { - // 看板 'mine' 范围的批量加载只拉"我创建/指派给我"的工单,而 webhook - // 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡 - // 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时 - // 才解析,省一次身份请求。 - const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE - const pendingCreatedLocally = pending !== undefined - let poster: string | undefined - let assignees: string[] = [] - let me: string | undefined - if (!pendingCreatedLocally && scope === 'mine') { - const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null - poster = raw?.issue?.user?.login - assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) - me = await loginForToken(remote.host, token) - } - if (!decideIssueAppend({ scope, pendingCreatedLocally, poster, assignees, me })) { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue #${event.issueNumber} 与我无关,scope=mine 过滤跳过 append`, - details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`, - }) - return - } - try { - const loaded = await loadSingleIssue({ - host: remote.host, - owner: remote.owner, - repo: remote.repo, - token, - workspaceRoot: ws, - issueNumber: event.issueNumber, - }) - const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null - const panel = this.activePanel - if (issue && panel) { - panel.noteIssueOwnership(issue) - panel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) - if (pending) { - panel.postMessage({ - type: 'toast/show', - id: `issue-created-${event.issueNumber}`, - level: 'success', - message: `#${event.issueNumber} 已创建`, - link: { label: '查看', url: event.htmlUrl }, - dismissOnTimer: 8000, - }) - // L462 的 linkPendingTerminalToIssue 也会推 brainstormTabOpen, - // 但那时 #N 还不在前端 issues 列表里、patch 会被丢弃; - // issue/append 之后再补发一次,让 X 关闭按钮立即出现。 - panel.postMessage({ - type: 'issue/patch', - issueNumber: event.issueNumber, - patch: { brainstormTabOpen: true }, - }) - } - } - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - logger.add({ - level: 'warn', - source: 'webhook', - message: 'loadSingleIssue 失败', - details: msg, - }) - } } } @@ -792,8 +761,10 @@ class WebhookCoordinator { raw: unknown /** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */ reason: string + creation?: LinkedIssueCreation + select?: boolean }): Promise { - const { issueNumber, raw, reason } = opts + const { issueNumber, raw, reason, creation } = opts if (!this.ctx) return // 没面板就没什么可更新,下次打开看板的批量加载会兜底。 @@ -835,13 +806,13 @@ class WebhookCoordinator { let poster: string | undefined let assignees: string[] = [] let me: string | undefined - if (scope === 'mine') { + if (scope === 'mine' && !creation) { const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null poster = payload?.issue?.user?.login assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) me = await loginForToken(remote.host, token) } - if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) { + if (!decideIssueAppend({ scope, pendingCreatedLocally: creation !== undefined, poster, assignees, me })) { logger.add({ level: 'info', source: 'webhook', @@ -867,7 +838,9 @@ class WebhookCoordinator { const panel = this.activePanel if (issue && panel) { panel.noteIssueOwnership(issue) - panel.postMessage({ type: 'issue/append', issue }) + panel.postMessage({ type: 'issue/append', issue: panel.withLiveTerminalTabState([issue])[0], ...(opts.select ? { select: true } : {}) }) + if (creation) + this.restoreIssueCreationTerminals(panel) } } catch (err) { @@ -885,11 +858,12 @@ class WebhookCoordinator { * issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板 * 要实时上卡/撤卡,不能等下次手动刷新。 */ - private async handleIssueAssigned(event: IssueWebhookEvent): Promise { + private async handleIssueAssigned(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: event.action === 'unassigned' ? '取消指派' : '指派', + creation, }) } @@ -897,11 +871,12 @@ class WebhookCoordinator { * issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着 * 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。 */ - private async handleIssueStateChanged(event: IssueWebhookEvent): Promise { + private async handleIssueStateChanged(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise { await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw, reason: event.action === 'closed' ? '工单关闭' : '工单重开', + creation, }) } diff --git a/vscode/src/webhook/eventParser.test.ts b/vscode/src/webhook/eventParser.test.ts index 1db1035..041069e 100644 --- a/vscode/src/webhook/eventParser.test.ts +++ b/vscode/src/webhook/eventParser.test.ts @@ -65,3 +65,11 @@ describe('parseEvent', () => { }) }) }) + +describe('issue creation correlation payload', () => { + it.each(['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'])('preserves the exact creation marker and repository for %s', (action) => { + const body = '' + const raw = { action, issue: { number: 483, body, title: 'created', html_url: 'https://host/owner/repo/issues/483' }, repository: { html_url: 'https://host/owner/repo' } } + expect(parseEvent('issues', undefined, raw)).toEqual({ kind: 'issue', action, issueNumber: 483, body, title: 'created', htmlUrl: raw.issue.html_url, raw }) + }) +}) diff --git a/vscode/test/cc/ccCommand.test.ts b/vscode/test/cc/ccCommand.test.ts index 306d953..1dc86e5 100644 --- a/vscode/test/cc/ccCommand.test.ts +++ b/vscode/test/cc/ccCommand.test.ts @@ -36,3 +36,17 @@ describe('buildCcArgv', () => { ]) }) }) + +describe('new session identity', () => { + const sessionId = '5a749776-d1c8-4cf0-8f85-3b8d7b804498' + + it('passes the allocated identity before the prompt', () => { + expect(buildCcArgv({ profilePath: '/p.json', sessionId, prompt: 'create issue' })) + .toEqual([...BASE, '--session-id', sessionId, 'create issue']) + }) + + it('rejects a new identity combined with resuming another session', () => { + expect(() => buildCcArgv({ profilePath: '/p.json', sessionId, resumeSessionId: 'existing' })) + .toThrow('sessionId') + }) +}) diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 02bbf41..49a52f7 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -216,6 +216,43 @@ describe('state JSON 尾部普通评论不丢状态', () => { }) }) +describe('state defaults', () => { + beforeEach(() => { + listIssueComments.mockReset() + postIssueComment.mockReset() + }) + + it.each([ + [{}, { column: 'todo', color: 'blue', pr: '5' }], + [{ column: 'review', color: 'red' }, { column: 'review', color: 'red', pr: '5' }], + ])('fills missing defaults without replacing existing values: %j', async (existing, expected) => { + const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const comments = [{ body: JSON.stringify(existing) }] + listIssueComments.mockImplementation(async () => [...comments]) + postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) + await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { pr: '5' } }) + expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual(expected) + expect(postIssueComment).toHaveBeenCalledOnce() + }) + + it('explicit changes override defaults and existing state', async () => { + const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const comments = [{ body: JSON.stringify({ column: 'review' }) }] + listIssueComments.mockImplementation(async () => [...comments]) + postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) + await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { column: 'done' } }) + expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'done', color: 'blue' }) + }) + + it('read-back does not force defaults over a concurrent update', async () => { + const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + listIssueComments.mockResolvedValueOnce([]).mockResolvedValue([{ body: JSON.stringify({ column: 'review', color: 'red' }) }]) + await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: {} }) + expect(postIssueComment).toHaveBeenCalledOnce() + expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'todo', color: 'blue' }) + }) +}) + describe('spawnClaude', () => { beforeEach(() => { execFile.mockReset()