diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 0bbbd63..d1c04b7 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -7,6 +7,7 @@ import type { import type { HookContext } from '../git/worktreeHooks' import type { Issue } from '../gitea/types' import type { IssueRef } from '../issues/stateRouter' +import type { KanbanScope } from './kanbanScope' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { randomBytes } from 'node:crypto' import * as fs from 'node:fs' @@ -36,13 +37,11 @@ import * as toolbar from './handlers/toolbar' import * as worktree from './handlers/worktree' import * as youtrackIssues from './handlers/youtrackIssues' import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor' +import { KANBAN_SCOPE_KEY } from './kanbanScope' /** 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' @@ -368,7 +367,7 @@ export class KanbanWebviewPanel { } if (msg.type === 'issues/set-scope') { // 视图偏好按工作区持久化;切换即重拉。 - void this.context.workspaceState.update(SCOPE_KEY, msg.scope) + void this.context.workspaceState.update(KANBAN_SCOPE_KEY, msg.scope) void this.loadAndPush() return } @@ -880,7 +879,7 @@ export class KanbanWebviewPanel { return } - const scope = this.context.workspaceState.get<'mine' | 'all'>(SCOPE_KEY) ?? 'mine' + const scope = this.context.workspaceState.get(KANBAN_SCOPE_KEY) ?? 'mine' try { const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope }) // YouTrack is a best-effort second source: a failure here must never diff --git a/vscode/src/panel/kanbanScope.ts b/vscode/src/panel/kanbanScope.ts new file mode 100644 index 0000000..699fcef --- /dev/null +++ b/vscode/src/panel/kanbanScope.ts @@ -0,0 +1,11 @@ +/** + * 看板范围的共享定义:KanbanPanel(批量加载/切换持久化)与 webhook + * coordinator(issue/append 门禁)必须读同一个 workspaceState key, + * 否则单独 append 的卡会违反当前范围的可见性规则。 + */ + +/** 看板范围:'mine' 只看我创建/指派给我的,'all' 团队全部。 */ +export type KanbanScope = 'mine' | 'all' + +/** workspaceState key:按工作区持久化的看板范围,缺省 'mine'。 */ +export const KANBAN_SCOPE_KEY = 'superpowers.kanbanScope' diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index d2de008..7a0a335 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -11,6 +11,7 @@ import type { ExtensionContext } from 'vscode' import type { KanbanWebviewPanel } from '../panel/KanbanPanel' +import type { KanbanScope } from '../panel/kanbanScope' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' import { promises as fsp } from 'node:fs' import { env, Uri, window, workspace } from 'vscode' @@ -24,9 +25,11 @@ import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComme import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { logger } from '../logging/logger' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' +import { KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { getSettings } from '../settings/store' import { decideEventOwnership } from './eventOwnership' +import { decideIssueAppend } from './issueAppend' import { WebhookServer } from './server' /** @@ -600,6 +603,30 @@ class WebhookCoordinator { } if (this.activePanel) { + // 看板 'mine' 范围的批量加载只拉"我创建/指派给我"的工单,而 webhook + // 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡 + // 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时 + // 才解析,省一次身份请求。 + const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? 'mine' + const pendingCreatedLocally = pending !== undefined + let poster: string | undefined + let assignees: string[] = [] + let me: string | undefined + if (!pendingCreatedLocally && scope === 'mine') { + const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null + poster = raw?.issue?.user?.login + assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) + me = await loginForToken(remote.host, token) + } + if (!decideIssueAppend({ scope, pendingCreatedLocally, poster, assignees, me })) { + logger.add({ + level: 'info', + source: 'webhook', + message: `issue #${event.issueNumber} 与我无关,scope=mine 过滤跳过 append`, + details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`, + }) + return + } try { const loaded = await loadSingleIssue({ host: remote.host, diff --git a/vscode/src/webhook/issueAppend.test.ts b/vscode/src/webhook/issueAppend.test.ts new file mode 100644 index 0000000..7521555 --- /dev/null +++ b/vscode/src/webhook/issueAppend.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { decideIssueAppend } from './issueAppend' + +describe('decideIssueAppend', () => { + it('本机 pending 创建 → 恒 append,范围和身份都不作数', () => { + expect(decideIssueAppend({ + scope: 'mine', + pendingCreatedLocally: true, + poster: 'chehongwei', + assignees: [], + me: undefined, + })).toBe(true) + }) + + it('scope=all → append', () => { + expect(decideIssueAppend({ + scope: 'all', + pendingCreatedLocally: false, + poster: 'chehongwei', + assignees: [], + me: 'cruldra', + })).toBe(true) + }) + + it('scope=mine 且 poster 是我 → append', () => { + expect(decideIssueAppend({ + scope: 'mine', + pendingCreatedLocally: false, + poster: 'cruldra', + assignees: [], + me: 'cruldra', + })).toBe(true) + }) + + it('scope=mine 且 assignee 含我 → append', () => { + expect(decideIssueAppend({ + scope: 'mine', + pendingCreatedLocally: false, + poster: 'chehongwei', + assignees: ['chehongwei', 'cruldra'], + me: 'cruldra', + })).toBe(true) + }) + + it('scope=mine 且与我无关 → 不 append', () => { + expect(decideIssueAppend({ + scope: 'mine', + pendingCreatedLocally: false, + poster: 'chehongwei', + assignees: ['chehongwei'], + me: 'cruldra', + })).toBe(false) + }) + + it('scope=mine 且身份未知 → 不 append(宁可少显示,刷新兜底)', () => { + expect(decideIssueAppend({ + scope: 'mine', + pendingCreatedLocally: false, + poster: 'cruldra', + assignees: ['cruldra'], + me: undefined, + })).toBe(false) + }) +}) diff --git a/vscode/src/webhook/issueAppend.ts b/vscode/src/webhook/issueAppend.ts new file mode 100644 index 0000000..9b49e9e --- /dev/null +++ b/vscode/src/webhook/issueAppend.ts @@ -0,0 +1,30 @@ +import type { KanbanScope } from '../panel/kanbanScope' + +/** + * issue opened 事件的看板 append 门禁:webhook 广播到所有开着看板的 + * 机器,而 'mine' 范围的批量加载只拉"我创建/指派给我"的工单。若无条件 + * append,别人建的单会在 'mine' 看板上凭空多出一张卡(刷新后又消失)。 + * append 必须与批量加载遵循同一套可见性规则。 + */ +export function decideIssueAppend(opts: { + /** 看板当前范围('mine' 只看我的 / 'all' 团队全部)。 */ + scope: KanbanScope + /** 本机面板发起的创建(nonce 匹配到 pending),用户正等这张卡上屏。 */ + pendingCreatedLocally: boolean + /** 工单创建者的 login;payload 缺失时为 undefined。 */ + poster: string | undefined + /** 工单 assignee 的 login 列表,空数组表示未指派。 */ + assignees: string[] + /** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */ + me: string | undefined +}): boolean { + // ① 本机发起的创建 → 恒显,范围和身份都不作数。 + if (opts.pendingCreatedLocally) + return true + // ② 'all' 范围本来就展示团队全部 → 恒显。 + if (opts.scope === 'all') + return true + // ③ 'mine' 范围:与批量加载(created_by=我 / assigned_by=我)同规则; + // 身份未知时宁可少显示,下次刷新会兜底。 + return opts.me !== undefined && (opts.poster === opts.me || opts.assignees.includes(opts.me)) +}