diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 75d5c92..f49ec72 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -161,6 +161,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: return } + // column 兜底成 'todo' 时挡不住「Gitea 已关闭但从没拖进看板」的工单—— + // 再拉一次远端 issue.state 兜底。 + const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) + if (!issue) { + toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) + return + } + if (issue.state === 'closed') { + toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`) + return + } + const branch = str(state.branch) const worktreePath = str(state.worktreePath) const sessions: HandoffSessions = { @@ -386,18 +398,29 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber return } - // 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被 - // 重新指派),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。 - const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) - if (!issue || !canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) { - toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`) - finish() - return - } - - const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 }) - const extracted = await scratchDir('spx-takeover-') + // spinner / extracted 声明在 try 外面:getIssue、mkdtemp 任一失败都要靠 + // 「是否已创建」来决定 catch/finally 里能不能 dismiss/rm,不能假设已存在。 + let spinner: string | undefined + let extracted: string | undefined try { + spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 }) + + // 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被 + // 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。 + const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) + if (!issue) { + dismiss(panel, spinner) + toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) + return + } + if (!canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) { + dismiss(panel, spinner) + toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`) + return + } + + extracted = await scratchDir('spx-takeover-') + // ① 下载 + 解包 + 校验清单 const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) const data = await downloadAttachment({ token, url: meta.browser_download_url }) @@ -454,13 +477,15 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message }) - dismiss(panel, spinner) + if (spinner) + dismiss(panel, spinner) toast(panel, 'error', `接管失败:${message}`) } finally { // finish 先发:下面的 rm 失败不该让弹窗的 spinner 卡住不收。 finish() // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 - await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) + if (extracted) + await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) } }