✨ 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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user