feat(vscode): 多人协作二期:状态分层/写校验/签名/团队视图

- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState,
  共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底
- mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次
- webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256),
  不匹配 401;设置面板新增 Webhook Secret 字段
- 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
This commit is contained in:
2026-08-19 00:32:06 +08:00
parent c370214d12
commit faf227412e
18 changed files with 665 additions and 202 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* 机器本地的工单状态(会话 id、worktree 路径、profile 路径等)。
*
* 这些字段描述"这台机器上"的会话与文件系统,跨机毫无语义(别人机器的
* 绝对路径/会话 id 在本机不可用),写进 Gitea 共享评论只会互相污染——
* 所以落在 workspaceState,按 `source:number` 键控。共享评论里的旧数据
* 仍按读兜底(本地无记录时透出),写侧从此只产生本地记录。
*/
import type { ExtensionContext } from 'vscode'
import type { Issue } from '../gitea/types'
import * as fs from 'node:fs'
import { resolveWorktreePath } from '../git/worktree'
export const LOCAL_STATE_FIELDS = [
'sessionId',
'implementSessionId',
'reviewSessionId',
'testSessionId',
'profilePath',
'brainstormProfilePath',
'testProfilePath',
'worktreePath',
'prDiffFile',
] as const
const KEY = 'superpowers.localIssueState'
type Store = Record<string, Record<string, string>>
function refKey(source: string | undefined, issueNumber: number): string {
return `${source ?? 'gitea'}:${issueNumber}`
}
export function getLocalIssueState(
ctx: ExtensionContext,
source: string | undefined,
issueNumber: number,
): Record<string, string> {
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
return store[refKey(source, issueNumber)] ?? {}
}
/**
* 空字符串是墓碑:字段已在本机清除,读侧不得回落到共享评论里的旧值
* (与 state JSON "空串=unset" 的既有约定一致)。null/undefined 视作清除。
*/
export async function mergeLocalIssueState(
ctx: ExtensionContext,
source: string | undefined,
issueNumber: number,
patch: Record<string, unknown>,
): Promise<void> {
const store = { ...(ctx.workspaceState.get<Store>(KEY) ?? {}) }
const key = refKey(source, issueNumber)
const next = { ...(store[key] ?? {}) }
for (const [k, v] of Object.entries(patch)) {
if (typeof v === 'string')
next[k] = v
else if (v === null || v === undefined)
next[k] = ''
}
store[key] = next
await ctx.workspaceState.update(KEY, store)
}
/** 把一次状态写入分成本地字段与共享字段两半。 */
export function splitLocalStateFields(extra: Record<string, unknown>): {
local: Record<string, unknown>
shared: Record<string, unknown>
} {
const localSet = new Set<string>(LOCAL_STATE_FIELDS)
const local: Record<string, unknown> = {}
const shared: Record<string, unknown> = {}
for (const [k, v] of Object.entries(extra)) {
if (localSet.has(k))
local[k] = v
else
shared[k] = v
}
return { local, shared }
}
/**
* 把本地记录叠加到加载出的工单列表上:本地有键即生效('' 墓碑 → 置空)。
* 覆盖 worktreePath 时同步重算 worktreeExists,口径与 buildIssue 一致。
*/
export function overlayLocalIssueState(
ctx: ExtensionContext,
issues: Issue[],
workspaceRoot?: string,
): Issue[] {
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
return issues.map((issue) => {
const local = store[refKey(issue.source, issue.number)]
if (!local)
return issue
const out = { ...issue } as Issue & Record<string, unknown>
for (const field of LOCAL_STATE_FIELDS) {
const v = local[field]
if (v === undefined)
continue
if (v === '')
delete out[field]
else
out[field] = v
if (field === 'worktreePath') {
delete out.worktreeExists
if (v !== '' && workspaceRoot) {
try {
out.worktreeExists = fs.existsSync(resolveWorktreePath(v, workspaceRoot))
}
catch {
out.worktreeExists = false
}
}
}
}
return out
})
}
+26 -7
View File
@@ -21,6 +21,7 @@ import { getSettings } from '../settings/store'
import { applyCommand, resolvedStateCommand } from '../youtrack/api'
import { youtrackHost } from '../youtrack/issueLoader'
import { mergeStateComment, readStateComment } from '../youtrack/stateComment'
import { getLocalIssueState, mergeLocalIssueState, splitLocalStateFields } from './localState'
export interface IssueRef {
/** Absent = gitea (back-compat). */
@@ -58,25 +59,43 @@ async function youtrackAuth(ctx: ExtensionContext): Promise<{ baseUrl: string, t
return { baseUrl, token }
}
/** Read the workflow-state blob for an issue from its tracker. */
/**
* Read the workflow-state blob for an issue from its tracker, with this
* machine's local record overlaid on top(本地有键即覆盖,'' 墓碑与
* "空串=unset" 的既有约定一致)。
*/
export async function readIssueState(ctx: ExtensionContext, ref: IssueRef): Promise<Record<string, unknown>> {
let base: Record<string, unknown>
if (isYouTrack(ref)) {
const auth = await youtrackAuth(ctx)
return readStateComment(auth, ref.externalId)
base = await readStateComment(auth, ref.externalId)
}
const d = await giteaDeps(ctx)
return readStateJsonComment({ ...d, issueNumber: ref.number })
else {
const d = await giteaDeps(ctx)
base = await readStateJsonComment({ ...d, issueNumber: ref.number })
}
return { ...base, ...getLocalIssueState(ctx, ref.source, ref.number) }
}
/** Merge `extra` into the issue's workflow-state blob in its tracker. */
/**
* Merge `extra` into the issue's workflow state。机器本地字段
* (会话 id / worktree / profile 路径)落 workspaceState,只有共享字段
* (column / pr / branch / spec 等)才写进 tracker 评论——跨机没有语义的
* 值从此不进共享存储,也不参与多机写竞争。
*/
export async function mergeIssueState(ctx: ExtensionContext, ref: IssueRef, extra: Record<string, unknown>): Promise<void> {
const { local, shared } = splitLocalStateFields(extra)
if (Object.keys(local).length > 0)
await mergeLocalIssueState(ctx, ref.source, ref.number, local)
if (Object.keys(shared).length === 0)
return
if (isYouTrack(ref)) {
const auth = await youtrackAuth(ctx)
await mergeStateComment(auth, ref.externalId, extra)
await mergeStateComment(auth, ref.externalId, shared)
return
}
const d = await giteaDeps(ctx)
await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra })
await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra: shared })
}
/**