✨ feat(vscode): 看板默认显示团队工单,同事的卡只读并贴头像

看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用
手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee
退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、
删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。
看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。

扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单
刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。
头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片
的限制,按 URL 进程内缓存。

Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV
This commit is contained in:
cruldra committed 2026-09-10 15:30:02 +08:00
1 parent e8963ac2a2
commit 9167b86708
26 files changed
+540 -24

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.124", "version": "0.2.125",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
+2 -2
View File
@@ -31,8 +31,8 @@ export interface GiteaIssue {
updated_at: string updated_at: string
body: string body: string
html_url: string html_url: string
user: { login: string } | null user: { login: string, avatar_url?: string } | null
assignees: Array<{ login: string }> | null assignees: Array<{ login: string, avatar_url?: string }> | null
labels: Array<{ name: string, color: string }> | null labels: Array<{ name: string, color: string }> | null
} }
+63
View File
@@ -0,0 +1,63 @@
/**
* 头像取回:webview 的 CSP 只放行扩展自身资源与 `data:`,直接把 Gitea 的
* avatar_url 塞进 <img> 会被拦掉(私有实例的头像还可能要带 token)。所以
* 统一由扩展侧抓回来转成 data URI 再下发。
*
* 按 URL 进程内缓存:同一批工单里重复出现的同事只下载一次,刷新看板也复用。
* 任何失败都缓存成「无头像」,前端回退到首字母圆点,不重试拖慢加载。
*/
import { Buffer } from 'node:buffer'
/** 单张头像的体积上限;Gitea 头像通常 <20KB,超限的直接当无头像。 */
const MAX_BYTES = 256 * 1024
const cache = new Map<string, string | undefined>()
/** 抓单张头像并转 data URI;失败或超限返回 undefined。 */
async function fetchAvatar(url: string, token: string): Promise<string | undefined> {
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<readonly [login: string, avatarUrl: string | undefined]>,
token: string,
): Promise<Record<string, string>> {
const wanted = new Map<string, string>()
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<string, string> = {}
for (const pair of pairs) {
if (pair)
out[pair[0]] = pair[1]
}
return out
}
+13 -2
View File
@@ -26,6 +26,7 @@ import {
PAGE_SIZE, PAGE_SIZE,
postIssueComment, postIssueComment,
} from './api' } from './api'
import { resolveAvatars } from './avatars'
const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
@@ -413,6 +414,7 @@ async function buildIssue(opts: {
...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}), ...(handoffFrom ? { handoffFrom } : {}),
assignees: (issue.assignees ?? []).map(a => a.login), assignees: (issue.assignees ?? []).map(a => a.login),
...(issue.user?.login ? { poster: issue.user.login } : {}),
htmlUrl: issue.html_url, htmlUrl: issue.html_url,
} }
} }
@@ -426,7 +428,7 @@ export async function loadIssues(opts: {
workspaceRoot?: string workspaceRoot?: string
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
scope?: 'mine' | 'all' scope?: 'mine' | 'all'
}): Promise<{ issues: Issue[], me: string }> { }): Promise<{ issues: Issue[], me: string, avatars: Record<string, string> }> {
const { host, token, owner, repo, workspaceRoot } = opts 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 }
} }
/** /**
+2
View File
@@ -118,6 +118,8 @@ export interface Issue {
autoReview?: boolean autoReview?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ /** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
handoffAttachmentId?: string handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */
+51 -3
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 { IssueOwnershipFacts } from './issueOwnership'
import type { KanbanScope } from './kanbanScope' 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'
@@ -41,8 +42,10 @@ 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' import { isIssueMine } from './issueOwnership'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope'
import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot' import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
import { isOwnerOnlyMessage } from './readOnlyGuard'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath } from '../cc/profiles' export { getDefaultProfilePath } from '../cc/profiles'
@@ -62,6 +65,10 @@ export class KanbanWebviewPanel {
* alone is an unambiguous key. * alone is an unambiguous key.
*/ */
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>() private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
private issueOwners = new Map<number, IssueOwnershipFacts>()
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
private me: string | undefined
/** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */ /** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */
private webviewReady = false private webviewReady = false
private loadInFlight: Promise<void> | undefined private loadInFlight: Promise<void> | undefined
@@ -346,7 +353,33 @@ export class KanbanWebviewPanel {
KanbanWebviewPanel.current?.loadAndPush().catch(() => {}) 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 { private handleMessage(msg: WebviewToExtension): void {
if (this.blockedAsReadOnly(msg))
return
if (msg.type === 'issues/refresh') { if (msg.type === 'issues/refresh') {
const first = !this.webviewReady const first = !this.webviewReady
this.webviewReady = true this.webviewReady = true
@@ -943,9 +976,9 @@ export class KanbanWebviewPanel {
return return
} }
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine' const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
try { 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 }), loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
this.loadYouTrackSafe(), this.loadYouTrackSafe(),
]) ])
@@ -959,6 +992,10 @@ export class KanbanWebviewPanel {
this.issueRefs = new Map( this.issueRefs = new Map(
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const), 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 ytSettings = getSettings(this.context)
const payload: Extract<ExtensionToWebview, { type: 'issues/update' }> = { const payload: Extract<ExtensionToWebview, { type: 'issues/update' }> = {
type: 'issues/update', type: 'issues/update',
@@ -967,12 +1004,14 @@ export class KanbanWebviewPanel {
globalAutoReview: ytSettings.autoReview, globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '', youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
me, me,
avatars,
} }
this.lastIssuesUpdate = payload this.lastIssuesUpdate = payload
void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, { void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, {
issues, issues,
scope, scope,
me, me,
avatars,
globalAutoReview: payload.globalAutoReview, globalAutoReview: payload.globalAutoReview,
youtrackConfigured: payload.youtrackConfigured, youtrackConfigured: payload.youtrackConfigured,
}) })
@@ -1037,6 +1076,15 @@ export class KanbanWebviewPanel {
return { source: entry?.source ?? 'gitea', number: issueNumber, externalId: entry?.externalId } 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. */ /** True when the board number belongs to a YouTrack-sourced card. */
// internal: handler 模块访问 // internal: handler 模块访问
isYouTrackIssue(issueNumber: number): boolean { isYouTrackIssue(issueNumber: number): boolean {
+39
View File
@@ -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()
})
})
+31
View File
@@ -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
}
+7 -1
View File
@@ -7,5 +7,11 @@
/** 看板范围:'mine' 只看我创建/指派给我的,'all' 团队全部。 */ /** 看板范围:'mine' 只看我创建/指派给我的,'all' 团队全部。 */
export type KanbanScope = 'mine' | 'all' export type KanbanScope = 'mine' | 'all'
/** workspaceState key:按工作区持久化的看板范围,缺省 'mine'。 */ /** workspaceState key:按工作区持久化的看板范围。 */
export const KANBAN_SCOPE_KEY = 'superpowers.kanbanScope' export const KANBAN_SCOPE_KEY = 'superpowers.kanbanScope'
/**
* 未选过范围时的默认值。默认团队全部:同事把工单移交过来、或改动落到共享
* 分支时,不切开关也能看见,否则协作信号会整个丢掉。
*/
export const DEFAULT_KANBAN_SCOPE: KanbanScope = 'all'
+7
View File
@@ -6,10 +6,16 @@ export interface KanbanSnapshot {
issues: Issue[] issues: Issue[]
scope: 'mine' | 'all' scope: 'mine' | 'all'
me?: string me?: string
/** Gitea login → 头像 data URI;团队视图下卡片按人贴头像。 */
avatars?: Record<string, string>
globalAutoReview: boolean globalAutoReview: boolean
youtrackConfigured: boolean youtrackConfigured: boolean
} }
function isAvatarMap(raw: unknown): raw is Record<string, string> {
return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string')
}
export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null { export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null {
if (!raw || typeof raw !== 'object') if (!raw || typeof raw !== 'object')
return null return null
@@ -22,6 +28,7 @@ export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null {
issues: obj.issues as Issue[], issues: obj.issues as Issue[],
scope: obj.scope, scope: obj.scope,
me: typeof obj.me === 'string' ? obj.me : undefined, me: typeof obj.me === 'string' ? obj.me : undefined,
avatars: isAvatarMap(obj.avatars) ? obj.avatars : undefined,
globalAutoReview: obj.globalAutoReview === true, globalAutoReview: obj.globalAutoReview === true,
youtrackConfigured: obj.youtrackConfigured === true, youtrackConfigured: obj.youtrackConfigured === true,
} }
+1 -1
View File
@@ -49,7 +49,7 @@ export type ToastLevel = 'info' | 'success' | 'error'
export type ExtensionToWebview export type ExtensionToWebview
= | { type: 'issues/loading' } = | { 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<string, string> }
| { type: 'issues/error', message: string } | { 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/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 } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
+42
View File
@@ -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)
})
})
+41
View File
@@ -0,0 +1,41 @@
/**
* 同事工单的写操作门禁:看板范围切到「团队全部」后,别人的卡也在板上,
* 前端会把按钮藏掉,但 webview 消息仍可能因为竞态(卡刚被重新指派、
* 快照过期)送到扩展侧,所以这里做最终拦截——归属不是我就直接丢掉。
*
* 只拦「会改工单状态或在本机替别人跑会话」的消息。看 PR 改动、勾已查看、
* 生成文件摘要属于审查动作,落地在本机,照常放行。
*/
/** 必须是工单归属人才能执行的消息类型。 */
export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = 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)
}
+7 -4
View File
@@ -26,7 +26,7 @@ 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 { DEFAULT_KANBAN_SCOPE, 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'
@@ -622,7 +622,7 @@ class WebhookCoordinator {
// 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡 // 广播到所有机器 → append 前按同一套可见性规则过门禁,否则别人的卡
// 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时 // 会凭空上屏、刷新后又消失。身份/payload 只在 'mine' 且非本机创建时
// 才解析,省一次身份请求。 // 才解析,省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine' const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
const pendingCreatedLocally = pending !== undefined const pendingCreatedLocally = pending !== undefined
let poster: string | undefined let poster: string | undefined
let assignees: string[] = [] let assignees: string[] = []
@@ -653,6 +653,7 @@ class WebhookCoordinator {
}) })
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
if (issue) { if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
if (pending) { if (pending) {
this.activePanel.postMessage({ this.activePanel.postMessage({
@@ -728,7 +729,7 @@ class WebhookCoordinator {
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡, // 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡,
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
// 省一次身份请求。 // 省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine' const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
let poster: string | undefined let poster: string | undefined
let assignees: string[] = [] let assignees: string[] = []
let me: string | undefined let me: string | undefined
@@ -761,9 +762,11 @@ class WebhookCoordinator {
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新 // 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新
// assignee,不在板上的新增——指派给我的工单借此实时上卡。 // assignee,不在板上的新增——指派给我的工单借此实时上卡。
if (issue) if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue }) this.activePanel.postMessage({ type: 'issue/append', issue })
} }
}
catch (err) { catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
logger.add({ logger.add({
+4
View File
@@ -27,6 +27,7 @@ export function App() {
profiles, profiles,
scope, scope,
toggleScope, toggleScope,
avatars,
setIssues, setIssues,
refresh, refresh,
saveSettings, saveSettings,
@@ -357,6 +358,8 @@ export function App() {
onColumnChange={changeColumn} onColumnChange={changeColumn}
onDependencySet={setDependency} onDependencySet={setDependency}
onDependencyClear={clearDependency} onDependencyClear={clearDependency}
me={me}
avatars={avatars}
/> />
</div> </div>
<div className="h-[40vh] shrink-0"> <div className="h-[40vh] shrink-0">
@@ -388,6 +391,7 @@ export function App() {
onUpdateTestProfilePath={updateIssueTestProfilePath} onUpdateTestProfilePath={updateIssueTestProfilePath}
onOpenLogs={() => setShowLogs(true)} onOpenLogs={() => setShowLogs(true)}
me={me} me={me}
avatars={avatars}
onStartHandoff={(n) => { onStartHandoff={(n) => {
setHandoffIssue(n) setHandoffIssue(n)
requestHandoffUsers(n) requestHandoffUsers(n)
@@ -51,6 +51,8 @@ interface BottomTabsProps {
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
onOpenLogs: () => void onOpenLogs: () => void
me?: string me?: string
/** Gitea login → 头像 data URI。 */
avatars?: Record<string, string>
onStartHandoff: (issueNumber: number) => void onStartHandoff: (issueNumber: number) => void
onAcceptHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void
isHandoffRunning: (issueNumber: number) => boolean isHandoffRunning: (issueNumber: number) => boolean
@@ -147,6 +149,7 @@ export function BottomTabs(props: BottomTabsProps) {
profiles={props.profiles} profiles={props.profiles}
onOpenLogs={props.onOpenLogs} onOpenLogs={props.onOpenLogs}
me={props.me} me={props.me}
avatars={props.avatars}
onStartHandoff={props.onStartHandoff} onStartHandoff={props.onStartHandoff}
onAcceptHandoff={props.onAcceptHandoff} onAcceptHandoff={props.onAcceptHandoff}
isHandoffRunning={props.isHandoffRunning} isHandoffRunning={props.isHandoffRunning}
+35 -5
View File
@@ -20,12 +20,39 @@ interface IssueCardProps {
locked?: boolean locked?: boolean
/** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */ /** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */
lockedPrerequisite?: number lockedPrerequisite?: number
/** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */
readOnly?: boolean
/** 归属人 Gitea login(只读卡片才显示)。 */
ownerLogin?: string
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
ownerAvatar?: string
}
/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */
function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
return avatar
? (
<img
src={avatar}
alt={login}
title={`${login} 的工单(只读)`}
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
/>
)
: (
<span
title={`${login} 的工单(只读)`}
className="grid size-4 shrink-0 place-items-center rounded-full bg-[var(--vscode-badge-background)] text-[8px] uppercase text-[var(--vscode-badge-foreground)]"
>
{login.slice(0, 1)}
</span>
)
} }
export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>( export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
({ 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 } 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 = { const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -52,16 +79,17 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
onClick={() => onSelect?.(issue.id)} onClick={() => onSelect?.(issue.id)}
className={cn( className={cn(
'relative select-none px-3 py-2 text-xs', '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', locked && 'opacity-75',
selected selected
&& 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]', && 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]',
)} )}
> >
<div className="absolute right-1.5 top-1.5 flex items-center gap-1">
{locked {locked
? ( ? (
<span <span
className="absolute right-1.5 top-1.5 grid place-items-center text-[var(--vscode-disabledForeground)]" className="grid place-items-center text-[var(--vscode-disabledForeground)]"
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'} title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'} aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
> >
@@ -69,6 +97,8 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
</span> </span>
) )
: null} : null}
{readOnly && ownerLogin ? <OwnerBadge login={ownerLogin} avatar={ownerAvatar} /> : null}
</div>
{issue.handoffAttachmentId {issue.handoffAttachmentId
? ( ? (
<span <span
@@ -79,7 +109,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
</span> </span>
) )
: null} : null}
<div className={cn('truncate', locked && 'pr-5')}> <div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}>
<span className="font-mono opacity-60"> <span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span> </span>
@@ -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 { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { isIssueLocked } from '../lib/dependencies' import { isIssueLocked } from '../lib/dependencies'
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid' import { PropertyGrid } from './property-grid'
@@ -107,6 +109,8 @@ interface IssueDetailPanelProps {
onStartHandoff: (issueNumber: number) => void onStartHandoff: (issueNumber: number) => void
onAcceptHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void
isHandoffRunning: (issueNumber: number) => boolean isHandoffRunning: (issueNumber: number) => boolean
/** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
avatars?: Record<string, string>
} }
/** /**
@@ -149,6 +153,7 @@ export function IssueDetailPanel({
onStartHandoff, onStartHandoff,
onAcceptHandoff, onAcceptHandoff,
isHandoffRunning, isHandoffRunning,
avatars,
}: IssueDetailPanelProps) { }: 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], [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<Record<string, unknown> | null>(() => { const data = useMemo<Record<string, unknown> | null>(() => {
if (!issue) if (!issue)
return null return null
@@ -589,6 +602,20 @@ export function IssueDetailPanel({
) )
: null} : null}
</h3> </h3>
{readOnly && ownerLogin
? (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
title={`${ownerLogin} 的工单,只能查看`}
>
{avatars?.[ownerLogin]
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
: null}
{ownerLogin}
{' · 只读'}
</span>
)
: null}
<button <button
type="button" type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)} onClick={() => onOpenInBrowser(issue.htmlUrl)}
@@ -599,7 +626,7 @@ export function IssueDetailPanel({
</button> </button>
{/* Close/delete write to gitea by number — hide for youtrack cards to {/* Close/delete write to gitea by number — hide for youtrack cards to
avoid acting on a gitea issue of the same number. */} 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) && ( {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
<button <button
@@ -691,11 +718,13 @@ export function IssueDetailPanel({
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<PropertyGrid <PropertyGrid
key={issue.number} key={issue.number}
schema={schema} schema={effectiveSchema}
data={data} data={data}
hideToolbar hideToolbar
fillHeight fillHeight
onChange={(key, value) => { onChange={(key, value) => {
if (readOnly)
return
if (key === 'autoReview' && issue && typeof value === 'boolean') if (key === 'autoReview' && issue && typeof value === 'boolean')
onUpdateAutoReview(issue.number, value) onUpdateAutoReview(issue.number, value)
if (key === 'brainstormProfilePath' && issue && typeof value === 'string') if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
@@ -17,6 +17,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
import { isIssueLocked } from '../lib/dependencies' import { isIssueLocked } from '../lib/dependencies'
import { compareIssuesInColumn } from '../lib/issueSort' import { compareIssuesInColumn } from '../lib/issueSort'
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag' import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
@@ -34,6 +35,10 @@ interface KanbanBoardProps {
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
/** 当前 Gitea login;用于判定哪些卡是同事的(只读)。 */
me?: string
/** Gitea login → 头像 data URI。 */
avatars?: Record<string, string>
} }
const kanbanCollisionDetection: CollisionDetection = (args) => { const kanbanCollisionDetection: CollisionDetection = (args) => {
@@ -105,6 +110,8 @@ export function KanbanBoard({
onColumnChange, onColumnChange,
onDependencySet, onDependencySet,
onDependencyClear, onDependencyClear,
me,
avatars,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
@@ -238,6 +245,8 @@ export function KanbanBoard({
<SortableContext items={ids} strategy={verticalListSortingStrategy}> <SortableContext items={ids} strategy={verticalListSortingStrategy}>
{ordered.map(({ issue, depth }) => { {ordered.map(({ issue, depth }) => {
const { locked, prerequisiteNumber } = isIssueLocked(issue, issues) const { locked, prerequisiteNumber } = isIssueLocked(issue, issues)
const readOnly = !isIssueMine(issue, me)
const ownerLogin = issueOwnerLogin(issue)
return ( return (
<IssueCard <IssueCard
key={issue.id} key={issue.id}
@@ -253,6 +262,9 @@ export function KanbanBoard({
onSelect={onSelectIssue} onSelect={onSelectIssue}
locked={locked} locked={locked}
lockedPrerequisite={prerequisiteNumber} lockedPrerequisite={prerequisiteNumber}
readOnly={readOnly}
ownerLogin={ownerLogin}
ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined}
/> />
) )
})} })}
+15 -2
View File
@@ -137,6 +137,8 @@ export interface UseIssuesResult {
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */ /** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
scope: 'mine' | 'all' scope: 'mine' | 'all'
toggleScope: () => void toggleScope: () => void
/** Gitea login → 头像 data URI;同事工单的卡片按它贴头像。 */
avatars: Record<string, string>
/** /**
* Latest known global `autoReview` value pushed alongside `issues/update`. * Latest known global `autoReview` value pushed alongside `issues/update`.
* Used by the detail panel to display the fallback value when an issue has * Used by the detail panel to display the fallback value when an issue has
@@ -293,10 +295,15 @@ export interface UseIssuesResult {
isHandoffRunning: (issueNumber: number) => boolean isHandoffRunning: (issueNumber: number) => boolean
} }
function isAvatarMap(raw: unknown): raw is Record<string, string> {
return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string')
}
function readInjectedSnapshot(): { function readInjectedSnapshot(): {
issues: Issue[] issues: Issue[]
scope: 'mine' | 'all' scope: 'mine' | 'all'
me?: string me?: string
avatars?: Record<string, string>
globalAutoReview: boolean globalAutoReview: boolean
youtrackConfigured: boolean youtrackConfigured: boolean
} | null { } | null {
@@ -316,6 +323,7 @@ function readInjectedSnapshot(): {
issues: obj.issues as Issue[], issues: obj.issues as Issue[],
scope: obj.scope, scope: obj.scope,
me: typeof obj.me === 'string' ? obj.me : undefined, me: typeof obj.me === 'string' ? obj.me : undefined,
avatars: isAvatarMap(obj.avatars) ? obj.avatars : undefined,
globalAutoReview: obj.globalAutoReview === true, globalAutoReview: obj.globalAutoReview === true,
youtrackConfigured: obj.youtrackConfigured === true, youtrackConfigured: obj.youtrackConfigured === true,
} }
@@ -333,7 +341,8 @@ export function useIssues(): UseIssuesResult {
const [settings, setSettings] = useState<SettingsOverlayState | null>(null) const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] }) const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(injected?.globalAutoReview ?? true) const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(injected?.globalAutoReview ?? true)
const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'mine') // 默认团队全部,与扩展侧 DEFAULT_KANBAN_SCOPE 一致(两个包不共享源码)。
const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'all')
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(injected?.youtrackConfigured ?? false) const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(injected?.youtrackConfigured ?? false)
const [toasts, setToasts] = useState<ToastItem[]>([]) const [toasts, setToasts] = useState<ToastItem[]>([])
const [profiles, setProfiles] = useState<ClaudeProfile[]>([]) const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
@@ -350,6 +359,7 @@ export function useIssues(): UseIssuesResult {
const prDiffSummaryRunningRef = useRef<Set<number>>(new Set()) const prDiffSummaryRunningRef = useRef<Set<number>>(new Set())
const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set()) const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set())
const [me, setMe] = useState<string | undefined>(injected?.me) const [me, setMe] = useState<string | undefined>(injected?.me)
const [avatars, setAvatars] = useState<Record<string, string>>(injected?.avatars ?? {})
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null) const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
const handoffRunningRef = useRef<Set<number>>(new Set()) const handoffRunningRef = useRef<Set<number>>(new Set())
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set()) const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
@@ -698,6 +708,9 @@ export function useIssues(): UseIssuesResult {
setGlobalAutoReview(msg.globalAutoReview) setGlobalAutoReview(msg.globalAutoReview)
setYoutrackConfigured(msg.youtrackConfigured) setYoutrackConfigured(msg.youtrackConfigured)
setMe(msg.me) setMe(msg.me)
// 头像抓取失败时下发空表,保留上一批已有的头像,避免卡片头像闪掉。
if (msg.avatars && Object.keys(msg.avatars).length > 0)
setAvatars(msg.avatars)
break break
case 'issues/error': case 'issues/error':
setState({ status: 'error', message: msg.message }) setState({ status: 'error', message: msg.message })
@@ -895,5 +908,5 @@ export function useIssues(): UseIssuesResult {
return cleanup return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning]) }, [clearPrDiffSummaryRunning, markHandoffRunning])
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } return { state, settings, avatars, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
} }
@@ -0,0 +1,35 @@
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: 'chehongwei' }, 'cruldra')).toBe(false)
expect(isIssueMine({ poster: 'cruldra' }, 'cruldra')).toBe(true)
})
it('身份未知 → 一律可写', () => {
expect(isIssueMine({ poster: 'chehongwei' }, undefined)).toBe(true)
})
it('youtrack 等无归属字段的工单 → 可写', () => {
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')
})
})
@@ -0,0 +1,26 @@
/**
* 工单归属判定(扩展侧 `src/panel/issueOwnership.ts` 的镜像,两个包不共享
* 源码,改一处要同步另一处)。
*
* 看板范围切到「团队全部」后同事的工单也会上板,这类卡片只读:不能拖列、
* 不能开会话、不能合并或删除,只保留查看与接管。
*/
import type { Issue } from '../types'
type OwnershipFacts = Pick<Issue, 'poster' | 'assignees'>
/** 该工单是否归当前登录者所有(可写)。身份未知时一律放行,不锁死自己的板。 */
export function isIssueMine(issue: OwnershipFacts, me: string | undefined): boolean {
if (me === undefined)
return true
const assignees = issue.assignees ?? []
if (assignees.length > 0)
return assignees.includes(me)
return issue.poster === undefined || issue.poster === me
}
/** 卡片头像取谁:assignee 优先(责任人),否则创建者。 */
export function issueOwnerLogin(issue: OwnershipFacts): string | undefined {
return (issue.assignees ?? [])[0] ?? issue.poster
}
+1 -1
View File
@@ -80,7 +80,7 @@ export interface ProfilesData {
export type ExtensionToWebview export type ExtensionToWebview
= | { type: 'issues/loading' } = | { 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<string, string> }
| { type: 'issues/error', message: string } | { 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/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 } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -0,0 +1,45 @@
import type { PropertyGroup } from '../components/property-grid'
import { describe, expect, it } from 'vitest'
import { toReadOnlySchema } from './readOnlySchema'
function schema(): PropertyGroup[] {
return [{
id: 'g',
label: '组',
properties: [
{ key: 'autoReview', label: '自动审查', type: 'boolean' },
{ key: 'session', label: '规划会话', type: 'action', onAction: () => {}, secondaryActions: [{ icon: null, onClick: () => {} }] },
{ key: 'pr', label: '合并请求', type: 'pr-link', onAction: () => {} },
{ key: 'plan', label: '计划', type: 'file-link', onOpen: () => {}, onReload: () => {} },
],
}]
}
describe('toReadOnlySchema', () => {
it('所有字段变只读', () => {
const out = toReadOnlySchema(schema())
expect(out[0].properties.every(p => p.readOnly)).toBe(true)
})
it('会话行的双击恢复被摘掉', () => {
const out = toReadOnlySchema(schema())
expect(out[0].properties.find(p => p.key === 'session')?.onAction).toBeUndefined()
})
it('打开 PR、打开文件仍可用', () => {
const out = toReadOnlySchema(schema())
expect(out[0].properties.find(p => p.key === 'pr')?.onAction).toBeTypeOf('function')
expect(out[0].properties.find(p => p.key === 'plan')?.onOpen).toBeTypeOf('function')
})
it('右侧图标按钮全部禁用', () => {
const out = toReadOnlySchema(schema())
expect(out[0].properties.find(p => p.key === 'session')?.secondaryActions?.[0].disabled).toBe(true)
})
it('不改动原 schema', () => {
const original = schema()
toReadOnlySchema(original)
expect(original[0].properties[0].readOnly).toBeUndefined()
})
})
@@ -0,0 +1,24 @@
/**
* 把详情面板的属性表变成只读版本:同事的工单在板上只能看。
*
* 一处消毒覆盖所有行,避免每加一行就得记得判断归属。放行的只有纯查看
* 动作——双击 PR 编号跳浏览器、双击文件名打开本地文件;会改工单状态、
* 开会话、删 worktree 的按钮一律禁用。
*/
import type { PropertyGroup } from '../components/property-grid'
export function toReadOnlySchema(schema: PropertyGroup[]): PropertyGroup[] {
return schema.map(group => ({
...group,
actions: undefined,
properties: group.properties.map(prop => ({
...prop,
readOnly: true,
// 'action' 行的双击是「在本机恢复会话」,别人的会话恢复不了也不该恢复。
onAction: prop.type === 'action' ? undefined : prop.onAction,
onReload: undefined,
secondaryActions: prop.secondaryActions?.map(a => ({ ...a, disabled: true })),
})),
}))
}
+2
View File
@@ -83,6 +83,8 @@ export interface Issue {
autoReview?: boolean autoReview?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ /** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
handoffAttachmentId?: string handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */