Compare commits

...
4 Commits
35 changed files with 2581 additions and 591 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
"version": "0.2.253",
"version": "0.2.255",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
+5
View File
@@ -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
@@ -0,0 +1,67 @@
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import { window } from 'vscode'
import { askCommentBody } from '../qa/claudeAsk'
import { PreviewEditorProvider } from './previewEditor'
vi.mock('vscode', () => ({
commands: { executeCommand: vi.fn() },
window: { showErrorMessage: vi.fn() },
workspace: {
onDidChangeTextDocument: () => ({ dispose: () => {} }),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
},
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
vi.mock('../folderStore', () => ({ locate: () => undefined }))
vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined }))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message }))
function preview(current: () => Promise<never>) {
const subscribe = () => ({ dispose: () => {} })
const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise<void>) => { dispose: () => void }>(subscribe)
const post = vi.fn<(message: ExtensionToWebview) => void>()
const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' }
const provider = new PreviewEditorProvider(...[
{},
{},
{},
{ subscribe },
async () => [],
{ imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } },
async () => [],
async () => [],
{ onDidChangeDiffBase: subscribe },
{ onDidChange: subscribe },
async () => false,
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
provider.resolveCustomTextEditor(...[
{ uri, isDirty: false },
{ webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe },
] as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>)
const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value!
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() }
}
describe('重写注释的模型选择失败', () => {
it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => {
const error = '文件问答模型不可用:official。请重新选择。'
const current = vi.fn<() => Promise<never>>().mockRejectedValue(new Error(error))
const editor = preview(current)
try {
await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined()
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error })
expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`)
expect(askCommentBody).not.toHaveBeenCalled()
await editor.send({ type: 'rewriteComment', anchor: 'block' })
expect(current).toHaveBeenCalledTimes(2)
expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2)
}
finally {
editor.dispose()
}
})
})
+15 -2
View File
@@ -1,4 +1,5 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
@@ -547,7 +548,13 @@ class Preview {
await this.ask(msg.askId, msg.anchor, msg.question, msg.images)
return
case 'selectAskProfile':
await this.owner.qa.profiles.select(msg.id)
try {
await this.owner.qa.profiles.select(msg.id)
}
catch (err) {
void window.showErrorMessage(err instanceof Error ? err.message : String(err))
await this.postProfiles()
}
return
case 'stopAsk':
this.owner.qa.stop(msg.askId)
@@ -968,7 +975,13 @@ class Preview {
return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
const at = locate(document.uri)
const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath)
const profile = await this.owner.qa.profiles.current()
let profile: ClaudeProfile | undefined
try {
profile = await this.owner.qa.profiles.current()
}
catch (err) {
return failed(err instanceof Error ? err.message : String(err))
}
if (signal.aborted)
return { status: 'stopped' }
let question = ''
+1 -1
View File
@@ -86,7 +86,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
return runtime.parse(adapter, languageId, source)
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
const qa = new CodeQa(context.globalState, context.workspaceState)
const qa = new CodeQa(context, context.workspaceState)
const namesIn = async (languageId: string, name: string): Promise<IndexedName[]> => {
const adapter = registry.languageFor(languageId)
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
+120 -30
View File
@@ -1,8 +1,7 @@
import type { Memento } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ProfileChoice, ProfileSource } from './askProfiles'
import { describe, expect, it } from 'vitest'
import { ASK_PROFILE_KEY, AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles'
import { AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles'
import { askArgs } from './claudeAsk'
const DIR = '/home/me/profiles'
@@ -10,19 +9,8 @@ const profile = (name: string): ClaudeProfile => ({ name, path: `${DIR}/${name}.
const PROFILES = [profile('dsflash'), profile('offical'), profile('sonnet')]
const DEFAULT_PATH = `${DIR}/offical.json`
function memento(initial: Record<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
const store = { ...initial }
return {
store,
get: <T>(key: string): T | undefined => store[key] as T | undefined,
update: async (key: string, value: unknown) => {
store[key] = value
},
} as Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> }
}
function source(profiles: ClaudeProfile[] = PROFILES): ProfileSource {
return { list: async () => profiles, defaultPath: () => DEFAULT_PATH }
return { list: async () => profiles, defaultPath: () => DEFAULT_PATH, directory: () => DIR }
}
describe('profile 列表 → 下拉的选项', () => {
@@ -43,27 +31,129 @@ describe('profile 列表 → 下拉的选项', () => {
})
it('一个 profile 都没有:只有「默认」', () => {
expect(profileChoice([], DEFAULT_PATH, 'sonnet')).toEqual({ options: [DEFAULT_OPTION], selected: '' })
expect(profileChoice([], DEFAULT_PATH, undefined)).toEqual({ options: [DEFAULT_OPTION], selected: '' })
})
it('选过的还在就选它;不在了(删了、目录换了)、记的是「默认」但已经有默认 profile,都回到默认那项', () => {
expect(profileChoice(PROFILES, DEFAULT_PATH, 'sonnet').selected).toBe('sonnet')
expect(profileChoice(PROFILES, DEFAULT_PATH, 'gone').selected).toBe('offical')
expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('offical')
expect(profileChoice(PROFILES, DEFAULT_PATH, 42).selected).toBe('offical')
it('临时选项保留;失效选项明确标记,不偷偷退回默认', () => {
expect(profileChoice(PROFILES, DEFAULT_PATH, `${DIR}/sonnet.json`).selected).toBe('sonnet')
const gone = `${DIR}/gone.json`
expect(profileChoice(PROFILES, DEFAULT_PATH, gone)).toMatchObject({ selected: gone, options: expect.arrayContaining([{ id: gone, label: `不可用:${gone}` }]) })
expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('')
expect(profileChoice(PROFILES, DEFAULT_PATH, '').options).toContainEqual(DEFAULT_OPTION)
})
})
describe('记住最近选的 profile', () => {
it('选了记进 globalState,换个预览、重启 VS Code(新的实例)读得回来', async () => {
const state = memento()
await new AskProfiles(state, source()).select('sonnet')
expect(state.store[ASK_PROFILE_KEY]).toBe('sonnet')
expect((await new AskProfiles(state, source()).choice()).selected).toBe('sonnet')
describe('文件问答默认模型与临时选择', () => {
it.each(['offical', 'official'])('临时选空默认后,%s.json 增加或删除都保持不带配置文件', async (officialName) => {
let available = [profile('sonnet')]
let defaultPath = DEFAULT_PATH
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, {
...source(),
list: async () => available,
defaultPath: () => defaultPath,
})
await profiles.select('')
for (const added of [true, false, true]) {
available = added ? [profile('sonnet'), profile(officialName)] : [profile('sonnet')]
defaultPath = added ? `${DIR}/${officialName}.json` : DEFAULT_PATH
await profiles.refresh()
const choice = await profiles.choice()
expect(choice.selected).toBe('')
expect(choice.options).toContainEqual(DEFAULT_OPTION)
expect(await profiles.current()).toBeUndefined()
expect(askArgs({ profilePath: (await profiles.current())?.path })).not.toContain('--settings')
}
})
it('真实目录变更才清除显式空选择并恢复设置默认', async () => {
let directory = DIR
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, {
...source([profile('sonnet')]),
directory: () => directory,
})
await profiles.select('')
expect(await profiles.current()).toBeUndefined()
directory = '/new/profiles'
await profiles.refresh()
expect(await profiles.current()).toEqual(profile('sonnet'))
})
it('仅官方文件删除改变自动默认路径时,保留失效的临时选择并阻止发问', async () => {
let available = [profile('dsflash'), profile('official')]
let defaultPath = `${DIR}/official.json`
const profiles = new AskProfiles(() => `${DIR}/dsflash.json`, {
...source(),
list: async () => available,
defaultPath: () => defaultPath,
})
await profiles.select('official')
available = [profile('dsflash')]
defaultPath = `${DIR}/offical.json`
expect((await profiles.choice()).selected).toBe(`${DIR}/official.json`)
await expect(profiles.current()).rejects.toThrow(`文件问答模型不可用:${DIR}/official.json`)
})
it('没有官方配置文件时,可以从配置的默认模型临时切到内置默认', async () => {
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source([profile('sonnet')]))
await profiles.select('')
expect((await profiles.choice()).selected).toBe('')
expect(await profiles.current()).toBeUndefined()
})
it('配置默认实际用于命令行;临时切换不改默认,重新创建服务恢复配置', async () => {
const configured = `${DIR}/dsflash.json`
const profiles = new AskProfiles(() => configured, source())
expect(await profiles.current()).toEqual(profile('dsflash'))
expect(askArgs({ profilePath: (await profiles.current())?.path }).slice(0, 2)).toEqual(['--settings', configured])
await profiles.select('sonnet')
expect((await profiles.choice()).selected).toBe('sonnet')
expect(await profiles.current()).toEqual(profile('sonnet'))
expect(configured).toBe(`${DIR}/dsflash.json`)
expect(await new AskProfiles(() => configured, source()).current()).toEqual(profile('dsflash'))
})
it('配置变更清除临时选择并通知所有预览;相同配置刷新保留临时选择', async () => {
let configured = `${DIR}/dsflash.json`
const profiles = new AskProfiles(() => configured, source())
const updates: string[] = []
profiles.subscribe(choice => updates.push(choice.selected))
await profiles.select('sonnet')
await profiles.refresh()
expect((await profiles.choice()).selected).toBe('sonnet')
configured = DEFAULT_PATH
await profiles.refresh()
expect(await profiles.current()).toEqual(profile('offical'))
expect(updates.at(-1)).toBe('offical')
configured = ''
await profiles.refresh()
expect((await profiles.choice()).selected).toBe('offical')
})
it('配置的模型已删除时保留不可用选项并报错,不会偷偷换成官方模型', async () => {
let available = [...PROFILES]
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, { ...source(), list: async () => available })
expect(await profiles.current()).toEqual(profile('sonnet'))
available = available.filter(item => item.name !== 'sonnet')
const choice = await profiles.choice()
expect(choice.options.find(item => item.id === choice.selected)?.label).toContain('不可用')
await expect(profiles.current()).rejects.toThrow('文件问答模型不可用')
await profiles.select('dsflash')
expect(await profiles.current()).toEqual(profile('dsflash'))
})
it('临时模型被删除和无效选择都会报错,不能静默换模型', async () => {
let available = [...PROFILES]
const profiles = new AskProfiles(() => '', { ...source(), list: async () => available })
await profiles.select('sonnet')
available = available.filter(item => item.name !== 'sonnet')
await expect(profiles.current()).rejects.toThrow('文件问答模型不可用')
await expect(profiles.select('unknown')).rejects.toThrow('文件问答模型不可用')
await profiles.select('offical')
expect(await profiles.current()).toEqual(profile('offical'))
})
it('一个预览选了,所有订阅的预览都收到新的选项和选中的那项;取消订阅后不再收到', async () => {
const profiles = new AskProfiles(memento(), source())
const profiles = new AskProfiles(() => '', source())
const first: ProfileChoice[] = []
const second: string[] = []
profiles.subscribe(choice => first.push(choice))
@@ -77,17 +167,17 @@ describe('记住最近选的 profile', () => {
})
it('下一个问题用选的那个 profile:命令行带它的 --settings;选「默认」时没有、不带 --settings', async () => {
const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source())
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source())
const chosen = await profiles.current()
expect(chosen).toEqual(profile('sonnet'))
expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
const none = await new AskProfiles(memento(), source([])).current()
const none = await new AskProfiles(() => '', source([])).current()
expect(none).toBeUndefined()
expect(askArgs({ profilePath: none?.path })).not.toContain('--settings')
})
it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => {
expect(await new AskProfiles(memento(), source()).current()).toEqual(profile('offical'))
expect(await new AskProfiles(() => '', source()).current()).toEqual(profile('offical'))
})
})
+43 -26
View File
@@ -1,9 +1,6 @@
import type { Disposable, Memento } from 'vscode'
import type { Disposable } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import { getDefaultProfilePath, listClaudeProfiles } from '../../cc/profiles'
/** globalState 里记的「问 AI」最近选的 profile 名(「默认」记成 '')。 */
export const ASK_PROFILE_KEY = 'superpowers.codeReader.askProfile'
import { getDefaultProfilePath, getProfilesDir, listClaudeProfiles } from '../../cc/profiles'
/** 下拉里的一项:id 是 profile 名;「默认」(不带 `--settings`,claude 用 ~/.claude 的登录)的 id 是 ''。 */
export interface ProfileOption {
@@ -18,60 +15,80 @@ export interface ProfileChoice {
export const DEFAULT_OPTION: ProfileOption = { id: '', label: '默认' }
/**
* 下拉的选项和选中的那项。插件的默认 profile(getDefaultProfilePath)在列表里时,没选过就选它;
* 不在列表里(一个 profile 都没有,或没有 offical.json)时最前面加一项「默认」,没选过就选它。
* 记着的那项不在列表里了(profile 删了、目录换了)也回到这一项。
*/
export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, remembered: unknown): ProfileChoice {
/** 下拉显示临时选择或设置默认值,失效的选择明确标出,避免回答时偷偷换模型。 */
export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, temporaryPath: string | undefined, configuredPath = ''): ProfileChoice {
const fallback = profiles.find(profile => profile.path === defaultPath)
const options = [...(fallback ? [] : [DEFAULT_OPTION]), ...profiles.map(({ name }) => ({ id: name, label: name }))]
const selected = options.find(option => option.id === remembered)?.id ?? fallback?.name ?? DEFAULT_OPTION.id
const requestedPath = temporaryPath ?? (configuredPath || fallback?.path)
const selected = profiles.find(profile => profile.path === requestedPath)?.name ?? requestedPath ?? DEFAULT_OPTION.id
const options = [...(!fallback || selected === '' ? [DEFAULT_OPTION] : []), ...profiles.map(({ name }) => ({ id: name, label: name }))]
if (!options.some(option => option.id === selected))
options.push({ id: selected, label: `不可用:${selected}` })
return { options, selected }
}
export interface ProfileSource {
list: () => Promise<ClaudeProfile[]>
defaultPath: () => string
directory: () => string
}
const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath }
const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath, directory: getProfilesDir }
/**
* 「问 AI」用哪个 Claude Code profile:所有预览共用一份,记在 globalState 里,换个预览、重启 VS Code 都还是它;
* 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。
* profile 列表每次现读(同看板:cc/profiles 的目录),不依赖 vscode 运行时,便于单元测试。
*/
/** 所有预览共用临时模型选择,重新创建服务或修改默认配置后恢复设置里的默认值。 */
export class AskProfiles {
private readonly listeners = new Set<(choice: ProfileChoice) => void>()
private temporary: string | undefined
private defaultKey: string | undefined
constructor(private readonly state: Pick<Memento, 'get' | 'update'>, private readonly source: ProfileSource = PROFILE_SOURCE) {}
constructor(private readonly configuredPath: () => string, private readonly source: ProfileSource = PROFILE_SOURCE) {}
async choice(): Promise<ProfileChoice> {
return (await this.read()).choice
}
/** 下一个问题用的 profile;选的是「默认」时没有(不带 `--settings`)。 */
/** 下一个问题使用当前显示的模型,失效时直接报错而不换成其他模型。 */
async current(): Promise<ClaudeProfile | undefined> {
const { profiles, choice } = await this.read()
return profiles.find(profile => profile.name === choice.selected)
this.publish(choice)
const profile = profiles.find(profile => profile.name === choice.selected)
if (choice.selected && !profile)
throw new Error(`文件问答模型不可用:${choice.selected}。请在设置中重新选择默认模型,或在输入框旁临时切换。`)
return profile
}
/** 选了就调 listener,带上新的选项和选中的那项。 */
/** 选择变化时通知所有打开的预览,让显示和实际发送使用同一个模型。 */
subscribe(listener: (choice: ProfileChoice) => void): Disposable {
this.listeners.add(listener)
return { dispose: () => this.listeners.delete(listener) }
}
async select(id: string): Promise<void> {
await this.state.update(ASK_PROFILE_KEY, id)
const choice = await this.choice()
const { profiles, choice } = await this.read()
const profile = profiles.find(profile => profile.name === id)
if (id ? !profile : !choice.options.some(option => option.id === ''))
throw new Error(`文件问答模型不可用:${id}。请重新选择。`)
this.temporary = profile?.path ?? ''
await this.refresh()
}
async refresh(): Promise<void> {
this.publish(await this.choice())
}
private publish(choice: ProfileChoice): void {
for (const listener of [...this.listeners])
listener(choice)
}
private async read(): Promise<{ profiles: ClaudeProfile[], choice: ProfileChoice }> {
const profiles = await this.source.list()
return { profiles, choice: profileChoice(profiles, this.source.defaultPath(), this.state.get(ASK_PROFILE_KEY)) }
const configuredPath = this.configuredPath().trim()
const defaultPath = this.source.defaultPath()
const key = JSON.stringify([configuredPath, this.source.directory()])
if (key !== this.defaultKey) {
this.defaultKey = key
this.temporary = undefined
}
return { profiles, choice: profileChoice(profiles, defaultPath, this.temporary, configuredPath) }
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { AskRunner } from './claudeAsk'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listClaudeProfiles } from '../../cc/profiles'
import { getSettings, saveSettings } from '../../settings/store'
import { CodeQa } from './codeQa'
vi.mock('vscode', async () => {
const { EventEmitter } = await import('node:events')
return {
EventEmitter: class {
private readonly emitter = new EventEmitter()
event = (listener: (value: unknown) => void) => {
this.emitter.on('event', listener)
return { dispose: () => this.emitter.off('event', listener) }
}
fire(value: unknown) { this.emitter.emit('event', value) }
dispose() { this.emitter.removeAllListeners() }
},
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: {},
window: { showErrorMessage: vi.fn() },
}
})
vi.mock('../folderStore', () => ({
FolderStore: class {
get() { return {} }
dispose() {}
},
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
}))
vi.mock('../editor/pathPreview', () => ({
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
}))
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
vi.mock('../../cc/profiles', () => ({
getDefaultProfilePath: () => '/p/offical.json',
getProfilesDir: () => '/p',
listClaudeProfiles: vi.fn(),
}))
const AVAILABLE = ['offical', 'sonnet', 'dsflash'].map(name => ({ name, path: `/p/${name}.json` }))
const uri = { fsPath: '/workspace/a.ts', toString: () => 'file:///workspace/a.ts' } as Uri
const dirs: string[] = []
const services: CodeQa[] = []
function context(): ExtensionContext {
const dir = mkdtempSync(path.join(tmpdir(), 'file-qa-settings-'))
dirs.push(dir)
return {
extensionPath: path.resolve(__dirname, '../../..'),
globalStorageUri: { fsPath: dir },
globalState: { get: (key: string) => key === 'superpowers.codeReader.askProfile' ? 'sonnet' : undefined },
workspaceState: { get: () => undefined, update: vi.fn() },
} as unknown as ExtensionContext
}
function service(ctx: ExtensionContext) {
const runner = vi.fn<AskRunner>(run => new Promise((_resolve, reject) => {
run.signal.addEventListener('abort', () => reject(new Error('停止')))
}))
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner)
services.push(qa)
return { qa, runner }
}
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
afterEach(() => {
services.splice(0).forEach(qa => qa.dispose())
dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true }))
})
describe('问这个文件实际使用设置的默认模型', () => {
it('新增官方文件和保存无关设置不改变显式空选择,真实默认变更才重置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' })
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE.filter(item => item.name !== 'offical'))
const { qa, runner } = service(ctx)
await qa.profiles.select('')
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)
const shown = vi.fn()
qa.profiles.subscribe(shown)
await saveSettings(ctx, { webhookPort: 9000 })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: '' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0]).not.toHaveProperty('profilePath')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/sonnet.json')
qa.stop('1')
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('忽略旧选择,临时切换不写默认;重新创建后恢复配置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
const { qa, runner } = service(ctx)
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
qa.stop('1')
await qa.profiles.select('sonnet')
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/sonnet.json')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json')
const restarted = service(ctx)
await restarted.qa.askPath(uri, 'file', { askId: '3', question: '这是什么', images: [] })
expect(restarted.runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('保存新默认后所有现有预览收到新选择,下一个问题和界面使用同一个模型', async () => {
const ctx = context()
const { qa, runner } = service(ctx)
await qa.profiles.select('sonnet')
const shown = vi.fn()
qa.profiles.subscribe(shown)
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('已删除配置以现有问答错误返回,不能启动模型;代码块问答同样阻止', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/deleted.json' })
const { qa, runner } = service(ctx)
expect(await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })).toContain('文件问答模型不可用')
expect(await qa.ask(uri, { askId: '2', anchor: 'a', question: '问题', prompt: '问题', followUp: '继续', images: [] })).toContain('文件问答模型不可用')
expect(runner).not.toHaveBeenCalled()
})
})
+27 -6
View File
@@ -1,4 +1,5 @@
import type { Event, Memento } from 'vscode'
import type { Event, ExtensionContext, Memento } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { QaMessage } from '../editor/messages'
import type { PathSubject } from '../editor/pathPreview'
@@ -7,6 +8,7 @@ import type { AskRunner } from './claudeAsk'
import type { QaEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { getSettings, onDidChangeSettings } from '../../settings/store'
import { pathQaLocation } from '../editor/pathPreview'
import { FolderStore, locate } from '../folderStore'
import { AskProfiles } from './askProfiles'
@@ -47,7 +49,8 @@ export class CodeQa implements Disposable {
private readonly pathChanged = new EventEmitter<Uri[]>()
/** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */
private pathQueue: Promise<void> = Promise.resolve()
/** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */
private readonly settingsSubscription: Disposable
/** 所有预览共用当前模型,临时切换不会写入默认设置。 */
readonly profiles: AskProfiles
/** 块下面或顶部的问答变了的那些文件 / 文件夹 */
readonly onDidChange: Event<Uri[]> = (listener, thisArgs, disposables) => {
@@ -62,11 +65,16 @@ export class CodeQa implements Disposable {
readonly onDidAnswer = this.answered.event
constructor(
profilesState: Pick<Memento, 'get' | 'update'>,
context: ExtensionContext,
private readonly paths: Pick<Memento, 'get' | 'update'>,
runner: AskRunner = askClaude,
) {
this.profiles = new AskProfiles(profilesState)
this.profiles = new AskProfiles(() => getSettings(context).defaultAskProfilePath)
this.settingsSubscription = onDidChangeSettings(context, () => {
void this.profiles.refresh().catch((err) => {
void window.showErrorMessage(`文件问答模型刷新失败:${err instanceof Error ? err.message : String(err)}`)
})
})
this.asks = new QaAsks(runner)
}
@@ -118,7 +126,13 @@ export class CodeQa implements Disposable {
const { askId, anchor, question, prompt, followUp, images } = request
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
const profile = await this.profiles.current()
let profile: ClaudeProfile | undefined
try {
profile = await this.profiles.current()
}
catch (err) {
return err instanceof Error ? err.message : String(err)
}
const owner = uri.toString()
const sessionId = latestSession(this.store.get(at.folder, at.key), anchor)
const run = {
@@ -173,7 +187,13 @@ export class CodeQa implements Disposable {
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
const profile = await this.profiles.current()
let profile: ClaudeProfile | undefined
try {
profile = await this.profiles.current()
}
catch (err) {
return err instanceof Error ? err.message : String(err)
}
const owner = uri.toString()
const key = pathQaKey(at.folder.uri.toString(), at.key)
const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId
@@ -242,6 +262,7 @@ export class CodeQa implements Disposable {
}
dispose(): void {
this.settingsSubscription.dispose()
this.asks.stopAll()
this.answered.dispose()
this.pathChanged.dispose()
+2
View File
@@ -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,
}
+472
View File
@@ -0,0 +1,472 @@
import type { ExtensionContext, Terminal, WebviewPanel } from 'vscode'
import type { Issue } from '../gitea/types'
import type { WebhookEvent } from '../webhook/eventParser'
import type { KanbanSnapshot } from './kanbanSnapshot'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLocalIssueState } from '../issues/localState'
import { recoverOpenReviewSessions } from './handlers/reviewSessionCapture'
import { KanbanWebviewPanel } from './KanbanPanel'
import { KANBAN_SNAPSHOT_KEY } from './kanbanSnapshot'
const mocks = vi.hoisted(() => ({
createPanel: vi.fn(),
load: vi.fn(),
loadSingle: vi.fn(),
receiveEvent: undefined as ((event: WebhookEvent) => void) | undefined,
detect: vi.fn(),
annotate: vi.fn(),
recover: vi.fn(),
watchSession: vi.fn(),
exec: vi.fn(),
findTerminal: vi.fn(),
log: vi.fn(),
closeIssue: vi.fn(),
setActivePanel: vi.fn(),
}))
vi.mock('vscode', () => ({
window: {
createWebviewPanel: mocks.createPanel,
onDidCloseTerminal: () => ({ dispose() {} }),
tabGroups: { onDidChangeTabs: () => ({ dispose() {} }), onDidChangeTabGroups: () => ({ dispose() {} }) },
},
workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] },
ViewColumn: { Active: 1 },
env: {},
Uri: {},
TabInputTerminal: class {},
ThemeColor: class {},
}))
vi.mock('node:child_process', () => ({ execFile: mocks.exec }))
vi.mock('../cc/codexSessionWatcher', () => ({ watchForCodexSession: mocks.watchSession }))
vi.mock('../auth/secrets', () => ({ getToken: async () => 'token', deleteToken: vi.fn() }))
vi.mock('../figma/importPlugin', () => ({}))
vi.mock('../git/remote', () => ({ detectRepo: mocks.detect }))
vi.mock('../gitea/api', () => ({ GiteaApiError: class extends Error {} }))
vi.mock('../gitea/issueLoader', () => ({ loadIssues: mocks.load, loadSingleIssue: mocks.loadSingle }))
vi.mock('../issues/localState', async importOriginal => ({
...await importOriginal<typeof import('../issues/localState')>(),
overlayLocalIssueState: (_ctx: unknown, issues: Issue[]) => issues,
}))
vi.mock('../issues/stateRouter', () => ({}))
vi.mock('../logging/logger', () => ({ logger: { add: mocks.log, onEntry: () => ({ dispose() {} }) } }))
vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: mocks.annotate, defaultCodexSessionsDir: () => '/sessions' }))
vi.mock('../settings/store', () => ({
getSettings: () => ({ autoReview: true, youtrackBaseUrl: '', youtrackProjectShortName: '' }),
}))
vi.mock('../webhook/coordinator', () => ({ webhookCoordinator: { setActivePanel: mocks.setActivePanel, restoreIssueCreationTerminals: vi.fn() } }))
vi.mock('../webhook/server', () => ({
WebhookServer: class {
setSecret() {}
start() { return Promise.resolve() }
stop() { return Promise.resolve() }
onEvent(receive: (event: WebhookEvent) => void) {
mocks.receiveEvent = receive
return { dispose() {} }
}
},
}))
vi.mock('../webview/html', () => ({ makeNonce: () => 'nonce', webviewHtml: () => '<html><body></body></html>', webviewUiRoot: () => '/ui' }))
vi.mock('../youtrack/issueLoader', () => ({ loadYouTrackIssues: async () => [] }))
vi.mock('../cc/profiles', () => ({}))
vi.mock('./handlers/handoffFlow', () => ({}))
vi.mock('./handlers/issueChat', () => ({}))
vi.mock('./handlers/issues', () => ({ handleCloseIssue: mocks.closeIssue }))
vi.mock('./handlers/managedSessions', () => ({}))
vi.mock('./handlers/prFiles', () => ({}))
vi.mock('./handlers/profileAssets', () => ({}))
vi.mock('./handlers/release', () => ({}))
vi.mock('./handlers/sessionHandoff', () => ({}))
vi.mock('./handlers/sessions', async () => ({
recoverOpenReviewSessions: mocks.recover,
stopReviewSessionCaptures: (await import('./handlers/reviewSessionCapture.js')).stopReviewSessionCaptures,
}))
vi.mock('./handlers/settings', () => ({ watchProfilesFile: () => undefined }))
vi.mock('./handlers/terminals', () => ({
withLiveTerminalTabState: (_panel: unknown, issues: Issue[]) => issues,
findExistingTerminal: mocks.findTerminal,
trackSessionTerminal: vi.fn(),
}))
vi.mock('./handlers/toolbar', () => ({ setupGitWatcher: vi.fn() }))
vi.mock('./handlers/worktree', () => ({}))
vi.mock('./handlers/youtrackIssues', () => ({}))
vi.mock('./issueColor', () => ({}))
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 issue(number = 462, extra: Partial<Issue> = {}): Issue {
return { id: `repo#${number}`, number, title: 'merged', column: 'review', htmlUrl: '', ...extra }
}
function snapshot(issues = [issue()]): KanbanSnapshot {
return { issues, scope: 'all', me: 'me', avatars: { me: 'avatar' }, globalAutoReview: true, youtrackConfigured: false }
}
function loaded(issues = [issue()]) {
return { issues, me: 'me', avatars: { me: 'avatar' } }
}
function webviewPanel() {
let receive!: (message: WebviewToExtension) => void
let disposeListener: (() => void) | undefined
const send = vi.fn()
const panel = {
webview: {
html: '',
options: {},
postMessage: send,
onDidReceiveMessage: (listener: typeof receive) => {
receive = listener
return { dispose() {} }
},
},
onDidDispose: (listener: () => void) => {
disposeListener = listener
return {
dispose: () => { disposeListener = undefined },
}
},
dispose: () => {
const listener = disposeListener
disposeListener = undefined
listener?.()
},
reveal: vi.fn(),
}
return { panel, send, receive: (message: WebviewToExtension) => receive(message) }
}
const views: ReturnType<typeof webviewPanel>[] = []
let stopCoordinator: (() => Promise<void>) | undefined
function fixture(initial: KanbanSnapshot | undefined = snapshot()) {
const disk = new Map<string, unknown>()
if (initial)
disk.set(KANBAN_SNAPSHOT_KEY, initial)
const saveGate = deferred<void>()
let holdSaves = false
const update = vi.fn(async (key: string, value: unknown) => {
if (holdSaves)
await saveGate.promise
if (value === undefined)
disk.delete(key)
else
disk.set(key, value)
})
const context = { extensionUri: '/extension', workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext
const open = () => {
const board = KanbanWebviewPanel.createOrShow(context)
const view = views.at(-1)!
return { board, ...view }
}
return {
context,
disk,
update,
open,
holdSaves: () => { holdSaves = true },
releaseSaves: () => saveGate.resolve(),
}
}
function injected(view: ReturnType<typeof webviewPanel>): KanbanSnapshot | undefined {
const json = view.panel.webview.html.match(/<script[^>]+id="kanban-snapshot">(.*?)<\/script>/)?.[1]
return json ? JSON.parse(json) : undefined
}
function updates(view: ReturnType<typeof webviewPanel>) {
return view.send.mock.calls.map(([msg]) => msg as ExtensionToWebview).filter(msg => msg.type === 'issues/update')
}
beforeEach(() => {
vi.clearAllMocks()
mocks.setActivePanel.mockReset()
mocks.loadSingle.mockReset()
mocks.createPanel.mockImplementation(() => {
const view = webviewPanel()
views.push(view)
return view.panel as unknown as WebviewPanel
})
mocks.detect.mockResolvedValue({ host: 'host', owner: 'owner', repo: 'repo' })
mocks.load.mockImplementation(() => new Promise(() => {}))
mocks.annotate.mockImplementation(async (issues: Issue[]) => issues)
mocks.recover.mockReset().mockResolvedValue(undefined)
mocks.findTerminal.mockReset()
mocks.watchSession.mockReset()
mocks.exec.mockImplementation((_cmd, args, _options, callback) => {
callback(null, args.at(-1) === 'lstart=' ? 'Fri Oct 2 00:42:25 2026\n' : 'codex\n')
})
})
afterEach(async () => {
for (const view of views.splice(0))
view.panel.dispose()
await stopCoordinator?.()
stopCoordinator = undefined
})
describe('kanban panel cache integration', () => {
it('真实 postMessage 更新后立即关闭重开,保存未完成首屏也保持 done', async () => {
const f = fixture()
f.holdSaves()
const first = f.open()
first.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', prMerged: true } })
first.panel.dispose()
const reopened = f.open()
expect(injected(reopened)?.issues[0]).toMatchObject({ column: 'done', prMerged: true })
expect(reopened.board.snapshotIssues()?.[0].column).toBe('done')
expect(KanbanWebviewPanel.issuesOnBoard(f.context)?.issues[0].column).toBe('done')
expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('review')
f.releaseSaves()
await vi.waitFor(() => expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done'))
})
it('ready 前增量立即进内存,加载未完成也回放最新状态和待选工单,不重复保存', async () => {
const f = fixture()
const view = f.open()
view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
view.board.selectIssueAndTab(462, 'changes')
expect(view.send.mock.calls.some(([message]) => message.type === 'issue/patch')).toBe(false)
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
view.receive({ type: 'issues/refresh' })
expect(updates(view).at(-1)?.issues[0].column).toBe('done')
expect(view.send).toHaveBeenCalledWith({ type: 'issue/select-by-number', issueNumber: 462, tab: 'changes' })
expect(view.send).toHaveBeenCalledWith({ type: 'issues/loading' })
await Promise.resolve()
expect(f.update).toHaveBeenCalledOnce()
})
it('完整列表在 ready 前也保存,ready 回放不用再次保存或重拉', async () => {
mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })]))
const f = fixture()
const view = f.open()
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.[0].column).toBe('done')
expect(updates(view)).toEqual([])
view.receive({ type: 'issues/refresh' })
expect(updates(view).at(-1)?.issues[0].column).toBe('done')
expect(mocks.load).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
})
it('加载第一个等待之前记录增量,旧完整列表返回时不会撤销 done', async () => {
const remote = deferred<{ host: string, owner: string, repo: string }>()
mocks.detect.mockReturnValue(remote.promise)
mocks.load.mockResolvedValue(loaded())
const f = fixture()
const view = f.open()
view.receive({ type: 'issues/refresh' })
view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', prMerged: true } })
remote.resolve({ host: 'host', owner: 'owner', repo: 'repo' })
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.[0]).toMatchObject({ column: 'done', prMerged: true })
expect(updates(view).at(-1)?.issues[0].column).toBe('done')
})
it('未知卡补丁也参与当次重放,append/remove 按消息顺序重放且不重复选卡', async () => {
const response = deferred<ReturnType<typeof loaded>>()
mocks.load.mockReturnValue(response.promise)
const f = fixture(snapshot([]))
const view = f.open()
view.receive({ type: 'issues/refresh' })
view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
view.board.postMessage({ type: 'issue/remove', issueNumber: 463 })
view.board.postMessage({ type: 'issue/append', issue: issue(464, { pr: 'old' }), select: true })
view.board.postMessage({ type: 'issue/patch', issueNumber: 464, patch: { pr: null, column: 'done' } })
response.resolve(loaded([issue(), issue(463)]))
await view.board.loadAndPush()
expect(view.board.snapshotIssues()).toEqual([issue(462, { column: 'done' }), issue(464, { column: 'done' })])
expect(view.send.mock.calls.filter(([msg]) => msg.type === 'issue/append')).toHaveLength(1)
})
it('首次没有快照也记录未知卡补丁,完整加载后才生成快照', async () => {
const response = deferred<ReturnType<typeof loaded>>()
mocks.load.mockReturnValue(response.promise)
const f = fixture()
f.disk.delete(KANBAN_SNAPSHOT_KEY)
const view = f.open()
view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
expect(view.board.snapshotIssues()).toBeUndefined()
expect(f.update).not.toHaveBeenCalled()
response.resolve(loaded())
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.[0].column).toBe('done')
})
it('销毁后的旧加载晚到,不改新面板内存、持久快照或发送消息', async () => {
const stale = deferred<ReturnType<typeof loaded>>()
mocks.load.mockReturnValueOnce(stale.promise)
const f = fixture()
const first = f.open()
const oldLoad = first.board.loadAndPush()
await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledOnce())
first.panel.dispose()
mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })]))
const second = f.open()
await second.board.loadAndPush()
await vi.waitFor(() => expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done'))
const sends = first.send.mock.calls.length
const writes = f.update.mock.calls.length
stale.resolve(loaded())
await oldLoad
expect(second.board.snapshotIssues()?.[0].column).toBe('done')
expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done')
expect(f.update).toHaveBeenCalledTimes(writes)
expect(first.send).toHaveBeenCalledTimes(sends)
})
it('事件等待时关闭重开,旧事件和新面板旧读取均不发布,随后补读最新状态', async () => {
const { webhookCoordinator } = await vi.importActual<typeof import('../webhook/coordinator')>('../webhook/coordinator')
const eventRead = deferred<Issue>()
const reopenedRead = deferred<ReturnType<typeof loaded>>()
mocks.load.mockResolvedValueOnce(loaded()).mockReturnValueOnce(reopenedRead.promise).mockResolvedValue(loaded([issue(462, { column: 'done' })]))
mocks.loadSingle.mockReturnValue(eventRead.promise)
mocks.setActivePanel.mockImplementation(panel => webhookCoordinator.setActivePanel(panel))
const f = fixture()
webhookCoordinator.init(f.context)
stopCoordinator = () => webhookCoordinator.dispose()
const first = f.open()
await first.board.loadAndPush()
mocks.receiveEvent!({ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} })
await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce())
first.panel.dispose()
const reopened = f.open()
expect(injected(reopened)).toBeUndefined()
reopened.receive({ type: 'issues/refresh' })
await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledTimes(2))
const oldLoad = reopened.board.loadAndPush()
reopened.send.mockClear()
f.update.mockClear()
eventRead.resolve(issue(462, { column: 'done' }))
await vi.waitFor(() => expect(f.update).toHaveBeenCalledWith(KANBAN_SNAPSHOT_KEY, undefined))
expect(reopened.send.mock.calls.some(([message]) => message.type === 'issue/append')).toBe(false)
reopenedRead.resolve(loaded())
await oldLoad
await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledTimes(3))
await vi.waitFor(() => expect(reopened.board.snapshotIssues()?.[0].column).toBe('done'))
expect(updates(reopened).map(message => message.issues[0]?.column)).toEqual(['done'])
const saved = f.update.mock.calls.filter(([key, value]) => key === KANBAN_SNAPSHOT_KEY && value !== undefined)
expect(saved.map(([, value]) => (value as KanbanSnapshot).issues[0]?.column)).toEqual(['done'])
})
it('最后一个异步步骤之后也拦截已销毁面板的结果', async () => {
const annotation = deferred<Issue[]>()
mocks.load.mockResolvedValue(loaded())
mocks.annotate.mockReturnValueOnce(annotation.promise)
const f = fixture()
const view = f.open()
const loading = view.board.loadAndPush()
await vi.waitFor(() => expect(mocks.annotate).toHaveBeenCalledOnce())
view.panel.dispose()
annotation.resolve([issue()])
await loading
expect(f.update).not.toHaveBeenCalled()
})
it('加载失败保留已收到的增量,下次全量不再受旧日志约束', async () => {
const response = deferred<ReturnType<typeof loaded>>()
mocks.load.mockReturnValueOnce(response.promise)
const f = fixture()
const view = f.open()
view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
response.reject(new Error('network unavailable'))
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.[0].column).toBe('done')
mocks.load.mockResolvedValue(loaded([issue(462, { column: 'todo' })]))
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.[0].column).toBe('todo')
})
it('空快照下两张 YouTrack 审查错峰恢复,第一张补丁不得改变第二张保存来源', async () => {
const secondProcess = deferred<number>()
const issues = [issue(41, { source: 'youtrack', externalId: 'YT-41' }), issue(42, { source: 'youtrack', externalId: 'YT-42' })]
const terminals = issues.map((item, index) => ({
creationOptions: {
cwd: `/worktree/${item.number}`,
shellArgs: ['-ilc', 'exec "$@"', 'zsh', 'codex', '--dangerously-bypass-approvals-and-sandbox', `review ${item.number}`],
},
processId: index === 0 ? Promise.resolve(41) : secondProcess.promise,
exitStatus: undefined,
} as unknown as Terminal))
mocks.findTerminal.mockImplementation((_panel, name) => terminals[issues.findIndex(item => name === `issue-${item.number}-审查`)])
mocks.watchSession.mockImplementation(async ({ cwd }) => {
const id = `session-${cwd.split('/').at(-1)}`
return { initialSessionId: id, sessionId: Promise.resolve(id) }
})
mocks.recover.mockImplementation(recoverOpenReviewSessions)
mocks.load.mockResolvedValue(loaded(issues))
const f = fixture()
f.disk.delete(KANBAN_SNAPSHOT_KEY)
const view = f.open()
const loading = view.board.loadAndPush()
await vi.waitFor(() => expect(getLocalIssueState(f.context, 'youtrack', 41).reviewSessionId).toBe('session-41'))
await vi.waitFor(() => expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ message: '已捕获审查会话 session-41 (#41)' })))
expect(view.board.snapshotIssues()).toBeUndefined()
secondProcess.resolve(42)
await loading
expect(getLocalIssueState(f.context, 'youtrack', 42).reviewSessionId).toBe('session-42')
expect(getLocalIssueState(f.context, 'gitea', 42).reviewSessionId).toBeUndefined()
expect(f.disk.has('superpowers.reviewSessionLaunch.youtrack:42')).toBe(false)
expect(f.disk.has('superpowers.reviewSessionLaunch.gitea:42')).toBe(false)
})
it('恢复会话失败时工单来源仍与当前快照一致', async () => {
const yt = issue(42, { source: 'youtrack', externalId: 'YT-42' })
mocks.load.mockResolvedValue(loaded([]))
mocks.recover.mockRejectedValueOnce(new Error('recovery unavailable'))
const f = fixture(snapshot([yt]))
const view = f.open()
await view.board.loadAndPush()
expect(view.board.snapshotIssues()).toEqual([yt])
expect(view.board.refFor(42)).toMatchObject({ source: 'youtrack', externalId: 'YT-42' })
})
it('只有缓存且首次加载失败时,ready 会重新加载而不是永久只回放缓存', async () => {
mocks.load.mockRejectedValueOnce(new Error('network unavailable'))
const f = fixture()
const view = f.open()
await view.board.loadAndPush()
mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })]))
view.receive({ type: 'issues/refresh' })
await view.board.loadAndPush()
expect(mocks.load).toHaveBeenCalledTimes(2)
expect(view.board.snapshotIssues()?.[0].column).toBe('done')
})
it('成功加载只重放当次增量,下次完整刷新仍可移除卡片', async () => {
mocks.load.mockResolvedValue(loaded())
const f = fixture()
const view = f.open()
view.board.postMessage({ type: 'issue/append', issue: issue(463) })
await view.board.loadAndPush()
expect(view.board.snapshotIssues()?.map(i => i.number)).toEqual([462, 463])
mocks.load.mockResolvedValue(loaded([]))
await view.board.loadAndPush()
expect(view.board.snapshotIssues()).toEqual([])
})
it('缓存与增量保持工单来源和归属索引同步', () => {
const yt = issue(42, { source: 'youtrack', externalId: 'YT-42', assignees: ['other'] })
const f = fixture(snapshot([yt]))
const view = f.open()
expect(view.board.refFor(42)).toMatchObject({ source: 'youtrack', externalId: 'YT-42' })
expect(view.board.isYouTrackIssue(42)).toBe(true)
view.receive({ type: 'issue/close', issueNumber: 42 })
expect(mocks.closeIssue).not.toHaveBeenCalled()
view.board.postMessage({ type: 'issue/patch', issueNumber: 42, patch: { assignees: ['me'] } })
view.receive({ type: 'issue/close', issueNumber: 42 })
expect(mocks.closeIssue).toHaveBeenCalledWith(view.board, 42)
view.board.postMessage({ type: 'issue/append', issue: { ...yt, externalId: 'NEW-42', assignees: ['me'] } })
expect(view.board.refFor(42).externalId).toBe('NEW-42')
view.board.postMessage({ type: 'issue/remove', issueNumber: 42 })
expect(view.board.isYouTrackIssue(42)).toBe(false)
})
})
+114 -121
View File
@@ -7,6 +7,7 @@ import type {
import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter'
import type { IssueUpdate } from '../shared/kanbanUpdates'
import type { SessionTabKind } from './handlers/terminals'
import type { IssueOwnershipFacts } from './issueOwnership'
import type { KanbanScope } from './kanbanScope'
@@ -25,6 +26,7 @@ import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stat
import { logger } from '../logging/logger'
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store'
import { applyIssueUpdate, isIssueUpdate } from '../shared/kanbanUpdates'
import { webhookCoordinator } from '../webhook/coordinator'
import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
@@ -46,12 +48,18 @@ import * as youtrackIssues from './handlers/youtrackIssues'
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
import { isIssueMine } from './issueOwnership'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope'
import { boardIssues, KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
import { boardIssues, snapshotScriptTag } from './kanbanSnapshot'
import { getKanbanState } from './kanbanState'
import { isOwnerOnlyMessage } from './readOnlyGuard'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath } from '../cc/profiles'
interface KanbanLoad {
updates: IssueUpdate[]
invalidated?: boolean
}
export class KanbanWebviewPanel {
static readonly viewType = 'superpowers.kanbanPanel'
@@ -59,23 +67,20 @@ export class KanbanWebviewPanel {
private readonly panel: WebviewPanel
private readonly disposables: { dispose: () => void }[] = []
/**
* `issue.number` → its source identity, rebuilt on every `loadAndPush`.
* Lets the workflow handlers (which only receive a number from the webview)
* route state persistence to the right tracker. YouTrack numbers are
* synthetic and collision-free (see `youtrack/issueLoader`), so the number
* alone is an unambiguous key.
*/
/** 按工单号保存来源,确保状态修改发往正确的工单系统。 */
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
private issueOwners = new Map<number, IssueOwnershipFacts>()
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
private me: string | undefined
/** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */
/** 页面发过首次刷新请求后才推送卡片,避免页面启动时丢消息。 */
private webviewReady = false
private pendingSelectOnReady?: { issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' }
private loadInFlight: Promise<void> | undefined
private lastIssuesUpdate: Extract<ExtensionToWebview, { type: 'issues/update' }> | undefined
private readonly kanbanState: ReturnType<typeof getKanbanState>
private activeLoad: KanbanLoad | undefined
private hasLoaded = false
private disposed = false
/**
* sessionId → its dedicated terminal in the editor area. Populated when the
* user presses Enter on a card; entry is removed when the terminal is
@@ -112,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>()
/**
@@ -237,6 +204,8 @@ export class KanbanWebviewPanel {
// internal: context 供 handler 模块访问
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
this.panel = panel
this.kanbanState = getKanbanState(context.workspaceState)
this.syncIssueIndexes()
this.panel.webview.options = {
enableScripts: true,
@@ -376,7 +345,10 @@ export class KanbanWebviewPanel {
/** 看板上现在摆着的工单(见 boardIssues),给别处(代码阅读的「选工单」)直接用,不再加载一遍。 */
static issuesOnBoard(context: ExtensionContext): BoardIssues | undefined {
return boardIssues(KanbanWebviewPanel.current?.snapshotIssues(), context.workspaceState.get(KANBAN_SNAPSHOT_KEY))
const current = KanbanWebviewPanel.current
const snapshot = getKanbanState(context.workspaceState).snapshot
const loaded = current?.context.workspaceState === context.workspaceState && current.hasLoaded
return boardIssues(loaded ? snapshot?.issues : undefined, snapshot)
}
/**
@@ -409,18 +381,14 @@ export class KanbanWebviewPanel {
if (msg.type === 'issues/refresh') {
const first = !this.webviewReady
this.webviewReady = true
if (this.loadInFlight)
return
if (first && this.lastIssuesUpdate) {
this.postMessage(this.lastIssuesUpdate)
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
if (this.pendingSelectOnReady) {
const { issueNumber, tab } = this.pendingSelectOnReady
this.pendingSelectOnReady = undefined
this.postMessage({ type: 'issue/select-by-number', issueNumber, tab })
}
if (first)
this.replayState()
if (this.loadInFlight) {
this.sendMessage({ type: 'issues/loading' })
return
}
if (first && this.hasLoaded)
return
void this.loadAndPush()
return
}
@@ -974,7 +942,7 @@ export class KanbanWebviewPanel {
*/
// internal: handler 模块访问
snapshotIssues(): Issue[] | undefined {
return this.lastIssuesUpdate?.issues
return this.kanbanState.snapshot?.issues
}
// internal: handler 模块访问
@@ -1020,19 +988,37 @@ export class KanbanWebviewPanel {
* always-on webhook coordinator can refresh us when a PR event arrives.
*/
async loadAndPush(): Promise<void> {
if (this.disposed)
return
if (this.loadInFlight)
return this.loadInFlight
// 每次真正开跑都成对推 loading/loaded,webview 据此转刷新图标;
// 无论成功、报错还是缺 token 中途返回,finally 都会收尾。
const load: KanbanLoad = { updates: [] }
this.activeLoad = load
this.postMessage({ type: 'issues/loading' })
this.loadInFlight = this.loadAndPushInner().finally(() => {
this.loadInFlight = this.loadAndPushInner(load).finally(() => {
if (this.activeLoad !== load)
return
this.syncIssueIndexes()
this.activeLoad = undefined
this.loadInFlight = undefined
this.postMessage({ type: 'issues/loaded' })
webhookCoordinator.restoreIssueCreationTerminals(this)
})
return this.loadInFlight
}
private async loadAndPushInner(): Promise<void> {
async reloadAfterInvalidation(): Promise<void> {
if (this.activeLoad)
this.activeLoad.invalidated = true
try {
await this.loadInFlight
}
finally {
await this.loadAndPush()
}
}
private async loadAndPushInner(load: KanbanLoad): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
// eslint-disable-next-line no-console
console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot)
@@ -1045,6 +1031,8 @@ export class KanbanWebviewPanel {
}
const remote = await detectRepo(workspaceRoot)
if (this.activeLoad !== load || load.invalidated)
return
if (!remote) {
this.postMessage({
type: 'issues/error',
@@ -1055,6 +1043,8 @@ export class KanbanWebviewPanel {
const { host, owner, repo } = remote
const token = await getToken(this.context, host)
if (this.activeLoad !== load || load.invalidated)
return
if (!token) {
const s = getSettings(this.context)
this.postMessage({
@@ -1085,6 +1075,7 @@ export class KanbanWebviewPanel {
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
defaultAskProfilePath: s.defaultAskProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context),
@@ -1101,10 +1092,15 @@ export class KanbanWebviewPanel {
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
this.loadYouTrackSafe(),
])
if (this.activeLoad !== load || load.invalidated)
return
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...giteaIssues, ...youtrackList])
this.issueRefs = new Map(
[...giteaIssues, ...youtrackList].map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
recoveryIssues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
)
await sessions.recoverOpenReviewSessions(this, [...giteaIssues, ...youtrackList].map(i => i.number))
await sessions.recoverOpenReviewSessions(this, recoveryIssues.map(i => i.number))
if (this.activeLoad !== load || load.invalidated)
return
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
// 会话文件的会话 id,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
@@ -1112,31 +1108,21 @@ export class KanbanWebviewPanel {
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
),
)
this.me = me
this.issueOwners = new Map(
issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const),
)
if (this.activeLoad !== load || load.invalidated)
return
const ytSettings = getSettings(this.context)
const payload: Extract<ExtensionToWebview, { type: 'issues/update' }> = {
type: 'issues/update',
issues,
issues: load.updates.reduce(applyIssueUpdate, issues),
scope,
globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
me,
avatars,
}
this.lastIssuesUpdate = payload
void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, {
issues,
scope,
me,
avatars,
globalAutoReview: payload.globalAutoReview,
youtrackConfigured: payload.youtrackConfigured,
})
this.hasLoaded = true
this.postMessage(payload)
if (this.webviewReady) {
this.postMessage(payload)
// Re-publish the cached git state so a slow webview boot (or a
// refresh after git events already fired) still picks up the latest
// value. Cheap — the webview discards if equal.
@@ -1149,6 +1135,8 @@ export class KanbanWebviewPanel {
}
}
catch (err) {
if (this.activeLoad !== load || load.invalidated)
return
if (err instanceof GiteaApiError && err.status === 401) {
await deleteToken(this.context, host)
const s = getSettings(this.context)
@@ -1181,6 +1169,7 @@ export class KanbanWebviewPanel {
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
defaultAskProfilePath: s.defaultAskProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context),
@@ -1285,7 +1274,44 @@ export class KanbanWebviewPanel {
}
postMessage(msg: ExtensionToWebview): void {
void this.panel.webview.postMessage(msg)
if (this.disposed)
return
const incremental = isIssueUpdate(msg)
if (incremental)
this.activeLoad?.updates.push(msg)
void this.kanbanState.apply(msg)
if (incremental || msg.type === 'issues/update') {
this.syncIssueIndexes()
if (!this.webviewReady)
return
}
this.sendMessage(msg)
}
private sendMessage(msg: ExtensionToWebview): void {
if (!this.disposed)
void this.panel.webview.postMessage(msg)
}
private syncIssueIndexes(): void {
const snapshot = this.kanbanState.snapshot
const issues = snapshot?.issues ?? []
this.me = snapshot?.me
this.issueRefs = new Map(issues.map(issue => [issue.number, { source: issue.source ?? 'gitea', externalId: issue.externalId }]))
this.issueOwners = new Map(issues.map(issue => [issue.number, { poster: issue.poster, assignees: issue.assignees }]))
}
private replayState(): void {
const snapshot = this.kanbanState.snapshot
if (!this.webviewReady || !snapshot)
return
this.sendMessage({ type: 'issues/update', ...snapshot })
this.sendMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
if (this.pendingSelectOnReady) {
const { issueNumber, tab } = this.pendingSelectOnReady
this.pendingSelectOnReady = undefined
this.sendMessage({ type: 'issue/select-by-number', issueNumber, tab })
}
}
/**
@@ -1317,44 +1343,11 @@ 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
this.disposed = true
this.activeLoad = undefined
sessions.stopReviewSessionCaptures(this)
KanbanWebviewPanel.current = undefined
webhookCoordinator.setActivePanel(undefined)
@@ -1370,7 +1363,7 @@ export class KanbanWebviewPanel {
private buildHtml(): string {
const nonce = makeNonce()
const html = webviewHtml(this.panel.webview, this.context.extensionUri, 'index.html', nonce)
const snapshot = parseKanbanSnapshot(this.context.workspaceState.get(KANBAN_SNAPSHOT_KEY))
const snapshot = this.kanbanState.snapshot
if (!snapshot)
return html
return html.replace(
@@ -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()
})
})
+41 -93
View File
@@ -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)
})
}
/**
@@ -1,5 +1,6 @@
import type { Terminal } from 'vscode'
import type { CodexSessionLaunch } from '../../cc/codexSessionWatcher'
import type { IssueRef } from '../../issues/stateRouter'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as path from 'node:path'
@@ -11,6 +12,7 @@ import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/sessionFiles'
interface ReviewCapture {
source: NonNullable<IssueRef['source']>
abort: AbortController
launch: CodexSessionLaunch
terminal?: Terminal
@@ -18,7 +20,7 @@ interface ReviewCapture {
}
const captures = new WeakMap<KanbanWebviewPanel, Map<number, ReviewCapture>>()
const recoveries = new WeakMap<KanbanWebviewPanel, Map<number, { abort: AbortController, result: Promise<string | null> }>>()
const recoveries = new WeakMap<KanbanWebviewPanel, Map<number, { source: ReviewCapture['source'], abort: AbortController, result: Promise<string | null> }>>()
function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
let map = captures.get(panel)
@@ -29,17 +31,20 @@ function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
return map
}
function launchKey(panel: KanbanWebviewPanel, issueNumber: number): string {
return `superpowers.reviewSessionLaunch.${panel.refFor(issueNumber).source ?? 'gitea'}:${issueNumber}`
function launchKey(source: ReviewCapture['source'], issueNumber: number): string {
return `superpowers.reviewSessionLaunch.${source}:${issueNumber}`
}
/** 用户清除关联时先中止捕获,防止稍后落盘的文件把关联补回来。 */
export async function cancelReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const map = captureMap(panel)
recoveries.get(panel)?.get(issueNumber)?.abort.abort()
map.get(issueNumber)?.abort.abort()
const capture = map.get(issueNumber)
const recovery = recoveries.get(panel)?.get(issueNumber)
const source = capture?.source ?? recovery?.source ?? panel.refFor(issueNumber).source ?? 'gitea'
recovery?.abort.abort()
capture?.abort.abort()
map.delete(issueNumber)
await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined)
await panel.context.workspaceState.update(launchKey(source, issueNumber), undefined)
}
export function stopReviewSessionCaptures(panel: KanbanWebviewPanel): void {
@@ -64,27 +69,32 @@ function reportCaptureFailure(issueNumber: number, err: unknown): void {
async function persistCapture(panel: KanbanWebviewPanel, issueNumber: number, capture: ReviewCapture, id: string): Promise<string | null> {
if (capture.abort.signal.aborted || captureMap(panel).get(issueNumber) !== capture || !capture.terminal)
return null
await mergeLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber, { reviewSessionId: id })
await mergeLocalIssueState(panel.context, capture.source, issueNumber, { reviewSessionId: id })
if (capture.abort.signal.aborted)
return null
panel.reviewTerminals.set(id, capture.terminal)
panel.trackSessionTerminal(capture.terminal, issueNumber, 'review')
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { reviewSessionId: id } })
await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined)
await panel.context.workspaceState.update(launchKey(capture.source, issueNumber), undefined)
logger.add({ level: 'info', source: 'review', message: `已捕获审查会话 ${id} (#${issueNumber})` })
return id
}
/** 返回前完成目录确认和首次扫描,调用方随后才创建终端。 */
export async function prepareReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number, launch: CodexSessionLaunch): Promise<{
export async function prepareReviewSessionCapture(
panel: KanbanWebviewPanel,
issueNumber: number,
launch: CodexSessionLaunch,
source: ReviewCapture['source'] = panel.refFor(issueNumber).source ?? 'gitea',
): Promise<{
attach: (terminal: Terminal) => Promise<string | null>
initialSessionId: string | null
}> {
captureMap(panel).get(issueNumber)?.abort.abort()
const capture: ReviewCapture = { abort: new AbortController(), launch }
const capture: ReviewCapture = { source, abort: new AbortController(), launch }
captureMap(panel).set(issueNumber, capture)
try {
await panel.context.workspaceState.update(launchKey(panel, issueNumber), launch)
await panel.context.workspaceState.update(launchKey(source, issueNumber), launch)
const watch = await watchForCodexSession({
...launch,
baseDir: defaultCodexSessionsDir(),
@@ -214,8 +224,8 @@ function terminalProcessId(terminal: Terminal, signal: AbortSignal): Promise<num
}
/** 已有文件立即补关联;尚未落盘则后台等待,刷新不会等完整审查运行。 */
async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal, signal: AbortSignal): Promise<string | null> {
const local = getLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber)
async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal, signal: AbortSignal, source: ReviewCapture['source']): Promise<string | null> {
const local = getLocalIssueState(panel.context, source, issueNumber)
if (local.reviewSessionId === '' || captureMap(panel).has(issueNumber))
return null
if (local.reviewSessionId)
@@ -230,11 +240,11 @@ async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issue
const processStartedAt = await codexProcessStartedAt(pid, signal)
if (signal.aborted || terminal.exitStatus !== undefined)
return null
const stored = panel.context.workspaceState.get<CodexSessionLaunch>(launchKey(panel, issueNumber))
const stored = panel.context.workspaceState.get<CodexSessionLaunch>(launchKey(source, issueNumber))
const startedAt = stored?.prompt === launch.prompt && stored.cwd === launch.cwd && Number.isFinite(stored.startedAt)
? Math.max(stored.startedAt, processStartedAt)
: processStartedAt
const prepared = await prepareReviewSessionCapture(panel, issueNumber, { ...launch, startedAt })
const prepared = await prepareReviewSessionCapture(panel, issueNumber, { ...launch, startedAt }, source)
if (signal.aborted) {
await cancelReviewSessionCapture(panel, issueNumber)
return null
@@ -259,12 +269,13 @@ export function recoverReviewSessionCapture(panel: KanbanWebviewPanel, issueNumb
if (existing)
return existing.result
const abort = new AbortController()
const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal)
const source = panel.refFor(issueNumber).source ?? 'gitea'
const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal, source)
.finally(() => {
if (map.get(issueNumber)?.result === result)
map.delete(issueNumber)
})
map.set(issueNumber, { abort, result })
map.set(issueNumber, { source, abort, result })
return result
}
+5 -2
View File
@@ -45,6 +45,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -85,6 +86,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
const trimmedDefaultImplementProfile = payload.defaultImplementProfilePath.trim()
const trimmedDefaultTestProfile = payload.defaultTestProfilePath.trim()
const trimmedDefaultPrototypeProfile = payload.defaultPrototypeProfilePath.trim()
const trimmedDefaultAskProfile = payload.defaultAskProfilePath.trim()
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH).
const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim()
// 提交代码 profile:'' 有意义(= 未配置 → 提交严格失败),只 trim。
@@ -134,6 +136,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile,
defaultAskProfilePath: trimmedDefaultAskProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
@@ -168,6 +171,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile,
defaultAskProfilePath: trimmedDefaultAskProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
@@ -178,8 +182,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
youtrackProjectShortName: trimmedYtProject,
youtrackCloseCommand: trimmedYtClose,
})
// 保存后立刻让 listClaudeProfiles / getDefaultProfilePath 吃到新目录。
setProfilesDirOverride(trimmedProfilesDirectory || undefined)
void handleProfilesList(panel)
if (!keepExisting) {
await setToken(panel.context, trimmedHost, trimmedToken)
@@ -279,6 +281,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
defaultAskProfilePath: s.defaultAskProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath,
prDiffSummaryProfilePath,
+13 -57
View File
@@ -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')
}
+129
View File
@@ -0,0 +1,129 @@
import type { Memento } from 'vscode'
import type { Issue } from '../gitea/types'
import type { KanbanSnapshot } from './kanbanSnapshot'
import { describe, expect, it, vi } from 'vitest'
import { KANBAN_SNAPSHOT_KEY } from './kanbanSnapshot'
import { getKanbanState } from './kanbanState'
const log = vi.hoisted(() => vi.fn())
vi.mock('../logging/logger', () => ({ logger: { add: log } }))
const snapshot: KanbanSnapshot = {
issues: [{ id: 'repo#462', number: 462, title: 'merged', column: 'review', htmlUrl: '', pr: '470' }],
scope: 'all',
me: 'owner',
avatars: { owner: 'data:image/png;base64,a' },
globalAutoReview: true,
youtrackConfigured: true,
}
function fixture(initial: unknown = snapshot) {
const disk = new Map<string, unknown>([[KANBAN_SNAPSHOT_KEY, initial]])
const pending: Array<{ key: string, value: unknown, resolve: () => void, reject: (error: Error) => void }> = []
const workspaceState = {
keys: () => [...disk.keys()],
get: vi.fn((key: string) => disk.get(key)),
update: vi.fn((key: string, value: unknown) => new Promise<void>((resolve, reject) => {
pending.push({
key,
value,
resolve: () => {
if (value === undefined)
disk.delete(key)
else
disk.set(key, value)
resolve()
},
reject,
})
})),
} as unknown as Memento
return { disk, workspaceState, pending, state: getKanbanState(workspaceState) }
}
describe('workspace kanban state', () => {
it('同工作区共享最新内存,保存严格串行且保留快照元数据', async () => {
const f = fixture()
const first = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', pr: null } })
const second = f.state.apply({ type: 'issue/append', issue: { ...snapshot.issues[0], number: 463 } })
expect(getKanbanState(f.workspaceState)).toBe(f.state)
expect(f.state.snapshot).toEqual({ ...snapshot, issues: [expect.objectContaining({ column: 'done' }), expect.objectContaining({ number: 463 })] })
expect(f.state.snapshot!.issues[0]).not.toHaveProperty('pr')
await vi.waitFor(() => expect(f.pending).toHaveLength(1))
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(snapshot)
f.pending[0].resolve()
await first
await vi.waitFor(() => expect(f.pending).toHaveLength(2))
f.pending[1].resolve()
await second
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toEqual(f.state.snapshot)
})
it('写失败有日志且不丢最新内存,后续保存仍可成功', async () => {
const f = fixture()
const first = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
const second = f.state.apply({ type: 'issue/remove', issueNumber: 462 })
await vi.waitFor(() => expect(f.pending).toHaveLength(1))
f.pending[0].reject(new Error('disk unavailable'))
await first
expect(log).toHaveBeenCalledWith(expect.objectContaining({ level: 'error', details: expect.stringContaining('disk unavailable') }))
await vi.waitFor(() => expect(f.pending).toHaveLength(2))
expect(f.state.snapshot!.issues).toEqual([])
f.pending[1].resolve()
await second
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toEqual({ ...snapshot, issues: [] })
})
it('失效立即清内存,删除排在旧保存后,旧保存不能复活快照', async () => {
const f = fixture()
const saving = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
const invalidating = f.state.invalidate()
expect(getKanbanState(f.workspaceState).snapshot).toBeUndefined()
await vi.waitFor(() => expect(f.pending).toHaveLength(1))
f.pending[0].resolve()
await saving
await vi.waitFor(() => expect(f.pending).toHaveLength(2))
expect(f.pending[1].value).toBeUndefined()
expect(getKanbanState(f.workspaceState).snapshot).toBeUndefined()
f.pending[1].resolve()
await invalidating
expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)
})
it.each([undefined, { issues: [], scope: 'invalid' }])('无有效快照时增量与无关消息不凭空创建快照 (%s)', async (initial) => {
const f = fixture(initial === undefined ? null : initial)
await f.state.apply({ type: 'issue/append', issue: snapshot.issues[0] })
await f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
await f.state.apply({ type: 'issue/remove', issueNumber: 462 })
await f.state.apply({ type: 'issues/loaded' })
expect(f.state.snapshot).toBeUndefined()
expect(f.workspaceState.update).not.toHaveBeenCalled()
})
it('完整列表可初始化空快照,无关消息不再次保存', async () => {
const f = fixture(null)
const saving = f.state.apply({ type: 'issues/update', ...snapshot, issues: [] })
expect(f.state.snapshot).toEqual({ ...snapshot, issues: [] })
await vi.waitFor(() => expect(f.pending).toHaveLength(1))
f.pending[0].resolve()
await saving
await f.state.apply({ type: 'issues/loading' })
expect(f.workspaceState.update).toHaveBeenCalledOnce()
})
it('完整列表替换快照,未知工单补丁不新增,工作区互相隔离', async () => {
const f = fixture()
const other = fixture()
const issues: Issue[] = [{ ...snapshot.issues[0], number: 99, column: 'todo' }]
const saving = f.state.apply({ type: 'issues/update', ...snapshot, issues, scope: 'mine' })
const patching = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } })
expect(f.state.snapshot).toEqual({ ...snapshot, scope: 'mine', issues })
expect(other.state.snapshot).toEqual(snapshot)
await vi.waitFor(() => expect(f.pending).toHaveLength(1))
f.pending[0].resolve()
await saving
await vi.waitFor(() => expect(f.pending).toHaveLength(2))
f.pending[1].resolve()
await patching
})
})
+63
View File
@@ -0,0 +1,63 @@
import type { Memento } from 'vscode'
import type { KanbanSnapshot } from './kanbanSnapshot'
import type { ExtensionToWebview } from './messages'
import { logger } from '../logging/logger'
import { applyIssueUpdate, isIssueUpdate } from '../shared/kanbanUpdates'
import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot } from './kanbanSnapshot'
class KanbanState {
private current: KanbanSnapshot | undefined
private writes: Promise<void> = Promise.resolve()
constructor(private readonly workspaceState: Memento) {
this.current = parseKanbanSnapshot(workspaceState.get(KANBAN_SNAPSHOT_KEY)) ?? undefined
}
get snapshot(): KanbanSnapshot | undefined {
return this.current
}
apply(message: ExtensionToWebview): Promise<void> {
if (message.type === 'issues/update') {
const { type: _, ...snapshot } = message
this.current = snapshot
}
else if (isIssueUpdate(message) && this.current) {
this.current = { ...this.current, issues: applyIssueUpdate(this.current.issues, message) }
}
else {
return this.writes
}
return this.persist(this.current)
}
invalidate(): Promise<void> {
this.current = undefined
return this.persist(undefined)
}
private persist(snapshot: KanbanSnapshot | undefined): Promise<void> {
this.writes = this.writes
.then(() => this.workspaceState.update(KANBAN_SNAPSHOT_KEY, snapshot))
.catch((error: unknown) => {
logger.add({
level: 'error',
source: 'kanban',
message: '保存看板快照失败',
details: error instanceof Error ? error.message : String(error),
})
})
return this.writes
}
}
const states = new WeakMap<Memento, KanbanState>()
export function getKanbanState(workspaceState: Memento): KanbanState {
let state = states.get(workspaceState)
if (!state) {
state = new KanbanState(workspaceState)
states.set(workspaceState, state)
}
return state
}
+2
View File
@@ -95,6 +95,7 @@ export type ExtensionToWebview
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -180,6 +181,7 @@ export type WebviewToExtension
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
+45
View File
@@ -38,6 +38,51 @@ function fakeCtx(dir: string, memento: Record<string, unknown> = {}): ExtensionC
}
describe('saveSettings 落盘', () => {
it('目录配置落盘后才切换候选目录,随后通知问答刷新', async () => {
const ctx = fakeCtx(tempDir())
const profileModule = await import('../cc/profiles.js')
const setDirectory = vi.spyOn(profileModule, 'setProfilesDirOverride').mockImplementation(() => {
expect(getSettings(ctx).profilesDirectory).toBe('/new/profiles')
})
const { onDidChangeSettings } = await import('./store.js')
const subscription = onDidChangeSettings(ctx, () => {
expect(setDirectory).toHaveBeenCalledWith('/new/profiles')
})
try {
await saveSettings(ctx, { profilesDirectory: '/new/profiles' })
expect(setDirectory).toHaveBeenCalledOnce()
}
finally {
subscription.dispose()
setDirectory.mockRestore()
}
})
it('文件问答默认模型独立落盘,重启读取且不受旧的上次选择影响', async () => {
const dir = tempDir()
const ctx = fakeCtx(dir, { 'superpowers.codeReader.askProfile': 'sonnet' })
expect(getSettings(ctx).defaultAskProfilePath).toBe('')
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
expect(JSON.parse(readFileSync(path.join(dir, 'settings.json'), 'utf8')).defaultAskProfilePath).toBe('/p/dsflash.json')
expect(getSettings(fakeCtx(dir)).defaultAskProfilePath).toBe('/p/dsflash.json')
await saveSettings(ctx, { webhookPort: 9000 })
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json')
await saveSettings(ctx, { defaultAskProfilePath: '' })
expect(getSettings(fakeCtx(dir)).defaultAskProfilePath).toBe('')
})
it('默认配置保存后通知订阅者,取消订阅后不再通知', async () => {
const ctx = fakeCtx(tempDir())
const { onDidChangeSettings } = await import('./store.js')
const listener = vi.fn(() => getSettings(ctx).defaultAskProfilePath)
const subscription = onDidChangeSettings(ctx, listener)
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' })
expect(listener).toHaveLastReturnedWith('/p/sonnet.json')
subscription.dispose()
await saveSettings(ctx, { defaultAskProfilePath: '' })
expect(listener).toHaveBeenCalledTimes(1)
})
it('写入 globalStorage 的 settings.json,清空 memento 后仍能读到 commitProfilePath', async () => {
const dir = tempDir()
const ctx = fakeCtx(dir)
+24 -2
View File
@@ -11,12 +11,13 @@
* a field.
*/
import type { ExtensionContext } from 'vscode'
import type { Disposable, ExtensionContext } from 'vscode'
import type { MigrationNote } from './promptMigration'
import { EventEmitter } from 'node:events'
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { seedCommitProfilePath } from '../cc/commitProfile'
import { listClaudeProfiles } from '../cc/profiles'
import { listClaudeProfiles, setProfilesDirOverride } from '../cc/profiles'
import { normalizeReleasePrefix } from '../git/release'
import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt'
import { planPromptMigration, planReviewPromptMigration } from './promptMigration'
@@ -210,6 +211,7 @@ export interface Settings {
defaultTestProfilePath: string
/** 原型会话的默认 Claude profile;工单级「原型配置文件」为空时用它。 */
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
/**
* Claude settings profile path for the conflict-resolution cc session
* (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH.
@@ -314,6 +316,7 @@ function defaults(ctx: ExtensionContext): Settings {
defaultImplementProfilePath: '',
defaultTestProfilePath: '',
defaultPrototypeProfilePath: '',
defaultAskProfilePath: '',
conflictResolutionProfilePath: '',
commitProfilePath: '',
prDiffSummaryProfilePath: '',
@@ -429,6 +432,9 @@ export function getSettings(ctx: ExtensionContext): Settings {
const defaultPrototypeProfilePath = typeof stored.defaultPrototypeProfilePath === 'string'
? stored.defaultPrototypeProfilePath
: base.defaultPrototypeProfilePath
const defaultAskProfilePath = typeof stored.defaultAskProfilePath === 'string'
? stored.defaultAskProfilePath.trim()
: base.defaultAskProfilePath
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH at
// launch time), so don't coerce. Non-string legacy values collapse to ''.
const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string'
@@ -489,6 +495,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
defaultImplementProfilePath,
defaultTestProfilePath,
defaultPrototypeProfilePath,
defaultAskProfilePath,
conflictResolutionProfilePath,
commitProfilePath,
prDiffSummaryProfilePath,
@@ -524,9 +531,24 @@ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext
return seedCommitProfilePath(undefined, profiles)
}
const settingsChanges = new EventEmitter()
export function onDidChangeSettings(ctx: ExtensionContext, listener: () => void): Disposable {
const key = settingsFilePath(ctx)
settingsChanges.on(key, listener)
return {
dispose: () => {
settingsChanges.off(key, listener)
},
}
}
export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings>): Promise<void> {
const merged: Partial<Settings> = { ...readStoredSettings(ctx), ...next }
writeStoredSettings(ctx, merged)
if (next.profilesDirectory !== undefined)
setProfilesDirOverride(next.profilesDirectory.trim() || undefined)
settingsChanges.emit(settingsFilePath(ctx))
}
/**
+49
View File
@@ -0,0 +1,49 @@
import type { Issue } from '../gitea/types'
import { describe, expect, it } from 'vitest'
import { applyIssueUpdate } from './kanbanUpdates'
function issue(number: number, extra: Partial<Issue> = {}): Issue {
return { id: `repo#${number}`, number, title: `Issue ${number}`, column: 'review', htmlUrl: '', ...extra }
}
describe('applyIssueUpdate', () => {
it('按 number 修改并删除 null 字段,不改变原数组、卡片或补丁', () => {
const first = Object.freeze(issue(1, { pr: '12', reviewSessionId: 'session' }))
const second = Object.freeze(issue(2))
const current = Object.freeze([first, second])
const patch = Object.freeze({ column: 'done', pr: null, reviewSessionId: null })
const result = applyIssueUpdate(current, { type: 'issue/patch', issueNumber: 1, patch })
expect(result).toEqual([issue(1, { column: 'done' }), second])
expect(result[0]).not.toHaveProperty('pr')
expect(result[0]).not.toHaveProperty('reviewSessionId')
expect(result[1]).toBe(second)
expect(first.pr).toBe('12')
expect(patch.pr).toBeNull()
})
it('未知工单的补丁不新增卡片', () => {
const current = [issue(1)]
expect(applyIssueUpdate(current, { type: 'issue/patch', issueNumber: 2, patch: { column: 'done' } })).toEqual(current)
})
it('append 同号卡片原位整对象替换,不保留旧字段', () => {
const current = Object.freeze([issue(1), issue(2, { pr: '12' }), issue(3)])
const replacement = Object.freeze(issue(2, { id: 'new-id', column: 'done' }))
const result = applyIssueUpdate(current, { type: 'issue/append', issue: replacement })
expect(result).toEqual([current[0], replacement, current[2]])
expect(result[1]).not.toHaveProperty('pr')
expect(current[1].pr).toBe('12')
})
it('append 新编号即追加,不按 id 去重', () => {
const first = issue(1)
const next = issue(2, { id: first.id })
expect(applyIssueUpdate([first], { type: 'issue/append', issue: next })).toEqual([first, next])
})
it('remove 删除所有同号卡片,不改变原数组', () => {
const current = Object.freeze([issue(1), issue(2), issue(1, { id: 'another' })])
expect(applyIssueUpdate(current, { type: 'issue/remove', issueNumber: 1 })).toEqual([current[1]])
expect(current).toHaveLength(3)
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { Issue } from '../gitea/types'
export type IssueUpdate
= | { type: 'issue/patch', issueNumber: number, patch: Record<string, unknown> }
| { type: 'issue/append', issue: Issue }
| { type: 'issue/remove', issueNumber: number }
export function isIssueUpdate(message: { type: string }): message is IssueUpdate {
return message.type === 'issue/patch' || message.type === 'issue/append' || message.type === 'issue/remove'
}
export function applyIssueUpdate(issues: readonly Issue[], message: IssueUpdate): Issue[] {
switch (message.type) {
case 'issue/patch':
return issues.map((issue) => {
if (issue.number !== message.issueNumber)
return issue
const next = { ...issue, ...message.patch } as Issue & Record<string, unknown>
for (const [key, value] of Object.entries(message.patch)) {
if (value === null)
delete next[key]
}
return next
})
case 'issue/append':
return issues.some(issue => issue.number === message.issue.number)
? issues.map(issue => issue.number === message.issue.number ? message.issue : issue)
: [...issues, message.issue]
case 'issue/remove':
return issues.filter(issue => issue.number !== message.issueNumber)
}
}
@@ -0,0 +1,198 @@
import type { ExtensionContext } from 'vscode'
import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { WebhookEvent } from './eventParser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { KANBAN_SNAPSHOT_KEY } from '../panel/kanbanSnapshot'
import { getKanbanState } from '../panel/kanbanState'
import { webhookCoordinator } from './coordinator'
const mocks = vi.hoisted(() => ({
receive: undefined as ((event: WebhookEvent) => void) | undefined,
token: vi.fn(),
loadSingle: vi.fn(),
readState: vi.fn(),
log: vi.fn(),
}))
vi.mock('vscode', () => ({ env: {}, Uri: {}, window: { showInformationMessage: async () => undefined }, workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] } }))
vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } }))
vi.mock('../auth/secrets', () => ({ getToken: mocks.token }))
vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' }))
vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' }))
vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) }))
vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) }))
vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle }))
vi.mock('../gitea/stateJson', async importOriginal => ({
hasStateColumn: (await importOriginal<typeof import('../gitea/stateJson')>()).hasStateColumn,
readStateJsonComment: mocks.readState,
mergeStateJsonComment: async () => undefined,
}))
vi.mock('../issues/localState', () => ({ overlayLocalIssueState: (_ctx: unknown, issues: unknown[]) => issues, getLocalIssueState: () => ({}) }))
vi.mock('../panel/handlers/issueChat', () => ({}))
vi.mock('../panel/handlers/terminals', () => ({ hasLiveIssueSessionTerminal: () => false }))
vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues }))
vi.mock('../settings/store', () => ({ getSettings: () => ({ webhookSecret: '', webhookPort: 12345, devBranch: 'main', releaseBranchPrefix: 'release/', autoReview: true }) }))
vi.mock('./server', () => ({
WebhookServer: class {
setSecret() {}
start() { return Promise.resolve() }
stop() { return Promise.resolve() }
onEvent(receive: (event: WebhookEvent) => void) {
mocks.receive = receive
return { dispose() {} }
}
},
}))
const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false }
const events: WebhookEvent[] = [
{ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} },
{ kind: 'issue_comment', action: 'created', issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} },
{ kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', raw: {} },
]
function fixture() {
const disk = new Map<string, unknown>([[KANBAN_SNAPSHOT_KEY, cached]])
const update = vi.fn(async (key: string, value: unknown) => {
if (value === undefined)
disk.delete(key)
else
disk.set(key, value)
})
const context = { workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext
webhookCoordinator.init(context)
return { disk, update, context }
}
beforeEach(() => {
vi.clearAllMocks()
mocks.token.mockReset().mockResolvedValue(undefined)
mocks.loadSingle.mockReset()
mocks.readState.mockReset().mockResolvedValue({ column: 'review', autoReview: true })
})
afterEach(async () => {
await webhookCoordinator.dispose()
})
describe('coordinator cache invalidation', () => {
it.each(events)('无面板收到 $kind 后删除持久快照', async (event) => {
const f = fixture()
mocks.receive!(event)
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
expect(f.update).toHaveBeenCalledWith(KANBAN_SNAPSHOT_KEY, undefined)
})
it('无面板事件立即清共享内存,删除必须等待正在保存的快照', async () => {
const f = fixture()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
f.update.mockImplementationOnce(async (key, value) => {
await gate
f.disk.set(key, value)
})
const state = getKanbanState(f.context.workspaceState)
const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false })
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
mocks.receive!(events[0])
expect(getKanbanState(f.context.workspaceState).snapshot).toBeUndefined()
expect(f.update).toHaveBeenCalledOnce()
release()
await saving
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
expect(f.update).toHaveBeenNthCalledWith(2, KANBAN_SNAPSHOT_KEY, undefined)
})
it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} })
expect(state.snapshot).toBeUndefined()
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
})
it.each(['created', 'edited', 'deleted'])('无面板收到普通讨论 %s 保留有效缓存', async (action) => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
const snapshot = state.snapshot
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '谢谢', commentAuthor: 'other', commentHtmlUrl: '', raw: {} })
await Promise.resolve()
expect(state.snapshot).toBe(snapshot)
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
it('读取单卡期间关闭面板,事件结束后不保留过期快照', async () => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
let release!: (value: unknown) => void
mocks.token.mockResolvedValue('token')
mocks.loadSingle.mockReturnValue(new Promise((resolve) => {
release = resolve
}))
const panel = { noteIssueOwnership: vi.fn(), postMessage: vi.fn() } as unknown as KanbanWebviewPanel
webhookCoordinator.setActivePanel(panel)
mocks.receive!(events[0])
await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce())
webhookCoordinator.setActivePanel(undefined)
release({ id: 'repo#462', number: 462, title: 'merged', column: 'done', htmlUrl: '' })
await vi.waitFor(() => expect(state.snapshot).toBeUndefined())
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
})
it('同一面板持续打开时仍可启动自动审查', async () => {
fixture()
mocks.token.mockResolvedValue('token')
const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
webhookCoordinator.setActivePanel(panel as unknown as KanbanWebviewPanel)
mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} })
await vi.waitFor(() => expect(panel.triggerAutoReviewTab).toHaveBeenCalledOnce())
})
it.each([
{ pendingRead: 1, reopen: false },
{ pendingRead: 1, reopen: true },
{ pendingRead: 2, reopen: false },
{ pendingRead: 2, reopen: true },
{ pendingRead: 3, reopen: false },
{ pendingRead: 3, reopen: true },
])('自动审查第 $pendingRead 次状态读取期间关闭,重开=$reopen,不启动或转移旧任务', async ({ pendingRead, reopen }) => {
fixture()
let release!: (value: Record<string, unknown>) => void
const pending = new Promise<Record<string, unknown>>((resolve) => {
release = resolve
})
let reads = 0
mocks.token.mockResolvedValue('token')
mocks.readState.mockImplementation(() => ++reads === pendingRead ? pending : Promise.resolve({ column: 'review', autoReview: true }))
const first = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
const second = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined), reloadAfterInvalidation: vi.fn().mockResolvedValue(undefined) }
webhookCoordinator.setActivePanel(first as unknown as KanbanWebviewPanel)
mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} })
await vi.waitFor(() => expect(mocks.readState).toHaveBeenCalledTimes(pendingRead))
webhookCoordinator.setActivePanel(undefined)
if (reopen)
webhookCoordinator.setActivePanel(second as unknown as KanbanWebviewPanel)
release({ column: 'review', autoReview: true })
await vi.waitFor(() => expect(mocks.log.mock.calls.some(([entry]) => entry.message.includes('跳过审查 #462') || entry.message.includes('审查 tab 已就绪 #462'))).toBe(true))
expect(first.triggerAutoReviewTab).not.toHaveBeenCalled()
expect(second.triggerAutoReviewTab).not.toHaveBeenCalled()
})
it('普通 push 不清看板', async () => {
const f = fixture()
mocks.receive!({ kind: 'push', branch: 'main', raw: {} })
await Promise.resolve()
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
it('有面板时保留快照,由现有事件处理路径更新', async () => {
const f = fixture()
webhookCoordinator.setActivePanel({} as KanbanWebviewPanel)
mocks.receive!(events[2])
await Promise.resolve()
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
})
@@ -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)
})
})
+252 -215
View File
@@ -9,17 +9,19 @@
* 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'
import { AsyncLocalStorage } from 'node:async_hooks'
import { execFile } from 'node:child_process'
import { promises as fsp } from 'node:fs'
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'
@@ -29,8 +31,9 @@ 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'
import { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership'
@@ -52,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 对应)。
@@ -100,12 +145,30 @@ function describeMarkers(patch: MarkerPatch): string {
*/
type ResolvedWebhookEvent = Omit<PrWebhookEvent, 'issueNumber'> & { issueNumber: number }
function changesKanban(event: WebhookEvent): boolean {
switch (event.kind) {
case 'push':
return false
case 'issue':
return ISSUE_ACTIONS.includes(event.action)
case 'issue_comment':
return ['created', 'edited', 'deleted'].includes(event.action)
&& event.prNumber === undefined && hasStateColumn(event.commentBody)
case 'pr':
return ['opened', 'reopened', 'synchronize', 'synchronized', 'closed', 'deleted'].includes(event.action)
&& (event.issueNumber !== undefined || parseClosingIssueNumber(event.body) !== undefined || event.branch !== '')
}
}
class WebhookCoordinator {
private ctx?: ExtensionContext
private readonly issueCreations = new Map<string, IssueCreationEntry>()
private server?: WebhookServer
// kept for legacy cleanup; new flow uses one shared webhook
private pendingHooks = new Map<number, PendingHook>()
private activePanel: KanbanWebviewPanel | undefined
private livePanel: KanbanWebviewPanel | undefined
private readonly eventPanels = new AsyncLocalStorage<{ panel: KanbanWebviewPanel | undefined }>()
private boardEventsInFlight = 0
private initialized = false
private eventSubscription?: { dispose: () => void }
/** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */
@@ -190,7 +253,8 @@ class WebhookCoordinator {
})
}
}
this.activePanel = undefined
this.livePanel = undefined
this.issueCreations.clear()
this.initialized = false
logger.add({
level: 'info',
@@ -250,9 +314,121 @@ 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 {
this.activePanel = p
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 {
const event = this.eventPanels.getStore()
if (event && event.panel !== this.livePanel)
return undefined
return this.livePanel
}
private async persist(): Promise<void> {
@@ -432,8 +608,31 @@ class WebhookCoordinator {
return
}
const ctx = this.ctx
const panel = this.livePanel
const affectsBoard = changesKanban(event)
if (affectsBoard) {
this.boardEventsInFlight++
if (!panel)
void getKanbanState(ctx.workspaceState).invalidate()
}
try {
await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx))
}
finally {
if (affectsBoard) {
this.boardEventsInFlight--
if (this.livePanel !== panel) {
void getKanbanState(ctx.workspaceState).invalidate()
void this.livePanel?.reloadAfterInvalidation()
}
}
}
}
private async dispatchEvent(event: WebhookEvent, ctx: ExtensionContext): Promise<void> {
if (event.kind === 'push') {
const s = getSettings(this.ctx)
const s = getSettings(ctx)
logger.add({
level: 'info',
source: 'webhook',
@@ -451,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({
@@ -534,197 +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
if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
if (pending) {
this.activePanel.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 关闭按钮立即出现。
this.activePanel.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,
})
}
}
}
@@ -740,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
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
@@ -783,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',
@@ -797,7 +820,7 @@ class WebhookCoordinator {
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
})
// 卡本来不在板上时前端 remove 是无害 no-op。
this.activePanel.postMessage({ type: 'issue/remove', issueNumber })
this.activePanel?.postMessage({ type: 'issue/remove', issueNumber })
return
}
try {
@@ -812,9 +835,12 @@ class WebhookCoordinator {
const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue })
const panel = this.activePanel
if (issue && panel) {
panel.noteIssueOwnership(issue)
panel.postMessage({ type: 'issue/append', issue: panel.withLiveTerminalTabState([issue])[0], ...(opts.select ? { select: true } : {}) })
if (creation)
this.restoreIssueCreationTerminals(panel)
}
}
catch (err) {
@@ -832,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,
})
}
@@ -844,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,
})
}
@@ -1739,7 +1767,8 @@ class WebhookCoordinator {
})
}
if (!this.activePanel) {
const panel = this.activePanel
if (!panel) {
logger.add({
level: 'warn',
source: 'webhook',
@@ -1794,7 +1823,15 @@ class WebhookCoordinator {
const prompt = renderReviewPrompt(this.ctx, { state, issueNumber: String(issueNumber), prNumber })
await this.activePanel.triggerAutoReviewTab({
if (this.activePanel !== panel) {
logger.add({
level: 'info',
source: 'webhook',
message: `面板已关闭或重建,跳过审查 #${issueNumber}`,
})
return
}
await panel.triggerAutoReviewTab({
issueNumber,
prNumber,
prompt,
+8
View File
@@ -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 })
})
})
+14
View File
@@ -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')
})
})
+37
View File
@@ -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()
+1
View File
@@ -605,6 +605,7 @@ export function App() {
initialDefaultImplementProfilePath={settings?.defaultImplementProfilePath ?? ''}
initialDefaultTestProfilePath={settings?.defaultTestProfilePath ?? ''}
initialDefaultPrototypeProfilePath={settings?.defaultPrototypeProfilePath ?? ''}
initialDefaultAskProfilePath={settings?.defaultAskProfilePath ?? ''}
initialConflictResolutionProfilePath={settings?.conflictResolutionProfilePath ?? ''}
initialCommitProfilePath={settings?.commitProfilePath ?? ''}
initialPrDiffSummaryProfilePath={settings?.prDiffSummaryProfilePath ?? ''}
@@ -0,0 +1,71 @@
import type { SettingsModalProps } from './SettingsModal'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { SettingsModal } from './SettingsModal'
const PROPS: SettingsModalProps = {
open: true,
host: 'gitea.example.com',
initialTokenSaved: true,
initialWebhookPort: 8080,
initialWebhookSecret: '',
initialBrainstormPrompt: '',
initialImplementPlanPrompt: '',
initialAutoReview: false,
initialReviewPrompt: '',
initialTestPrompt: '',
initialPrototypePrompt: '',
initialDevBranch: 'main',
initialReleaseBranchPrefix: 'release/',
initialJenkinsJob: '',
initialJenkinsProdHost: '',
initialWorktreeDirectory: '',
initialWorktreeOpenWith: 'vscode',
initialPycharmCommand: '',
initialWorktreePostCreateScript: '',
initialWorktreePreRemoveScript: '',
initialImplTabPreCreateScript: '',
initialImplTabPostCloseScript: '',
initialSplitNewTerminals: false,
initialDefaultBrainstormProfilePath: '',
initialDefaultImplementProfilePath: '',
initialDefaultTestProfilePath: '',
initialDefaultPrototypeProfilePath: '',
initialDefaultAskProfilePath: '/p/sonnet.json',
initialConflictResolutionProfilePath: '',
initialCommitProfilePath: '',
initialPrDiffSummaryProfilePath: '',
initialCodexModel: '',
initialCodexReasoningEffort: '',
initialProfilesDirectory: '',
initialYoutrackBaseUrl: '',
initialYoutrackProjectShortName: '',
initialYoutrackCloseCommand: '',
initialYoutrackTokenSaved: false,
profiles: [{ name: 'sonnet', path: '/p/sonnet.json' }],
youtrackProjects: { status: 'idle', projects: [] },
onListYouTrackProjects: () => {},
onSubmit: () => {},
}
function field(props: Partial<SettingsModalProps> = {}): string {
const html = renderToStaticMarkup(<SettingsModal {...PROPS} {...props} />)
expect(html).toContain('文件问答默认模型')
return html.slice(html.indexOf('文件问答默认模型'), html.indexOf('冲突解决配置文件'))
}
describe('设置面板的文件问答默认模型', () => {
it('与其他默认配置在同一区域,显示保存的名称并说明临时切换不会改默认', () => {
const html = field()
expect(html).toContain('sonnet')
expect(html).toContain('问这个文件')
expect(html).toContain('临时切换')
expect(html).toContain('不会修改这里的默认值')
expect(html).toContain('aria-haspopup="listbox"')
})
it('未配置时显示官方默认,失效路径不能伪装成默认', () => {
expect(field({ initialDefaultAskProfilePath: '' })).toContain('(默认)')
expect(field({ initialDefaultAskProfilePath: '/p/deleted.json' })).toContain('不可用:/p/deleted.json')
})
})
@@ -42,6 +42,7 @@ interface SubmitValues {
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -97,6 +98,7 @@ export interface SettingsModalProps {
initialDefaultImplementProfilePath: string
initialDefaultTestProfilePath: string
initialDefaultPrototypeProfilePath: string
initialDefaultAskProfilePath: string
initialConflictResolutionProfilePath: string
initialCommitProfilePath: string
initialPrDiffSummaryProfilePath: string
@@ -189,6 +191,7 @@ export function SettingsModal({
initialDefaultImplementProfilePath,
initialDefaultTestProfilePath,
initialDefaultPrototypeProfilePath,
initialDefaultAskProfilePath,
initialConflictResolutionProfilePath,
initialCommitProfilePath,
initialPrDiffSummaryProfilePath,
@@ -232,6 +235,7 @@ export function SettingsModal({
const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath)
const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath)
const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath)
const [defaultAskProfilePath, setDefaultAskProfilePath] = useState(initialDefaultAskProfilePath)
const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath)
const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath)
const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath)
@@ -277,6 +281,7 @@ export function SettingsModal({
setDefaultImplementProfilePath(initialDefaultImplementProfilePath)
setDefaultTestProfilePath(initialDefaultTestProfilePath)
setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath)
setDefaultAskProfilePath(initialDefaultAskProfilePath)
setConflictResolutionProfilePath(initialConflictResolutionProfilePath)
setCommitProfilePath(initialCommitProfilePath)
setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath)
@@ -374,6 +379,7 @@ export function SettingsModal({
defaultImplementProfilePath: defaultImplementProfilePath.trim(),
defaultTestProfilePath: defaultTestProfilePath.trim(),
defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(),
defaultAskProfilePath: defaultAskProfilePath.trim(),
// 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH
conflictResolutionProfilePath: conflictResolutionProfilePath.trim(),
// 提交代码 profile:空串 = 未配置 → 提交严格失败
@@ -656,6 +662,26 @@ export function SettingsModal({
/>
</Field>
<Field
label="文件问答默认模型"
hint="代码阅读底部「问这个文件」默认使用此模型,也用于代码块和文件夹问答;留空沿用官方默认。输入框旁可临时切换,不会修改这里的默认值;重启或修改此设置后恢复默认。"
>
<SelectMenu
value={defaultAskProfilePath}
onChange={setDefaultAskProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultAskProfilePath
&& !profiles.some(p => p.path === defaultAskProfilePath)
? [{ value: defaultAskProfilePath, label: `不可用:${defaultAskProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="冲突解决配置文件"
hint={(
+8 -18
View File
@@ -21,19 +21,10 @@ import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/me
import type { Issue, IssueColumn } from '../types'
import { useCallback, useEffect, useRef, useState } from 'react'
import { handoffRequestNotices } from '../../../src/panel/handlers/handoffRequest'
import { applyIssueUpdate } from '../../../src/shared/kanbanUpdates'
import { compareIssuesInColumn } from '../lib/issueSort'
import { onMessage, postMessage } from '../lib/vscode'
/** postMessage 用 null 清空 optional 字段;合并时 delete 还原 optional,避免残留 null */
function applyIssuePatch(issue: Issue, patch: Record<string, unknown>): Issue {
const next = { ...issue, ...patch } as Issue & Record<string, unknown>
for (const [k, v] of Object.entries(patch)) {
if (v === null)
delete next[k]
}
return next as Issue
}
export interface SettingsValues {
host: string
token: string
@@ -61,6 +52,7 @@ export interface SettingsValues {
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -102,6 +94,7 @@ export interface SettingsOverlayState {
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -465,6 +458,7 @@ export function useIssues(): UseIssuesResult {
defaultImplementProfilePath: values.defaultImplementProfilePath,
defaultTestProfilePath: values.defaultTestProfilePath,
defaultPrototypeProfilePath: values.defaultPrototypeProfilePath,
defaultAskProfilePath: values.defaultAskProfilePath,
conflictResolutionProfilePath: values.conflictResolutionProfilePath,
commitProfilePath: values.commitProfilePath,
prDiffSummaryProfilePath: values.prDiffSummaryProfilePath,
@@ -890,9 +884,7 @@ export function useIssues(): UseIssuesResult {
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === msg.issueNumber ? applyIssuePatch(i, msg.patch) : i,
),
issues: applyIssueUpdate(prev.issues, msg),
}
})
if (msg.patch.prDiffFile)
@@ -915,7 +907,7 @@ export function useIssues(): UseIssuesResult {
if (prev.status !== 'ready')
return prev
const removed = prev.issues.find(i => i.number === msg.issueNumber)
const remaining = prev.issues.filter(i => i.number !== msg.issueNumber)
const remaining = applyIssueUpdate(prev.issues, msg)
if (removed) {
const sameCol = remaining
.filter(i => i.column === removed.column)
@@ -931,12 +923,9 @@ export function useIssues(): UseIssuesResult {
setState((prev) => {
if (prev.status !== 'ready')
return prev
const exists = prev.issues.some(i => i.number === msg.issue.number)
return {
status: 'ready',
issues: exists
? prev.issues.map(i => i.number === msg.issue.number ? msg.issue : i)
: [...prev.issues, msg.issue],
issues: applyIssueUpdate(prev.issues, msg),
}
})
if (msg.select)
@@ -991,6 +980,7 @@ export function useIssues(): UseIssuesResult {
defaultImplementProfilePath: msg.defaultImplementProfilePath,
defaultTestProfilePath: msg.defaultTestProfilePath,
defaultPrototypeProfilePath: msg.defaultPrototypeProfilePath,
defaultAskProfilePath: msg.defaultAskProfilePath,
conflictResolutionProfilePath: msg.conflictResolutionProfilePath,
commitProfilePath: msg.commitProfilePath,
prDiffSummaryProfilePath: msg.prDiffSummaryProfilePath,
+2
View File
@@ -134,6 +134,7 @@ export type ExtensionToWebview
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -219,6 +220,7 @@ export type WebviewToExtension
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string