✨ 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:
1 parent
e8963ac2a2
commit
9167b86708
26 files changed
+550
-34
No files matched your search
@@ -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<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
|
||||
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
|
||||
private issueOwners = new Map<number, IssueOwnershipFacts>()
|
||||
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
|
||||
private me: string | undefined
|
||||
/** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */
|
||||
private webviewReady = false
|
||||
private loadInFlight: Promise<void> | 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<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
|
||||
const scope = this.context.workspaceState.get<KanbanScope>(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<ExtensionToWebview, { type: 'issues/update' }> = {
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user