🐛 fix(vscode): 修复新建工单后会话关联丢失
This commit is contained in:
1 parent
172e6ba77d
commit
9345b6a16e
12 files changed
+836
-435
No files matched your search
@@ -18,6 +18,7 @@ export interface CcCommandOpts {
|
||||
effort?: string
|
||||
/** When set, append `--resume <id>` 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
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface MergeStateJsonCommentOpts {
|
||||
token: string
|
||||
issueNumber: number
|
||||
extra: Record<string, unknown>
|
||||
defaults?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +153,7 @@ async function postMergedStateJsonComment(
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const merged: Record<string, unknown> = {
|
||||
...opts.defaults,
|
||||
...currentState,
|
||||
...extra,
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -117,45 +117,7 @@ export class KanbanWebviewPanel {
|
||||
// internal: handler 模块访问
|
||||
readonly managedTerminals = new Map<string, Terminal>()
|
||||
|
||||
/**
|
||||
* 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<string, {
|
||||
sessionId?: string
|
||||
brainstormProfilePath?: string
|
||||
/** Palette id (e.g. `terminal.ansiBlue`) — same shape stored in state JSON. */
|
||||
color: string
|
||||
workspaceRoot: string
|
||||
inboxDir: string
|
||||
terminalName: string
|
||||
/**
|
||||
* The brainstorm terminal created synchronously in `handleIssueCreate`.
|
||||
* Stored here so `linkPendingTerminalToIssue` can promote it into
|
||||
* `newIssueTerminals` once the webhook tells us the issueNumber.
|
||||
*/
|
||||
terminal: Terminal
|
||||
createdAt: number
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 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<number, Terminal>()
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import('node:fs')>()),
|
||||
promises: { ...(await importOriginal<typeof import('node:fs')>()).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()
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
@@ -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<typeof window.createTerminal> | 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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<typeof import('node:fs')>()),
|
||||
promises: { ...(await importOriginal<typeof import('node:fs')>()).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<typeof import('../git/remote')>()),
|
||||
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<typeof import('../gitea/stateJson')>()).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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((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<!-- spx:nonce=${nonce} -->`, 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<void> }).handleEvent(value)
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const disk = new Map<string, unknown>()
|
||||
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<void>()
|
||||
const entered = deferred<void>()
|
||||
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 ['', '<!-- spx:nonce=f961e23b -->', '<!-- spx:nonce=11111111-1111-4111-8111-111111111111 -->', `${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<void>()
|
||||
const saving = deferred<void>()
|
||||
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<void>()
|
||||
const entered = deferred<void>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
+183
-208
@@ -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<LinkedIssueCreation | undefined>
|
||||
}
|
||||
|
||||
const ISSUE_ACTIONS = ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned']
|
||||
|
||||
function issueCreationNonce(body: string): string | undefined {
|
||||
const matches = [...body.matchAll(/<!--\s*spx:nonce=([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\s*-->/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<string, IssueCreationEntry>()
|
||||
private server?: WebhookServer
|
||||
// kept for legacy cleanup; new flow uses one shared webhook
|
||||
private pendingHooks = new Map<number, PendingHook>()
|
||||
@@ -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<LinkedIssueCreation | undefined> {
|
||||
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<LinkedIssueCreation | undefined> {
|
||||
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 `<!-- spx:nonce=... -->` 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<void> {
|
||||
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<void> {
|
||||
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(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/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<string, unknown> = {}
|
||||
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<KanbanScope>(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<void> {
|
||||
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<void> {
|
||||
private async handleIssueAssigned(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise<void> {
|
||||
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<void> {
|
||||
private async handleIssueStateChanged(event: IssueWebhookEvent, creation?: LinkedIssueCreation): Promise<void> {
|
||||
await this.refreshIssueCard({
|
||||
issueNumber: event.issueNumber,
|
||||
raw: event.raw,
|
||||
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
|
||||
creation,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '<!-- spx:nonce=f961e23b-f841-48f5-869e-2f59473b53d4 -->'
|
||||
const raw = { action, issue: { number: 483, body, title: 'created', html_url: 'https://host/owner/repo/issues/483' }, repository: { html_url: 'https://host/owner/repo' } }
|
||||
expect(parseEvent('issues', undefined, raw)).toEqual({ kind: 'issue', action, issueNumber: 483, body, title: 'created', htmlUrl: raw.issue.html_url, raw })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user