♻️ refactor(vscode): 工单来源 gitea 改名 forge,激活时把本机按旧名存的记录迁过去
This commit is contained in:
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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 文件配置移交存储。
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }]))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 : '')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user