From faf227412ea29ea6692600902d82cc3d8017d2eb Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 19 Aug 2026 00:32:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=A4=9A=E4=BA=BA?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E4=BA=8C=E6=9C=9F:=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=88=86=E5=B1=82/=E5=86=99=E6=A0=A1=E9=AA=8C/=E7=AD=BE?= =?UTF-8?q?=E5=90=8D/=E5=9B=A2=E9=98=9F=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState, 共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底 - mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次 - webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256), 不匹配 401;设置面板新增 Webhook Secret 字段 - 看板新增 我的/全部 范围切换(团队视图),按工作区持久化 --- vscode/src/gitea/api.ts | 8 +- vscode/src/gitea/issueLoader.ts | 44 +++-- vscode/src/gitea/stateJson.ts | 33 +++- vscode/src/issues/localState.ts | 121 ++++++++++++++ vscode/src/issues/stateRouter.ts | 33 +++- vscode/src/panel/KanbanPanel.ts | 40 +++-- vscode/src/panel/handlers/issues.ts | 38 ++--- vscode/src/panel/handlers/settings.ts | 57 ++++++- vscode/src/panel/messages.ts | 5 +- vscode/src/settings/store.ts | 17 +- vscode/src/webhook/coordinator.ts | 86 +++++----- vscode/src/webhook/server.ts | 41 ++++- vscode/test/gitea/stateJson.test.ts | 44 ++++- vscode/webview-ui/src/App.tsx | 13 +- .../webview-ui/src/components/PanelHeader.tsx | 70 +++++--- .../src/components/SettingsModal.tsx | 55 ++++++- vscode/webview-ui/src/hooks/useIssues.ts | 155 ++++++++++++------ vscode/webview-ui/src/lib/messages.ts | 7 +- 18 files changed, 665 insertions(+), 202 deletions(-) create mode 100644 vscode/src/issues/localState.ts diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 841ad3e..c1377ee 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -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 { 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)) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 6476378..d20819e 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -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 { 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> + let allComments: Awaited + 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 远程拉取。 diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 25895bf..6ddc985 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -71,14 +71,31 @@ export interface MergeStateJsonCommentOpts { * are responsible for surfacing errors. */ export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise { - 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 { diff --git a/vscode/src/issues/localState.ts b/vscode/src/issues/localState.ts new file mode 100644 index 0000000..994b098 --- /dev/null +++ b/vscode/src/issues/localState.ts @@ -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> + +function refKey(source: string | undefined, issueNumber: number): string { + return `${source ?? 'gitea'}:${issueNumber}` +} + +export function getLocalIssueState( + ctx: ExtensionContext, + source: string | undefined, + issueNumber: number, +): Record { + const store = ctx.workspaceState.get(KEY) ?? {} + return store[refKey(source, issueNumber)] ?? {} +} + +/** + * 空字符串是墓碑:字段已在本机清除,读侧不得回落到共享评论里的旧值 + * (与 state JSON "空串=unset" 的既有约定一致)。null/undefined 视作清除。 + */ +export async function mergeLocalIssueState( + ctx: ExtensionContext, + source: string | undefined, + issueNumber: number, + patch: Record, +): Promise { + const store = { ...(ctx.workspaceState.get(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): { + local: Record + shared: Record +} { + const localSet = new Set(LOCAL_STATE_FIELDS) + const local: Record = {} + const shared: Record = {} + 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(KEY) ?? {} + return issues.map((issue) => { + const local = store[refKey(issue.source, issue.number)] + if (!local) + return issue + const out = { ...issue } as Issue & Record + 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 + }) +} diff --git a/vscode/src/issues/stateRouter.ts b/vscode/src/issues/stateRouter.ts index 9b0eb2c..aebac3e 100644 --- a/vscode/src/issues/stateRouter.ts +++ b/vscode/src/issues/stateRouter.ts @@ -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> { + let base: Record 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): Promise { + 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 }) } /** diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 0b4b77d..70cf9a3 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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 { return closeIssueByRef(this.context, this.refFor(issueNumber)) diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index c88e21a..f1e69f7 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -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, diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index 5ecc682..be01ac2 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -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= 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, diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 1606afb..52b42b0 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -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 { 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 = { - column: 'todo', - color: pending.color, - } + // 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写 + // column / color 这类团队可见字段。 + const localPatch: Record = {} 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({ diff --git a/vscode/src/webhook/server.ts b/vscode/src/webhook/server.ts index 2f77aed..77b1a97 100644 --- a/vscode/src/webhook/server.ts +++ b/vscode/src/webhook/server.ts @@ -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() readonly onEvent: Event = 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() || '' logger.add({ level: 'info', diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 7a2b806..52484fd 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -133,11 +133,17 @@ describe('state JSON 尾部普通评论不丢状态', () => { worktreePath: '.claude/worktrees/be3beabc', implementStatus: 'running', } - listIssueComments.mockResolvedValue([ + // merge 现在 post 后会读回校验(乐观并发检测),mock 必须有状态: + // postIssueComment 之后列表要包含新评论,否则校验误判丢失导致重放。 + const comments = [ { body: JSON.stringify(fullState) }, { body: '这是一条普通评论,不是 state JSON' }, { body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) }, - ]) + ] + listIssueComments.mockImplementation(async () => [...comments]) + postIssueComment.mockImplementation(async (opts: { body: string }) => { + comments.push({ body: opts.body }) + }) await mergeStateJsonComment({ host: 'https://gitea.example', @@ -156,6 +162,40 @@ describe('state JSON 尾部普通评论不丢状态', () => { }) }) + it('post 后读回发现字段被并发覆盖时,在最新状态上重放一次', async () => { + const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const comments: Array<{ body: string }> = [ + { body: JSON.stringify({ column: 'in-progress' }) }, + ] + listIssueComments.mockImplementation(async () => [...comments]) + let postCount = 0 + postIssueComment.mockImplementation(async (opts: { body: string }) => { + postCount++ + if (postCount === 1) { + // 模拟并发写者在我们 post 之后又盖了一条:我们的字段丢失。 + comments.push({ body: JSON.stringify({ column: 'review' }) }) + return + } + comments.push({ body: opts.body }) + }) + + await mergeStateJsonComment({ + host: 'https://gitea.example', + owner: 'owner', + repo: 'repo', + token: 'token', + issueNumber: 7, + extra: { pr: '99' }, + }) + + expect(postIssueComment).toHaveBeenCalledTimes(2) + // 重放基于并发写者的最新状态,两边的字段都保留。 + expect(JSON.parse(postIssueComment.mock.calls[1][0].body)).toEqual({ + column: 'review', + pr: '99', + }) + }) + it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => { const { readStateJsonComment } = await import('../../src/gitea/stateJson.js') listIssueComments.mockResolvedValue([ diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index e4d05b0..7286313 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -1,8 +1,9 @@ +import type { PastedImage } from './components/NewIssueModal' +import type { Issue } from './types' import { useEffect, useMemo, useRef, useState } from 'react' import { BottomTabs } from './components/BottomTabs' import { KanbanBoard } from './components/KanbanBoard' import { LogModal } from './components/LogModal' -import type { PastedImage } from './components/NewIssueModal' import { NewIssueModal } from './components/NewIssueModal' import { PanelHeader } from './components/PanelHeader' import { SettingsModal } from './components/SettingsModal' @@ -11,7 +12,6 @@ import { useIssues } from './hooks/useIssues' import { useManagedSessions } from './hooks/useManagedSessions' import { useProfiles } from './hooks/useProfiles' import { compareIssuesInColumn } from './lib/issueSort' -import type { Issue } from './types' import { COLUMN_ORDER } from './types' export function App() { @@ -23,6 +23,8 @@ export function App() { importYouTrack, toasts, profiles, + scope, + toggleScope, setIssues, refresh, saveSettings, @@ -283,6 +285,8 @@ export function App() { <> void + /** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部工单。 */ + scope: 'mine' | 'all' + onToggleScope: () => void onEditAuth: () => void commitRunning: boolean - /** Whether the workspace git working tree has uncommitted changes. The + /** + * Whether the workspace git working tree has uncommitted changes. The * commit button is only rendered when this is true (or `commitRunning` is * true — we keep it visible mid-run so the spinner stays on screen even - * if `cc` clears the tree before finishing). */ + * if `cc` clears the tree before finishing). + */ hasChanges: boolean onCommit: () => void - /** Commits the remote auto-build branch is behind the remote dev branch. + /** + * Commits the remote auto-build branch is behind the remote dev branch. * Combined with `branchSyncDisabled` to compute the button's disabled - * state. */ + * state. + */ branchSyncBehind: number branchSyncRunning: boolean - /** True when sync is structurally unavailable (same-branch / fetch fail - * / not a repo / …). Disables the button regardless of `branchSyncBehind`. */ + /** + * True when sync is structurally unavailable (same-branch / fetch fail + * / not a repo / …). Disables the button regardless of `branchSyncBehind`. + */ branchSyncDisabled: boolean - /** Hover tooltip, already includes branch names + behind count or the - * unavailable reason. */ + /** + * Hover tooltip, already includes branch names + behind count or the + * unavailable reason. + */ branchSyncTitle: string onSyncBranch: () => void - /** Whether the workspace's `.env*` files are currently chmod-locked (444). - * Icon flips between Lock and Unlock based on this. */ + /** + * Whether the workspace's `.env*` files are currently chmod-locked (444). + * Icon flips between Lock and Unlock based on this. + */ envLocked: boolean - /** Number of `.env*` files discovered in the workspace at the last scan. - * The button is disabled when 0 — nothing to lock. */ + /** + * Number of `.env*` files discovered in the workspace at the last scan. + * The button is disabled when 0 — nothing to lock. + */ envFileCount: number - /** True while a chmod batch is in flight; disables the button to avoid - * duplicate toggles. */ + /** + * True while a chmod batch is in flight; disables the button to avoid + * duplicate toggles. + */ envLockRunning: boolean /** Hover tooltip, already includes file count + current lock state. */ envLockTitle: string onToggleEnvLock: () => void - /** Whether YouTrack is configured. The「导入 YouTrack 工单」button is only - * rendered when true. */ + /** + * Whether YouTrack is configured. The「导入 YouTrack 工单」button is only + * rendered when true. + */ youtrackConfigured: boolean - /** Open the native multi-select dialog to pick which YouTrack issues to - * mirror onto the board. */ + /** + * Open the native multi-select dialog to pick which YouTrack issues to + * mirror onto the board. + */ onImportYouTrack: () => void } export function PanelHeader({ onRefresh, + scope, + onToggleScope, onEditAuth, commitRunning, hasChanges, @@ -122,6 +145,15 @@ export function PanelHeader({ )} +