From 38ca193434c224eb6759e0680042325d9dc0a4eb Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 19 Oct 2026 07:35:55 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=AF=84=E8=AE=BA=E6=94=B9=E5=86=99=E6=88=90?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=A0=BC=E5=BC=8F=EF=BC=8CGitea=20=E5=92=8C?= =?UTF-8?q?=20YouTrack=20=E5=85=B1=E7=94=A8=E4=B8=80=E5=A5=97=E8=AF=BB?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/gitea/issueComment.test.ts | 6 + vscode/src/gitea/issueComment.ts | 35 +- vscode/src/gitea/issueLoader.test.ts | 26 ++ vscode/src/gitea/issueLoader.ts | 302 +----------------- vscode/src/gitea/stateJson.ts | 217 ++----------- vscode/src/issues/stateComment.test.ts | 151 +++++++++ vscode/src/issues/stateComment.ts | 144 +++++++++ vscode/src/issues/stateFields.test.ts | 46 +++ vscode/src/issues/stateFields.ts | 91 ++++++ vscode/src/panel/handlers/issues.ts | 3 +- vscode/src/webhook/coordinator.cache.test.ts | 3 +- .../webhook/coordinator.issueCreation.test.ts | 3 +- vscode/src/webhook/coordinator.ts | 12 +- vscode/src/youtrack/issueLoader.ts | 44 +-- vscode/src/youtrack/stateComment.ts | 88 +---- vscode/test/gitea/stateJson.test.ts | 21 +- vscode/test/youtrack/stateComment.test.ts | 56 ++-- 17 files changed, 588 insertions(+), 660 deletions(-) create mode 100644 vscode/src/issues/stateComment.test.ts create mode 100644 vscode/src/issues/stateComment.ts create mode 100644 vscode/src/issues/stateFields.test.ts create mode 100644 vscode/src/issues/stateFields.ts diff --git a/vscode/src/gitea/issueComment.test.ts b/vscode/src/gitea/issueComment.test.ts index ee572ea..2d81b58 100644 --- a/vscode/src/gitea/issueComment.test.ts +++ b/vscode/src/gitea/issueComment.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { serializeStateComment } from '../issues/stateComment' import { systemCommentText } from './issueComment' describe('systemCommentText', () => { @@ -15,6 +16,11 @@ describe('systemCommentText', () => { expect(systemCommentText('{"column":"review","reviewPassed":false}')).toBe('更新了工单状态,放到审查') }) + it('隐藏格式的状态评论也是系统消息', () => { + expect(systemCommentText(serializeStateComment({ column: 'todo' }))).toBe('把工单放到待办') + expect(systemCommentText(serializeStateComment({ specFile: 'docs/specs/a.md' }))).toBe('更新了工单状态') + }) + it('普通评论不是系统消息', () => { expect(systemCommentText('这个方案可以')).toBeUndefined() }) diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/gitea/issueComment.ts index 5f38d33..6b2c446 100644 --- a/vscode/src/gitea/issueComment.ts +++ b/vscode/src/gitea/issueComment.ts @@ -1,3 +1,4 @@ +import { parseStateComment } from '../issues/stateComment' import { hasReviewMarker, stripReviewMarker } from './markers' const COLUMN_LABEL: Record = { @@ -7,25 +8,6 @@ const COLUMN_LABEL: Record = { 'done': '完成', } -const STATE_KEYS = new Set([ - 'column', - 'sessionId', - 'implementSessionId', - 'reviewSessionId', - 'testSessionId', - 'prototypeSessionId', - 'handoffBundleKey', - 'handoffFrom', - 'handoffRequestFrom', - 'handoffRequestError', - 'pr', - 'branch', - 'worktreePath', - 'color', - 'autoReview', - 'reviewPassed', -]) - /** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */ export function systemCommentText(body: string): string | undefined { const trimmed = body.trim() @@ -33,19 +15,8 @@ export function systemCommentText(body: string): string | undefined { const text = stripReviewMarker(trimmed) return text.length > 0 ? text : '提交了审查' } - if (!trimmed.startsWith('{')) - return undefined - let parsed: unknown - try { - parsed = JSON.parse(trimmed) - } - catch { - return undefined - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) - return undefined - const record = parsed as Record - if (!Object.keys(record).some(key => STATE_KEYS.has(key))) + const record = parseStateComment(trimmed) + if (!record) return undefined const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined if (Object.keys(record).length === 1 && column) diff --git a/vscode/src/gitea/issueLoader.test.ts b/vscode/src/gitea/issueLoader.test.ts index fe6a749..ab6f67a 100644 --- a/vscode/src/gitea/issueLoader.test.ts +++ b/vscode/src/gitea/issueLoader.test.ts @@ -142,3 +142,29 @@ describe('loadIssues', () => { expect(postIssueComment).not.toHaveBeenCalled() }) }) + +describe('loadIssues 读状态评论', () => { + beforeEach(() => { + getCurrentUser.mockReset() + listIssuesByFilter.mockReset() + listIssueComments.mockReset() + getDependencies.mockReset() + getPullRequest.mockReset() + getCurrentUser.mockResolvedValue({ login: 'me', id: 1 }) + getDependencies.mockResolvedValue([]) + }) + + it('认隐藏格式,最新一条有效的状态胜出', async () => { + const { loadIssues } = await import('./issueLoader.js') + listIssuesByFilter.mockResolvedValue([issue(8, { comments: 3 })]) + listIssueComments.mockResolvedValue([ + comment('{"column":"todo"}', 8, 1), + comment('', 8, 2), + comment('普通讨论', 8, 3), + ]) + + const { issues } = await loadIssues({ host: 'gitea.example', token: 't', owner: 'o', repo: 'r', scope: 'all' }) + + expect(issues[0]).toMatchObject({ column: 'review', branch: 'feature/x', specFile: 'docs/a.md' }) + }) +}) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index d9b5e6e..91298ea 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -14,6 +14,8 @@ import type { GiteaComment, GiteaIssue } from './api' import type { Issue, IssueColumn } from './types' import * as fs from 'node:fs' import { resolveWorktreePath } from '../git/worktree' +import { latestState } from '../issues/stateComment' +import { issueStateFields } from '../issues/stateFields' import { getCurrentUser, getDependencies, @@ -24,235 +26,13 @@ import { } from './api' import { resolveAvatars } from './avatars' -const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] - -/** - * 过滤 specFile / planFile 字段里的垃圾值: - * 真实路径必须包含 `/` 且以 `.md` 结尾。`...`、`…` 这类是 cc - * 在 brainstorm 阶段写 issue body 时塞进去的占位说明文,webhook - * 历史版本正则太宽松会把它们写进 state JSON,这里读出时兜底清掉。 - */ -export function isValidSpxFilePath(v: unknown): v is string { - return typeof v === 'string' - && v.length > 0 - && v.includes('/') - && v.endsWith('.md') - && v !== '...' - && v !== '…' -} - -/** - * prototypeFile 指向 Figma 插件的 manifest.json,规则与 isValidSpxFilePath - * 同款,只是后缀换成 .json(可以是仓库相对路径,也可以是绝对路径)。 - */ -export function isValidPrototypeFilePath(v: unknown): v is string { - return typeof v === 'string' - && v.length > 0 - && v.includes('/') - && v.endsWith('.json') - && v !== '...' - && v !== '…' -} - -function isValidPrDiffFilePath(v: unknown): v is string { - return typeof v === 'string' - && /^docs\/pr-diff\/\S+\.md$/.test(v) -} - -function isIssueColumn(value: unknown): value is IssueColumn { - return typeof value === 'string' && (COLUMN_IDS as readonly string[]).includes(value) -} - function defaultColumnForState(state: string): IssueColumn { return state === 'open' ? 'todo' : 'done' } -/** - * Scans comments from the tail for the most recent JSON state payload like - * `{ "column": "todo", ... }`, skipping ordinary text/JSON comments that lack - * a `column` field so they can't shadow the real state. Returns - * `{ column: null }` if no valid payload is present. - */ -function parseColumnFromComments(comments: GiteaComment[]): { - column: IssueColumn | null - sessionId?: string - profilePath?: string - brainstormProfilePath?: string - testProfilePath?: string - prototypeProfilePath?: string - specFile?: string - planFile?: string - prototypeFile?: string - prDiffFile?: string - pr?: string - prMerged?: boolean - prMergedAt?: string - branch?: string - worktreePath?: string - implementStatus?: 'running' | 'done' | 'failed' - implementSessionId?: string - reviewSessionId?: string - testSessionId?: string - prototypeSessionId?: string - color?: string - autoReview?: boolean - reviewPassed?: boolean - handoffBundleKey?: string - handoffFrom?: string - handoffRequestFrom?: string - handoffRequestError?: string -} { - if (comments.length === 0) - return { column: null } - // 从尾往前找第一条「parse 成功 && 是 object && 含 column」的 comment。 - // 中途插入的普通文本/JSON 内容评论不带 column,被跳过——否则它们会 - // 顶替真正的 state comment,导致整张卡片的列与元数据全部丢失。 - let parsed: unknown - for (let i = comments.length - 1; i >= 0; i--) { - const body = (comments[i].body ?? '').trim() - if (!body) - continue - let candidate: unknown - try { - candidate = JSON.parse(body) - } - catch { - continue - } - if (candidate && typeof candidate === 'object' && 'column' in candidate) { - parsed = candidate - break - } - } - if (parsed && typeof parsed === 'object' && 'column' in parsed) { - const obj = parsed as { - column: unknown - sessionId?: unknown - profilePath?: unknown - brainstormProfilePath?: unknown - testProfilePath?: unknown - prototypeProfilePath?: unknown - specFile?: unknown - planFile?: unknown - prototypeFile?: unknown - prDiffFile?: unknown - pr?: unknown - prMerged?: unknown - prMergedAt?: unknown - branch?: unknown - worktreePath?: unknown - implementStatus?: unknown - implementSessionId?: unknown - reviewSessionId?: unknown - testSessionId?: unknown - prototypeSessionId?: unknown - color?: unknown - autoReview?: unknown - reviewPassed?: unknown - handoffBundleKey?: unknown - handoffFrom?: unknown - handoffRequestFrom?: unknown - handoffRequestError?: unknown - } - if (isIssueColumn(obj.column)) { - const sessionId = typeof obj.sessionId === 'string' && obj.sessionId.length > 0 - ? obj.sessionId - : undefined - const profilePath = typeof obj.profilePath === 'string' && obj.profilePath.length > 0 - ? obj.profilePath - : undefined - const brainstormProfilePath = typeof obj.brainstormProfilePath === 'string' && obj.brainstormProfilePath.length > 0 - ? obj.brainstormProfilePath - : undefined - const testProfilePath = typeof obj.testProfilePath === 'string' && obj.testProfilePath.length > 0 - ? obj.testProfilePath - : undefined - const prototypeProfilePath = typeof obj.prototypeProfilePath === 'string' && obj.prototypeProfilePath.length > 0 - ? obj.prototypeProfilePath - : undefined - const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined - const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined - const prototypeFile = isValidPrototypeFilePath(obj.prototypeFile) ? obj.prototypeFile : undefined - const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined - const pr = typeof obj.pr === 'string' && obj.pr.length > 0 - ? obj.pr - : undefined - const prMerged = typeof obj.prMerged === 'boolean' ? obj.prMerged : undefined - const prMergedAt = typeof obj.prMergedAt === 'string' && obj.prMergedAt.length > 0 - ? obj.prMergedAt - : undefined - const branch = typeof obj.branch === 'string' && obj.branch.length > 0 - ? obj.branch - : undefined - const worktreePath = typeof obj.worktreePath === 'string' && obj.worktreePath.length > 0 - ? obj.worktreePath - : undefined - const implementStatus = obj.implementStatus === 'running' - || obj.implementStatus === 'done' - || obj.implementStatus === 'failed' - ? obj.implementStatus - : undefined - const implementSessionId = typeof obj.implementSessionId === 'string' && obj.implementSessionId.length > 0 - ? obj.implementSessionId - : undefined - const reviewSessionId = typeof obj.reviewSessionId === 'string' && obj.reviewSessionId.length > 0 - ? obj.reviewSessionId - : undefined - const testSessionId = typeof obj.testSessionId === 'string' && obj.testSessionId.length > 0 - ? obj.testSessionId - : undefined - const prototypeSessionId = typeof obj.prototypeSessionId === 'string' && obj.prototypeSessionId.length > 0 - ? obj.prototypeSessionId - : undefined - const color = typeof obj.color === 'string' && obj.color.length > 0 - ? obj.color - : undefined - const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined - const reviewPassed = typeof obj.reviewPassed === 'boolean' ? obj.reviewPassed : undefined - const handoffBundleKey = typeof obj.handoffBundleKey === 'string' && obj.handoffBundleKey.length > 0 - ? obj.handoffBundleKey - : undefined - const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0 - ? obj.handoffFrom - : undefined - const handoffRequestFrom = typeof obj.handoffRequestFrom === 'string' && obj.handoffRequestFrom.length > 0 - ? obj.handoffRequestFrom - : undefined - const handoffRequestError = typeof obj.handoffRequestError === 'string' && obj.handoffRequestError.length > 0 - ? obj.handoffRequestError - : undefined - return { - column: obj.column, - sessionId, - profilePath, - brainstormProfilePath, - testProfilePath, - prototypeProfilePath, - specFile, - planFile, - prototypeFile, - prDiffFile, - pr, - prMerged, - prMergedAt, - branch, - worktreePath, - implementStatus, - implementSessionId, - reviewSessionId, - testSessionId, - prototypeSessionId, - color, - autoReview, - reviewPassed, - handoffBundleKey, - handoffFrom, - handoffRequestFrom, - handoffRequestError, - } - } - } - return { column: null } +/** 工单当前状态:评论里最新一条状态评论,没有就是空对象。 */ +function stateOf(comments: GiteaComment[]): Record { + return latestState(comments.map(comment => comment.body)) ?? {} } /** Merges issue lists by `number`, preferring the first occurrence. */ @@ -362,53 +142,24 @@ async function buildIssue(opts: { const id = `${owner}/${repo}#${issue.number}` const { column: fromComment, - sessionId, - profilePath, - brainstormProfilePath, - testProfilePath, - prototypeProfilePath, - specFile, - planFile, - prototypeFile, - prDiffFile, - pr, prMerged: persistedMerged, prMergedAt: persistedMergedAt, - branch, - worktreePath, - implementStatus, - implementSessionId, - reviewSessionId, - testSessionId, - prototypeSessionId, - color, - autoReview, - reviewPassed, - handoffBundleKey, - handoffFrom, - handoffRequestFrom, - handoffRequestError, - } = parseColumnFromComments(comments) + ...stateFields + } = issueStateFields(stateOf(comments)) // 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。 const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged // 完成列排序用的 merged_at:实时查询优先,否则用持久化值(完成工单不再实时拉 PR)。 const mergedAt = liveMergedAt ?? persistedMergedAt - let column: IssueColumn - if (fromComment) { - column = fromComment - } - else { - // 缺评论只在本机按工单开/关给默认列。不能在加载时补评论:谁的看板先刷到,评论就挂在谁名下。 - column = defaultColumnForState(issue.state) - } + // 缺状态时只在本机按工单开/关给默认列,不在加载时补评论:谁的看板先刷到,评论就挂在谁名下。 + const column = fromComment ?? defaultColumnForState(issue.state) // Synchronously stat the worktree path so the UI can render it as a // live link vs. plain text. A single existsSync per issue is cheap and // avoids racing with the rest of the async pipeline. let worktreeExists: boolean | undefined - if (worktreePath && workspaceRoot) { + if (stateFields.worktreePath && workspaceRoot) { try { - worktreeExists = fs.existsSync(resolveWorktreePath(worktreePath, workspaceRoot)) + worktreeExists = fs.existsSync(resolveWorktreePath(stateFields.worktreePath, workspaceRoot)) } catch { worktreeExists = false @@ -420,35 +171,12 @@ async function buildIssue(opts: { number: issue.number, title: issue.title, column, - ...(sessionId ? { sessionId } : {}), - ...(profilePath ? { profilePath } : {}), - ...(brainstormProfilePath ? { brainstormProfilePath } : {}), - ...(testProfilePath ? { testProfilePath } : {}), - ...(prototypeProfilePath ? { prototypeProfilePath } : {}), - ...(specFile ? { specFile } : {}), - ...(planFile ? { planFile } : {}), - ...(prototypeFile ? { prototypeFile } : {}), - ...(prDiffFile ? { prDiffFile } : {}), - ...(pr ? { pr } : {}), + ...stateFields, ...(prMerged !== undefined ? { prMerged } : {}), ...(mergedAt ? { prMergedAt: mergedAt } : {}), ...(issue.closed_at ? { closedAt: issue.closed_at } : {}), - ...(branch ? { branch } : {}), - ...(worktreePath ? { worktreePath } : {}), ...(worktreeExists !== undefined ? { worktreeExists } : {}), - ...(implementStatus ? { implementStatus } : {}), - ...(implementSessionId ? { implementSessionId } : {}), - ...(reviewSessionId ? { reviewSessionId } : {}), - ...(testSessionId ? { testSessionId } : {}), - ...(prototypeSessionId ? { prototypeSessionId } : {}), ...(prerequisite !== undefined ? { prerequisite } : {}), - ...(color ? { color } : {}), - ...(autoReview !== undefined ? { autoReview } : {}), - ...(reviewPassed !== undefined ? { reviewPassed } : {}), - ...(handoffBundleKey ? { handoffBundleKey } : {}), - ...(handoffFrom ? { handoffFrom } : {}), - ...(handoffRequestFrom ? { handoffRequestFrom } : {}), - ...(handoffRequestError ? { handoffRequestError } : {}), assignees: (issue.assignees ?? []).map(a => a.login), ...(issue.user?.login ? { poster: issue.user.login } : {}), htmlUrl: issue.html_url, @@ -509,7 +237,7 @@ export async function loadIssues(opts: { // column 计算与 buildIssue 的判断保持一致:state comment 优先,缺失时按 issue.state 默认。 const columns: IssueColumn[] = merged.map((issue) => { const bucket = buckets.get(issue.number) ?? [] - return parseColumnFromComments(bucket).column ?? defaultColumnForState(issue.state) + return issueStateFields(stateOf(bucket)).column ?? defaultColumnForState(issue.state) }) // Concurrently fetch each issue's dependencies + live PR merged state. @@ -534,7 +262,7 @@ export async function loadIssues(opts: { if (columns[i] === 'done') return {} const bucket = buckets.get(issue.number) ?? [] - const { pr } = parseColumnFromComments(bucket) + const { pr } = issueStateFields(stateOf(bucket)) return fetchPrStatus({ host, token, owner, repo, pr }) })), ]) @@ -605,7 +333,7 @@ export async function loadSingleIssue(opts: { ]) // 解析 state JSON 拿到 pr 字段后实时查 merged 状态;失败 fallback undefined。 - const { pr } = parseColumnFromComments(comments) + const { pr } = issueStateFields(stateOf(comments)) const { merged: liveMerged, mergedAt: liveMergedAt } = await fetchPrStatus({ host, token, owner, repo, pr }) return buildIssue({ diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 2fc6c5b..ab8c8bc 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -1,172 +1,11 @@ /** - * Shared helper for merging extra fields into an issue's "state JSON" comment. - * - * Convention: the most recent state-JSON comment on a gitea issue holds a - * JSON blob with orchestration state (column, branch, pr, implementStatus, - * etc.). We locate it by scanning comments from the tail and picking the first - * one that parses as an object carrying a known state field — so an ordinary - * text/JSON comment posted afterwards (cc chatter, review notes, Gitea's PR - * auto-link) can't shadow it. This helper reads that blob, merges `extra` on - * top, and posts a new comment with the resulting JSON body. With no prior - * state comment it starts fresh from `{}`. + * Gitea 工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.ts)。 */ -import type { GiteaComment } from './api' +import type { StateCommentThread } from '../issues/stateComment' +import { mergeState, mergeStateGuarded, readState } from '../issues/stateComment' import { listIssueComments, postIssueComment } from './api' -/** - * 已知 state 字段集合,用于把 state JSON comment 与普通 JSON 内容评论区分开。 - * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 - * 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 - */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffBundleKey', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const - -/** - * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。 - * 返回解析后的对象,否则返回 null。 - */ -function parseStateComment(body: string | undefined | null): Record | null { - const trimmed = (body ?? '').trim() - if (!trimmed) - return null - let parsed: unknown - try { - parsed = JSON.parse(trimmed) - } - catch { - return null - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) - return null - const obj = parsed as Record - const hasKnownField = KNOWN_STATE_FIELDS.some(field => field in obj) - return hasKnownField ? obj : null -} - -/** - * 评论是否是一条带 `column` 的 state JSON——即「这张卡换列了」的信号。 - * webhook 收到这种评论时据此刷新对应卡片。 - */ -export function hasStateColumn(body: string | undefined | null): boolean { - const parsed = parseStateComment(body) - return parsed !== null && 'column' in parsed -} - -/** - * 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。 - * 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。 - */ -function findLatestState(comments: GiteaComment[]): Record | null { - for (let i = comments.length - 1; i >= 0; i--) { - const parsed = parseStateComment(comments[i].body) - if (parsed) - return parsed - } - return null -} - -export interface MergeStateJsonCommentOpts { - host: string - owner: string - repo: string - token: string - issueNumber: number - extra: Record - defaults?: Record -} - -/** - * Merge `extra` into the latest state-JSON comment of the issue and post a - * new comment with the merged JSON. Throws on network/API failure — callers - * are responsible for surfacing errors. - */ -export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise { - // 乐观并发:state 评论是"最后一条全量覆盖",两个写者并发时后写者会把 - // 先写者的字段整体冲掉。门禁后共享写基本回到单写者,但人工拖列仍可能 - // 与 webhook 写并发——post 后读回校验,本次字段若被并发覆盖,就在最新 - // 状态上重放一次。两轮后仍冲突的概率可忽略,按最后一轮结果收场。 - for (let attempt = 0; attempt < 2; attempt++) { - const currentState = await readStateJsonComment({ - host: opts.host, - owner: opts.owner, - repo: opts.repo, - token: opts.token, - issueNumber: opts.issueNumber, - }) - await postMergedStateJsonComment(opts, currentState, opts.extra) - const latest = await readStateJsonComment({ - host: opts.host, - owner: opts.owner, - repo: opts.repo, - token: opts.token, - issueNumber: opts.issueNumber, - }) - const lost = Object.entries(opts.extra) - .some(([k, v]) => JSON.stringify(latest[k]) !== JSON.stringify(v)) - if (!lost) - return - } -} - -export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts { - protectDoneColumn?: boolean -} - -/** - * Merge `extra` while protecting terminal `done` cards from stale automatic - * column updates. When protection removes the only incoming field, no comment - * is posted. - */ -export async function mergeStateJsonCommentGuarded( - opts: MergeStateJsonCommentGuardedOpts, -): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { - const currentState = await readStateJsonComment({ - host: opts.host, - owner: opts.owner, - repo: opts.repo, - token: opts.token, - issueNumber: opts.issueNumber, - }) - const extra = { ...opts.extra } - let protectedDoneColumn = false - - if ( - opts.protectDoneColumn - && currentState.column === 'done' - && typeof extra.column === 'string' - && extra.column !== 'done' - ) { - delete extra.column - protectedDoneColumn = true - } - - if (Object.keys(extra).length === 0) - return { posted: false, protectedDoneColumn } - - await postMergedStateJsonComment(opts, currentState, extra) - return { posted: true, protectedDoneColumn } -} - -async function postMergedStateJsonComment( - opts: MergeStateJsonCommentOpts, - currentState: Record, - extra: Record, -): Promise { - const merged: Record = { - ...opts.defaults, - ...currentState, - ...extra, - } - await postIssueComment({ - host: opts.host, - token: opts.token, - owner: opts.owner, - repo: opts.repo, - index: opts.issueNumber, - body: JSON.stringify(merged), - }) -} - export interface ReadStateJsonCommentOpts { host: string owner: string @@ -175,22 +14,36 @@ export interface ReadStateJsonCommentOpts { issueNumber: number } -/** - * Read the latest state-JSON comment of the issue and return it as a parsed - * object. Scans comments from the tail and returns the first one that parses - * as an object carrying a known state field, so ordinary comments inserted - * afterwards don't shadow the real state. Returns an empty object when no such - * comment exists. Throws on network/API failure. - */ -export async function readStateJsonComment( - opts: ReadStateJsonCommentOpts, -): Promise> { - const comments = await listIssueComments({ - host: opts.host, - token: opts.token, - owner: opts.owner, - repo: opts.repo, - index: opts.issueNumber, - }) - return findLatestState(comments) ?? {} +export interface MergeStateJsonCommentOpts extends ReadStateJsonCommentOpts { + extra: Record + defaults?: Record +} + +export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts { + protectDoneColumn?: boolean +} + +function giteaThread(opts: ReadStateJsonCommentOpts): StateCommentThread { + const where = { host: opts.host, token: opts.token, owner: opts.owner, repo: opts.repo, index: opts.issueNumber } + return { + bodies: async () => (await listIssueComments(where)).map(comment => comment.body), + post: body => postIssueComment({ ...where, body }), + } +} + +/** 把 `extra` 合并进工单状态并写一条新的状态评论;网络或接口失败直接抛出。 */ +export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise { + await mergeState(giteaThread(opts), opts.extra, opts.defaults) +} + +/** 同 [mergeStateJsonComment],但可以保护完成列不被自动流程改掉。 */ +export function mergeStateJsonCommentGuarded( + opts: MergeStateJsonCommentGuardedOpts, +): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { + return mergeStateGuarded(giteaThread(opts), opts.extra, { protectDoneColumn: opts.protectDoneColumn, defaults: opts.defaults }) +} + +/** 读工单当前状态;没有状态评论时是空对象,网络或接口失败直接抛出。 */ +export function readStateJsonComment(opts: ReadStateJsonCommentOpts): Promise> { + return readState(giteaThread(opts)) } diff --git a/vscode/src/issues/stateComment.test.ts b/vscode/src/issues/stateComment.test.ts new file mode 100644 index 0000000..bc69cc4 --- /dev/null +++ b/vscode/src/issues/stateComment.test.ts @@ -0,0 +1,151 @@ +import type { StateCommentThread } from './stateComment' +import { describe, expect, it } from 'vitest' +import stateSchema from '../../schemas/state-json.schema.json' +import { + hasStateColumn, + latestState, + mergeState, + mergeStateGuarded, + parseStateComment, + readState, + serializeStateComment, + STATE_FIELDS, +} from './stateComment' + +/** 内存里的评论串:post 追加到末尾,afterPost 可以模拟别的写者紧跟着插一条。 */ +function memoryThread(initial: string[], afterPost?: (bodies: string[]) => void): StateCommentThread & { all: string[] } { + const all = [...initial] + return { + all, + bodies: async () => [...all], + post: async (body) => { + all.push(body) + afterPost?.(all) + }, + } +} + +describe('已知字段', () => { + it('取自 state JSON schema 的 properties', () => { + expect([...STATE_FIELDS].sort()).toEqual(Object.keys(stateSchema.properties).sort()) + }) +}) + +describe('serializeStateComment', () => { + it('写成隐藏的 HTML 注释,里面一行紧凑 JSON', () => { + expect(serializeStateComment({ column: 'review', pr: '9' })) + .toBe('') + }) + + it('把 < > & 转成 \\u 转义,正文里不会出现 -->', () => { + const body = serializeStateComment({ column: 'todo', branch: 'a-->b'.length) + expect(inner).not.toContain('-->') + expect(inner).not.toMatch(/[<>&]/) + expect(inner).toContain('a--\\u003eb\\u003cc\\u0026d') + expect(parseStateComment(body)).toEqual({ column: 'todo', branch: 'a-->b { + it('认隐藏格式,前后空白不影响', () => { + expect(parseStateComment(' \n')).toEqual({ column: 'done', prMerged: true }) + }) + + it('认旧的 YouTrack 标记格式', () => { + expect(parseStateComment('\n{"column":"in-progress","sessionId":"abc"}')).toEqual({ column: 'in-progress', sessionId: 'abc' }) + }) + + it('认旧的裸 JSON', () => { + expect(parseStateComment('{"column":"todo"}')).toEqual({ column: 'todo' }) + }) + + it('不含已知字段、不是对象、不是 JSON 的都不算', () => { + expect(parseStateComment('{"note":"像 JSON 但没有已知字段"}')).toBeNull() + expect(parseStateComment('')).toBeNull() + expect(parseStateComment('[{"column":"done"}]')).toBeNull() + expect(parseStateComment('')).toBeNull() + expect(parseStateComment('\n看着没问题')).toBeNull() + expect(parseStateComment('普通评论')).toBeNull() + expect(parseStateComment('')).toBeNull() + expect(parseStateComment(undefined)).toBeNull() + expect(parseStateComment(null)).toBeNull() + }) +}) + +describe('latestState', () => { + it('从尾往前取最新一条有效的,三种格式混着也行', () => { + expect(latestState([ + '{"column":"todo"}', + '\n{"column":"in-progress"}', + serializeStateComment({ column: 'review', pr: '3' }), + '普通评论', + '{"note":"不是状态"}', + ])).toEqual({ column: 'review', pr: '3' }) + }) + + it('没有状态评论返回 null', () => { + expect(latestState(['hi', ''])).toBeNull() + }) +}) + +describe('hasStateColumn', () => { + it('带 column 的状态评论才算换列信号', () => { + expect(hasStateColumn(serializeStateComment({ column: 'done' }))).toBe(true) + expect(hasStateColumn('{"column":"in-progress"}')).toBe(true) + expect(hasStateColumn(serializeStateComment({ pr: '318' }))).toBe(false) + expect(hasStateColumn('拖到完成列了')).toBe(false) + }) +}) + +describe('readState / mergeState', () => { + it('读时跳过尾部普通评论', async () => { + const thread = memoryThread(['{"column":"review","pr":"9"}', 'PR 已关联']) + expect(await readState(thread)).toEqual({ column: 'review', pr: '9' }) + }) + + it('没有状态时读出空对象', async () => { + expect(await readState(memoryThread([]))).toEqual({}) + }) + + it('合并到最新状态上,用新格式写一条', async () => { + const thread = memoryThread(['{"column":"review","branch":"feature/x"}', '普通评论']) + await mergeState(thread, { pr: '5' }) + expect(thread.all.at(-1)).toBe(serializeStateComment({ column: 'review', branch: 'feature/x', pr: '5' })) + }) + + it('默认值垫在最底下,现有状态和这次的改动都盖过它', async () => { + const thread = memoryThread(['{"column":"review"}']) + await mergeState(thread, { pr: '5' }, { column: 'todo', color: 'blue' }) + expect(parseStateComment(thread.all.at(-1))).toEqual({ column: 'review', color: 'blue', pr: '5' }) + }) + + it('写完读回发现被并发覆盖,就在最新状态上重放一次', async () => { + let raced = false + const thread = memoryThread(['{"column":"in-progress"}'], (all) => { + if (raced) + return + raced = true + all.push('{"column":"review"}') + }) + await mergeState(thread, { pr: '7' }) + expect(parseStateComment(thread.all.at(-1))).toEqual({ column: 'review', pr: '7' }) + }) +}) + +describe('mergeStateGuarded', () => { + it('保护完成列:自动流程想改回审查时只丢掉 column,别的字段照写', async () => { + const thread = memoryThread(['{"column":"done","implementStatus":"done"}']) + const result = await mergeStateGuarded(thread, { column: 'review', reviewPassed: true }, { protectDoneColumn: true }) + expect(result).toEqual({ posted: true, protectedDoneColumn: true }) + expect(parseStateComment(thread.all.at(-1))).toEqual({ column: 'done', implementStatus: 'done', reviewPassed: true }) + }) + + it('保护后什么都不剩就不写', async () => { + const thread = memoryThread(['{"column":"done"}']) + const result = await mergeStateGuarded(thread, { column: 'review' }, { protectDoneColumn: true }) + expect(result).toEqual({ posted: false, protectedDoneColumn: true }) + expect(thread.all).toHaveLength(1) + }) +}) diff --git a/vscode/src/issues/stateComment.ts b/vscode/src/issues/stateComment.ts new file mode 100644 index 0000000..88271b5 --- /dev/null +++ b/vscode/src/issues/stateComment.ts @@ -0,0 +1,144 @@ +/** + * 工单状态评论的读写格式,代码托管仓库和 YouTrack 共用这一份。 + * + * 状态(列、PR、分支、会话 id 等)存成工单上的一条评论,最新一条有效的状态评论就是当前状态。 + * 写出的格式是一段隐藏的 HTML 注释,网页上看不见: + * + * ``` + * + * ``` + * + * 读时还认两种旧格式:YouTrack 用过的 `` 前缀加 JSON,和最早的裸 JSON 对象。 + */ + +import stateSchema from '../../schemas/state-json.schema.json' + +/** 状态 JSON 的全部已知字段,取自 [state JSON schema](vscode/schemas/state-json.schema.json),spx 的 Go 端读的也是这份。 */ +export const STATE_FIELDS: readonly string[] = Object.keys(stateSchema.properties) + +const HIDDEN_PREFIX = '' +const LEGACY_MARKER = '' + +/** 与 Go 的 `json.Marshal` 默认转义一致,正文里因此不会出现能提前结束注释的 `-->`。 */ +const HTML_ESCAPES: Record = { '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' } + +/** 把状态写成一条隐藏的状态评论。 */ +export function serializeStateComment(state: Record): string { + const json = JSON.stringify(state).replace(/[<>&]/g, ch => HTML_ESCAPES[ch]) + return `${HIDDEN_PREFIX}\n${json}\n${HIDDEN_SUFFIX}` +} + +/** 去掉格式外壳,剩下的就是 JSON 文本;三种格式都不是时原样返回,交给 JSON 解析去判。 */ +function stateJsonText(body: string): string { + if (body.startsWith(HIDDEN_PREFIX) && body.endsWith(HIDDEN_SUFFIX)) + return body.slice(HIDDEN_PREFIX.length, -HIDDEN_SUFFIX.length) + if (body.startsWith(LEGACY_MARKER)) + return body.slice(LEGACY_MARKER.length) + return body +} + +/** + * 评论是状态评论就返回解析出的状态,否则返回 `null`。 + * + * 解析结果必须是 JSON 对象且至少带一个 [STATE_FIELDS] 里的字段,普通讨论、审查评论、像 JSON 的闲聊都因此被跳过。 + */ +export function parseStateComment(body: string | null | undefined): Record | null { + const text = (body ?? '').trim() + if (!text) + return null + let parsed: unknown + try { + parsed = JSON.parse(stateJsonText(text)) + } + catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + return null + const state = parsed as Record + return STATE_FIELDS.some(field => field in state) ? state : null +} + +/** 从最后一条往前找,返回最新一条状态评论的状态;一条都没有返回 `null`。 */ +export function latestState(bodies: readonly (string | null | undefined)[]): Record | null { + for (let i = bodies.length - 1; i >= 0; i--) { + const state = parseStateComment(bodies[i]) + if (state) + return state + } + return null +} + +/** 评论是不是一条带 `column` 的状态评论,也就是「这张卡换列了」的信号。 */ +export function hasStateColumn(body: string | null | undefined): boolean { + const state = parseStateComment(body) + return state !== null && 'column' in state +} + +/** 一个工单的评论串:状态的读写只需要列出评论正文和追加一条评论。 */ +export interface StateCommentThread { + /** 全部评论正文,从旧到新。 */ + bodies: () => Promise + post: (body: string) => Promise +} + +/** 读工单当前状态;没有状态评论时是空对象。 */ +export async function readState(thread: StateCommentThread): Promise> { + return latestState(await thread.bodies()) ?? {} +} + +async function postMerged( + thread: StateCommentThread, + current: Record, + extra: Record, + defaults?: Record, +): Promise { + await thread.post(serializeStateComment({ ...defaults, ...current, ...extra })) +} + +/** + * 把 `extra` 合并到最新状态上,再写一条完整的状态评论;`defaults` 垫在最底下,只补缺的字段。 + * + * 状态评论是「最后一条整份覆盖」,两个写者同时写时后写的会冲掉先写的字段,所以写完读回来核对,本次字段被冲掉了就在最新状态上再写一次; + * 两轮还冲突的概率可以忽略,按第二轮结果收场。 + */ +export async function mergeState( + thread: StateCommentThread, + extra: Record, + defaults?: Record, +): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + await postMerged(thread, await readState(thread), extra, defaults) + const latest = await readState(thread) + const lost = Object.entries(extra).some(([k, v]) => JSON.stringify(latest[k]) !== JSON.stringify(v)) + if (!lost) + return + } +} + +/** + * 同 [mergeState],但 `protectDoneColumn` 时不让自动流程把已完成的卡改回别的列:只丢掉 `column`,别的字段照写, + * 丢完什么都不剩就不写。 + * + * @return `posted` 是否写了评论;`protectedDoneColumn` 是否丢掉了 `column` + */ +export async function mergeStateGuarded( + thread: StateCommentThread, + extra: Record, + opts: { protectDoneColumn?: boolean, defaults?: Record }, +): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { + const current = await readState(thread) + const next = { ...extra } + let protectedDoneColumn = false + if (opts.protectDoneColumn && current.column === 'done' && typeof next.column === 'string' && next.column !== 'done') { + delete next.column + protectedDoneColumn = true + } + if (Object.keys(next).length === 0) + return { posted: false, protectedDoneColumn } + await postMerged(thread, current, next, opts.defaults) + return { posted: true, protectedDoneColumn } +} diff --git a/vscode/src/issues/stateFields.test.ts b/vscode/src/issues/stateFields.test.ts new file mode 100644 index 0000000..ac71986 --- /dev/null +++ b/vscode/src/issues/stateFields.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { issueStateFields } from './stateFields' + +describe('issueStateFields', () => { + it('取出合法的列、字符串字段和布尔字段', () => { + expect(issueStateFields({ + column: 'review', + pr: '9', + branch: 'feature/x', + prMerged: false, + autoReview: true, + implementStatus: 'running', + specFile: 'docs/specs/a.md', + prototypeFile: 'docs/proto/manifest.json', + prDiffFile: 'docs/pr-diff/9.md', + handoffFrom: 'alice', + })).toEqual({ + column: 'review', + pr: '9', + branch: 'feature/x', + prMerged: false, + autoReview: true, + implementStatus: 'running', + specFile: 'docs/specs/a.md', + prototypeFile: 'docs/proto/manifest.json', + prDiffFile: 'docs/pr-diff/9.md', + handoffFrom: 'alice', + }) + }) + + it('丢掉空串、类型不对、取值不在范围里的字段,不留 undefined 键', () => { + const fields = issueStateFields({ + column: 'archived', + pr: '', + branch: 3, + prMerged: 'yes', + implementStatus: 'paused', + specFile: '...', + planFile: 'plan.md', + prDiffFile: 'other/x.md', + unknown: 'x', + }) + expect(fields).toEqual({}) + expect(Object.keys(fields)).toEqual([]) + }) +}) diff --git a/vscode/src/issues/stateFields.ts b/vscode/src/issues/stateFields.ts new file mode 100644 index 0000000..ff468e9 --- /dev/null +++ b/vscode/src/issues/stateFields.ts @@ -0,0 +1,91 @@ +import type { Issue, IssueColumn } from '../gitea/types' + +const COLUMNS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] + +/** 非空字符串才算有值:状态里写空串表示把字段清掉。 */ +const STRING_FIELDS = [ + 'sessionId', + 'implementSessionId', + 'reviewSessionId', + 'testSessionId', + 'prototypeSessionId', + 'profilePath', + 'brainstormProfilePath', + 'testProfilePath', + 'prototypeProfilePath', + 'pr', + 'prMergedAt', + 'branch', + 'worktreePath', + 'color', + 'handoffBundleKey', + 'handoffFrom', + 'handoffRequestFrom', + 'handoffRequestError', +] as const + +const BOOLEAN_FIELDS = ['prMerged', 'autoReview', 'reviewPassed'] as const + +/** 状态里能直接放到卡片上的字段;读不出合法值的字段不出现。 */ +export type IssueStateFields = Partial> + +type CardStateField = 'column' | 'specFile' | 'planFile' | 'prototypeFile' | 'prDiffFile' | 'implementStatus' | typeof STRING_FIELDS[number] | typeof BOOLEAN_FIELDS[number] + +/** + * 过滤 specFile / planFile 里的垃圾值:真实路径必须含 `/` 且以 `.md` 结尾。 + * + * `...`、`…` 是 cc 在头脑风暴阶段往工单正文里写的占位说明,旧版 webhook 曾把它们当路径存进状态,读出时在这里清掉。 + */ +export function isValidSpxFilePath(v: unknown): v is string { + return typeof v === 'string' + && v.length > 0 + && v.includes('/') + && v.endsWith('.md') + && v !== '...' + && v !== '…' +} + +/** prototypeFile 指向 Figma 插件的 manifest.json,规则同 [isValidSpxFilePath],只是后缀换成 `.json`。 */ +function isValidPrototypeFilePath(v: unknown): v is string { + return typeof v === 'string' + && v.length > 0 + && v.includes('/') + && v.endsWith('.json') + && v !== '...' + && v !== '…' +} + +function isValidPrDiffFilePath(v: unknown): v is string { + return typeof v === 'string' && /^docs\/pr-diff\/\S+\.md$/.test(v) +} + +function isIssueColumn(v: unknown): v is IssueColumn { + return typeof v === 'string' && (COLUMNS as readonly string[]).includes(v) +} + +/** 把一份工单状态翻成卡片字段,只留合法的值,代码托管仓库和 YouTrack 的卡片都按它读。 */ +export function issueStateFields(state: Record): IssueStateFields { + const out: Record = {} + if (isIssueColumn(state.column)) + out.column = state.column + for (const field of STRING_FIELDS) { + const v = state[field] + if (typeof v === 'string' && v.length > 0) + out[field] = v + } + for (const field of BOOLEAN_FIELDS) { + if (typeof state[field] === 'boolean') + out[field] = state[field] + } + if (isValidSpxFilePath(state.specFile)) + out.specFile = state.specFile + if (isValidSpxFilePath(state.planFile)) + out.planFile = state.planFile + if (isValidPrototypeFilePath(state.prototypeFile)) + out.prototypeFile = state.prototypeFile + if (isValidPrDiffFilePath(state.prDiffFile)) + out.prDiffFile = state.prDiffFile + if (state.implementStatus === 'running' || state.implementStatus === 'done' || state.implementStatus === 'failed') + out.implementStatus = state.implementStatus + return out as IssueStateFields +} diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index a57edcc..fa3a245 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -32,8 +32,9 @@ import { mergePullRequest, removeDependency, } from '../../gitea/api' -import { isValidSpxFilePath, loadIssues } from '../../gitea/issueLoader' +import { loadIssues } from '../../gitea/issueLoader' import { mergeStateJsonComment } from '../../gitea/stateJson' +import { isValidSpxFilePath } from '../../issues/stateFields' import { logger } from '../../logging/logger' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' diff --git a/vscode/src/webhook/coordinator.cache.test.ts b/vscode/src/webhook/coordinator.cache.test.ts index 84a693a..33beed1 100644 --- a/vscode/src/webhook/coordinator.cache.test.ts +++ b/vscode/src/webhook/coordinator.cache.test.ts @@ -21,8 +21,7 @@ vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) })) vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) })) vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle })) -vi.mock('../gitea/stateJson', async importOriginal => ({ - hasStateColumn: (await importOriginal()).hasStateColumn, +vi.mock('../gitea/stateJson', () => ({ readStateJsonComment: mocks.readState, mergeStateJsonComment: async () => undefined, })) diff --git a/vscode/src/webhook/coordinator.issueCreation.test.ts b/vscode/src/webhook/coordinator.issueCreation.test.ts index 572db51..71b18ce 100644 --- a/vscode/src/webhook/coordinator.issueCreation.test.ts +++ b/vscode/src/webhook/coordinator.issueCreation.test.ts @@ -40,8 +40,7 @@ vi.mock('../git/remote', async importOriginal => ({ })) vi.mock('../gitea/api', () => ({ getIssue: mocks.getIssue })) vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.load })) -vi.mock('../gitea/stateJson', async importOriginal => ({ - hasStateColumn: (await importOriginal()).hasStateColumn, +vi.mock('../gitea/stateJson', () => ({ readStateJsonComment: mocks.readShared, mergeStateJsonComment: mocks.mergeShared, })) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index b13b82f..29f08cf 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -26,8 +26,9 @@ import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api' import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' -import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' +import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' +import { hasStateColumn, parseStateComment } from '../issues/stateComment' import { logger } from '../logging/logger' import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest' import * as issueChat from '../panel/handlers/issueChat' @@ -114,13 +115,8 @@ const BODY_MARKERS = [ type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string } function handoffRequestFromOf(body: string): string { - try { - const obj = JSON.parse(body) as { handoffRequestFrom?: unknown } - return typeof obj.handoffRequestFrom === 'string' ? obj.handoffRequestFrom.trim() : '' - } - catch { - return '' - } + const from = parseStateComment(body)?.handoffRequestFrom + return typeof from === 'string' ? from.trim() : '' } function parseBodyMarkers(body: string): MarkerPatch { diff --git a/vscode/src/youtrack/issueLoader.ts b/vscode/src/youtrack/issueLoader.ts index 20d8215..6aba48c 100644 --- a/vscode/src/youtrack/issueLoader.ts +++ b/vscode/src/youtrack/issueLoader.ts @@ -7,16 +7,15 @@ */ import type { ExtensionContext } from 'vscode' -import type { Issue, IssueColumn } from '../gitea/types' +import type { Issue } from '../gitea/types' import type { YouTrackIssue } from './api' import { workspace } from 'vscode' import { getYouTrackToken } from '../auth/secrets' +import { latestState } from '../issues/stateComment' +import { issueStateFields } from '../issues/stateFields' import { getSettings } from '../settings/store' import { issueHtmlUrl, listIssues } from './api' import { readImportedIds } from './importStore' -import { findLatestState } from './stateComment' - -const VALID_COLUMNS = new Set(['todo', 'in-progress', 'review', 'done']) /** Host part of the configured base URL — the SecretStorage token key. */ export function youtrackHost(baseUrl: string): string { @@ -28,10 +27,6 @@ export function youtrackHost(baseUrl: string): string { } } -function str(v: unknown): string | undefined { - return typeof v === 'string' && v.length > 0 ? v : undefined -} - /** * Offset added to a YouTrack issue's numeric id so its board `number` never * collides with a gitea issue number (which is the routing key for @@ -47,45 +42,18 @@ function syntheticNumber(idReadable: string): number { return YOUTRACK_NUMBER_OFFSET + (m ? Number(m[1]) : 0) } -function pickColumn(rawColumn: unknown, resolved: number | null): IssueColumn { - if (typeof rawColumn === 'string' && VALID_COLUMNS.has(rawColumn as IssueColumn)) - return rawColumn as IssueColumn - return resolved != null ? 'done' : 'todo' -} - function toIssue(baseUrl: string, it: YouTrackIssue): Issue { - const state = findLatestState(it.comments) ?? {} + const { column, ...fields } = issueStateFields(latestState(it.comments.map(comment => comment.text)) ?? {}) return { id: `youtrack:${it.idReadable}`, externalId: it.idReadable, source: 'youtrack', number: syntheticNumber(it.idReadable), title: it.summary, - column: pickColumn(state.column, it.resolved), + column: column ?? (it.resolved != null ? 'done' : 'todo'), htmlUrl: issueHtmlUrl(baseUrl, it.idReadable), attachments: it.attachments, - sessionId: str(state.sessionId), - implementSessionId: str(state.implementSessionId), - reviewSessionId: str(state.reviewSessionId), - testSessionId: str(state.testSessionId), - prototypeSessionId: str(state.prototypeSessionId), - profilePath: str(state.profilePath), - brainstormProfilePath: str(state.brainstormProfilePath), - testProfilePath: str(state.testProfilePath), - prototypeProfilePath: str(state.prototypeProfilePath), - specFile: str(state.specFile), - planFile: str(state.planFile), - prototypeFile: str(state.prototypeFile), - prDiffFile: str(state.prDiffFile), - pr: str(state.pr), - branch: str(state.branch), - worktreePath: str(state.worktreePath), - color: str(state.color), - implementStatus: state.implementStatus === 'running' || state.implementStatus === 'done' || state.implementStatus === 'failed' - ? state.implementStatus - : undefined, - autoReview: typeof state.autoReview === 'boolean' ? state.autoReview : undefined, - reviewPassed: typeof state.reviewPassed === 'boolean' ? state.reviewPassed : undefined, + ...fields, } } diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index 4f7bc88..dd9d43f 100644 --- a/vscode/src/youtrack/stateComment.ts +++ b/vscode/src/youtrack/stateComment.ts @@ -1,85 +1,25 @@ /** - * YouTrack equivalent of `src/gitea/stateJson.ts`. - * - * Workflow state (column, sessionId, specFile, …) for a YouTrack-sourced issue - * is persisted as a comment on the issue. The comment body is a marker line - * followed by the JSON blob: - * - * - * {"column":"in-progress","sessionId":"..."} - * - * The marker makes the internal blob identifiable in the customer-facing - * tracker (the user accepted that this state lives in comments). We scan - * comments from the tail and pick the latest one carrying the marker, so - * ordinary customer replies posted afterwards never shadow the real state. + * YouTrack 工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.ts)。 */ -import type { YouTrackAuth, YouTrackComment } from './api' +import type { StateCommentThread } from '../issues/stateComment' +import type { YouTrackAuth } from './api' +import { mergeState, readState } from '../issues/stateComment' import { addComment, listComments } from './api' -export const STATE_MARKER = '' - -/** Same field set as the Gitea state blob — kept independent to avoid coupling. */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffBundleKey', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const - -/** - * Parse a comment body into a state object, or null if it isn't a state - * comment. Accepts either a marker-prefixed body or a bare JSON object that - * carries a known state field (tolerant of older blobs written without the - * marker). - */ -export function parseStateComment(body: string | undefined | null): Record | null { - let text = (body ?? '').trim() - if (!text) - return null - if (text.startsWith(STATE_MARKER)) - text = text.slice(STATE_MARKER.length).trim() - let parsed: unknown - try { - parsed = JSON.parse(text) +function youtrackThread(auth: YouTrackAuth, issueId: string): StateCommentThread { + return { + bodies: async () => (await listComments(auth, issueId)).map(comment => comment.text), + post: body => addComment(auth, issueId, body), } - catch { - return null - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) - return null - const obj = parsed as Record - return KNOWN_STATE_FIELDS.some(f => f in obj) ? obj : null } -/** Latest state blob across a comment list (tail-first), or null. */ -export function findLatestState(comments: YouTrackComment[]): Record | null { - for (let i = comments.length - 1; i >= 0; i--) { - const parsed = parseStateComment(comments[i].text) - if (parsed) - return parsed - } - return null +/** 读工单当前状态;没有状态评论时是空对象。 */ +export function readStateComment(auth: YouTrackAuth, issueId: string): Promise> { + return readState(youtrackThread(auth, issueId)) } -function serialize(state: Record): string { - return `${STATE_MARKER}\n${JSON.stringify(state)}` -} - -/** Read the issue's latest state blob, or `{}` when none exists. */ -export async function readStateComment( - auth: YouTrackAuth, - issueId: string, -): Promise> { - const comments = await listComments(auth, issueId) - return findLatestState(comments) ?? {} -} - -/** - * Merge `extra` into the issue's latest state blob and post a new state - * comment. Re-reads comments first so we never merge onto a stale blob. - */ -export async function mergeStateComment( - auth: YouTrackAuth, - issueId: string, - extra: Record, -): Promise { - const comments = await listComments(auth, issueId) - const current = findLatestState(comments) ?? {} - await addComment(auth, issueId, serialize({ ...current, ...extra })) +/** 把 `extra` 合并进工单状态并写一条新的状态评论。 */ +export function mergeStateComment(auth: YouTrackAuth, issueId: string, extra: Record): Promise { + return mergeState(youtrackThread(auth, issueId), extra) } diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 49a52f7..dc13996 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer' import { EventEmitter } from 'node:events' import { delimiter } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { parseStateComment } from '../../src/issues/stateComment' const execFile = vi.fn() const spawn = vi.fn() @@ -89,7 +90,7 @@ describe('mergeStateJsonCommentGuarded', () => { }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'done', implementStatus: 'done', prMerged: true, @@ -156,7 +157,7 @@ describe('state JSON 尾部普通评论不丢状态', () => { }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ ...fullState, pr: '182', implementStatus: 'done', @@ -191,7 +192,7 @@ describe('state JSON 尾部普通评论不丢状态', () => { expect(postIssueComment).toHaveBeenCalledTimes(2) // 重放基于并发写者的最新状态,两边的字段都保留。 - expect(JSON.parse(postIssueComment.mock.calls[1][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[1][0].body)).toEqual({ column: 'review', pr: '99', }) @@ -231,7 +232,7 @@ describe('state defaults', () => { listIssueComments.mockImplementation(async () => [...comments]) postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { pr: '5' } }) - expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual(expected) + expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual(expected) expect(postIssueComment).toHaveBeenCalledOnce() }) @@ -241,7 +242,7 @@ describe('state defaults', () => { listIssueComments.mockImplementation(async () => [...comments]) postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { column: 'done' } }) - expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'done', color: 'blue' }) + expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'done', color: 'blue' }) }) it('read-back does not force defaults over a concurrent update', async () => { @@ -249,7 +250,7 @@ describe('state defaults', () => { listIssueComments.mockResolvedValueOnce([]).mockResolvedValue([{ body: JSON.stringify({ column: 'review', color: 'red' }) }]) await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: {} }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'todo', color: 'blue' }) + expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'todo', color: 'blue' }) }) }) @@ -434,18 +435,18 @@ describe('deleteLocalBranch', () => { describe('hasStateColumn', () => { it('带 column 的 state JSON → true', async () => { - const { hasStateColumn } = await import('../../src/gitea/stateJson.js') + const { hasStateColumn } = await import('../../src/issues/stateComment.js') expect(hasStateColumn('{"column":"done","pr":"318"}')).toBe(true) expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true) }) it('没有 column 的 state JSON → false', async () => { - const { hasStateColumn } = await import('../../src/gitea/stateJson.js') + const { hasStateColumn } = await import('../../src/issues/stateComment.js') expect(hasStateColumn('{"pr":"318","branch":"feature/x"}')).toBe(false) }) it('普通文本、审查评论、空值 → false', async () => { - const { hasStateColumn } = await import('../../src/gitea/stateJson.js') + const { hasStateColumn } = await import('../../src/issues/stateComment.js') expect(hasStateColumn('\n看着没问题')).toBe(false) expect(hasStateColumn('拖到完成列了')).toBe(false) expect(hasStateColumn('')).toBe(false) @@ -453,7 +454,7 @@ describe('hasStateColumn', () => { }) it('json 数组不算 state 评论', async () => { - const { hasStateColumn } = await import('../../src/gitea/stateJson.js') + const { hasStateColumn } = await import('../../src/issues/stateComment.js') expect(hasStateColumn('[{"column":"done"}]')).toBe(false) }) }) diff --git a/vscode/test/youtrack/stateComment.test.ts b/vscode/test/youtrack/stateComment.test.ts index c896833..eace3a9 100644 --- a/vscode/test/youtrack/stateComment.test.ts +++ b/vscode/test/youtrack/stateComment.test.ts @@ -1,32 +1,40 @@ -import { describe, expect, it } from 'vitest' -import { findLatestState, parseStateComment, STATE_MARKER } from '../../src/youtrack/stateComment' +import { beforeEach, describe, expect, it, vi } from 'vitest' -describe('youtrack stateComment parsing', () => { - it('parses a marker-prefixed state blob', () => { - const body = `${STATE_MARKER}\n${JSON.stringify({ column: 'in-progress', sessionId: 'abc' })}` - expect(parseStateComment(body)).toEqual({ column: 'in-progress', sessionId: 'abc' }) +const listComments = vi.fn() +const addComment = vi.fn() + +vi.mock('../../src/youtrack/api', () => ({ listComments, addComment })) + +const auth = { baseUrl: 'https://yt.example', token: 't' } + +describe('youtrack 状态评论', () => { + beforeEach(() => { + listComments.mockReset() + addComment.mockReset() }) - it('tolerates a bare JSON blob written without the marker', () => { - expect(parseStateComment(JSON.stringify({ column: 'done' }))).toEqual({ column: 'done' }) - }) - - it('rejects JSON objects without any known state field (customer replies)', () => { - expect(parseStateComment(JSON.stringify({ hello: 'world' }))).toBeNull() - expect(parseStateComment('just a plain comment')).toBeNull() - expect(parseStateComment('')).toBeNull() - }) - - it('returns the latest state comment, skipping trailing non-state comments', () => { - const comments = [ - { id: '1', text: `${STATE_MARKER}\n${JSON.stringify({ column: 'todo' })}` }, - { id: '2', text: `${STATE_MARKER}\n${JSON.stringify({ column: 'in-progress', branch: 'feat/x' })}` }, + it('读到旧标记格式写的状态,跳过客户后来的回复', async () => { + const { readStateComment } = await import('../../src/youtrack/stateComment.js') + listComments.mockResolvedValue([ + { id: '1', text: '\n{"column":"todo"}' }, + { id: '2', text: '\n{"column":"in-progress","branch":"feat/x"}' }, { id: '3', text: '客户后续补充:还是不行' }, - ] - expect(findLatestState(comments)).toEqual({ column: 'in-progress', branch: 'feat/x' }) + ]) + expect(await readStateComment(auth, 'LXF-1')).toEqual({ column: 'in-progress', branch: 'feat/x' }) }) - it('returns null when no state comment exists', () => { - expect(findLatestState([{ id: '1', text: 'hi' }, { id: '2', text: 'thanks' }])).toBeNull() + it('合并后用隐藏格式写回', async () => { + const { mergeStateComment } = await import('../../src/youtrack/stateComment.js') + const { serializeStateComment } = await import('../../src/issues/stateComment.js') + const comments = [{ id: '1', text: '{"column":"todo"}' }] + listComments.mockImplementation(async () => [...comments]) + addComment.mockImplementation(async (_auth: unknown, _id: string, text: string) => { + comments.push({ id: String(comments.length + 1), text }) + }) + + await mergeStateComment(auth, 'LXF-1', { branch: 'feat/y' }) + + expect(addComment).toHaveBeenCalledOnce() + expect(addComment).toHaveBeenCalledWith(auth, 'LXF-1', serializeStateComment({ column: 'todo', branch: 'feat/y' })) }) })