🐛 fix(vscode): issue opened 广播按看板 scope 门禁,mine 不再凭空上别人的卡

This commit is contained in:
2026-08-19 20:41:11 +08:00
parent ea4c4e4658
commit fea7b2e604
5 changed files with 136 additions and 5 deletions
+4 -5
View File
@@ -7,6 +7,7 @@ import type {
import type { HookContext } from '../git/worktreeHooks' import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types' import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter' import type { IssueRef } from '../issues/stateRouter'
import type { KanbanScope } from './kanbanScope'
import type { ExtensionToWebview, WebviewToExtension } from './messages' import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs' import * as fs from 'node:fs'
@@ -36,13 +37,11 @@ import * as toolbar from './handlers/toolbar'
import * as worktree from './handlers/worktree' import * as worktree from './handlers/worktree'
import * as youtrackIssues from './handlers/youtrackIssues' import * as youtrackIssues from './handlers/youtrackIssues'
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor' import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
import { KANBAN_SCOPE_KEY } from './kanbanScope'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles' export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
/** workspaceState key:看板范围('mine' 只看我的 / 'all' 团队全部)。 */
const SCOPE_KEY = 'superpowers.kanbanScope'
export class KanbanWebviewPanel { export class KanbanWebviewPanel {
static readonly viewType = 'superpowers.kanbanPanel' static readonly viewType = 'superpowers.kanbanPanel'
@@ -368,7 +367,7 @@ export class KanbanWebviewPanel {
} }
if (msg.type === 'issues/set-scope') { 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() void this.loadAndPush()
return return
} }
@@ -880,7 +879,7 @@ export class KanbanWebviewPanel {
return return
} }
const scope = this.context.workspaceState.get<'mine' | 'all'>(SCOPE_KEY) ?? 'mine' const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
try { try {
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope }) const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
// YouTrack is a best-effort second source: a failure here must never // YouTrack is a best-effort second source: a failure here must never
+11
View File
@@ -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'
+27
View File
@@ -11,6 +11,7 @@
import type { ExtensionContext } from 'vscode' import type { ExtensionContext } from 'vscode'
import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { KanbanScope } from '../panel/kanbanScope'
import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
import { env, Uri, window, workspace } from 'vscode' import { env, Uri, window, workspace } from 'vscode'
@@ -24,9 +25,11 @@ import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComme
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
import { getSettings } from '../settings/store' import { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership' import { decideEventOwnership } from './eventOwnership'
import { decideIssueAppend } from './issueAppend'
import { WebhookServer } from './server' import { WebhookServer } from './server'
/** /**
@@ -600,6 +603,30 @@ class WebhookCoordinator {
} }
if (this.activePanel) { if (this.activePanel) {
// 看板 'mine' 范围的批量加载只拉"我创建/指派给我"的工单,而 webhook
// 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡
// 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时
// 才解析,省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(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 { try {
const loaded = await loadSingleIssue({ const loaded = await loadSingleIssue({
host: remote.host, host: remote.host,
+64
View File
@@ -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)
})
})
+30
View File
@@ -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))
}