/** * Source-aware workflow-state persistence. * * Gitea and YouTrack cards share the same board and the same session/worktree * machinery, but their workflow state (column, sessionId, worktreePath, …) * lives in different places: a gitea issue comment vs a YouTrack issue comment. * Handlers call these helpers with an `IssueRef` instead of hard-coding the * gitea backend, so the same handler body serves both sources. * * The gitea branch reproduces exactly what the handlers did inline before, so * gitea behavior is unchanged. */ import type { ExtensionContext } from 'vscode' import { workspace } from 'vscode' import { getToken, getYouTrackToken } from '../auth/secrets' import { detectRepo } from '../git/remote' import { closeIssue as giteaCloseIssue } from '../gitea/api' import { mergeStateJsonComment, readStateJsonComment } from '../gitea/stateJson' import { getSettings } from '../settings/store' import { applyCommand, resolvedStateCommand } from '../youtrack/api' import { youtrackHost } from '../youtrack/issueLoader' import { mergeStateComment, readStateComment } from '../youtrack/stateComment' import { getLocalIssueState, mergeLocalIssueState, splitLocalStateFields } from './localState' export interface IssueRef { /** Absent = gitea (back-compat). */ source?: 'gitea' | 'youtrack' /** The board's (possibly synthetic) issue number — the routing key. */ number: number /** YouTrack readable id (`LXF-12`); required when source is youtrack. */ externalId?: string } function isYouTrack(ref: IssueRef): ref is IssueRef & { externalId: string } { return ref.source === 'youtrack' && typeof ref.externalId === 'string' && ref.externalId.length > 0 } async function giteaDeps(ctx: ExtensionContext): Promise<{ host: string, owner: string, repo: string, token: string }> { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) throw new Error('未打开工作区文件夹') const remote = await detectRepo(workspaceRoot) if (!remote) throw new Error('当前工作区没有 Gitea 远程仓库') const token = await getToken(ctx, remote.host) if (!token) throw new Error('请先完成 Gitea 配置') return { host: remote.host, owner: remote.owner, repo: remote.repo, token } } async function youtrackAuth(ctx: ExtensionContext): Promise<{ baseUrl: string, token: string }> { const baseUrl = getSettings(ctx).youtrackBaseUrl.trim() if (!baseUrl) throw new Error('未配置 YouTrack Base URL') const token = await getYouTrackToken(ctx, youtrackHost(baseUrl)) if (!token) throw new Error('未配置 YouTrack Token') return { baseUrl, token } } /** * Read the workflow-state blob for an issue from its tracker, with this * machine's local record overlaid on top(本地有键即覆盖,'' 墓碑与 * "空串=unset" 的既有约定一致)。 */ export async function readIssueState(ctx: ExtensionContext, ref: IssueRef): Promise> { let base: Record if (isYouTrack(ref)) { const auth = await youtrackAuth(ctx) base = await readStateComment(auth, ref.externalId) } else { const d = await giteaDeps(ctx) base = await readStateJsonComment({ ...d, issueNumber: ref.number }) } return { ...base, ...getLocalIssueState(ctx, ref.source, ref.number) } } /** * Merge `extra` into the issue's workflow state。机器本地字段 * (会话 id / worktree / profile 路径)落 workspaceState,只有共享字段 * (column / pr / branch / spec 等)才写进 tracker 评论——跨机没有语义的 * 值从此不进共享存储,也不参与多机写竞争。 */ export async function mergeIssueState(ctx: ExtensionContext, ref: IssueRef, extra: Record): Promise { const { local, shared } = splitLocalStateFields(extra) if (Object.keys(local).length > 0) await mergeLocalIssueState(ctx, ref.source, ref.number, local) if (Object.keys(shared).length === 0) return if (isYouTrack(ref)) { const auth = await youtrackAuth(ctx) await mergeStateComment(auth, ref.externalId, shared) return } const d = await giteaDeps(ctx) await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra: shared }) } /** * Resolve/close an issue in its tracker. Gitea: PATCH state=closed. YouTrack: * apply the configured close command, else auto-detect the project's first * `isResolved` state value. Returns false (with no throw) when a YouTrack close * command can't be determined, so callers can surface a hint. */ export async function closeIssueByRef(ctx: ExtensionContext, ref: IssueRef): Promise { if (isYouTrack(ref)) { const auth = await youtrackAuth(ctx) const configured = getSettings(ctx).youtrackCloseCommand.trim() const command = configured || (await resolvedStateCommand(auth, ref.externalId)) if (!command) return false await applyCommand(auth, ref.externalId, command) return true } const d = await giteaDeps(ctx) await giteaCloseIssue({ ...d, issueNumber: ref.number }) return true }