♻️ refactor(vscode): 工单来源 gitea 改名 forge,激活时把本机按旧名存的记录迁过去

This commit is contained in:
cruldra committed 2026-10-19 08:00:29 +11:00
1 parent 14bee9f505
commit 451bc91a6b
24 files changed
+234 -90

No files matched your search

@@ -41,7 +41,7 @@ describe('选工单弹框列哪些工单', () => {
const issues = [
{ number: 12, column: 'review' as const, source: 'youtrack' as const },
{ number: 13, column: 'in-progress' as const, source: 'youtrack' as const, pr: '14' },
{ number: 446, column: 'review' as const, source: 'gitea' as const },
{ number: 446, column: 'review' as const, source: 'forge' as const },
{ number: 438, column: 'review' as const },
]
expect(reviewCandidates(issues).map(item => item.number)).toEqual([446, 438])
+1 -1
View File
@@ -36,7 +36,7 @@ function inReviewColumns(column: IssueColumn): boolean {
export function reviewCandidates<T extends Pick<Issue, 'number' | 'column' | 'source' | 'pr'>>(issues: readonly T[]): T[] {
const order = (issue: T): number => REVIEW_COLUMNS.findIndex(item => item.column === issue.column)
return issues
.filter(issue => (issue.source ?? 'gitea') === 'gitea'
.filter(issue => (issue.source ?? 'forge') === 'forge'
&& inReviewColumns(issue.column)
&& (issue.column === 'review' || prNumberOf(issue) !== undefined))
.sort((a, b) => order(a) - order(b) || b.number - a.number)
+6 -1
View File
@@ -9,6 +9,7 @@ import { commands, EventEmitter, window } from 'vscode'
import { initIdentity } from './auth/identity'
import { setProfilesDirOverride } from './cc/profiles'
import { registerCodeReader } from './codeReader'
import { migrateLegacySourceKeys } from './issues/localState'
import { logger } from './logging/logger'
import { CONFIGURE_HANDOFF_STORE_COMMAND, configureHandoffStore } from './oss/handoffStoreSetup'
import { KanbanWebviewPanel } from './panel/KanbanPanel'
@@ -40,6 +41,10 @@ export interface SuperpowersApi {
}
export function activate(context: ExtensionContext): SuperpowersApi {
// 工单来源改名(gitea → forge)前存下的本机记录先挪到新名下,后面读本机状态的地方才认得。
void migrateLegacySourceKeys(context.workspaceState).catch((err: unknown) => {
logger.add({ level: 'error', source: 'settings', message: '本机工单记录迁移失败', details: err instanceof Error ? err.message : String(err) })
})
// Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。
setProfilesDirOverride(getSettings(context).profilesDirectory || undefined)
// 存量提示词先迁到 Handlebars,再让 persistSeededProfiles 在迁移结果上补 seed。
@@ -53,7 +58,7 @@ export function activate(context: ExtensionContext): SuperpowersApi {
context.subscriptions.push(logger, {
dispose: () => { void webhookCoordinator.dispose() },
})
// 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。
// 状态栏常驻展示当前代码托管仓库的身份;token 用错人(同步/复制)时第一眼能看出来。
void initIdentity(context)
// vscode://clurdra.superpowers-vscode-clurdra/<path> 外部入口:开 cc 会话 tab、从 env 文件配置移交存储。
+95
View File
@@ -0,0 +1,95 @@
import type { ExtensionContext, Memento } from 'vscode'
import { describe, expect, it, vi } from 'vitest'
import { getLocalIssueState, migrateLegacySourceKeys } from './localState'
/** 内存里的 workspaceState,记下每次 update,方便断言迁移有没有动手。 */
function memento(initial: Record<string, unknown>): Memento & { data: Map<string, unknown>, update: ReturnType<typeof vi.fn> } {
const data = new Map(Object.entries(initial))
return {
data,
keys: () => [...data.keys()],
get: <T>(key: string, fallback?: T) => (data.has(key) ? data.get(key) as T : fallback) as T,
update: vi.fn(async (key: string, value: unknown) => {
if (value === undefined)
data.delete(key)
else
data.set(key, value)
}),
}
}
const STORE = 'superpowers.localIssueState'
describe('migrateLegacySourceKeys', () => {
it('把本机工单状态里 gitea:N 的记录挪到 forge:N,别的来源不动', async () => {
const state = memento({
[STORE]: {
'gitea:1': { sessionId: 's1' },
'forge:2': { worktreePath: '/w2' },
'youtrack:1000003': { sessionId: 'y3' },
},
})
await migrateLegacySourceKeys(state)
expect(state.data.get(STORE)).toEqual({
'forge:1': { sessionId: 's1' },
'forge:2': { worktreePath: '/w2' },
'youtrack:1000003': { sessionId: 'y3' },
})
})
it('新旧两份都有时新的字段说了算,旧的只补缺', async () => {
const state = memento({
[STORE]: {
'gitea:4': { sessionId: 'old', worktreePath: '/old' },
'forge:4': { sessionId: 'new' },
},
})
await migrateLegacySourceKeys(state)
expect(state.data.get(STORE)).toEqual({ 'forge:4': { sessionId: 'new', worktreePath: '/old' } })
})
it('审查会话的启动记录也换成新来源名,已有新记录时只删旧的', async () => {
const state = memento({
'superpowers.reviewSessionLaunch.gitea:5': { startedAt: 1 },
'superpowers.reviewSessionLaunch.gitea:6': { startedAt: 2 },
'superpowers.reviewSessionLaunch.forge:6': { startedAt: 3 },
'superpowers.reviewSessionLaunch.youtrack:1000007': { startedAt: 4 },
'superpowers.kanbanScope': 'all',
})
await migrateLegacySourceKeys(state)
expect(Object.fromEntries(state.data)).toEqual({
'superpowers.reviewSessionLaunch.forge:5': { startedAt: 1 },
'superpowers.reviewSessionLaunch.forge:6': { startedAt: 3 },
'superpowers.reviewSessionLaunch.youtrack:1000007': { startedAt: 4 },
'superpowers.kanbanScope': 'all',
})
})
it('迁过一次再跑什么都不写', async () => {
const state = memento({
[STORE]: { 'gitea:1': { sessionId: 's1' } },
'superpowers.reviewSessionLaunch.gitea:5': { startedAt: 1 },
})
await migrateLegacySourceKeys(state)
state.update.mockClear()
await migrateLegacySourceKeys(state)
expect(state.update).not.toHaveBeenCalled()
})
it('迁移后按新来源名、也按缺省来源读得到', async () => {
const state = memento({ [STORE]: { 'gitea:9': { sessionId: 's9' } } })
await migrateLegacySourceKeys(state)
const ctx = { workspaceState: state } as unknown as ExtensionContext
expect(getLocalIssueState(ctx, 'forge', 9)).toEqual({ sessionId: 's9' })
expect(getLocalIssueState(ctx, undefined, 9)).toEqual({ sessionId: 's9' })
})
})
+52 -7
View File
@@ -2,12 +2,12 @@
* 机器本地的工单状态(会话 id、worktree 路径、profile 路径等)。
*
* 这些字段描述"这台机器上"的会话与文件系统,跨机毫无语义(别人机器的
* 绝对路径/会话 id 在本机不可用),写进 Gitea 共享评论只会互相污染——
* 绝对路径/会话 id 在本机不可用),写进共享的工单评论只会互相污染——
* 所以落在 workspaceState,按 `source:number` 键控。共享评论里的旧数据
* 仍按读兜底(本地无记录时透出),写侧从此只产生本地记录。
*/
import type { ExtensionContext } from 'vscode'
import type { ExtensionContext, Memento } from 'vscode'
import type { Issue } from './types'
import * as fs from 'node:fs'
import { resolveWorktreePath } from '../git/worktree'
@@ -35,8 +35,53 @@ const KEY = 'superpowers.localIssueState'
type Store = Record<string, Record<string, string>>
function refKey(source: string | undefined, issueNumber: number): string {
return `${source ?? 'gitea'}:${issueNumber}`
/** 本机按工单存的记录用的键:`来源:工单号`,缺省来源是代码托管仓库(`forge`)。 */
export function localIssueKey(source: string | undefined, issueNumber: number): string {
return `${source ?? 'forge'}:${issueNumber}`
}
/** 代码托管仓库的工单以前叫 `gitea` 来源,本机记录的键里还带着这个旧名。 */
const LEGACY_SOURCE_PREFIX = 'gitea:'
/** workspaceState 里按工单存的单条记录(如审查会话的启动记录),键以 `.gitea:<工单号>` 结尾的是旧来源名。 */
const LEGACY_KEY_SUFFIX = /\.gitea:(\d+)$/
/**
* 把本机按旧来源名 `gitea` 存的记录挪到 `forge` 名下,激活时跑一次;跑过之后再跑什么都不写。
*
* 新旧两份都在时新的说了算:本机状态按字段合并、新字段优先,单条记录直接丢掉旧的。
* 所有写入在同一轮里发出,内存里的值立刻就是迁移后的,不用等落盘。
*/
export async function migrateLegacySourceKeys(state: Memento): Promise<void> {
const writes: Thenable<void>[] = []
const store = state.get<Store>(KEY)
if (store && Object.keys(store).some(key => key.startsWith(LEGACY_SOURCE_PREFIX))) {
const next: Store = {}
for (const [key, fields] of Object.entries(store)) {
if (!key.startsWith(LEGACY_SOURCE_PREFIX))
next[key] = fields
}
for (const [key, fields] of Object.entries(store)) {
if (key.startsWith(LEGACY_SOURCE_PREFIX)) {
const target = `forge:${key.slice(LEGACY_SOURCE_PREFIX.length)}`
next[target] = { ...fields, ...next[target] }
}
}
writes.push(state.update(KEY, next))
}
for (const key of state.keys()) {
const match = LEGACY_KEY_SUFFIX.exec(key)
if (!match)
continue
const target = `${key.slice(0, match.index)}.forge:${match[1]}`
if (state.get(target) === undefined)
writes.push(state.update(target, state.get(key)))
writes.push(state.update(key, undefined))
}
await Promise.all(writes)
}
export function getLocalIssueState(
@@ -45,7 +90,7 @@ export function getLocalIssueState(
issueNumber: number,
): Record<string, string> {
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
return store[refKey(source, issueNumber)] ?? {}
return store[localIssueKey(source, issueNumber)] ?? {}
}
/**
@@ -59,7 +104,7 @@ export async function mergeLocalIssueState(
patch: Record<string, unknown>,
): Promise<void> {
const store = { ...(ctx.workspaceState.get<Store>(KEY) ?? {}) }
const key = refKey(source, issueNumber)
const key = localIssueKey(source, issueNumber)
const next = { ...(store[key] ?? {}) }
for (const [k, v] of Object.entries(patch)) {
if (typeof v === 'string')
@@ -99,7 +144,7 @@ export function overlayLocalIssueState(
): Issue[] {
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
return issues.map((issue) => {
const local = store[refKey(issue.source, issue.number)]
const local = store[localIssueKey(issue.source, issue.number)]
if (!local)
return issue
const out = { ...issue } as Issue & Record<string, unknown>
+2 -2
View File
@@ -16,8 +16,8 @@ import { mergeStateComment, readStateComment } from '../youtrack/stateComment'
import { getLocalIssueState, mergeLocalIssueState, splitLocalStateFields } from './localState'
export interface IssueRef {
/** Absent = gitea (back-compat). */
source?: 'gitea' | 'youtrack'
/** 缺省是代码托管仓库(`forge`)。 */
source?: 'forge' | 'youtrack'
/** The board's (possibly synthetic) issue number — the routing key. */
number: number
/** YouTrack readable id (`LXF-12`); required when source is youtrack. */
+13 -14
View File
@@ -1,31 +1,30 @@
/**
* Gitea issue types used by the extension host.
* 看板卡片的类型:代码托管仓库和 YouTrack 的工单都转成它。
*
* Mirrors the shape declared in `webview-ui/src/types.ts`. The two packages
* don't share types directly today; if these drift, that's a step-3 concern.
* 与 [webview 里的同名类型](vscode/webview-ui/src/types.ts#Issue) 形状一致,两边不共享代码,改一边要同步另一边。
*/
export type IssueColumn = 'todo' | 'in-progress' | 'review' | 'done'
export interface Issue {
/**
* Stable identifier: `${owner}/${repo}#${index}` (gitea) or
* Stable identifier: `${owner}/${repo}#${index}` (forge) or
* `youtrack:${idReadable}` (youtrack). Used to route mutations to the right
* source when both kinds of cards share the board.
*/
id: string
/**
* Which tracker this issue came from. Absent is treated as `'gitea'` for
* backward compatibility with state written before the second source.
* 工单来自哪里:`forge` 是工作区 origin 所在的代码托管仓库(Gitea 或 GitHub),`youtrack` 是镜像来的 YouTrack 工单;
* 缺省按 `forge` 处理。
*/
source?: 'gitea' | 'youtrack'
source?: 'forge' | 'youtrack'
/**
* YouTrack readable id (`LXF-12`) — present only on youtrack issues; needed
* for REST calls that key by readable id.
*/
externalId?: string
/**
* Equals the Gitea issue number / tea's `index` field; for youtrack it's the
* The forge issue number; for youtrack it's the
* numeric tail of `externalId` (collides across sources — never use alone as
* a key, pair with `source`).
*/
@@ -76,7 +75,7 @@ export interface Issue {
prMerged?: boolean
/** PR 合并时间(ISO 8601,来自 PR API 的 merged_at)。完成列按它降序排序,未合并为 undefined。合并时持久化进 state JSON;完成工单加载时不再实时拉 PR,直接读持久化值(缺失则按工单号回退排序)。 */
prMergedAt?: string
/** 工单关闭时间(ISO 8601,来自 Gitea issue 的 closed_at)。完成列缺 prMergedAt 时按它排序,直接关掉、没走 PR 的工单才不会沉底。 */
/** 工单关闭时间(ISO 8601,来自代码托管仓库的工单)。完成列缺 prMergedAt 时按它排序,直接关掉、没走 PR 的工单才不会沉底。 */
closedAt?: string
/** Branch name created for implementation, e.g. `feature/<hash>`. */
branch?: string
@@ -115,9 +114,9 @@ export interface Issue {
* 之后该行改为可 resume。与 PR 无关,任何阶段都能起。
*/
prototypeSessionId?: string
/** Browser URL to the Gitea issue page (escape hatch button in the UI). */
/** Browser URL to the issue page (escape hatch button in the UI). */
htmlUrl: string
/** 前置任务 issue number (Gitea dependencies 取第一个) */
/** 前置任务 issue number(工单依赖取第一个) */
prerequisite?: number
/**
* Terminal/tab color id (a `terminal.ansi*` ThemeColor key) chosen the
@@ -133,13 +132,13 @@ export interface Issue {
autoReview?: boolean
/** 最近一次审查是否通过。只影响审查列卡片上的小字,不改列。 */
reviewPassed?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
/** 指派人 login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
/** 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string
/** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
handoffBundleKey?: string
/** 发起移交的 Gitea login。 */
/** 发起移交的人的 login。 */
handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */
handoffRequestFrom?: string
+2 -2
View File
@@ -412,9 +412,9 @@ describe('kanban panel cache integration', () => {
secondProcess.resolve(42)
await loading
expect(getLocalIssueState(f.context, 'youtrack', 42).reviewSessionId).toBe('session-42')
expect(getLocalIssueState(f.context, 'gitea', 42).reviewSessionId).toBeUndefined()
expect(getLocalIssueState(f.context, 'forge', 42).reviewSessionId).toBeUndefined()
expect(f.disk.has('superpowers.reviewSessionLaunch.youtrack:42')).toBe(false)
expect(f.disk.has('superpowers.reviewSessionLaunch.gitea:42')).toBe(false)
expect(f.disk.has('superpowers.reviewSessionLaunch.forge:42')).toBe(false)
})
it('恢复会话失败时工单来源仍与当前快照一致', async () => {
+5 -5
View File
@@ -66,7 +66,7 @@ export class KanbanWebviewPanel {
private readonly panel: WebviewPanel
private readonly disposables: { dispose: () => void }[] = []
/** 按工单号保存来源,确保状态修改发往正确的工单系统。 */
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
private issueRefs = new Map<number, { source: 'forge' | 'youtrack', externalId?: string }>()
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
private issueOwners = new Map<number, IssueOwnershipFacts>()
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
@@ -1034,7 +1034,7 @@ export class KanbanWebviewPanel {
return
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...giteaIssues, ...youtrackList])
this.issueRefs = new Map(
recoveryIssues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
recoveryIssues.map(i => [i.number, { source: i.source ?? 'forge', externalId: i.externalId }] as const),
)
await sessions.recoverOpenReviewSessions(this, recoveryIssues.map(i => i.number))
if (this.activeLoad !== load || load.invalidated)
@@ -1131,12 +1131,12 @@ export class KanbanWebviewPanel {
/**
* Resolve an issue number to its source identity, for routing state
* persistence. Unknown numbers default to gitea (back-compat / pre-load).
* persistence. Unknown numbers default to the forge (pre-load).
*/
// internal: handler 模块访问
refFor(issueNumber: number): IssueRef {
const entry = this.issueRefs.get(issueNumber)
return { source: entry?.source ?? 'gitea', number: issueNumber, externalId: entry?.externalId }
return { source: entry?.source ?? 'forge', number: issueNumber, externalId: entry?.externalId }
}
/**
@@ -1241,7 +1241,7 @@ export class KanbanWebviewPanel {
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.issueRefs = new Map(issues.map(issue => [issue.number, { source: issue.source ?? 'forge', externalId: issue.externalId }]))
this.issueOwners = new Map(issues.map(issue => [issue.number, { poster: issue.poster, assignees: issue.assignees }]))
}
+1 -1
View File
@@ -17,7 +17,7 @@ const KEY = 'spx-handoff/o/r/issue-9-20261008T010203Z.tar.xz'
describe('canStartHandoff', () => {
it('gitea 工单、非 done、无待接管、无 assignee 或 assignee 含我 → true', () => {
expect(canStartHandoff({ column: 'in-progress' }, 'me')).toBe(true)
expect(canStartHandoff({ column: 'todo', source: 'gitea' }, 'me')).toBe(true)
expect(canStartHandoff({ column: 'todo', source: 'forge' }, 'me')).toBe(true)
expect(canStartHandoff({ column: 'todo', assignees: [] }, 'me')).toBe(true)
expect(canStartHandoff({ column: 'todo', assignees: ['me', 'x'] }, 'me')).toBe(true)
})
+2 -2
View File
@@ -4,7 +4,7 @@ import { formatByteSize } from '../../cc/handoffArchive'
import { LOCAL_PROFILE_FIELDS, LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack'
source?: 'forge' | 'youtrack'
column: IssueColumn
handoffBundleKey?: string
assignees?: string[]
@@ -15,7 +15,7 @@ export function canStartHandoff(issue: {
}
export function canAcceptHandoff(issue: {
source?: 'gitea' | 'youtrack'
source?: 'forge' | 'youtrack'
handoffBundleKey?: string
assignees?: string[]
}, me: string | undefined): boolean {
+3 -3
View File
@@ -185,7 +185,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
const assignees = issue.assignees.map(a => a.login)
if (!canStartHandoff({ source: 'gitea', column, handoffBundleKey: str(state.handoffBundleKey), assignees }, me)) {
if (!canStartHandoff({ source: 'forge', column, handoffBundleKey: str(state.handoffBundleKey), assignees }, me)) {
const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
toast(panel, 'error', message)
return { ok: false, message }
@@ -377,7 +377,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
}
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffBundleKey?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'forge', column: IssueColumn, handoffBundleKey?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
@@ -399,7 +399,7 @@ async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Pro
return {
me: ctx.me,
facts: {
source: 'gitea',
source: 'forge',
column,
...(str(state.handoffBundleKey) ? { handoffBundleKey: str(state.handoffBundleKey) } : {}),
...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}),
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest, handoffRequestNotices, handoffRequestSystemNotice } from './handoffRequest'
const review = {
source: 'gitea' as const,
source: 'forge' as const,
column: 'review' as const,
assignees: ['chw'],
}
+1 -1
View File
@@ -1,5 +1,5 @@
export interface HandoffRequestFacts {
source?: 'gitea' | 'youtrack'
source?: 'forge' | 'youtrack'
column: 'todo' | 'in-progress' | 'review' | 'done'
handoffBundleKey?: string
handoffRequestFrom?: string
@@ -45,7 +45,7 @@ const LAUNCH = { cwd: '/worktree', startedAt: STARTED_AT, prompt: PROMPT }
const panels: KanbanWebviewPanel[] = []
let resolveSession!: (id: string | null) => void
function fixture(source: 'gitea' | 'youtrack' = 'gitea') {
function fixture(source: 'forge' | 'youtrack' = 'forge') {
const store = new Map<string, unknown>()
const context = {
workspaceState: {
@@ -126,7 +126,7 @@ describe('reviewSessionCapture', () => {
mocks.watch.mockResolvedValue({ sessionId: Promise.resolve(SID), initialSessionId: SID })
await recoverOpenReviewSessions(f.panel, [466])
expect(mocks.watch).toHaveBeenCalledWith(expect.objectContaining({ ...LAUNCH, startedAt: STARTED_AT }))
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBe(SID)
expect(f.terminal.sendText).not.toHaveBeenCalled()
expect(f.terminal.show).not.toHaveBeenCalled()
})
@@ -134,28 +134,28 @@ describe('reviewSessionCapture', () => {
it('尚未落盘的恢复立即返回,之后后台补回ID', async () => {
const f = fixture()
await expect(recoverReviewSessionCapture(f.panel, 466, f.terminal)).resolves.toBeNull()
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBeUndefined()
expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBeUndefined()
resolveSession(SID)
await vi.waitFor(() => expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID))
await vi.waitFor(() => expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBe(SID))
})
it('用户清除留下的墓碑不得自动补回', async () => {
const f = fixture()
f.store.set('superpowers.localIssueState', { 'gitea:466': { reviewSessionId: '' } })
f.store.set('superpowers.localIssueState', { 'forge:466': { reviewSessionId: '' } })
await expect(recoverReviewSessionCapture(f.panel, 466, f.terminal)).resolves.toBeNull()
expect(mocks.exec).not.toHaveBeenCalled()
expect(mocks.watch).not.toHaveBeenCalled()
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe('')
expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBe('')
})
it('用户明确新建的审查可以替换之前清除留下的墓碑', async () => {
const f = fixture()
f.store.set('superpowers.localIssueState', { 'gitea:466': { reviewSessionId: '' } })
f.store.set('superpowers.localIssueState', { 'forge:466': { reviewSessionId: '' } })
const prepared = await prepareReviewSessionCapture(f.panel, 466, LAUNCH)
const captured = prepared.attach(f.terminal)
resolveSession(SID)
await expect(captured).resolves.toBe(SID)
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBe(SID)
})
it('清除时中止待捕获并删除启动记录,之后不写回ID', async () => {
@@ -166,7 +166,7 @@ describe('reviewSessionCapture', () => {
resolveSession(SID)
await expect(captured).resolves.toBeNull()
expect(f.store.has(f.launchKey)).toBe(false)
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBeUndefined()
expect(getLocalIssueState(f.context, 'forge', 466).reviewSessionId).toBeUndefined()
})
it('准备期间被取消,不允许稍后继续创建终端', async () => {
@@ -7,7 +7,7 @@ import * as path from 'node:path'
import process from 'node:process'
import { window } from 'vscode'
import { watchForCodexSession } from '../../cc/codexSessionWatcher'
import { getLocalIssueState, mergeLocalIssueState } from '../../issues/localState'
import { getLocalIssueState, localIssueKey, mergeLocalIssueState } from '../../issues/localState'
import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/sessionFiles'
@@ -32,7 +32,7 @@ function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
}
function launchKey(source: ReviewCapture['source'], issueNumber: number): string {
return `superpowers.reviewSessionLaunch.${source}:${issueNumber}`
return `superpowers.reviewSessionLaunch.${localIssueKey(source, issueNumber)}`
}
/** 用户清除关联时先中止捕获,防止稍后落盘的文件把关联补回来。 */
@@ -40,7 +40,7 @@ export async function cancelReviewSessionCapture(panel: KanbanWebviewPanel, issu
const map = captureMap(panel)
const capture = map.get(issueNumber)
const recovery = recoveries.get(panel)?.get(issueNumber)
const source = capture?.source ?? recovery?.source ?? panel.refFor(issueNumber).source ?? 'gitea'
const source = capture?.source ?? recovery?.source ?? panel.refFor(issueNumber).source ?? 'forge'
recovery?.abort.abort()
capture?.abort.abort()
map.delete(issueNumber)
@@ -85,7 +85,7 @@ export async function prepareReviewSessionCapture(
panel: KanbanWebviewPanel,
issueNumber: number,
launch: CodexSessionLaunch,
source: ReviewCapture['source'] = panel.refFor(issueNumber).source ?? 'gitea',
source: ReviewCapture['source'] = panel.refFor(issueNumber).source ?? 'forge',
): Promise<{
attach: (terminal: Terminal) => Promise<string | null>
initialSessionId: string | null
@@ -269,7 +269,7 @@ export function recoverReviewSessionCapture(panel: KanbanWebviewPanel, issueNumb
if (existing)
return existing.result
const abort = new AbortController()
const source = panel.refFor(issueNumber).source ?? 'gitea'
const source = panel.refFor(issueNumber).source ?? 'forge'
const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal, source)
.finally(() => {
if (map.get(issueNumber)?.result === result)
+1 -1
View File
@@ -217,7 +217,7 @@ export type WebviewToExtension
| { type: 'worktree/open', path: string }
| { type: 'worktree/delete', issueNumber: number, path: string }
| { type: 'git/merge-preview', issueNumber: number, branch: string }
| { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
| { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'forge' | 'youtrack', externalId?: string }
| { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number }
@@ -159,7 +159,7 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.terminals.splice(0)
mocks.token.mockReset().mockResolvedValue('token')
mocks.load.mockReset().mockResolvedValue({ number: 483, source: 'gitea', title: 'issue' })
mocks.load.mockReset().mockResolvedValue({ number: 483, source: 'forge', title: 'issue' })
mocks.getIssue.mockReset().mockResolvedValue({ state: 'open', assignees: [{ login: 'me' }] })
mocks.mergeShared.mockReset().mockResolvedValue(undefined)
mocks.readShared.mockReset().mockResolvedValue({})
@@ -174,7 +174,7 @@ describe('issue creation reconciliation', () => {
const f = fixture()
f.register()
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483)).toMatchObject({ sessionId: SID, brainstormProfilePath: '/profiles/brainstorm.json' })
expect(getLocalIssueState(f.context, 'forge', 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 }) }))
@@ -185,7 +185,7 @@ describe('issue creation reconciliation', () => {
const f = fixture()
f.register()
await dispatch(event(action))
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
expect(f.panel.terminals.get(SID)).toBe(f.terminal)
})
@@ -227,10 +227,10 @@ describe('issue creation reconciliation', () => {
const f = fixture()
f.register()
await dispatch(event('assigned', 483, repository))
expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
expect(mocks.mergeShared).not.toHaveBeenCalled()
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
})
it('ignores unknown, partial and ambiguous markers', async () => {
@@ -238,7 +238,7 @@ describe('issue creation reconciliation', () => {
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(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
expect(mocks.mergeShared).not.toHaveBeenCalled()
})
@@ -261,8 +261,8 @@ describe('issue creation reconciliation', () => {
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)
expect(getLocalIssueState(f.context, 'forge', 484)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
})
it('retains pending metadata on local save failure and retries on the next event', async () => {
@@ -270,11 +270,11 @@ describe('issue creation reconciliation', () => {
f.register()
f.update.mockRejectedValueOnce(new Error('local disk unavailable'))
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 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)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
})
it.each(['shared', 'cleanup'])('%s failure cannot undo local success or repeat initialization', async (failure) => {
@@ -286,7 +286,7 @@ describe('issue creation reconciliation', () => {
mocks.rm.mockRejectedValueOnce(new Error('cleanup unavailable'))
await dispatch(event())
await dispatch(event('opened'))
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
expect(f.update.mock.calls.filter(([key]) => key === 'superpowers.localIssueState')).toHaveLength(1)
expect(mocks.mergeShared).toHaveBeenCalledOnce()
})
@@ -304,11 +304,11 @@ describe('issue creation reconciliation', () => {
f.register()
webhookCoordinator.setActivePanel(undefined)
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 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 })
expect(overlayLocalIssueState(f.context, [{ number: 483, source: 'forge' } as never], '/repo')[0]).toMatchObject({ sessionId: SID })
})
it('does not deliver an old event to the board opened during its save', async () => {
@@ -333,7 +333,7 @@ describe('issue creation reconciliation', () => {
await handling
expect(f.panel.postMessage).not.toHaveBeenCalled()
expect(reopened.postMessage).not.toHaveBeenCalled()
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
expect(reopened.reloadAfterInvalidation).toHaveBeenCalled()
await dispatch(event())
expect(reopened.terminals.get(SID)).toBe(f.terminal)
@@ -344,7 +344,7 @@ describe('issue creation reconciliation', () => {
f.register()
mocks.terminals.splice(0)
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
expect(f.panel.terminals.size).toBe(0)
const reopened = f.makePanel()
webhookCoordinator.setActivePanel(reopened)
@@ -356,12 +356,12 @@ describe('issue creation reconciliation', () => {
f.register()
webhookCoordinator.cancelIssueCreation(NONCE)
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
f.register()
await webhookCoordinator.dispose()
webhookCoordinator.init(f.context)
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
})
})
@@ -370,11 +370,11 @@ describe('issue creation lifecycle boundaries', () => {
const f = fixture()
f.register()
await dispatch(event())
await f.update('superpowers.localIssueState', { 'gitea:483': { sessionId: '' } })
await f.update('superpowers.localIssueState', { 'forge:483': { sessionId: '' } })
const reopened = f.makePanel()
webhookCoordinator.setActivePanel(reopened)
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe('')
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe('')
expect(reopened.terminals.size).toBe(0)
})
@@ -398,9 +398,9 @@ describe('issue creation lifecycle boundaries', () => {
f.register()
await dispatch(event('label_updated'))
await dispatch({ ...event(), raw: { issue: {} } })
expect(getLocalIssueState(f.context, 'gitea', 483)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 483)).toEqual({})
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
})
it('a terminal with an exit status is not restored even while its tab remains visible', async () => {
@@ -408,7 +408,7 @@ describe('issue creation lifecycle boundaries', () => {
f.register()
Object.assign(f.terminal, { exitStatus: { code: 1 } })
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
expect(f.panel.terminals.size).toBe(0)
})
@@ -418,8 +418,8 @@ describe('issue creation lifecycle boundaries', () => {
f.update.mockRejectedValueOnce(new Error('local unavailable'))
await dispatch(event())
await dispatch(event('assigned', 484))
expect(getLocalIssueState(f.context, 'gitea', 484)).toEqual({})
expect(getLocalIssueState(f.context, 'forge', 484)).toEqual({})
await dispatch(event())
expect(getLocalIssueState(f.context, 'gitea', 483).sessionId).toBe(SID)
expect(getLocalIssueState(f.context, 'forge', 483).sessionId).toBe(SID)
})
})
+3 -3
View File
@@ -335,7 +335,7 @@ class WebhookCoordinator {
return
for (const entry of this.issueCreations.values()) {
const linked = entry.linked
if (linked && getLocalIssueState(this.ctx, 'gitea', linked.issueNumber).sessionId === linked.sessionId)
if (linked && getLocalIssueState(this.ctx, 'forge', linked.issueNumber).sessionId === linked.sessionId)
linkIssueCreationTerminal(panel, { sessionId: linked.sessionId, terminal: entry.terminal }, linked.issueNumber)
}
}
@@ -370,7 +370,7 @@ class WebhookCoordinator {
if (!pending || !ctx)
return undefined
try {
await mergeLocalIssueState(ctx, 'gitea', issueNumber, {
await mergeLocalIssueState(ctx, 'forge', issueNumber, {
sessionId: pending.sessionId,
...(pending.brainstormProfilePath ? { brainstormProfilePath: pending.brainstormProfilePath } : {}),
})
@@ -1625,7 +1625,7 @@ class WebhookCoordinator {
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
// 不回落);本地无记录时才用共享评论里的值(存量数据)。
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
const local = getLocalIssueState(this.ctx, 'forge', issueNumber)
const worktreePath = local.worktreePath
?? (typeof state.worktreePath === 'string' ? state.worktreePath : '')