✨ 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 -38
View File
@@ -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,
}
}
+14 -74
View File
@@ -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)
}