✨ feat(vscode): 工单状态评论改写成隐藏格式,Gitea 和 YouTrack 共用一套读写
This commit is contained in:
1 parent
fa7b32d205
commit
38ca193434
17 files changed
+588
-660
No files matched your search
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseStateComment } from '../issues/stateComment'
|
||||
import { hasReviewMarker, stripReviewMarker } from './markers'
|
||||
|
||||
const COLUMN_LABEL: Record<string, string> = {
|
||||
@@ -7,25 +8,6 @@ const COLUMN_LABEL: Record<string, string> = {
|
||||
'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<string, unknown>
|
||||
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)
|
||||
|
||||
@@ -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('<!-- 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
@@ -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<string, unknown> {
|
||||
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({
|
||||
|
||||
+35
-182
@@ -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<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 {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>
|
||||
defaults?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof import('../gitea/stateJson')>()).hasStateColumn,
|
||||
vi.mock('../gitea/stateJson', () => ({
|
||||
readStateJsonComment: mocks.readState,
|
||||
mergeStateJsonComment: async () => undefined,
|
||||
}))
|
||||
|
||||
@@ -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<typeof import('../gitea/stateJson')>()).hasStateColumn,
|
||||
vi.mock('../gitea/stateJson', () => ({
|
||||
readStateJsonComment: mocks.readShared,
|
||||
mergeStateJsonComment: mocks.mergeShared,
|
||||
}))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<IssueColumn>(['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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
* <!--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.
|
||||
* 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 = '<!--spx-state-->'
|
||||
|
||||
/** 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<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)
|
||||
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<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 {
|
||||
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<Record<string, unknown>> {
|
||||
return readState(youtrackThread(auth, issueId))
|
||||
}
|
||||
|
||||
function serialize(state: Record<string, unknown>): 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<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 }))
|
||||
/** 把 `extra` 合并进工单状态并写一条新的状态评论。 */
|
||||
export function mergeStateComment(auth: YouTrackAuth, issueId: string, extra: Record<string, unknown>): Promise<void> {
|
||||
return mergeState(youtrackThread(auth, issueId), extra)
|
||||
}
|
||||
@@ -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('<!-- spx:review=1 -->\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)
|
||||
})
|
||||
})
|
||||
@@ -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: '<!--spx-state-->\n{"column":"todo"}' },
|
||||
{ id: '2', text: '<!--spx-state-->\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' }))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user