/** * 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 `{}`. */ import type { GiteaComment } 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', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const /** * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。 * 返回解析后的对象,否则返回 null。 */ function parseStateComment(body: string | undefined | null): Record | null { const trimmed = (body ?? '').trim() if (!trimmed) return null let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { return null } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null const obj = parsed as Record const hasKnownField = KNOWN_STATE_FIELDS.some(field => field in obj) return hasKnownField ? obj : null } /** * 评论是否是一条带 `column` 的 state JSON——即「这张卡换列了」的信号。 * webhook 收到这种评论时据此刷新对应卡片。 */ export function hasStateColumn(body: string | undefined | null): boolean { const parsed = parseStateComment(body) return parsed !== null && 'column' in parsed } /** * 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。 * 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。 */ function findLatestState(comments: GiteaComment[]): Record | null { for (let i = comments.length - 1; i >= 0; i--) { const parsed = parseStateComment(comments[i].body) if (parsed) return parsed } return null } export interface MergeStateJsonCommentOpts { host: string owner: string repo: string token: string issueNumber: number extra: Record } /** * Merge `extra` into the latest state-JSON comment of the issue and post a * new comment with the merged JSON. Throws on network/API failure — callers * are responsible for surfacing errors. */ export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise { // 乐观并发:state 评论是"最后一条全量覆盖",两个写者并发时后写者会把 // 先写者的字段整体冲掉。门禁后共享写基本回到单写者,但人工拖列仍可能 // 与 webhook 写并发——post 后读回校验,本次字段若被并发覆盖,就在最新 // 状态上重放一次。两轮后仍冲突的概率可忽略,按最后一轮结果收场。 for (let attempt = 0; attempt < 2; attempt++) { const currentState = await readStateJsonComment({ host: opts.host, owner: opts.owner, repo: opts.repo, token: opts.token, issueNumber: opts.issueNumber, }) await postMergedStateJsonComment(opts, currentState, opts.extra) const latest = await readStateJsonComment({ host: opts.host, owner: opts.owner, repo: opts.repo, token: opts.token, issueNumber: opts.issueNumber, }) const lost = Object.entries(opts.extra) .some(([k, v]) => JSON.stringify(latest[k]) !== JSON.stringify(v)) if (!lost) return } } export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts { protectDoneColumn?: boolean } /** * Merge `extra` while protecting terminal `done` cards from stale automatic * column updates. When protection removes the only incoming field, no comment * is posted. */ export async function mergeStateJsonCommentGuarded( opts: MergeStateJsonCommentGuardedOpts, ): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { const currentState = await readStateJsonComment({ host: opts.host, owner: opts.owner, repo: opts.repo, token: opts.token, issueNumber: opts.issueNumber, }) const extra = { ...opts.extra } let protectedDoneColumn = false if ( opts.protectDoneColumn && currentState.column === 'done' && typeof extra.column === 'string' && extra.column !== 'done' ) { delete extra.column protectedDoneColumn = true } if (Object.keys(extra).length === 0) return { posted: false, protectedDoneColumn } await postMergedStateJsonComment(opts, currentState, extra) return { posted: true, protectedDoneColumn } } async function postMergedStateJsonComment( opts: MergeStateJsonCommentOpts, currentState: Record, extra: Record, ): Promise { const merged: Record = { ...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 repo: string token: string issueNumber: number } /** * Read the latest state-JSON comment of the issue and return it as a parsed * object. Scans comments from the tail and returns the first one that parses * as an object carrying a known state field, so ordinary comments inserted * afterwards don't shadow the real state. Returns an empty object when no such * comment exists. Throws on network/API failure. */ export async function readStateJsonComment( opts: ReadStateJsonCommentOpts, ): Promise> { const comments = await listIssueComments({ host: opts.host, token: opts.token, owner: opts.owner, repo: opts.repo, index: opts.issueNumber, }) return findLatestState(comments) ?? {} }