✨ 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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, string> }> {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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。 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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'
|
||||
@@ -6,10 +6,16 @@ export interface KanbanSnapshot {
|
||||
issues: Issue[]
|
||||
scope: 'mine' | 'all'
|
||||
me?: string
|
||||
/** Gitea login → 头像 data URI;团队视图下卡片按人贴头像。 */
|
||||
avatars?: Record<string, string>
|
||||
globalAutoReview: 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 {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<string, 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/pr-diff-summary-done', issueNumber: number }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
|
||||
const scope = this.ctx.workspaceState.get<KanbanScope>(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<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
|
||||
const scope = this.ctx.workspaceState.get<KanbanScope>(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)
|
||||
|
||||
Reference in new issue
Block a user