Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0750b4ef | ||
|
|
9167b86708 | ||
|
|
e8963ac2a2 |
No files matched your search
+1
-1
@@ -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.123",
|
"version": "0.2.126",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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。 */
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -395,7 +428,7 @@ export class KanbanWebviewPanel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'session/resume') {
|
if (msg.type === 'session/resume') {
|
||||||
void sessions.handleResumeSession(this, msg.sessionId, msg.profilePath, msg.cwd, msg.issueNumber)
|
void sessions.handleResumeSession(this, msg.sessionId, msg.kind, msg.profilePath, msg.cwd, msg.issueNumber)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'session/focus') {
|
if (msg.type === 'session/focus') {
|
||||||
@@ -879,8 +912,12 @@ export class KanbanWebviewPanel {
|
|||||||
async loadAndPush(): Promise<void> {
|
async loadAndPush(): Promise<void> {
|
||||||
if (this.loadInFlight)
|
if (this.loadInFlight)
|
||||||
return this.loadInFlight
|
return this.loadInFlight
|
||||||
|
// 每次真正开跑都成对推 loading/loaded,webview 据此转刷新图标;
|
||||||
|
// 无论成功、报错还是缺 token 中途返回,finally 都会收尾。
|
||||||
|
this.postMessage({ type: 'issues/loading' })
|
||||||
this.loadInFlight = this.loadAndPushInner().finally(() => {
|
this.loadInFlight = this.loadAndPushInner().finally(() => {
|
||||||
this.loadInFlight = undefined
|
this.loadInFlight = undefined
|
||||||
|
this.postMessage({ type: 'issues/loaded' })
|
||||||
})
|
})
|
||||||
return this.loadInFlight
|
return this.loadInFlight
|
||||||
}
|
}
|
||||||
@@ -943,9 +980,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 +996,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 +1008,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 +1080,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 {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
||||||
|
|
||||||
|
const ROOT = '/repo'
|
||||||
|
const WT = '/repo-worktrees/feature-x'
|
||||||
|
|
||||||
|
describe('resumeTerminalName', () => {
|
||||||
|
it('实施会话 → issue-N-实施', () => {
|
||||||
|
expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施')
|
||||||
|
})
|
||||||
|
it('规划会话 → issue-N-规划', () => {
|
||||||
|
expect(resumeTerminalName('brainstorm', 'abcdef1234', 398)).toBe('issue-398-规划')
|
||||||
|
})
|
||||||
|
it('没有工单号 → 按会话 id 前 8 位命名', () => {
|
||||||
|
expect(resumeTerminalName('implement', 'abcdef1234', undefined)).toBe('Claude · abcdef12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('planResumeCwd', () => {
|
||||||
|
it('规划会话永远在工作区根目录', () => {
|
||||||
|
expect(planResumeCwd({ kind: 'brainstorm', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true }))
|
||||||
|
.toEqual({ cwd: ROOT, worktreeMissing: false })
|
||||||
|
})
|
||||||
|
it('实施会话 worktree 在 → cwd 用 worktree', () => {
|
||||||
|
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true }))
|
||||||
|
.toEqual({ cwd: WT, worktreeMissing: false })
|
||||||
|
})
|
||||||
|
it('实施会话 worktree 记了但磁盘上没了 → 退回根目录并标记缺失', () => {
|
||||||
|
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: false }))
|
||||||
|
.toEqual({ cwd: ROOT, worktreeMissing: true })
|
||||||
|
})
|
||||||
|
it('实施会话工单里没记 worktreePath(PR 合并后清掉)→ 同样标记缺失,角色不变', () => {
|
||||||
|
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: undefined, worktreeExists: false }))
|
||||||
|
.toEqual({ cwd: ROOT, worktreeMissing: true })
|
||||||
|
expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 实施 / 规划会话 resume 的落点决策。纯函数,不碰 vscode API,便于单测。
|
||||||
|
*
|
||||||
|
* 角色由调用方明确传入,不从「有没有 cwd」反推:worktree 被清理后
|
||||||
|
* 工单里的 worktreePath 会清空,靠 cwd 推断会把实施会话误判成规划。
|
||||||
|
*/
|
||||||
|
export type ResumeKind = 'implement' | 'brainstorm'
|
||||||
|
|
||||||
|
export function resumeSessionRole(kind: ResumeKind): '实施' | '规划' {
|
||||||
|
return kind === 'implement' ? '实施' : '规划'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeTerminalName(kind: ResumeKind, sessionId: string, issueNumber?: number): string {
|
||||||
|
return issueNumber !== undefined
|
||||||
|
? `issue-${issueNumber}-${resumeSessionRole(kind)}`
|
||||||
|
: `Claude · ${sessionId.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeCwdInput {
|
||||||
|
kind: ResumeKind
|
||||||
|
workspaceRoot: string
|
||||||
|
/** 已解析成绝对路径的 worktree;工单没记就 undefined */
|
||||||
|
worktreeAbs?: string
|
||||||
|
worktreeExists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeCwdPlan {
|
||||||
|
cwd: string
|
||||||
|
/** 实施会话但 worktree 已不在磁盘上(含工单里没记 worktreePath 的情况) */
|
||||||
|
worktreeMissing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planResumeCwd(input: ResumeCwdInput): ResumeCwdPlan {
|
||||||
|
if (input.kind !== 'implement')
|
||||||
|
return { cwd: input.workspaceRoot, worktreeMissing: false }
|
||||||
|
if (input.worktreeAbs && input.worktreeExists)
|
||||||
|
return { cwd: input.worktreeAbs, worktreeMissing: false }
|
||||||
|
return { cwd: input.workspaceRoot, worktreeMissing: true }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Terminal } from 'vscode'
|
import type { Terminal } from 'vscode'
|
||||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
|
import type { ResumeKind } from './resumeTarget'
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
@@ -25,13 +26,10 @@ import { decideAutoReviewPath } from '../autoReviewDecision'
|
|||||||
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
|
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
||||||
import { submitToSessionTerminal } from './terminals'
|
import { submitToSessionTerminal } from './terminals'
|
||||||
|
|
||||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
|
||||||
// resumeInFlight 锁 key)。relCwd 在则是实施 resume,否则视为规划/讨论。
|
|
||||||
const sessionKind: 'implement' | 'brainstorm' = relCwd ? 'implement' : 'brainstorm'
|
|
||||||
|
|
||||||
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
|
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
|
||||||
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
|
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
|
||||||
// (legacy session)跳过锁,直接走原路径。
|
// (legacy session)跳过锁,直接走原路径。
|
||||||
@@ -91,45 +89,25 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
// Implementation sessions live in a worktree; `relCwd` may be absolute /
|
// 实施会话住在 worktree 里;relCwd 可能是绝对路径 / `~/...` / 工作区相对
|
||||||
// `~/...` / workspace-relative — must go through resolveWorktreePath
|
// 路径,统一走 resolveWorktreePath。worktree 已被清理(典型:卡片拖到
|
||||||
// (path.join(workspaceRoot, abs) would mangle absolute paths).
|
// 「完成」→ 自动 `git worktree remove`,工单里 worktreePath 随之清空)
|
||||||
// If the worktree has since been cleaned up (typical: drop card to
|
// 时退回工作区根目录;角色只看 sessionKind,不受 cwd 有无影响。
|
||||||
// "完成" → auto `git worktree remove`), fall back to the workspace
|
const worktreeAbs = relCwd && workspaceRoot ? resolveWorktreePath(relCwd, workspaceRoot) : undefined
|
||||||
// root instead of pointing the terminal at a missing directory.
|
const plan = workspaceRoot
|
||||||
let effectiveCwd: string | undefined = workspaceRoot
|
? planResumeCwd({ kind: sessionKind, workspaceRoot, worktreeAbs, worktreeExists: !!worktreeAbs && fs.existsSync(worktreeAbs) })
|
||||||
let cwdFallback = false
|
: undefined
|
||||||
if (relCwd && workspaceRoot) {
|
const effectiveCwd = plan?.cwd
|
||||||
const resolved = resolveWorktreePath(relCwd, workspaceRoot)
|
const worktreeMissing = plan?.worktreeMissing ?? false
|
||||||
if (fs.existsSync(resolved)) {
|
if (worktreeMissing) {
|
||||||
effectiveCwd = resolved
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
cwdFallback = true
|
|
||||||
effectiveCwd = workspaceRoot
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
source: 'panel',
|
source: 'panel',
|
||||||
message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd}`,
|
message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd ?? '(工单未记录 worktreePath)'}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
// 终端开成编辑区 tab(不在底部面板),和看板并排。
|
||||||
// Open the terminal as an editor tab beside the kanban (not in the
|
const terminalName = resumeTerminalName(sessionKind, sessionId, issueNumber)
|
||||||
// bottom panel), so the user can see both side-by-side. `Beside` opens
|
|
||||||
// in a new editor group when needed.
|
|
||||||
// Name reflects which of the three session roles this is:
|
|
||||||
// relCwd present → 实施 session (resumed inside the worktree)
|
|
||||||
// relCwd absent → 规划 / discussion session (workspace root)
|
|
||||||
// Fall back to the legacy id-suffixed name if we somehow lack issueNumber.
|
|
||||||
const sessionRole = relCwd ? '实施' : '规划'
|
|
||||||
// 注:sessionKind 已在方法入口处判定(用于 resumeInFlight 锁 key)。
|
|
||||||
// 原来两条 trackSessionTerminal 路径都硬写 'brainstorm',导致实施 resume
|
|
||||||
// 后 terminalOrigin map 里 kind 错,onDidCloseTerminal 里的
|
|
||||||
// `kind === 'implement'` 判断永远不命中,impl-tab-post-close 钩子(关
|
|
||||||
// pycharm)不会执行。
|
|
||||||
const terminalName = issueNumber !== undefined
|
|
||||||
? `issue-${issueNumber}-${sessionRole}`
|
|
||||||
: `Claude · ${sessionId.slice(0, 8)}`
|
|
||||||
// Scan live terminals before creating a new one — survives panel
|
// Scan live terminals before creating a new one — survives panel
|
||||||
// reload / webview rebuild where `this.terminals` got wiped but the
|
// reload / webview rebuild where `this.terminals` got wiped but the
|
||||||
// tab is still open. Refresh the Map too so subsequent lookups (and
|
// tab is still open. Refresh the Map too so subsequent lookups (and
|
||||||
@@ -142,12 +120,12 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
|||||||
existingByName.show(false)
|
existingByName.show(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 实施 resume 走新建 terminal 路径时(且 worktree 还在)需触发
|
// 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
|
||||||
// impl-tab-pre-create 钩子(启动 pycharm 等)。existingByName fast path
|
// 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
|
||||||
// 不调钩子——pycharm 应该已经在跑,重复启会冲突;cwdFallback=true 时
|
// 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
|
||||||
// worktree 已清理,pycharm 不该指向 workspace root;legacy session
|
// pycharm,指向工作区根目录会把主仓库窗口拉起来;legacy session
|
||||||
// (issueNumber === undefined) 钩子签名要 issueNumber,也跳过。
|
// (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。
|
||||||
if (sessionRole === '实施' && !cwdFallback && issueNumber !== undefined && workspaceRoot) {
|
if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) {
|
||||||
const settingsForHook = getSettings(panel.context)
|
const settingsForHook = getSettings(panel.context)
|
||||||
// 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里
|
// 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里
|
||||||
// BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。
|
// BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。
|
||||||
@@ -205,7 +183,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
|||||||
})
|
})
|
||||||
// Worktree 已清理时给用户一个明确提示,避免他们误以为 cc 在原
|
// Worktree 已清理时给用户一个明确提示,避免他们误以为 cc 在原
|
||||||
// worktree 路径里跑。toast 上 dismissOnTimer 5s 自动消失。
|
// worktree 路径里跑。toast 上 dismissOnTimer 5s 自动消失。
|
||||||
if (cwdFallback) {
|
if (worktreeMissing) {
|
||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'toast/show',
|
type: 'toast/show',
|
||||||
id: makeNonce(),
|
id: makeNonce(),
|
||||||
|
|||||||
@@ -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' 团队全部。 */
|
/** 看板范围:'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'
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ 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/loaded' }
|
||||||
|
| { 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 }
|
||||||
@@ -180,7 +181,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'youtrack/import' }
|
| { type: 'youtrack/import' }
|
||||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
||||||
| { type: 'toast/open-url', url: string }
|
| { type: 'toast/open-url', url: string }
|
||||||
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number }
|
| { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number }
|
||||||
| { type: 'session/focus', issueNumber: number }
|
| { type: 'session/focus', issueNumber: number }
|
||||||
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
|
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
|
||||||
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
|
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
|
||||||
|
|||||||
@@ -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 { 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({
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ export function App() {
|
|||||||
profiles,
|
profiles,
|
||||||
scope,
|
scope,
|
||||||
toggleScope,
|
toggleScope,
|
||||||
|
avatars,
|
||||||
setIssues,
|
setIssues,
|
||||||
refresh,
|
refresh,
|
||||||
|
refreshing,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
listYouTrackProjects,
|
listYouTrackProjects,
|
||||||
youtrackProjects,
|
youtrackProjects,
|
||||||
@@ -225,7 +227,7 @@ export function App() {
|
|||||||
const profileForResume = isImpl
|
const profileForResume = isImpl
|
||||||
? selectedIssue?.profilePath
|
? selectedIssue?.profilePath
|
||||||
: selectedIssue?.brainstormProfilePath
|
: selectedIssue?.brainstormProfilePath
|
||||||
resumeSession(sid, profileForResume, cwd, selectedIssue?.number)
|
resumeSession(sid, isImpl ? 'implement' : 'brainstorm', profileForResume, cwd, selectedIssue?.number)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,6 +306,7 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onToggleScope={toggleScope}
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
@@ -330,6 +333,7 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onToggleScope={toggleScope}
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
@@ -357,6 +361,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 +394,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)
|
||||||
@@ -425,6 +432,7 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onToggleScope={toggleScope}
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface BottomTabsProps {
|
|||||||
allIssues: Issue[]
|
allIssues: Issue[]
|
||||||
globalAutoReview: boolean
|
globalAutoReview: boolean
|
||||||
onOpenInBrowser: (url: string) => void
|
onOpenInBrowser: (url: string) => void
|
||||||
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||||||
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
onStartTestSession: (issueNumber: number) => void
|
onStartTestSession: (issueNumber: number) => void
|
||||||
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ interface IssueDetailPanelProps {
|
|||||||
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
|
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
|
||||||
globalAutoReview: boolean
|
globalAutoReview: boolean
|
||||||
onOpenInBrowser: (url: string) => void
|
onOpenInBrowser: (url: string) => void
|
||||||
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||||||
/**
|
/**
|
||||||
* Open a new terminal that runs `codex resume <id>` for the auto-review
|
* Open a new terminal that runs `codex resume <id>` for the auto-review
|
||||||
* conversation associated with this issue.
|
* conversation associated with this issue.
|
||||||
@@ -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) {
|
||||||
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
|
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
|
||||||
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
|
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
|
||||||
@@ -243,7 +248,7 @@ export function IssueDetailPanel({
|
|||||||
return
|
return
|
||||||
if (!throttleBySessionId(v))
|
if (!throttleBySessionId(v))
|
||||||
return
|
return
|
||||||
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
|
onResumeSession(v, 'brainstorm', issue?.brainstormProfilePath, undefined, issue?.number)
|
||||||
},
|
},
|
||||||
secondaryActions,
|
secondaryActions,
|
||||||
}
|
}
|
||||||
@@ -265,7 +270,7 @@ export function IssueDetailPanel({
|
|||||||
return
|
return
|
||||||
if (!throttleBySessionId(v))
|
if (!throttleBySessionId(v))
|
||||||
return
|
return
|
||||||
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
|
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
|
||||||
},
|
},
|
||||||
secondaryActions: issue?.implementTabOpen
|
secondaryActions: issue?.implementTabOpen
|
||||||
? [{
|
? [{
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
/** host 正在重拉看板;为 true 时刷新图标转起来。 */
|
||||||
|
refreshing: boolean
|
||||||
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部工单。 */
|
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部工单。 */
|
||||||
scope: 'mine' | 'all'
|
scope: 'mine' | 'all'
|
||||||
onToggleScope: () => void
|
onToggleScope: () => void
|
||||||
@@ -65,6 +67,7 @@ function releaseTitle(release: ReleaseStatus | undefined): string {
|
|||||||
|
|
||||||
export function PanelHeader({
|
export function PanelHeader({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
scope,
|
scope,
|
||||||
onToggleScope,
|
onToggleScope,
|
||||||
onEditAuth,
|
onEditAuth,
|
||||||
@@ -207,11 +210,13 @@ export function PanelHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
title="刷新"
|
disabled={refreshing}
|
||||||
|
title={refreshing ? '正在刷新…' : '刷新'}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
|
aria-busy={refreshing}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} className={refreshing ? 'animate-spin' : undefined} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,6 +159,11 @@ export interface UseIssuesResult {
|
|||||||
profiles: ClaudeProfile[]
|
profiles: ClaudeProfile[]
|
||||||
setIssues: (issues: Issue[]) => void
|
setIssues: (issues: Issue[]) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
|
/**
|
||||||
|
* True while the host is reloading the board (手动刷新、切换范围、webhook
|
||||||
|
* 触发都算)。看板已有列表时不会退回 loading 空屏,靠它转刷新图标给反馈。
|
||||||
|
*/
|
||||||
|
refreshing: boolean
|
||||||
saveSettings: (values: SettingsValues) => void
|
saveSettings: (values: SettingsValues) => void
|
||||||
/**
|
/**
|
||||||
* Fetch the YouTrack project list for the settings dropdown using the
|
* Fetch the YouTrack project list for the settings dropdown using the
|
||||||
@@ -170,7 +177,7 @@ export interface UseIssuesResult {
|
|||||||
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void
|
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void
|
||||||
dismissToast: (id: string) => void
|
dismissToast: (id: string) => void
|
||||||
openUrl: (url: string) => void
|
openUrl: (url: string) => void
|
||||||
resumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||||||
resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
startTestSession: (issueNumber: number) => void
|
startTestSession: (issueNumber: number) => void
|
||||||
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
@@ -293,10 +300,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 +328,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,8 +346,10 @@ 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 [refreshing, setRefreshing] = useState<boolean>(false)
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
@@ -350,6 +365,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())
|
||||||
@@ -454,11 +470,12 @@ export function useIssues(): UseIssuesResult {
|
|||||||
|
|
||||||
const resumeSession = useCallback((
|
const resumeSession = useCallback((
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
kind: 'implement' | 'brainstorm',
|
||||||
profilePath?: string,
|
profilePath?: string,
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
issueNumber?: number,
|
issueNumber?: number,
|
||||||
): void => {
|
): void => {
|
||||||
postMessage({ type: 'session/resume', sessionId, profilePath, cwd, issueNumber })
|
postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const focusSession = useCallback((issueNumber: number): void => {
|
const focusSession = useCallback((issueNumber: number): void => {
|
||||||
@@ -689,14 +706,21 @@ export function useIssues(): UseIssuesResult {
|
|||||||
const cleanup = onMessage((msg) => {
|
const cleanup = onMessage((msg) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'issues/loading':
|
case 'issues/loading':
|
||||||
|
setRefreshing(true)
|
||||||
setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
|
setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
|
||||||
break
|
break
|
||||||
|
case 'issues/loaded':
|
||||||
|
setRefreshing(false)
|
||||||
|
break
|
||||||
case 'issues/update':
|
case 'issues/update':
|
||||||
setState({ status: 'ready', issues: msg.issues })
|
setState({ status: 'ready', issues: msg.issues })
|
||||||
setScope(msg.scope)
|
setScope(msg.scope)
|
||||||
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 })
|
||||||
@@ -894,5 +918,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, refreshing, 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
|
||||||
|
}
|
||||||
@@ -80,7 +80,8 @@ 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/loaded' }
|
||||||
|
| { 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 }
|
||||||
@@ -211,7 +212,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'youtrack/import' }
|
| { type: 'youtrack/import' }
|
||||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
||||||
| { type: 'toast/open-url', url: string }
|
| { type: 'toast/open-url', url: string }
|
||||||
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number }
|
| { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number }
|
||||||
| { type: 'session/focus', issueNumber: number }
|
| { type: 'session/focus', issueNumber: number }
|
||||||
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
|
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
|
||||||
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
|
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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。 */
|
||||||
|
|||||||
Reference in new issue
Block a user