✨ feat(vscode): 工单状态评论改写成隐藏格式,Gitea 和 YouTrack 共用一套读写

This commit is contained in:
cruldra committed 2026-10-19 07:35:55 +11:00
1 parent fa7b32d205
commit 38ca193434
17 files changed
+588 -660

No files matched your search

+6
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { serializeStateComment } from '../issues/stateComment'
import { systemCommentText } from './issueComment' import { systemCommentText } from './issueComment'
describe('systemCommentText', () => { describe('systemCommentText', () => {
@@ -15,6 +16,11 @@ describe('systemCommentText', () => {
expect(systemCommentText('{"column":"review","reviewPassed":false}')).toBe('更新了工单状态,放到审查') expect(systemCommentText('{"column":"review","reviewPassed":false}')).toBe('更新了工单状态,放到审查')
}) })
it('隐藏格式的状态评论也是系统消息', () => {
expect(systemCommentText(serializeStateComment({ column: 'todo' }))).toBe('把工单放到待办')
expect(systemCommentText(serializeStateComment({ specFile: 'docs/specs/a.md' }))).toBe('更新了工单状态')
})
it('普通评论不是系统消息', () => { it('普通评论不是系统消息', () => {
expect(systemCommentText('这个方案可以')).toBeUndefined() expect(systemCommentText('这个方案可以')).toBeUndefined()
}) })
+3 -32
View File
@@ -1,3 +1,4 @@
import { parseStateComment } from '../issues/stateComment'
import { hasReviewMarker, stripReviewMarker } from './markers' import { hasReviewMarker, stripReviewMarker } from './markers'
const COLUMN_LABEL: Record<string, string> = { const COLUMN_LABEL: Record<string, string> = {
@@ -7,25 +8,6 @@ const COLUMN_LABEL: Record<string, string> = {
'done': '完成', 'done': '完成',
} }
const STATE_KEYS = new Set([
'column',
'sessionId',
'implementSessionId',
'reviewSessionId',
'testSessionId',
'prototypeSessionId',
'handoffBundleKey',
'handoffFrom',
'handoffRequestFrom',
'handoffRequestError',
'pr',
'branch',
'worktreePath',
'color',
'autoReview',
'reviewPassed',
])
/** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */ /** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */
export function systemCommentText(body: string): string | undefined { export function systemCommentText(body: string): string | undefined {
const trimmed = body.trim() const trimmed = body.trim()
@@ -33,19 +15,8 @@ export function systemCommentText(body: string): string | undefined {
const text = stripReviewMarker(trimmed) const text = stripReviewMarker(trimmed)
return text.length > 0 ? text : '提交了审查' return text.length > 0 ? text : '提交了审查'
} }
if (!trimmed.startsWith('{')) const record = parseStateComment(trimmed)
return undefined if (!record)
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<string, unknown>
if (!Object.keys(record).some(key => STATE_KEYS.has(key)))
return undefined return undefined
const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined
if (Object.keys(record).length === 1 && column) if (Object.keys(record).length === 1 && column)
+26
View File
@@ -142,3 +142,29 @@ describe('loadIssues', () => {
expect(postIssueComment).not.toHaveBeenCalled() 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('<!-- spx-state\n{"column":"review","branch":"feature/x","specFile":"docs/a.md"}\n-->', 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' })
})
})
+15 -287
View File
@@ -14,6 +14,8 @@ import type { GiteaComment, GiteaIssue } from './api'
import type { Issue, IssueColumn } from './types' import type { Issue, IssueColumn } from './types'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { resolveWorktreePath } from '../git/worktree' import { resolveWorktreePath } from '../git/worktree'
import { latestState } from '../issues/stateComment'
import { issueStateFields } from '../issues/stateFields'
import { import {
getCurrentUser, getCurrentUser,
getDependencies, getDependencies,
@@ -24,235 +26,13 @@ import {
} from './api' } from './api'
import { resolveAvatars } from './avatars' 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 { function defaultColumnForState(state: string): IssueColumn {
return state === 'open' ? 'todo' : 'done' return state === 'open' ? 'todo' : 'done'
} }
/** /** 工单当前状态:评论里最新一条状态评论,没有就是空对象。 */
* Scans comments from the tail for the most recent JSON state payload like function stateOf(comments: GiteaComment[]): Record<string, unknown> {
* `{ "column": "todo", ... }`, skipping ordinary text/JSON comments that lack return latestState(comments.map(comment => comment.body)) ?? {}
* 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 }
} }
/** Merges issue lists by `number`, preferring the first occurrence. */ /** Merges issue lists by `number`, preferring the first occurrence. */
@@ -362,53 +142,24 @@ async function buildIssue(opts: {
const id = `${owner}/${repo}#${issue.number}` const id = `${owner}/${repo}#${issue.number}`
const { const {
column: fromComment, column: fromComment,
sessionId,
profilePath,
brainstormProfilePath,
testProfilePath,
prototypeProfilePath,
specFile,
planFile,
prototypeFile,
prDiffFile,
pr,
prMerged: persistedMerged, prMerged: persistedMerged,
prMergedAt: persistedMergedAt, prMergedAt: persistedMergedAt,
branch, ...stateFields
worktreePath, } = issueStateFields(stateOf(comments))
implementStatus,
implementSessionId,
reviewSessionId,
testSessionId,
prototypeSessionId,
color,
autoReview,
reviewPassed,
handoffBundleKey,
handoffFrom,
handoffRequestFrom,
handoffRequestError,
} = parseColumnFromComments(comments)
// 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。 // 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。
const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged
// 完成列排序用的 merged_at:实时查询优先,否则用持久化值(完成工单不再实时拉 PR)。 // 完成列排序用的 merged_at:实时查询优先,否则用持久化值(完成工单不再实时拉 PR)。
const mergedAt = liveMergedAt ?? persistedMergedAt const mergedAt = liveMergedAt ?? persistedMergedAt
let column: IssueColumn // 缺状态时只在本机按工单开/关给默认列,不在加载时补评论:谁的看板先刷到,评论就挂在谁名下。
if (fromComment) { const column = fromComment ?? defaultColumnForState(issue.state)
column = fromComment
}
else {
// 缺评论只在本机按工单开/关给默认列。不能在加载时补评论:谁的看板先刷到,评论就挂在谁名下。
column = defaultColumnForState(issue.state)
}
// Synchronously stat the worktree path so the UI can render it as a // 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 // live link vs. plain text. A single existsSync per issue is cheap and
// avoids racing with the rest of the async pipeline. // avoids racing with the rest of the async pipeline.
let worktreeExists: boolean | undefined let worktreeExists: boolean | undefined
if (worktreePath && workspaceRoot) { if (stateFields.worktreePath && workspaceRoot) {
try { try {
worktreeExists = fs.existsSync(resolveWorktreePath(worktreePath, workspaceRoot)) worktreeExists = fs.existsSync(resolveWorktreePath(stateFields.worktreePath, workspaceRoot))
} }
catch { catch {
worktreeExists = false worktreeExists = false
@@ -420,35 +171,12 @@ async function buildIssue(opts: {
number: issue.number, number: issue.number,
title: issue.title, title: issue.title,
column, column,
...(sessionId ? { sessionId } : {}), ...stateFields,
...(profilePath ? { profilePath } : {}),
...(brainstormProfilePath ? { brainstormProfilePath } : {}),
...(testProfilePath ? { testProfilePath } : {}),
...(prototypeProfilePath ? { prototypeProfilePath } : {}),
...(specFile ? { specFile } : {}),
...(planFile ? { planFile } : {}),
...(prototypeFile ? { prototypeFile } : {}),
...(prDiffFile ? { prDiffFile } : {}),
...(pr ? { pr } : {}),
...(prMerged !== undefined ? { prMerged } : {}), ...(prMerged !== undefined ? { prMerged } : {}),
...(mergedAt ? { prMergedAt: mergedAt } : {}), ...(mergedAt ? { prMergedAt: mergedAt } : {}),
...(issue.closed_at ? { closedAt: issue.closed_at } : {}), ...(issue.closed_at ? { closedAt: issue.closed_at } : {}),
...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
...(worktreeExists !== undefined ? { worktreeExists } : {}), ...(worktreeExists !== undefined ? { worktreeExists } : {}),
...(implementStatus ? { implementStatus } : {}),
...(implementSessionId ? { implementSessionId } : {}),
...(reviewSessionId ? { reviewSessionId } : {}),
...(testSessionId ? { testSessionId } : {}),
...(prototypeSessionId ? { prototypeSessionId } : {}),
...(prerequisite !== undefined ? { prerequisite } : {}), ...(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), assignees: (issue.assignees ?? []).map(a => a.login),
...(issue.user?.login ? { poster: issue.user.login } : {}), ...(issue.user?.login ? { poster: issue.user.login } : {}),
htmlUrl: issue.html_url, htmlUrl: issue.html_url,
@@ -509,7 +237,7 @@ export async function loadIssues(opts: {
// column 计算与 buildIssue 的判断保持一致:state comment 优先,缺失时按 issue.state 默认。 // column 计算与 buildIssue 的判断保持一致:state comment 优先,缺失时按 issue.state 默认。
const columns: IssueColumn[] = merged.map((issue) => { const columns: IssueColumn[] = merged.map((issue) => {
const bucket = buckets.get(issue.number) ?? [] 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. // Concurrently fetch each issue's dependencies + live PR merged state.
@@ -534,7 +262,7 @@ export async function loadIssues(opts: {
if (columns[i] === 'done') if (columns[i] === 'done')
return {} return {}
const bucket = buckets.get(issue.number) ?? [] const bucket = buckets.get(issue.number) ?? []
const { pr } = parseColumnFromComments(bucket) const { pr } = issueStateFields(stateOf(bucket))
return fetchPrStatus({ host, token, owner, repo, pr }) return fetchPrStatus({ host, token, owner, repo, pr })
})), })),
]) ])
@@ -605,7 +333,7 @@ export async function loadSingleIssue(opts: {
]) ])
// 解析 state JSON 拿到 pr 字段后实时查 merged 状态;失败 fallback undefined。 // 解析 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 }) const { merged: liveMerged, mergedAt: liveMergedAt } = await fetchPrStatus({ host, token, owner, repo, pr })
return buildIssue({ return buildIssue({
+35 -182
View File
@@ -1,172 +1,11 @@
/** /**
* Shared helper for merging extra fields into an issue's "state JSON" comment. * Gitea 工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.ts)。
*
* 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 `{}`.
*/ */
import type { GiteaComment } from './api' import type { StateCommentThread } from '../issues/stateComment'
import { mergeState, mergeStateGuarded, readState } from '../issues/stateComment'
import { listIssueComments, postIssueComment } from './api' 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<string, unknown> | 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<string, unknown>
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<string, unknown> | 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<string, unknown>
defaults?: Record<string, unknown>
}
/**
* 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<void> {
// 乐观并发: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<string, unknown>,
extra: Record<string, unknown>,
): Promise<void> {
const merged: Record<string, unknown> = {
...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 { export interface ReadStateJsonCommentOpts {
host: string host: string
owner: string owner: string
@@ -175,22 +14,36 @@ export interface ReadStateJsonCommentOpts {
issueNumber: number issueNumber: number
} }
/** export interface MergeStateJsonCommentOpts extends ReadStateJsonCommentOpts {
* Read the latest state-JSON comment of the issue and return it as a parsed extra: Record<string, unknown>
* object. Scans comments from the tail and returns the first one that parses defaults?: Record<string, unknown>
* 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 interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts {
*/ protectDoneColumn?: boolean
export async function readStateJsonComment( }
opts: ReadStateJsonCommentOpts,
): Promise<Record<string, unknown>> { function giteaThread(opts: ReadStateJsonCommentOpts): StateCommentThread {
const comments = await listIssueComments({ const where = { host: opts.host, token: opts.token, owner: opts.owner, repo: opts.repo, index: opts.issueNumber }
host: opts.host, return {
token: opts.token, bodies: async () => (await listIssueComments(where)).map(comment => comment.body),
owner: opts.owner, post: body => postIssueComment({ ...where, body }),
repo: opts.repo, }
index: opts.issueNumber, }
})
return findLatestState(comments) ?? {} /** 把 `extra` 合并进工单状态并写一条新的状态评论;网络或接口失败直接抛出。 */
export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise<void> {
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<Record<string, unknown>> {
return readState(giteaThread(opts))
} }
+151
View File
@@ -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('<!-- spx-state\n{"column":"review","pr":"9"}\n-->')
})
it('把 < > & 转成 \\u 转义,正文里不会出现 -->', () => {
const body = serializeStateComment({ column: 'todo', branch: 'a-->b<c&d' })
const inner = body.slice('<!-- spx-state'.length, -'-->'.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<c&d' })
})
})
describe('parseStateComment', () => {
it('认隐藏格式,前后空白不影响', () => {
expect(parseStateComment(' <!-- spx-state\n{"column":"done","prMerged":true}\n-->\n')).toEqual({ column: 'done', prMerged: true })
})
it('认旧的 YouTrack 标记格式', () => {
expect(parseStateComment('<!--spx-state-->\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('<!-- spx-state\n{"hello":1}\n-->')).toBeNull()
expect(parseStateComment('[{"column":"done"}]')).toBeNull()
expect(parseStateComment('<!-- spx-state\n[1]\n-->')).toBeNull()
expect(parseStateComment('<!-- spx-state\n{"column":"done"}')).toBeNull()
expect(parseStateComment('<!-- spx:review=1 -->\n看着没问题')).toBeNull()
expect(parseStateComment('普通评论')).toBeNull()
expect(parseStateComment('')).toBeNull()
expect(parseStateComment(undefined)).toBeNull()
expect(parseStateComment(null)).toBeNull()
})
})
describe('latestState', () => {
it('从尾往前取最新一条有效的,三种格式混着也行', () => {
expect(latestState([
'{"column":"todo"}',
'<!--spx-state-->\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)
})
})
+144
View File
@@ -0,0 +1,144 @@
/**
* 工单状态评论的读写格式,代码托管仓库和 YouTrack 共用这一份。
*
* 状态(列、PR、分支、会话 id 等)存成工单上的一条评论,最新一条有效的状态评论就是当前状态。
* 写出的格式是一段隐藏的 HTML 注释,网页上看不见:
*
* ```
* <!-- spx-state
* {"column":"review","pr":"9"}
* -->
* ```
*
* 读时还认两种旧格式:YouTrack 用过的 `<!--spx-state-->` 前缀加 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 = '<!-- spx-state'
const HIDDEN_SUFFIX = '-->'
const LEGACY_MARKER = '<!--spx-state-->'
/** 与 Go 的 `json.Marshal` 默认转义一致,正文里因此不会出现能提前结束注释的 `-->`。 */
const HTML_ESCAPES: Record<string, string> = { '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' }
/** 把状态写成一条隐藏的状态评论。 */
export function serializeStateComment(state: Record<string, unknown>): 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<string, unknown> | 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<string, unknown>
return STATE_FIELDS.some(field => field in state) ? state : null
}
/** 从最后一条往前找,返回最新一条状态评论的状态;一条都没有返回 `null`。 */
export function latestState(bodies: readonly (string | null | undefined)[]): Record<string, unknown> | 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<string[]>
post: (body: string) => Promise<void>
}
/** 读工单当前状态;没有状态评论时是空对象。 */
export async function readState(thread: StateCommentThread): Promise<Record<string, unknown>> {
return latestState(await thread.bodies()) ?? {}
}
async function postMerged(
thread: StateCommentThread,
current: Record<string, unknown>,
extra: Record<string, unknown>,
defaults?: Record<string, unknown>,
): Promise<void> {
await thread.post(serializeStateComment({ ...defaults, ...current, ...extra }))
}
/**
* 把 `extra` 合并到最新状态上,再写一条完整的状态评论;`defaults` 垫在最底下,只补缺的字段。
*
* 状态评论是「最后一条整份覆盖」,两个写者同时写时后写的会冲掉先写的字段,所以写完读回来核对,本次字段被冲掉了就在最新状态上再写一次;
* 两轮还冲突的概率可以忽略,按第二轮结果收场。
*/
export async function mergeState(
thread: StateCommentThread,
extra: Record<string, unknown>,
defaults?: Record<string, unknown>,
): Promise<void> {
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<string, unknown>,
opts: { protectDoneColumn?: boolean, defaults?: Record<string, unknown> },
): 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 }
}
+46
View File
@@ -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([])
})
})
+91
View File
@@ -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<Pick<Issue, CardStateField>>
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<string, unknown>): IssueStateFields {
const out: Record<string, unknown> = {}
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
}
+2 -1
View File
@@ -32,8 +32,9 @@ import {
mergePullRequest, mergePullRequest,
removeDependency, removeDependency,
} from '../../gitea/api' } from '../../gitea/api'
import { isValidSpxFilePath, loadIssues } from '../../gitea/issueLoader' import { loadIssues } from '../../gitea/issueLoader'
import { mergeStateJsonComment } from '../../gitea/stateJson' import { mergeStateJsonComment } from '../../gitea/stateJson'
import { isValidSpxFilePath } from '../../issues/stateFields'
import { logger } from '../../logging/logger' import { logger } from '../../logging/logger'
import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator' import { webhookCoordinator } from '../../webhook/coordinator'
+1 -2
View File
@@ -21,8 +21,7 @@ vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' }))
vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) })) vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) }))
vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) })) vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) }))
vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle })) vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle }))
vi.mock('../gitea/stateJson', async importOriginal => ({ vi.mock('../gitea/stateJson', () => ({
hasStateColumn: (await importOriginal<typeof import('../gitea/stateJson')>()).hasStateColumn,
readStateJsonComment: mocks.readState, readStateJsonComment: mocks.readState,
mergeStateJsonComment: async () => undefined, mergeStateJsonComment: async () => undefined,
})) }))
@@ -40,8 +40,7 @@ vi.mock('../git/remote', async importOriginal => ({
})) }))
vi.mock('../gitea/api', () => ({ getIssue: mocks.getIssue })) vi.mock('../gitea/api', () => ({ getIssue: mocks.getIssue }))
vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.load })) vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.load }))
vi.mock('../gitea/stateJson', async importOriginal => ({ vi.mock('../gitea/stateJson', () => ({
hasStateColumn: (await importOriginal<typeof import('../gitea/stateJson')>()).hasStateColumn,
readStateJsonComment: mocks.readShared, readStateJsonComment: mocks.readShared,
mergeStateJsonComment: mocks.mergeShared, mergeStateJsonComment: mocks.mergeShared,
})) }))
+4 -8
View File
@@ -26,8 +26,9 @@ import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api'
import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' 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 { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { hasStateColumn, parseStateComment } from '../issues/stateComment'
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest' import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest'
import * as issueChat from '../panel/handlers/issueChat' import * as issueChat from '../panel/handlers/issueChat'
@@ -114,13 +115,8 @@ const BODY_MARKERS = [
type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string } type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string }
function handoffRequestFromOf(body: string): string { function handoffRequestFromOf(body: string): string {
try { const from = parseStateComment(body)?.handoffRequestFrom
const obj = JSON.parse(body) as { handoffRequestFrom?: unknown } return typeof from === 'string' ? from.trim() : ''
return typeof obj.handoffRequestFrom === 'string' ? obj.handoffRequestFrom.trim() : ''
}
catch {
return ''
}
} }
function parseBodyMarkers(body: string): MarkerPatch { function parseBodyMarkers(body: string): MarkerPatch {
+6 -38
View File
@@ -7,16 +7,15 @@
*/ */
import type { ExtensionContext } from 'vscode' import type { ExtensionContext } from 'vscode'
import type { Issue, IssueColumn } from '../gitea/types' import type { Issue } from '../gitea/types'
import type { YouTrackIssue } from './api' import type { YouTrackIssue } from './api'
import { workspace } from 'vscode' import { workspace } from 'vscode'
import { getYouTrackToken } from '../auth/secrets' import { getYouTrackToken } from '../auth/secrets'
import { latestState } from '../issues/stateComment'
import { issueStateFields } from '../issues/stateFields'
import { getSettings } from '../settings/store' import { getSettings } from '../settings/store'
import { issueHtmlUrl, listIssues } from './api' import { issueHtmlUrl, listIssues } from './api'
import { readImportedIds } from './importStore' import { readImportedIds } from './importStore'
import { findLatestState } from './stateComment'
const VALID_COLUMNS = new Set<IssueColumn>(['todo', 'in-progress', 'review', 'done'])
/** Host part of the configured base URL — the SecretStorage token key. */ /** Host part of the configured base URL — the SecretStorage token key. */
export function youtrackHost(baseUrl: string): string { 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 * 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 * 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) 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 { function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
const state = findLatestState(it.comments) ?? {} const { column, ...fields } = issueStateFields(latestState(it.comments.map(comment => comment.text)) ?? {})
return { return {
id: `youtrack:${it.idReadable}`, id: `youtrack:${it.idReadable}`,
externalId: it.idReadable, externalId: it.idReadable,
source: 'youtrack', source: 'youtrack',
number: syntheticNumber(it.idReadable), number: syntheticNumber(it.idReadable),
title: it.summary, title: it.summary,
column: pickColumn(state.column, it.resolved), column: column ?? (it.resolved != null ? 'done' : 'todo'),
htmlUrl: issueHtmlUrl(baseUrl, it.idReadable), htmlUrl: issueHtmlUrl(baseUrl, it.idReadable),
attachments: it.attachments, attachments: it.attachments,
sessionId: str(state.sessionId), ...fields,
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,
} }
} }
+14 -74
View File
@@ -1,85 +1,25 @@
/** /**
* YouTrack equivalent of `src/gitea/stateJson.ts`. * YouTrack 工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.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:
*
* <!--spx-state-->
* {"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.
*/ */
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' import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->' function youtrackThread(auth: YouTrackAuth, issueId: string): StateCommentThread {
return {
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */ bodies: async () => (await listComments(auth, issueId)).map(comment => comment.text),
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 post: body => addComment(auth, issueId, body),
/**
* 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<string, unknown> | 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)
} }
catch {
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return null
const obj = parsed as Record<string, unknown>
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<string, unknown> | null { export function readStateComment(auth: YouTrackAuth, issueId: string): Promise<Record<string, unknown>> {
for (let i = comments.length - 1; i >= 0; i--) { return readState(youtrackThread(auth, issueId))
const parsed = parseStateComment(comments[i].text)
if (parsed)
return parsed
}
return null
} }
function serialize(state: Record<string, unknown>): string { /** 把 `extra` 合并进工单状态并写一条新的状态评论。 */
return `${STATE_MARKER}\n${JSON.stringify(state)}` export function mergeStateComment(auth: YouTrackAuth, issueId: string, extra: Record<string, unknown>): Promise<void> {
} return mergeState(youtrackThread(auth, issueId), extra)
/** Read the issue's latest state blob, or `{}` when none exists. */
export async function readStateComment(
auth: YouTrackAuth,
issueId: string,
): Promise<Record<string, unknown>> {
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<string, unknown>,
): Promise<void> {
const comments = await listComments(auth, issueId)
const current = findLatestState(comments) ?? {}
await addComment(auth, issueId, serialize({ ...current, ...extra }))
} }
+11 -10
View File
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import { delimiter } from 'node:path' import { delimiter } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { parseStateComment } from '../../src/issues/stateComment'
const execFile = vi.fn() const execFile = vi.fn()
const spawn = vi.fn() const spawn = vi.fn()
@@ -89,7 +90,7 @@ describe('mergeStateJsonCommentGuarded', () => {
}) })
expect(postIssueComment).toHaveBeenCalledOnce() expect(postIssueComment).toHaveBeenCalledOnce()
expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({
column: 'done', column: 'done',
implementStatus: 'done', implementStatus: 'done',
prMerged: true, prMerged: true,
@@ -156,7 +157,7 @@ describe('state JSON 尾部普通评论不丢状态', () => {
}) })
expect(postIssueComment).toHaveBeenCalledOnce() expect(postIssueComment).toHaveBeenCalledOnce()
expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({ expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({
...fullState, ...fullState,
pr: '182', pr: '182',
implementStatus: 'done', implementStatus: 'done',
@@ -191,7 +192,7 @@ describe('state JSON 尾部普通评论不丢状态', () => {
expect(postIssueComment).toHaveBeenCalledTimes(2) 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', column: 'review',
pr: '99', pr: '99',
}) })
@@ -231,7 +232,7 @@ describe('state defaults', () => {
listIssueComments.mockImplementation(async () => [...comments]) listIssueComments.mockImplementation(async () => [...comments])
postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) 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' } }) 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() expect(postIssueComment).toHaveBeenCalledOnce()
}) })
@@ -241,7 +242,7 @@ describe('state defaults', () => {
listIssueComments.mockImplementation(async () => [...comments]) listIssueComments.mockImplementation(async () => [...comments])
postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) 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' } }) 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 () => { 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' }) }]) 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: {} }) await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: {} })
expect(postIssueComment).toHaveBeenCalledOnce() 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', () => { describe('hasStateColumn', () => {
it('带 column 的 state JSON → true', async () => { 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":"done","pr":"318"}')).toBe(true)
expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true) expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true)
}) })
it('没有 column 的 state JSON → false', async () => { 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) expect(hasStateColumn('{"pr":"318","branch":"feature/x"}')).toBe(false)
}) })
it('普通文本、审查评论、空值 → false', async () => { it('普通文本、审查评论、空值 → false', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson.js') const { hasStateColumn } = await import('../../src/issues/stateComment.js')
expect(hasStateColumn('<!-- spx:review=1 -->\n看着没问题')).toBe(false) expect(hasStateColumn('<!-- spx:review=1 -->\n看着没问题')).toBe(false)
expect(hasStateColumn('拖到完成列了')).toBe(false) expect(hasStateColumn('拖到完成列了')).toBe(false)
expect(hasStateColumn('')).toBe(false) expect(hasStateColumn('')).toBe(false)
@@ -453,7 +454,7 @@ describe('hasStateColumn', () => {
}) })
it('json 数组不算 state 评论', async () => { 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) expect(hasStateColumn('[{"column":"done"}]')).toBe(false)
}) })
}) })
+32 -24
View File
@@ -1,32 +1,40 @@
import { describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findLatestState, parseStateComment, STATE_MARKER } from '../../src/youtrack/stateComment'
describe('youtrack stateComment parsing', () => { const listComments = vi.fn()
it('parses a marker-prefixed state blob', () => { const addComment = vi.fn()
const body = `${STATE_MARKER}\n${JSON.stringify({ column: 'in-progress', sessionId: 'abc' })}`
expect(parseStateComment(body)).toEqual({ column: 'in-progress', sessionId: 'abc' }) 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', () => { it('读到旧标记格式写的状态,跳过客户后来的回复', async () => {
expect(parseStateComment(JSON.stringify({ column: 'done' }))).toEqual({ column: 'done' }) const { readStateComment } = await import('../../src/youtrack/stateComment.js')
}) listComments.mockResolvedValue([
{ id: '1', text: '<!--spx-state-->\n{"column":"todo"}' },
it('rejects JSON objects without any known state field (customer replies)', () => { { id: '2', text: '<!--spx-state-->\n{"column":"in-progress","branch":"feat/x"}' },
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' })}` },
{ id: '3', text: '客户后续补充:还是不行' }, { 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', () => { it('合并后用隐藏格式写回', async () => {
expect(findLatestState([{ id: '1', text: 'hi' }, { id: '2', text: 'thanks' }])).toBeNull() 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' }))
}) })
}) })