✨ feat(vscode): 审查列可以申请同事移交工单

This commit is contained in:
cruldra committed 2026-09-28 00:17:09 +08:00
1 parent 65583a001c
commit 6342f39de0
22 files changed
+450 -31

No files matched your search

+110 -24
View File
@@ -43,6 +43,7 @@ import {
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest'
export interface RepoCtx {
workspaceRoot: string
@@ -134,14 +135,16 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
*/
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
export type HandoffStartResult = { ok: true } | { ok: false, message: string }
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<HandoffStartResult> {
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
return { ok: false, message: '无法开始移交' }
const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown>
@@ -149,8 +152,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
return
const message = `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`
toast(panel, 'error', message)
return { ok: false, message }
}
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
@@ -158,11 +162,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
return { ok: false, message: `工单 #${issueNumber} 不存在或已删除` }
}
if (issue.state === 'closed') {
toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`)
return
return { ok: false, message: `#${issueNumber} 已关闭,不能移交` }
}
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到
@@ -172,8 +176,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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)) {
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`)
return
const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
toast(panel, 'error', message)
return { ok: false, message }
}
const branch = str(state.branch)
@@ -193,10 +198,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
const say = (step: string): string => toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}:${step}`, { id: spinner, spinner: true, dismissOnTimer: 0 })
const fail = (message: string): void => {
const fail = (message: string): HandoffStartResult => {
if (spinner)
dismiss(panel, spinner)
toast(panel, 'error', message)
return { ok: false, message }
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
@@ -206,21 +212,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) {
fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
return
return fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
}
const status = await git(worktreeAbs, ['status', '--porcelain'])
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
if (!status.ok || !fetched.ok) {
fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
return
return fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
}
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
if (!check.ok) {
fail(`无法移交 #${issueNumber}:${check.reason}`)
return
return fail(`无法移交 #${issueNumber}:${check.reason}`)
}
}
else if (branch) {
@@ -231,8 +234,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
return
return fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
}
}
}
@@ -254,12 +256,10 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
catch (err) {
if (err instanceof HandoffSourcesTooLargeError) {
fail(`#${issueNumber} ${err.message}`)
return
return fail(`#${issueNumber} ${err.message}`)
}
const message = err instanceof Error ? err.message : String(err)
fail(`移交失败(检查会话大小):${message}`)
return
return fail(`移交失败(检查会话大小):${message}`)
}
// ② 关终端,等 jsonl 刷盘
@@ -318,7 +318,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(${prefix}):${message}`)
return
return { ok: false, message: `移交失败(${prefix}):${message}` }
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
@@ -338,7 +338,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`)
return
return { ok: false, message: `移交失败(写状态):${message}` }
}
say('指派')
try {
@@ -351,7 +351,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
return
return { ok: false, message: `移交失败(指派 ${to}):${message}` }
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
@@ -396,6 +396,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
return { ok: true }
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -403,6 +406,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
return { ok: false, message: `移交失败:${message}` }
}
finally {
finish()
@@ -414,6 +418,88 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffAttachmentId?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
return
}
const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber })
if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
}
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
return {
me: ctx.me,
facts: {
source: 'gitea',
column,
...(str(state.handoffAttachmentId) ? { handoffAttachmentId: str(state.handoffAttachmentId) } : {}),
...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
},
}
}
export async function handleHandoffRequest(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAskHandoff(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 现在不能申请移交`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: loaded.me, handoffRequestError: '' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: loaded.me, handoffRequestError: null } })
}
export async function handleHandoffRequestCancel(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canCancelHandoffRequest(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 没有你的移交申请`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
}
export async function handleHandoffRequestReject(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAnswerHandoffRequest(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 不能拒绝这个申请`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: 'rejected' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: 'rejected' } })
}
export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAnswerHandoffRequest(loaded.facts, loaded.me) || !loaded.facts.handoffRequestFrom) {
toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`)
return
}
const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom)
if (result.ok)
return
await panel.mergeIssueState(issueNumber, { handoffRequestError: result.message }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestError: result.message } })
}
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })