From 9167b867088c27fa2c5c9c84c618f8cd15480e09 Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 10 Sep 2026 15:30:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=98=BE=E7=A4=BA=E5=9B=A2=E9=98=9F=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=EF=BC=8C=E5=90=8C=E4=BA=8B=E7=9A=84=E5=8D=A1=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=B9=B6=E8=B4=B4=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用 手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee 退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、 删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。 看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。 扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单 刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。 头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片 的限制,按 URL 进程内缓存。 Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV --- vscode/package.json | 2 +- vscode/src/gitea/api.ts | 4 +- vscode/src/gitea/avatars.ts | 63 +++++++++++++++++++ vscode/src/gitea/issueLoader.ts | 15 ++++- vscode/src/gitea/types.ts | 2 + vscode/src/panel/KanbanPanel.ts | 54 +++++++++++++++- vscode/src/panel/issueOwnership.test.ts | 39 ++++++++++++ vscode/src/panel/issueOwnership.ts | 31 +++++++++ vscode/src/panel/kanbanScope.ts | 8 ++- vscode/src/panel/kanbanSnapshot.ts | 7 +++ vscode/src/panel/messages.ts | 2 +- vscode/src/panel/readOnlyGuard.test.ts | 42 +++++++++++++ vscode/src/panel/readOnlyGuard.ts | 41 ++++++++++++ vscode/src/webhook/coordinator.ts | 11 ++-- vscode/webview-ui/src/App.tsx | 4 ++ .../webview-ui/src/components/BottomTabs.tsx | 3 + .../webview-ui/src/components/IssueCard.tsx | 60 +++++++++++++----- .../src/components/IssueDetailPanel.tsx | 33 +++++++++- .../webview-ui/src/components/KanbanBoard.tsx | 12 ++++ vscode/webview-ui/src/hooks/useIssues.ts | 17 ++++- .../webview-ui/src/lib/issueOwnership.test.ts | 35 +++++++++++ vscode/webview-ui/src/lib/issueOwnership.ts | 26 ++++++++ vscode/webview-ui/src/lib/messages.ts | 2 +- .../webview-ui/src/lib/readOnlySchema.test.ts | 45 +++++++++++++ vscode/webview-ui/src/lib/readOnlySchema.ts | 24 +++++++ vscode/webview-ui/src/types.ts | 2 + 26 files changed, 550 insertions(+), 34 deletions(-) create mode 100644 vscode/src/gitea/avatars.ts create mode 100644 vscode/src/panel/issueOwnership.test.ts create mode 100644 vscode/src/panel/issueOwnership.ts create mode 100644 vscode/src/panel/readOnlyGuard.test.ts create mode 100644 vscode/src/panel/readOnlyGuard.ts create mode 100644 vscode/webview-ui/src/lib/issueOwnership.test.ts create mode 100644 vscode/webview-ui/src/lib/issueOwnership.ts create mode 100644 vscode/webview-ui/src/lib/readOnlySchema.test.ts create mode 100644 vscode/webview-ui/src/lib/readOnlySchema.ts diff --git a/vscode/package.json b/vscode/package.json index da6f66f..c729764 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.124", + "version": "0.2.125", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 42e6486..4c5360a 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -31,8 +31,8 @@ export interface GiteaIssue { updated_at: string body: string html_url: string - user: { login: string } | null - assignees: Array<{ login: string }> | null + user: { login: string, avatar_url?: string } | null + assignees: Array<{ login: string, avatar_url?: string }> | null labels: Array<{ name: string, color: string }> | null } diff --git a/vscode/src/gitea/avatars.ts b/vscode/src/gitea/avatars.ts new file mode 100644 index 0000000..85f5759 --- /dev/null +++ b/vscode/src/gitea/avatars.ts @@ -0,0 +1,63 @@ +/** + * 头像取回:webview 的 CSP 只放行扩展自身资源与 `data:`,直接把 Gitea 的 + * avatar_url 塞进 会被拦掉(私有实例的头像还可能要带 token)。所以 + * 统一由扩展侧抓回来转成 data URI 再下发。 + * + * 按 URL 进程内缓存:同一批工单里重复出现的同事只下载一次,刷新看板也复用。 + * 任何失败都缓存成「无头像」,前端回退到首字母圆点,不重试拖慢加载。 + */ + +import { Buffer } from 'node:buffer' + +/** 单张头像的体积上限;Gitea 头像通常 <20KB,超限的直接当无头像。 */ +const MAX_BYTES = 256 * 1024 + +const cache = new Map() + +/** 抓单张头像并转 data URI;失败或超限返回 undefined。 */ +async function fetchAvatar(url: string, token: string): Promise { + try { + const res = await fetch(url, { headers: { Authorization: `token ${token}` } }) + if (!res.ok) + return undefined + const buf = await res.arrayBuffer() + if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) + return undefined + const mime = res.headers.get('content-type')?.split(';')[0].trim() || 'image/png' + if (!mime.startsWith('image/')) + return undefined + return `data:${mime};base64,${Buffer.from(buf).toString('base64')}` + } + catch { + return undefined + } +} + +/** + * 把 `login -> avatar_url` 解析成 `login -> data URI`。没有 URL、抓取失败 + * 的 login 不出现在结果里。 + */ +export async function resolveAvatars( + entries: Iterable, + token: string, +): Promise> { + const wanted = new Map() + for (const [login, url] of entries) { + if (login && url && !wanted.has(login)) + wanted.set(login, url) + } + + const pairs = await Promise.all([...wanted].map(async ([login, url]) => { + if (!cache.has(url)) + cache.set(url, await fetchAvatar(url, token)) + const dataUri = cache.get(url) + return dataUri ? [login, dataUri] as const : undefined + })) + + const out: Record = {} + for (const pair of pairs) { + if (pair) + out[pair[0]] = pair[1] + } + return out +} diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 3a0a368..d654983 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -26,6 +26,7 @@ import { PAGE_SIZE, postIssueComment, } from './api' +import { resolveAvatars } from './avatars' const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] @@ -413,6 +414,7 @@ async function buildIssue(opts: { ...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffFrom ? { handoffFrom } : {}), assignees: (issue.assignees ?? []).map(a => a.login), + ...(issue.user?.login ? { poster: issue.user.login } : {}), htmlUrl: issue.html_url, } } @@ -426,7 +428,7 @@ export async function loadIssues(opts: { workspaceRoot?: string /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ scope?: 'mine' | 'all' -}): Promise<{ issues: Issue[], me: string }> { +}): Promise<{ issues: Issue[], me: string, avatars: Record }> { const { host, token, owner, repo, workspaceRoot } = opts // ① 工单列表 + 当前用户 @@ -522,7 +524,16 @@ export async function loadIssues(opts: { })) } - return { issues: resolved, me } + // 团队视图下卡片按人贴头像;抓不到头像不算失败,前端回退首字母。 + const avatars = await resolveAvatars( + merged.flatMap(issue => [ + ...(issue.assignees ?? []).map(a => [a.login, a.avatar_url] as const), + ...(issue.user ? [[issue.user.login, issue.user.avatar_url] as const] : []), + ]), + token, + ) + + return { issues: resolved, me, avatars } } /** diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index 1f3c8e3..cd274a6 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -118,6 +118,8 @@ export interface Issue { autoReview?: boolean /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ assignees?: string[] + /** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */ + poster?: string /** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ handoffAttachmentId?: string /** 发起移交的 Gitea login。 */ diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 62b8975..93ecdbb 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 { IssueOwnershipFacts } from './issueOwnership' import type { KanbanScope } from './kanbanScope' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { randomBytes } from 'node:crypto' @@ -41,8 +42,10 @@ 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' +import { isIssueMine } from './issueOwnership' +import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope' import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot' +import { isOwnerOnlyMessage } from './readOnlyGuard' /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ export { getDefaultProfilePath } from '../cc/profiles' @@ -62,6 +65,10 @@ export class KanbanWebviewPanel { * alone is an unambiguous key. */ private issueRefs = new Map() + /** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */ + private issueOwners = new Map() + /** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */ + private me: string | undefined /** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */ private webviewReady = false private loadInFlight: Promise | undefined @@ -346,7 +353,33 @@ export class KanbanWebviewPanel { KanbanWebviewPanel.current?.loadAndPush().catch(() => {}) } + /** + * 同事工单的写操作最终拦截点。前端已按归属藏掉按钮,这里兜住快照过期 + * 或工单刚被重新指派的竞态:不是我的工单就丢消息 + 提示,不落任何副作用。 + */ + private blockedAsReadOnly(msg: WebviewToExtension): boolean { + if (!isOwnerOnlyMessage(msg.type)) + return false + const issueNumber = (msg as { issueNumber?: number }).issueNumber + if (issueNumber === undefined) + return false + const facts = this.issueOwners.get(issueNumber) + if (!facts || isIssueMine(facts, this.me)) + return false + const owner = facts.assignees?.[0] ?? facts.poster ?? '别人' + this.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'info', + message: `#${issueNumber} 是 ${owner} 的工单,只能查看`, + dismissOnTimer: 4000, + }) + return true + } + private handleMessage(msg: WebviewToExtension): void { + if (this.blockedAsReadOnly(msg)) + return if (msg.type === 'issues/refresh') { const first = !this.webviewReady this.webviewReady = true @@ -943,9 +976,9 @@ export class KanbanWebviewPanel { return } - const scope = this.context.workspaceState.get(KANBAN_SCOPE_KEY) ?? 'mine' + const scope = this.context.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE try { - const [{ issues: giteaIssues, me }, youtrackList] = await Promise.all([ + const [{ issues: giteaIssues, me, avatars }, youtrackList] = await Promise.all([ loadIssues({ host, token, owner, repo, workspaceRoot, scope }), this.loadYouTrackSafe(), ]) @@ -959,6 +992,10 @@ export class KanbanWebviewPanel { this.issueRefs = new Map( issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const), ) + this.me = me + this.issueOwners = new Map( + issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const), + ) const ytSettings = getSettings(this.context) const payload: Extract = { type: 'issues/update', @@ -967,12 +1004,14 @@ export class KanbanWebviewPanel { globalAutoReview: ytSettings.autoReview, youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '', me, + avatars, } this.lastIssuesUpdate = payload void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, { issues, scope, me, + avatars, globalAutoReview: payload.globalAutoReview, youtrackConfigured: payload.youtrackConfigured, }) @@ -1037,6 +1076,15 @@ export class KanbanWebviewPanel { return { source: entry?.source ?? 'gitea', number: issueNumber, externalId: entry?.externalId } } + /** + * 记下一张增量上板的卡的归属。webhook 单卡 append 不走批量加载,漏了这一步 + * 归属表里就没有它,写门禁查不到会当成我的工单放行。 + */ + // internal: webhook coordinator 访问 + noteIssueOwnership(issue: Issue): void { + this.issueOwners.set(issue.number, { poster: issue.poster, assignees: issue.assignees }) + } + /** True when the board number belongs to a YouTrack-sourced card. */ // internal: handler 模块访问 isYouTrackIssue(issueNumber: number): boolean { diff --git a/vscode/src/panel/issueOwnership.test.ts b/vscode/src/panel/issueOwnership.test.ts new file mode 100644 index 0000000..e7c80af --- /dev/null +++ b/vscode/src/panel/issueOwnership.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { isIssueMine, issueOwnerLogin } from './issueOwnership' + +describe('isIssueMine', () => { + it('assignee 含我 → 可写', () => { + expect(isIssueMine({ poster: 'chehongwei', assignees: ['cruldra'] }, 'cruldra')).toBe(true) + }) + + it('assignee 是别人 → 只读,创建者是我也不算', () => { + expect(isIssueMine({ poster: 'cruldra', assignees: ['chehongwei'] }, 'cruldra')).toBe(false) + }) + + it('无 assignee 时按创建者判定', () => { + expect(isIssueMine({ poster: 'cruldra' }, 'cruldra')).toBe(true) + expect(isIssueMine({ poster: 'chehongwei' }, 'cruldra')).toBe(false) + }) + + it('身份未知 → 一律可写,不锁死自己的板', () => { + expect(isIssueMine({ poster: 'chehongwei', assignees: ['chehongwei'] }, undefined)).toBe(true) + }) + + it('非 Gitea 来源(无 poster 无 assignee)→ 可写', () => { + expect(isIssueMine({}, 'cruldra')).toBe(true) + }) +}) + +describe('issueOwnerLogin', () => { + it('assignee 优先于创建者', () => { + expect(issueOwnerLogin({ poster: 'cruldra', assignees: ['chehongwei'] })).toBe('chehongwei') + }) + + it('无 assignee 时回退创建者', () => { + expect(issueOwnerLogin({ poster: 'cruldra' })).toBe('cruldra') + }) + + it('都没有 → undefined', () => { + expect(issueOwnerLogin({})).toBeUndefined() + }) +}) diff --git a/vscode/src/panel/issueOwnership.ts b/vscode/src/panel/issueOwnership.ts new file mode 100644 index 0000000..d50127c --- /dev/null +++ b/vscode/src/panel/issueOwnership.ts @@ -0,0 +1,31 @@ +/** + * 看板工单归属判定:范围切到「团队全部」后同事的工单也会上板,这类卡片 + * 只读——能看详情、spec/plan、PR 改动,不能改列、开会话、合并或删除。 + * + * 口径与 webhook/eventOwnership 一致:有 assignee 时指派即真相;无 assignee + * 的存量工单退回创建者。身份未知(token 解析失败)时一律按「我的」放行, + * 宁可不锁也不能把自己的板锁死。 + */ + +export interface IssueOwnershipFacts { + /** Gitea 工单创建者 login;YouTrack 工单没有该字段。 */ + poster?: string + /** Gitea assignee login 列表;空/缺省表示未指派。 */ + assignees?: string[] +} + +/** 该工单是否归当前登录者所有(可写)。 */ +export function isIssueMine(facts: IssueOwnershipFacts, me: string | undefined): boolean { + if (me === undefined) + return true + const assignees = facts.assignees ?? [] + if (assignees.length > 0) + return assignees.includes(me) + // 无 assignee:创建者说了算;poster 缺省(非 Gitea 来源)不参与判定。 + return facts.poster === undefined || facts.poster === me +} + +/** 卡片头像取谁:assignee 优先(责任人),否则创建者。都没有则 undefined。 */ +export function issueOwnerLogin(facts: IssueOwnershipFacts): string | undefined { + return (facts.assignees ?? [])[0] ?? facts.poster +} diff --git a/vscode/src/panel/kanbanScope.ts b/vscode/src/panel/kanbanScope.ts index 699fcef..7f09817 100644 --- a/vscode/src/panel/kanbanScope.ts +++ b/vscode/src/panel/kanbanScope.ts @@ -7,5 +7,11 @@ /** 看板范围:'mine' 只看我创建/指派给我的,'all' 团队全部。 */ export type KanbanScope = 'mine' | 'all' -/** workspaceState key:按工作区持久化的看板范围,缺省 'mine'。 */ +/** workspaceState key:按工作区持久化的看板范围。 */ export const KANBAN_SCOPE_KEY = 'superpowers.kanbanScope' + +/** + * 未选过范围时的默认值。默认团队全部:同事把工单移交过来、或改动落到共享 + * 分支时,不切开关也能看见,否则协作信号会整个丢掉。 + */ +export const DEFAULT_KANBAN_SCOPE: KanbanScope = 'all' diff --git a/vscode/src/panel/kanbanSnapshot.ts b/vscode/src/panel/kanbanSnapshot.ts index 659e33f..8c16fca 100644 --- a/vscode/src/panel/kanbanSnapshot.ts +++ b/vscode/src/panel/kanbanSnapshot.ts @@ -6,10 +6,16 @@ export interface KanbanSnapshot { issues: Issue[] scope: 'mine' | 'all' me?: string + /** Gitea login → 头像 data URI;团队视图下卡片按人贴头像。 */ + avatars?: Record globalAutoReview: boolean youtrackConfigured: boolean } +function isAvatarMap(raw: unknown): raw is Record { + return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string') +} + export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null { if (!raw || typeof raw !== 'object') return null @@ -22,6 +28,7 @@ export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null { issues: obj.issues as Issue[], scope: obj.scope, me: typeof obj.me === 'string' ? obj.me : undefined, + avatars: isAvatarMap(obj.avatars) ? obj.avatars : undefined, globalAutoReview: obj.globalAutoReview === true, youtrackConfigured: obj.youtrackConfigured === true, } diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 33c9c4a..8d4eaf4 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -49,7 +49,7 @@ export type ToastLevel = 'info' | 'success' | 'error' export type ExtensionToWebview = | { type: 'issues/loading' } - | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } + | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { type: 'issues/error', message: string } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } diff --git a/vscode/src/panel/readOnlyGuard.test.ts b/vscode/src/panel/readOnlyGuard.test.ts new file mode 100644 index 0000000..066ebb9 --- /dev/null +++ b/vscode/src/panel/readOnlyGuard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { isOwnerOnlyMessage } from './readOnlyGuard' + +describe('isOwnerOnlyMessage', () => { + it('改工单状态的消息要门禁', () => { + expect(isOwnerOnlyMessage('column/change')).toBe(true) + expect(isOwnerOnlyMessage('issue/delete')).toBe(true) + expect(isOwnerOnlyMessage('issue/close')).toBe(true) + expect(isOwnerOnlyMessage('dependency/set')).toBe(true) + expect(isOwnerOnlyMessage('issue/update-auto-review')).toBe(true) + }) + + it('在本机替别人跑会话的消息要门禁', () => { + expect(isOwnerOnlyMessage('issue/implement')).toBe(true) + expect(isOwnerOnlyMessage('session/resume')).toBe(true) + expect(isOwnerOnlyMessage('session/start-test')).toBe(true) + expect(isOwnerOnlyMessage('brainstorm/continue')).toBe(true) + }) + + it('合并分支、删 worktree 要门禁', () => { + expect(isOwnerOnlyMessage('git/merge-preview')).toBe(true) + expect(isOwnerOnlyMessage('worktree/delete')).toBe(true) + }) + + it('看 PR 改动与本机审查记录放行', () => { + expect(isOwnerOnlyMessage('pr-files/get')).toBe(false) + expect(isOwnerOnlyMessage('pr-file-diff/get')).toBe(false) + expect(isOwnerOnlyMessage('pr-file-summary/generate')).toBe(false) + expect(isOwnerOnlyMessage('pr-review/set')).toBe(false) + expect(isOwnerOnlyMessage('pr/open')).toBe(false) + }) + + it('接管别人移交过来的工单放行(此时 assignee 已是我)', () => { + expect(isOwnerOnlyMessage('handoff/accept')).toBe(false) + }) + + it('看板级操作与工单无关,放行', () => { + expect(isOwnerOnlyMessage('issues/refresh')).toBe(false) + expect(isOwnerOnlyMessage('issues/set-scope')).toBe(false) + expect(isOwnerOnlyMessage('issue/create')).toBe(false) + }) +}) diff --git a/vscode/src/panel/readOnlyGuard.ts b/vscode/src/panel/readOnlyGuard.ts new file mode 100644 index 0000000..7f5c150 --- /dev/null +++ b/vscode/src/panel/readOnlyGuard.ts @@ -0,0 +1,41 @@ +/** + * 同事工单的写操作门禁:看板范围切到「团队全部」后,别人的卡也在板上, + * 前端会把按钮藏掉,但 webview 消息仍可能因为竞态(卡刚被重新指派、 + * 快照过期)送到扩展侧,所以这里做最终拦截——归属不是我就直接丢掉。 + * + * 只拦「会改工单状态或在本机替别人跑会话」的消息。看 PR 改动、勾已查看、 + * 生成文件摘要属于审查动作,落地在本机,照常放行。 + */ + +/** 必须是工单归属人才能执行的消息类型。 */ +export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet = new Set([ + // 会话:在本机替别人开/恢复/关闭会话没有意义,worktree 也不在本机 + 'session/resume', + 'session/focus', + 'session/resume-review', + 'session/resume-test', + 'session/start-test', + 'session/close-tab', + 'brainstorm/continue', + 'issue/implement', + // 工单状态:写回 Gitea + 'column/change', + 'issue/delete', + 'issue/close', + 'issue/generate-pr-diff-summary', + 'issue/update-auto-review', + 'issue/update-profile-path', + 'issue/update-brainstorm-profile-path', + 'issue/update-test-profile-path', + 'dependency/set', + 'dependency/clear', + 'handoff/users', + 'handoff/start', + // 分支与 worktree + 'git/merge-preview', + 'worktree/delete', +]) + +export function isOwnerOnlyMessage(type: string): boolean { + return OWNER_ONLY_MESSAGE_TYPES.has(type) +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index abcc81b..2a8df8a 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -26,7 +26,7 @@ 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 { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { getSettings } from '../settings/store' import { decideEventOwnership } from './eventOwnership' @@ -622,7 +622,7 @@ class WebhookCoordinator { // 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡 // 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时 // 才解析,省一次身份请求。 - const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? 'mine' + const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE const pendingCreatedLocally = pending !== undefined let poster: string | undefined let assignees: string[] = [] @@ -653,6 +653,7 @@ class WebhookCoordinator { }) const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null if (issue) { + this.activePanel.noteIssueOwnership(issue) this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) if (pending) { this.activePanel.postMessage({ @@ -728,7 +729,7 @@ class WebhookCoordinator { // 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, // 省一次身份请求。 - const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? 'mine' + const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE let poster: string | undefined let assignees: string[] = [] let me: string | undefined @@ -761,8 +762,10 @@ class WebhookCoordinator { const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null // 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新 // assignee,不在板上的新增——指派给我的工单借此实时上卡。 - if (issue) + if (issue) { + this.activePanel.noteIssueOwnership(issue) this.activePanel.postMessage({ type: 'issue/append', issue }) + } } catch (err) { const msg = err instanceof Error ? err.message : String(err) diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 74601b6..276c28b 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -27,6 +27,7 @@ export function App() { profiles, scope, toggleScope, + avatars, setIssues, refresh, saveSettings, @@ -357,6 +358,8 @@ export function App() { onColumnChange={changeColumn} onDependencySet={setDependency} onDependencyClear={clearDependency} + me={me} + avatars={avatars} />
@@ -388,6 +391,7 @@ export function App() { onUpdateTestProfilePath={updateIssueTestProfilePath} onOpenLogs={() => setShowLogs(true)} me={me} + avatars={avatars} onStartHandoff={(n) => { setHandoffIssue(n) requestHandoffUsers(n) diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 246e055..5f2cbe6 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -51,6 +51,8 @@ interface BottomTabsProps { onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void onOpenLogs: () => void me?: string + /** Gitea login → 头像 data URI。 */ + avatars?: Record onStartHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void isHandoffRunning: (issueNumber: number) => boolean @@ -147,6 +149,7 @@ export function BottomTabs(props: BottomTabsProps) { profiles={props.profiles} onOpenLogs={props.onOpenLogs} me={props.me} + avatars={props.avatars} onStartHandoff={props.onStartHandoff} onAcceptHandoff={props.onAcceptHandoff} isHandoffRunning={props.isHandoffRunning} diff --git a/vscode/webview-ui/src/components/IssueCard.tsx b/vscode/webview-ui/src/components/IssueCard.tsx index 439139f..deae1d6 100644 --- a/vscode/webview-ui/src/components/IssueCard.tsx +++ b/vscode/webview-ui/src/components/IssueCard.tsx @@ -20,12 +20,39 @@ interface IssueCardProps { locked?: boolean /** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */ lockedPrerequisite?: number + /** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */ + readOnly?: boolean + /** 归属人 Gitea login(只读卡片才显示)。 */ + ownerLogin?: string + /** 归属人头像 data URI;缺失时回退首字母圆点。 */ + ownerAvatar?: string +} + +/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */ +function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) { + return avatar + ? ( + {login} + ) + : ( + + {login.slice(0, 1)} + + ) } export const IssueCard = forwardRef( - ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite }, ref) => { + ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } - = useSortable({ id: issue.id, data: { column: issue.column } }) + = useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly }) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -52,23 +79,26 @@ export const IssueCard = forwardRef( onClick={() => onSelect?.(issue.id)} className={cn( 'relative select-none px-3 py-2 text-xs', - 'cursor-grab active:cursor-grabbing', + readOnly ? 'cursor-default' : 'cursor-grab active:cursor-grabbing', locked && 'opacity-75', selected && 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]', )} > - {locked - ? ( - - - - ) - : null} +
+ {locked + ? ( + + + + ) + : null} + {readOnly && ownerLogin ? : null} +
{issue.handoffAttachmentId ? ( ( ) : null} -
+
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 073bde1..bf34eb3 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -4,6 +4,8 @@ import type { PropertyGroup, RowAction } from './property-grid' import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react' import { useMemo, useRef } from 'react' import { isIssueLocked } from '../lib/dependencies' +import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' +import { toReadOnlySchema } from '../lib/readOnlySchema' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { PropertyGrid } from './property-grid' @@ -107,6 +109,8 @@ interface IssueDetailPanelProps { onStartHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void isHandoffRunning: (issueNumber: number) => boolean + /** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */ + avatars?: Record } /** @@ -149,6 +153,7 @@ export function IssueDetailPanel({ onStartHandoff, onAcceptHandoff, isHandoffRunning, + avatars, }: IssueDetailPanelProps) { // 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮, // 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。 @@ -520,6 +525,14 @@ export function IssueDetailPanel({ [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], ) + // 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。 + const readOnly = issue ? !isIssueMine(issue, me) : false + const ownerLogin = issue ? issueOwnerLogin(issue) : undefined + const effectiveSchema = useMemo( + () => readOnly ? toReadOnlySchema(schema) : schema, + [schema, readOnly], + ) + const data = useMemo | null>(() => { if (!issue) return null @@ -589,6 +602,20 @@ export function IssueDetailPanel({ ) : null} + {readOnly && ownerLogin + ? ( + + {avatars?.[ownerLogin] + ? {ownerLogin} + : null} + {ownerLogin} + {' · 只读'} + + ) + : null} {/* Close/delete write to gitea by number — hide for youtrack cards to avoid acting on a gitea issue of the same number. */} - {issue.source !== 'youtrack' && ( + {issue.source !== 'youtrack' && !readOnly && ( <> {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (