diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index 2894754..2c95577 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -11,14 +11,18 @@ import { } from './handoff' describe('canStartHandoff', () => { - it('gitea 工单、非 done、无待接管 → true', () => { - expect(canStartHandoff({ column: 'in-progress' })).toBe(true) - expect(canStartHandoff({ column: 'todo', source: 'gitea' })).toBe(true) + it('gitea 工单、非 done、无待接管、无 assignee 或 assignee 含我 → true', () => { + expect(canStartHandoff({ column: 'in-progress' }, 'me')).toBe(true) + expect(canStartHandoff({ column: 'todo', source: 'gitea' }, 'me')).toBe(true) + expect(canStartHandoff({ column: 'todo', assignees: [] }, 'me')).toBe(true) + expect(canStartHandoff({ column: 'todo', assignees: ['me', 'x'] }, 'me')).toBe(true) }) - it('done / youtrack / 已在移交中 → false', () => { - expect(canStartHandoff({ column: 'done' })).toBe(false) - expect(canStartHandoff({ column: 'review', source: 'youtrack' })).toBe(false) - expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' })).toBe(false) + it('done / youtrack / 已在移交中 / 不是我负责 → false', () => { + expect(canStartHandoff({ column: 'done' }, 'me')).toBe(false) + expect(canStartHandoff({ column: 'review', source: 'youtrack' }, 'me')).toBe(false) + expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' }, 'me')).toBe(false) + expect(canStartHandoff({ column: 'todo', assignees: ['x'] }, 'me')).toBe(false) + expect(canStartHandoff({ column: 'todo', assignees: ['me'] }, undefined)).toBe(false) }) }) @@ -162,4 +166,27 @@ describe('payloads', () => { worktreeExists: false, }) }) + it('接管 UI patch:带 reviewSessionFileExists 时一并下发,UI 不用等下次刷新才能点链接', () => { + expect(handoffAcceptedUiPatch({ + sessions: { reviewSessionId: 'r' }, + profiles: {}, + worktreeExists: false, + reviewSessionFileExists: true, + })).toEqual({ + handoffAttachmentId: null, + handoffFrom: null, + reviewSessionId: 'r', + worktreePath: null, + worktreeExists: false, + reviewSessionFileExists: true, + }) + }) + it('接管 UI patch:reviewSessionFileExists 未传时不出现在 patch 里', () => { + const patch = handoffAcceptedUiPatch({ + sessions: { sessionId: 's' }, + profiles: {}, + worktreeExists: false, + }) + expect(patch).not.toHaveProperty('reviewSessionFileExists') + }) }) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 0c7874d..a485757 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -6,8 +6,11 @@ export function canStartHandoff(issue: { source?: 'gitea' | 'youtrack' column: IssueColumn handoffAttachmentId?: string -}): boolean { - return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId + assignees?: string[] +}, me: string | undefined): boolean { + const assignees = issue.assignees ?? [] + const mine = assignees.length === 0 || (me !== undefined && assignees.includes(me)) + return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId && mine } export function canAcceptHandoff(issue: { @@ -105,6 +108,7 @@ export function handoffAcceptedUiPatch(local: { profiles: HandoffProfiles worktreePath?: string worktreeExists: boolean + reviewSessionFileExists?: boolean }): Record { return { handoffAttachmentId: null, @@ -115,5 +119,6 @@ export function handoffAcceptedUiPatch(local: { // 与 worktreeExists: false 对不上。 worktreePath: local.worktreePath ?? null, worktreeExists: local.worktreeExists, + ...(local.reviewSessionFileExists !== undefined ? { reviewSessionFileExists: local.reviewSessionFileExists } : {}), } } diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index f49ec72..a8889a2 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -152,17 +152,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: return } - // 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到 - // 完成),用刚读到的最新 state 再判一次 canStartHandoff,避免重复移交。 - const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] - const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' - if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId) })) { - toast(panel, 'error', `#${issueNumber} 已在移交中或已完成,不能再次移交`) - return - } - - // column 兜底成 'todo' 时挡不住「Gitea 已关闭但从没拖进看板」的工单—— - // 再拉一次远端 issue.state 兜底。 + // 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到 + // assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。 const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) if (!issue) { toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) @@ -173,6 +164,21 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: return } + // 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到 + // 完成、指派已变更),用刚读到的最新 state + issue 再判一次 canStartHandoff, + // 避免重复移交或抢别人的工单。 + const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] + const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' + const assignees = (issue.assignees ?? []).map(a => a.login) + if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) { + // 区分「不是我的工单」和「已在移交中/已完成」两种拒绝原因,措辞不同。 + const notMine = assignees.length > 0 && !(me !== undefined && assignees.includes(me)) + toast(panel, 'error', notMine + ? `#${issueNumber} 不是你负责的工单,不能移交` + : `#${issueNumber} 已在移交中或已完成,不能再次移交`) + return + } + const branch = str(state.branch) const worktreePath = str(state.worktreePath) const sessions: HandoffSessions = { @@ -456,6 +462,10 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber }) // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) + // reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists); + // 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。 + const reviewSessionFileExists = !!manifest.sessions.reviewSessionId + && manifest.codex.some(c => c.id.toLowerCase() === manifest.sessions.reviewSessionId!.toLowerCase()) const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) try { @@ -469,7 +479,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber panel.postMessage({ type: 'issue/patch', issueNumber, - patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs }), + patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }), }) toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) }) diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 4a96d8c..d3b2ade 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -597,7 +597,7 @@ export function IssueDetailPanel({ 接管 )} - {!issue.handoffAttachmentId && issue.column !== 'done' && ( + {!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (