diff --git a/vscode/src/codeReader/review/reviewRules.test.ts b/vscode/src/codeReader/review/reviewRules.test.ts index 011568c..615ea24 100644 --- a/vscode/src/codeReader/review/reviewRules.test.ts +++ b/vscode/src/codeReader/review/reviewRules.test.ts @@ -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]) diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts index 50e0cca..874547e 100644 --- a/vscode/src/codeReader/review/reviewRules.ts +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -36,7 +36,7 @@ function inReviewColumns(column: IssueColumn): boolean { export function reviewCandidates>(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) diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index dca15d7..2e08a5d 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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/ 外部入口:开 cc 会话 tab、从 env 文件配置移交存储。 diff --git a/vscode/src/issues/localState.test.ts b/vscode/src/issues/localState.test.ts new file mode 100644 index 0000000..4888e06 --- /dev/null +++ b/vscode/src/issues/localState.test.ts @@ -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): Memento & { data: Map, update: ReturnType } { + const data = new Map(Object.entries(initial)) + return { + data, + keys: () => [...data.keys()], + get: (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' }) + }) +}) diff --git a/vscode/src/issues/localState.ts b/vscode/src/issues/localState.ts index 90fe9e4..49b1906 100644 --- a/vscode/src/issues/localState.ts +++ b/vscode/src/issues/localState.ts @@ -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> -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 { + const writes: Thenable[] = [] + + const store = state.get(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 { const store = ctx.workspaceState.get(KEY) ?? {} - return store[refKey(source, issueNumber)] ?? {} + return store[localIssueKey(source, issueNumber)] ?? {} } /** @@ -59,7 +104,7 @@ export async function mergeLocalIssueState( patch: Record, ): Promise { const store = { ...(ctx.workspaceState.get(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(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 diff --git a/vscode/src/issues/stateRouter.ts b/vscode/src/issues/stateRouter.ts index 11b7987..8a84303 100644 --- a/vscode/src/issues/stateRouter.ts +++ b/vscode/src/issues/stateRouter.ts @@ -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. */ diff --git a/vscode/src/issues/types.ts b/vscode/src/issues/types.ts index 148a682..f4d4b36 100644 --- a/vscode/src/issues/types.ts +++ b/vscode/src/issues/types.ts @@ -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/`. */ 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 diff --git a/vscode/src/panel/KanbanPanel.cache.test.ts b/vscode/src/panel/KanbanPanel.cache.test.ts index 1bc5c08..2d49fcd 100644 --- a/vscode/src/panel/KanbanPanel.cache.test.ts +++ b/vscode/src/panel/KanbanPanel.cache.test.ts @@ -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 () => { diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index d70df60..236db96 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -66,7 +66,7 @@ export class KanbanWebviewPanel { private readonly panel: WebviewPanel private readonly disposables: { dispose: () => void }[] = [] /** 按工单号保存来源,确保状态修改发往正确的工单系统。 */ - private issueRefs = new Map() + private issueRefs = new Map() /** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */ private issueOwners = new Map() /** 本机 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 }])) } diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index 5c02af2..8e7a16a 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -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) }) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 97717c8..b6695f6 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -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 { diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 3c14f7e..7ff40a1 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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) } : {}), diff --git a/vscode/src/panel/handlers/handoffRequest.test.ts b/vscode/src/panel/handlers/handoffRequest.test.ts index 453d863..a9dee1e 100644 --- a/vscode/src/panel/handlers/handoffRequest.test.ts +++ b/vscode/src/panel/handlers/handoffRequest.test.ts @@ -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'], } diff --git a/vscode/src/panel/handlers/handoffRequest.ts b/vscode/src/panel/handlers/handoffRequest.ts index 5cdebdd..adaa6ad 100644 --- a/vscode/src/panel/handlers/handoffRequest.ts +++ b/vscode/src/panel/handlers/handoffRequest.ts @@ -1,5 +1,5 @@ export interface HandoffRequestFacts { - source?: 'gitea' | 'youtrack' + source?: 'forge' | 'youtrack' column: 'todo' | 'in-progress' | 'review' | 'done' handoffBundleKey?: string handoffRequestFrom?: string diff --git a/vscode/src/panel/handlers/reviewSessionCapture.test.ts b/vscode/src/panel/handlers/reviewSessionCapture.test.ts index 80dc12d..ee9c2ee 100644 --- a/vscode/src/panel/handlers/reviewSessionCapture.test.ts +++ b/vscode/src/panel/handlers/reviewSessionCapture.test.ts @@ -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() 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 () => { diff --git a/vscode/src/panel/handlers/reviewSessionCapture.ts b/vscode/src/panel/handlers/reviewSessionCapture.ts index 07065a3..d6a685a 100644 --- a/vscode/src/panel/handlers/reviewSessionCapture.ts +++ b/vscode/src/panel/handlers/reviewSessionCapture.ts @@ -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 { } 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 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) diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 7b99630..c8f00d8 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 } diff --git a/vscode/src/webhook/coordinator.issueCreation.test.ts b/vscode/src/webhook/coordinator.issueCreation.test.ts index 54f4a4f..3b9a545 100644 --- a/vscode/src/webhook/coordinator.issueCreation.test.ts +++ b/vscode/src/webhook/coordinator.issueCreation.test.ts @@ -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 ['', '', '', `${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) }) }) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index bc331f8..eef797d 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -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 : '') diff --git a/vscode/webview-ui/src/components/KanbanBoard.tsx b/vscode/webview-ui/src/components/KanbanBoard.tsx index 3152998..6d66d05 100644 --- a/vscode/webview-ui/src/components/KanbanBoard.tsx +++ b/vscode/webview-ui/src/components/KanbanBoard.tsx @@ -38,7 +38,7 @@ interface KanbanBoardProps { onCreateIssue?: () => void selectedId?: string | null onSelectIssue?: (id: string | null) => void - onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void + onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'forge' | 'youtrack', externalId?: string) => void onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void onSetReviewPassed?: (issueNumber: number, value: boolean) => void diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index c0432a7..c285945 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -223,7 +223,7 @@ export interface UseIssuesResult { * session jsonl, then writes the id back as `sessionId`. */ continueBrainstorm: (issueNumber: number) => void - changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void + changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'forge' | 'youtrack', externalId?: string) => void setDependency: (issueNumber: number, prerequisiteNumber: number) => void clearDependency: (issueNumber: number, prerequisiteNumber: number) => void updateIssueAutoReview: (issueNumber: number, value: boolean) => void @@ -699,7 +699,7 @@ export function useIssues(): UseIssuesResult { postMessage({ type: 'brainstorm/continue', issueNumber }) }, []) - const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string): void => { + const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'forge' | 'youtrack', externalId?: string): void => { postMessage({ type: 'column/change', issueNumber, toColumn, source, externalId }) }, []) diff --git a/vscode/webview-ui/src/lib/kanbanDrag.ts b/vscode/webview-ui/src/lib/kanbanDrag.ts index 1b0d9d3..39f79dd 100644 --- a/vscode/webview-ui/src/lib/kanbanDrag.ts +++ b/vscode/webview-ui/src/lib/kanbanDrag.ts @@ -8,7 +8,7 @@ export function isColumnId(id: string): id is IssueColumn { export type KanbanDragResult = | { type: 'none' } - | { type: 'column', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string } + | { type: 'column', issueNumber: number, toColumn: IssueColumn, source?: 'forge' | 'youtrack', externalId?: string } | { type: 'dependency-set', issueNumber: number, prerequisiteNumber: number } | { type: 'dependency-clear', issueNumber: number, prerequisiteNumber: number } | { type: 'reorder', fromIndex: number, toIndex: number } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 32c0c7b..4dc73df 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -256,7 +256,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: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string } + | { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'forge' | 'youtrack', externalId?: string } | { type: 'handoff/users', issueNumber: number } | { type: 'handoff/start', issueNumber: number, to: string } | { type: 'handoff/accept', issueNumber: number } diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 5640044..dbd0748 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -2,8 +2,8 @@ export type IssueColumn = 'todo' | 'in-progress' | 'review' | 'done' export interface Issue { id: string - /** Which tracker this issue came from. Absent = gitea (back-compat). */ - source?: 'gitea' | 'youtrack' + /** 工单来自哪里:`forge` 是工作区 origin 所在的代码托管仓库,`youtrack` 是镜像来的 YouTrack 工单;缺省按 `forge` 处理。 */ + source?: 'forge' | 'youtrack' /** YouTrack readable id (`LXF-12`); present only on youtrack issues. */ externalId?: string number: number