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
+5 -3
View File
@@ -87,8 +87,9 @@ export async function listIssuesByFilter(opts: {
token: string
owner: string
repo: string
filter: 'assigned_by' | 'created_by'
user: string
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
filter?: 'assigned_by' | 'created_by'
user?: string
}): Promise<GiteaIssue[]> {
const out: GiteaIssue[] = []
let page = 1
@@ -98,7 +99,8 @@ export async function listIssuesByFilter(opts: {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
url.searchParams.set('type', 'issues')
url.searchParams.set('state', 'all')
url.searchParams.set(opts.filter, opts.user)
if (opts.filter && opts.user)
url.searchParams.set(opts.filter, opts.user)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
+26 -18
View File
@@ -17,11 +17,10 @@
* still display the issue in the computed column.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { resolveWorktreePath } from '../git/worktree'
import type { GiteaComment, GiteaIssue } from './api'
import type { Issue, IssueColumn } from './types'
import * as fs from 'node:fs'
import { resolveWorktreePath } from '../git/worktree'
import {
getCurrentUser,
getDependencies,
@@ -52,7 +51,7 @@ export function isValidSpxFilePath(v: unknown): v is string {
function isValidPrDiffFilePath(v: unknown): v is string {
return typeof v === 'string'
&& /^docs\/pr-diff\/[^\s]+\.md$/.test(v)
&& /^docs\/pr-diff\/\S+\.md$/.test(v)
}
function isIssueColumn(value: unknown): value is IssueColumn {
@@ -351,7 +350,6 @@ async function buildIssue(opts: {
})
}
catch (postErr) {
// eslint-disable-next-line no-console
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
}
}
@@ -405,24 +403,34 @@ export async function loadIssues(opts: {
repo: string
/** Absolute workspace root used to resolve `worktreeExists` against disk. */
workspaceRoot?: string
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
scope?: 'mine' | 'all'
}): Promise<Issue[]> {
const { host, token, owner, repo, workspaceRoot } = opts
// `/user` validates the token and gives us the login. The repo-wide
// comments firehose doesn't need the login, so we kick it off in parallel.
// The two issue-filter calls need `user.login` and run together after.
const userPromise = getCurrentUser({ host, token })
// The repo-wide comments firehose doesn't depend on scope; kick it off first.
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
const user = await userPromise
const [assigned, created, allComments] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
commentsPromise,
])
const merged = mergeIssues(assigned, created)
let merged: Awaited<ReturnType<typeof listIssuesByFilter>>
let allComments: Awaited<typeof commentsPromise>
if (opts.scope === 'all') {
;[merged, allComments] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo }),
commentsPromise,
])
}
else {
// `/user` validates the token and gives us the login. The two
// issue-filter calls need `user.login` and run together after.
const user = await getCurrentUser({ host, token })
const [assigned, created, comments] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
commentsPromise,
])
merged = mergeIssues(assigned, created)
allComments = comments
}
const buckets = groupCommentsByIssue(allComments)
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
+25 -8
View File
@@ -71,14 +71,31 @@ export interface MergeStateJsonCommentOpts {
* are responsible for surfacing errors.
*/
export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise<void> {
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)
// 乐观并发: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 {
+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 })
}
/**
+29 -11
View File
@@ -4,7 +4,9 @@ import type {
TerminalEditorLocationOptions,
WebviewPanel,
} from 'vscode'
import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs'
@@ -12,13 +14,11 @@ import * as path from 'node:path'
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
import { deleteToken, getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote'
import type { HookContext } from '../git/worktreeHooks'
import {
GiteaApiError,
postIssueComment,
} from '../gitea/api'
import { loadIssues } from '../gitea/issueLoader'
import type { IssueRef } from '../issues/stateRouter'
import { overlayLocalIssueState } from '../issues/localState'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger'
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
@@ -39,6 +39,9 @@ import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
/** workspaceState key:看板范围('mine' 只看我的 / 'all' 团队全部)。 */
const SCOPE_KEY = 'superpowers.kanbanScope'
export class KanbanWebviewPanel {
static readonly viewType = 'superpowers.kanbanPanel'
@@ -108,9 +111,11 @@ export class KanbanWebviewPanel {
workspaceRoot: string
inboxDir: string
terminalName: string
/** The brainstorm terminal created synchronously in `handleIssueCreate`.
/**
* The brainstorm terminal created synchronously in `handleIssueCreate`.
* Stored here so `linkPendingTerminalToIssue` can promote it into
* `newIssueTerminals` once the webhook tells us the issueNumber. */
* `newIssueTerminals` once the webhook tells us the issueNumber.
*/
terminal: Terminal
createdAt: number
}>()
@@ -360,6 +365,12 @@ export class KanbanWebviewPanel {
void this.loadAndPush()
return
}
if (msg.type === 'issues/set-scope') {
// 视图偏好按工作区持久化;切换即重拉。
void this.context.workspaceState.update(SCOPE_KEY, msg.scope)
void this.loadAndPush()
return
}
if (msg.type === 'settings/save') {
void settings.handleSettingsSave(this, msg)
return
@@ -594,7 +605,6 @@ export class KanbanWebviewPanel {
}
if (msg.type === 'pr-diff-mode/set') {
void prFiles.handleSetPrDiffMode(this, msg.mode)
return
}
}
@@ -777,7 +787,6 @@ export class KanbanWebviewPanel {
return worktree.dispatchImplTabPostCloseAsync(this, issueNumber)
}
/**
* Server-side lock check for prerequisite gating. Fetches a fresh issues
* snapshot (we don't trust webview state) and reports whether
@@ -846,6 +855,7 @@ export class KanbanWebviewPanel {
host,
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -869,8 +879,9 @@ export class KanbanWebviewPanel {
return
}
const scope = this.context.workspaceState.get<'mine' | 'all'>(SCOPE_KEY) ?? 'mine'
try {
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot })
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
// YouTrack is a best-effort second source: a failure here must never
// break the gitea board, so swallow it into a toast + log.
let youtrackList: Issue[] = []
@@ -888,7 +899,10 @@ export class KanbanWebviewPanel {
dismissOnTimer: 6000,
})
}
const issues = this.withLiveTerminalTabState([...giteaIssues, ...youtrackList])
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
)
this.issueRefs = new Map(
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
)
@@ -896,6 +910,7 @@ export class KanbanWebviewPanel {
this.postMessage({
type: 'issues/update',
issues,
scope,
globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
})
@@ -914,6 +929,7 @@ export class KanbanWebviewPanel {
errorMessage: 'Token 无效或已过期,请重新填写',
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -970,8 +986,10 @@ export class KanbanWebviewPanel {
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
}
/** Resolve/close the issue in its tracker. Returns false when a youtrack
* close command can't be determined. */
/**
* Resolve/close the issue in its tracker. Returns false when a youtrack
* close command can't be determined.
*/
// internal: handler 模块访问
closeIssueByNumber(issueNumber: number): Promise<boolean> {
return closeIssueByRef(this.context, this.refFor(issueNumber))
+13 -25
View File
@@ -6,15 +6,16 @@ import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { buildCcCommand } from '../../cc/ccCommand'
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
import { getBrainstormPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
import { spawnClaude } from '../../cc/spawnClaude'
import { gitFetch, resolveFeatureBranch } from '../../git/branchSync'
import { detectRepo } from '../../git/remote'
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import {
addDependency,
closeIssue,
@@ -31,7 +32,6 @@ import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import * as sessions from './sessions'
@@ -345,22 +345,16 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
}
}
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath
// 到 state JSON。state JSON 用空字符串清空(loader 把 length===0 视为 unset)。
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath
// 走 mergeIssueState 漏斗:worktreePath 是本机字段落 workspaceState,
// 其余共享字段进 state JSON(空字符串清空,loader 把 length===0 视为 unset)。
try {
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber,
extra: {
column: 'done',
worktreePath: '',
branch: '',
prMerged: true,
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
},
await panel.mergeIssueState(issueNumber, {
column: 'done',
worktreePath: '',
branch: '',
prMerged: true,
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
})
logger.add({
level: 'info',
@@ -2035,14 +2029,8 @@ export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, iss
fs.mkdirSync(path.dirname(outputAbsPath), { recursive: true })
fs.writeFileSync(outputAbsPath, summary, 'utf8')
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber,
extra: { prDiffFile: outputRelPath },
})
// prDiffFile 是本机生成的文件路径,经漏斗落 workspaceState,不进共享评论。
await panel.mergeIssueState(issueNumber, { prDiffFile: outputRelPath })
panel.postMessage({
type: 'issue/patch',
issueNumber,
+51 -6
View File
@@ -20,6 +20,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
@@ -90,6 +91,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret,
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
@@ -114,6 +116,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
}
await saveSettings(panel.context, {
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret.trim(),
brainstormPrompt: payload.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt,
@@ -164,6 +167,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
message: `端口配置变更,重启监听 :${newPort}`,
})
}
webhookCoordinator.ensureSecret(getSettings(panel.context).webhookSecret)
try {
await webhookCoordinator.ensurePort(newPort)
}
@@ -216,6 +220,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -310,12 +315,52 @@ export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<voi
* are treated as text too — is handed to the OS default application.
*/
const TEXT_OPEN_EXTENSIONS = new Set([
'.md', '.markdown', '.txt', '.text', '.csv', '.tsv', '.json', '.jsonc',
'.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.log', '.xml', '.html',
'.htm', '.css', '.scss', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
'.vue', '.py', '.go', '.rs', '.java', '.kt', '.c', '.h', '.cpp', '.hpp',
'.cc', '.sh', '.bash', '.zsh', '.sql', '.env', '.properties', '.gradle',
'.dockerfile', '.gitignore',
'.md',
'.markdown',
'.txt',
'.text',
'.csv',
'.tsv',
'.json',
'.jsonc',
'.yaml',
'.yml',
'.toml',
'.ini',
'.conf',
'.cfg',
'.log',
'.xml',
'.html',
'.htm',
'.css',
'.scss',
'.js',
'.mjs',
'.cjs',
'.ts',
'.tsx',
'.jsx',
'.vue',
'.py',
'.go',
'.rs',
'.java',
'.kt',
'.c',
'.h',
'.cpp',
'.hpp',
'.cc',
'.sh',
'.bash',
'.zsh',
'.sql',
'.env',
'.properties',
'.gradle',
'.dockerfile',
'.gitignore',
])
/**
+4 -1
View File
@@ -40,7 +40,7 @@ export type ToastLevel = 'info' | 'success' | 'error'
export type ExtensionToWebview
= | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -53,6 +53,7 @@ export type ExtensionToWebview
canCancel?: boolean
tokenSaved: boolean
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
@@ -117,11 +118,13 @@ export type ExtensionToWebview
export type WebviewToExtension
= | { type: 'issues/refresh' }
| { type: 'issues/set-scope', scope: 'mine' | 'all' }
| {
type: 'settings/save'
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
+14 -3
View File
@@ -10,11 +10,11 @@
* a field.
*/
import type { ExtensionContext } from 'vscode'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import type { ExtensionContext } from 'vscode'
import { listClaudeProfiles } from '../cc/profiles'
import { seedCommitProfilePath } from '../cc/commitProfile'
import { listClaudeProfiles } from '../cc/profiles'
/**
* Fallback prompt strings used when the on-disk markdown can't be read.
@@ -46,7 +46,6 @@ export function readDefaultPrompt(extensionPath: string, name: PromptName): stri
return readFileSync(filePath, 'utf-8')
}
catch (err) {
// eslint-disable-next-line no-console
console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err)
switch (name) {
case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT
@@ -62,6 +61,12 @@ export const DEFAULT_WEBHOOK_PORT = 17421
export interface Settings {
/** Local HTTP port for receiving gitea webhook callbacks. */
webhookPort: number
/**
* Gitea webhook 的 secret,用于校验 `X-Gitea-Signature`(HMAC-SHA256)。
* webhook 经 frp 暴露公网,不校验等于任何人都能伪造事件触发 codex。
* 空 = 不校验(未在 Gitea 侧配置 secret 的过渡状态)。
*/
webhookSecret: string
/**
* Prompt template for the brainstorming flow (issue creation + ongoing
* session conventions for spec/plan body annotations). `{userRequest}`
@@ -198,6 +203,7 @@ export const SETTINGS_KEY = 'superpowers.settings'
function defaults(ctx: ExtensionContext): Settings {
return {
webhookPort: DEFAULT_WEBHOOK_PORT,
webhookSecret: '',
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'),
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
@@ -231,6 +237,10 @@ export function getSettings(ctx: ExtensionContext): Settings {
&& stored.webhookPort >= 1 && stored.webhookPort <= 65535
? stored.webhookPort
: base.webhookPort
// webhook secret:'' 有意义(= 不校验),只认字符串类型。
const webhookSecret = typeof stored.webhookSecret === 'string'
? stored.webhookSecret
: base.webhookSecret
const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0
? stored.brainstormPrompt
@@ -323,6 +333,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
return {
webhookPort,
webhookSecret,
brainstormPrompt,
brainstormContinuePrompt,
implementPlanPrompt,
+47 -39
View File
@@ -21,6 +21,7 @@ import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { getSettings } from '../settings/store'
@@ -86,6 +87,7 @@ class WebhookCoordinator {
}
this.server = new WebhookServer()
this.server.setSecret(getSettings(ctx).webhookSecret)
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
void this.handleEvent(event)
})
@@ -160,6 +162,11 @@ class WebhookCoordinator {
await srv.start(port)
}
/** 设置面板保存后热更新签名 secret,无需重启监听。 */
ensureSecret(secret: string): void {
this.server?.setSecret(secret)
}
/** Register a pending hook after createWebhook on gitea succeeded. */
async addPending(issueNumber: number, info: PendingHook): Promise<void> {
this.pendingHooks.set(issueNumber, info)
@@ -529,14 +536,15 @@ class WebhookCoordinator {
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
})
const extra: Record<string, unknown> = {
column: 'todo',
color: pending.color,
}
// 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写
// column / color 这类团队可见字段。
const localPatch: Record<string, unknown> = {}
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
extra.sessionId = pending.sessionId
localPatch.sessionId = pending.sessionId
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
extra.brainstormProfilePath = pending.brainstormProfilePath
localPatch.brainstormProfilePath = pending.brainstormProfilePath
if (Object.keys(localPatch).length > 0)
await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch)
try {
await mergeStateJsonComment({
@@ -545,7 +553,10 @@ class WebhookCoordinator {
repo: remote.repo,
token,
issueNumber: event.issueNumber,
extra,
extra: {
column: 'todo',
color: pending.color,
},
})
}
catch (err) {
@@ -589,7 +600,7 @@ class WebhookCoordinator {
if (this.activePanel) {
try {
const issue = await loadSingleIssue({
const loaded = await loadSingleIssue({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
@@ -597,6 +608,7 @@ class WebhookCoordinator {
workspaceRoot: ws,
issueNumber: event.issueNumber,
})
const issue = loaded ? overlayLocalIssueState(this.ctx, [loaded], ws)[0] : null
if (issue) {
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
if (pending) {
@@ -1473,39 +1485,35 @@ class WebhookCoordinator {
return
}
// Re-fetch state JSON to pick up the issue's worktreePath. We don't
// hard-fail if it's missing — triggerAutoReviewTab will fall back to
// workspaceRoot and toast the user.
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
// 不回落);本地无记录时读共享评论兜底(存量数据)。缺失不硬失败——
// triggerAutoReviewTab 会回退 workspaceRoot toast
let worktreePath = ''
try {
const comments = await listIssueComments({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
})
const last = comments[comments.length - 1]
const body = (last?.body ?? '').trim()
if (body) {
try {
const parsed = JSON.parse(body) as { worktreePath?: unknown }
if (typeof parsed?.worktreePath === 'string' && parsed.worktreePath.length > 0)
worktreePath = parsed.worktreePath
}
catch {
// last comment isn't JSON — proceed without a worktree.
}
}
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
if (local.worktreePath !== undefined) {
worktreePath = local.worktreePath
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 state JSON 失败(triggerReview#${issueNumber}`,
details: message,
})
else {
try {
const state = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber,
})
if (typeof state.worktreePath === 'string')
worktreePath = state.worktreePath
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 state JSON 失败(triggerReview#${issueNumber}`,
details: message,
})
}
}
logger.add({
+40 -1
View File
@@ -24,6 +24,8 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { Event } from 'vscode'
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
import { EventEmitter } from 'vscode'
import { logger } from '../logging/logger'
@@ -111,10 +113,33 @@ export interface PushWebhookEvent {
export class WebhookServer {
private server?: Server
private port?: number
private secret = ''
private readonly emitter = new EventEmitter<WebhookEvent>()
readonly onEvent: Event<WebhookEvent> = this.emitter.event
/**
* 设置(或清空)Gitea webhook secret。非空时对每个请求校验
* `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401
* ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。
*/
setSecret(secret: string): void {
this.secret = secret
}
/** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */
private verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
const expected = createHmac('sha256', this.secret).update(rawBody).digest()
let received: Buffer
try {
received = Buffer.from(signatureHeader, 'hex')
}
catch {
return false
}
return received.length === expected.length && timingSafeEqual(received, expected)
}
/** The port currently bound, or undefined if the server is not listening. */
get currentPort(): number | undefined {
return this.port
@@ -220,7 +245,21 @@ export class WebhookServer {
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf-8')
const rawBody = Buffer.concat(chunks)
if (this.secret) {
const signatureHeader = (req.headers['x-gitea-signature'] || '').toString()
if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) {
logger.add({
level: 'warn',
source: 'webhook',
message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`,
})
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'invalid_signature' }))
return
}
}
const body = rawBody.toString('utf-8')
const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '<missing>'
logger.add({
level: 'info',