@@ -1,4 +1,5 @@
import type { HandoffManifest , HandoffProfiles , HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
@@ -19,6 +20,7 @@ import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '.
import {
deleteIssueAttachment ,
downloadAttachment ,
getIssue ,
getIssueAttachment ,
listIssueAttachments ,
listRepoAssignees ,
@@ -31,6 +33,8 @@ import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import {
canAcceptHandoff ,
canStartHandoff ,
checkWorktreePushed ,
handoffAcceptedStateExtra ,
handoffAcceptedUiPatch ,
@@ -121,12 +125,242 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
}
/**
* 发送方:校验 worktree 已 push( worktree 存在但 state 缺 branch 直接拒绝 )→
* 发送方:服务端复查 canStartHandoff( webview 按钮的显示条件可能过期 )→
* 校验 worktree 已 push( worktree 存在但 state 缺 branch 直接拒绝;没有
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
* 与墓碑写入失败只告警,移交在远端已经生效。
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
*/
export async function handleHandoffStart ( panel : KanbanWebviewPanel , issueNumber : number , to : string ) : Promise < void > {
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
const { workspaceRoot , host , owner , repo , token , me } = ctx
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
}
// 前置复查: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
}
const branch = str ( state . branch )
const worktreePath = str ( state . worktreePath )
const sessions : HandoffSessions = {
. . . ( str ( state . sessionId ) ? { sessionId : str ( state . sessionId ) } : { } ) ,
. . . ( str ( state . implementSessionId ) ? { implementSessionId : str ( state . implementSessionId ) } : { } ) ,
. . . ( str ( state . testSessionId ) ? { testSessionId : str ( state . testSessionId ) } : { } ) ,
. . . ( str ( state . reviewSessionId ) ? { reviewSessionId : str ( state . reviewSessionId ) } : { } ) ,
}
const profiles : HandoffProfiles = {
. . . ( str ( state . profilePath ) ? { profilePath : str ( state . profilePath ) } : { } ) ,
. . . ( str ( state . brainstormProfilePath ) ? { brainstormProfilePath : str ( state . brainstormProfilePath ) } : { } ) ,
. . . ( str ( state . testProfilePath ) ? { testProfilePath : str ( state . testProfilePath ) } : { } ) ,
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
const worktreeAbs = worktreePath ? resolveWorktreePath ( worktreePath , workspaceRoot ) : undefined
if ( worktreeAbs && fs . existsSync ( worktreeAbs ) ) {
if ( ! branch ) {
toast ( panel , 'error' , ` 无法移交 # ${ issueNumber } : state 缺少 branch,无法校验是否已 push` )
return
}
const status = await git ( worktreeAbs , [ 'status' , '--porcelain' ] )
const fetched = await git ( worktreeAbs , [ 'fetch' , 'origin' , branch ] )
if ( ! status . ok || ! fetched . ok ) {
toast ( panel , 'error' , ` 检查 worktree 失败: ${ ( status . stderr || fetched . stderr ) . trim ( ) } ` )
return
}
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 ) {
toast ( panel , 'error' , ` 无法移交 # ${ issueNumber } : ${ check . reason } ` )
return
}
}
else if ( branch ) {
const localRef = await git ( workspaceRoot , [ 'rev-parse' , '--verify' , '--quiet' , ` refs/heads/ ${ branch } ` ] )
// 本地没有这个分支就没什么可丢的,直接放行。
if ( localRef . ok ) {
const fetched = await git ( workspaceRoot , [ 'fetch' , 'origin' , branch ] )
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 ( ) ) {
toast ( panel , 'error' , ` 无法移交 # ${ issueNumber } :本地分支 ${ branch } 与远端不一致,请先 push ` )
return
}
}
}
spinner = toast ( panel , 'info' , ` 正在移交 # ${ issueNumber } 给 ${ to } … ` , { spinner : true , dismissOnTimer : 120_000 } )
// ② 关终端,等 jsonl 刷盘
let disposedAny = false
for ( const [ terminal , origin ] of panel . terminalOrigin ) {
if ( origin . issueNumber === issueNumber ) {
try {
terminal . dispose ( )
disposedAny = true
}
// 终端已经死了也无所谓,目的只是确保不再有活跃会话
catch { }
}
}
for ( const t of window . terminals ) {
if ( t . name === ` issue- ${ issueNumber } -冲突解决 ` ) {
try {
t . dispose ( )
disposedAny = true
}
// 同上:冲突解决终端不进 terminalOrigin,按名字关,关不掉不影响移交
catch { }
}
}
if ( disposedAny )
await new Promise < void > ( r = > setTimeout ( r , 600 ) )
// ③ 打包 + 上传
let attachmentId : number
let manifest : HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir ( 'spx-handoff-' )
const outDir = await scratchDir ( 'spx-handoff-out-' )
try {
manifest = await stageHandoffBundle (
{ issueNumber , from : me , branch , sessions , profiles } ,
staging ,
{ projectsRoot : claudeProjectsRoot ( ) , codexRoot : defaultCodexSessionsDir ( ) } ,
)
const name = handoffAttachmentName ( issueNumber )
const archive = path . join ( outDir , name )
await packHandoff ( staging , archive )
for ( const old of await listIssueAttachments ( { host , token , owner , repo , index : issueNumber } ) ) {
if ( old . name === name )
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId : old.id } )
}
const uploaded = await uploadIssueAttachment ( { host , token , owner , repo , index : issueNumber , name , data : await fsp . readFile ( archive ) } )
attachmentId = uploaded . id
logger . add ( { level : 'info' , source : 'panel' , message : ` 移交 # ${ issueNumber } :会话包已上传 (attachment ${ attachmentId } , ${ uploaded . size } bytes) ` , details : JSON.stringify ( manifest ) } )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'error' , source : 'panel' , message : ` 移交 # ${ issueNumber } :打包/上传失败 ` , details : message } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(打包/上传): ${ message } ` )
return
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
await fsp . rm ( staging , { recursive : true , force : true } ) . catch ( ( ) = > { } )
await fsp . rm ( outDir , { recursive : true , force : true } ) . catch ( ( ) = > { } )
}
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
// ⑤ 重新指派。失败回滚远端改动。
try {
await panel . mergeIssueState ( issueNumber , handoffStartedStateExtra ( attachmentId , me ) )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
// 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId } ) . catch ( ( ) = > { } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(写状态): ${ message } ` )
return
}
try {
await updateIssueAssignees ( { host , token , owner , repo , index : issueNumber , assignees : [ to ] } )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
// 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。
await panel . mergeIssueState ( issueNumber , { handoffAttachmentId : '' , handoffFrom : '' } ) . catch ( ( ) = > { } )
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId } ) . catch ( ( ) = > { } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(指派 ${ to } ): ${ message } ` )
return
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
// 远端移交已经生效,这里不回滚。
let worktreeRemovalFailed = false
if ( worktreeAbs && fs . existsSync ( worktreeAbs ) ) {
const settings = getSettings ( panel . context )
try {
await panel . dispatchWorktreeHook ( 'pre-remove' , {
workspaceRoot ,
worktreePath : worktreeAbs ,
branch : branch ? ? '' ,
issueNumber ,
mainBranch : settings.devBranch || 'main' ,
customScriptPath : settings.worktreePreRemoveScript ,
} )
killProcessesUsingWorktree ( worktreeAbs )
await removeWorktreeDir ( workspaceRoot , worktreeAbs )
}
catch ( err ) {
worktreeRemovalFailed = true
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'warn' , source : 'panel' , message : ` 移交 # ${ issueNumber } :清理 worktree 失败 ` , details : message } )
toast ( panel , 'error' , ` 已移交,但本机 worktree 清理失败: ${ message } ` )
}
}
if ( branch ) {
const settings = getSettings ( panel . context )
await cleanupFeatureBranch ( { workspaceRoot , branch , issueNumber , devBranch : settings.devBranch , autoBuildBranch : settings.autoBuildBranch } )
}
// ⑦ 本机墓碑:sid/profile/worktreePath 归零。worktree 没删掉时保留
// worktreePath,别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑
// 失败只告警——远端移交已经生效,不回滚。
await panel . mergeIssueState ( issueNumber , handoffLocalTombstones ( { keepWorktreePath : worktreeRemovalFailed } ) ) . catch ( ( err ) = > {
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'warn' , source : 'panel' , message : ` 移交 # ${ issueNumber } :写本机墓碑失败 ` , details : message } )
} )
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 会话) ` )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'error' , source : 'panel' , message : ` 移交 # ${ issueNumber } :未预期错误 ` , details : message } )
toast ( panel , 'error' , ` 移交失败: ${ message } ` )
if ( spinner )
dismiss ( panel , spinner )
}
finally {
finish ( )
}
}
/**
* 接管方:服务端复查 canAcceptHandoff( webview 按钮的显示条件可能过期)→
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept ( panel : KanbanWebviewPanel , issueNumber : number ) : Promise < void > {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = ( ) : void = > panel . postMessage ( { type : 'handoff/done' , issueNumber } )
const ctx = await resolveRepoCtx ( panel )
@@ -136,204 +370,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
const { workspaceRoot , host , owner , repo , token , me } = ctx
let state : Record < string , unknown >
try {
state = await panel . readIssueState ( issueNumber )
}
catch ( err ) {
toast ( panel , 'error' , ` 读取工单 # ${ issueNumber } 状态失败: ${ err instanceof Error ? err.message : String ( err ) } ` )
finish ( )
return
}
const branch = str ( state . branch )
const worktreePath = str ( state . worktreePath )
const sessions : HandoffSessions = {
. . . ( str ( state . sessionId ) ? { sessionId : str ( state . sessionId ) } : { } ) ,
. . . ( str ( state . implementSessionId ) ? { implementSessionId : str ( state . implementSessionId ) } : { } ) ,
. . . ( str ( state . testSessionId ) ? { testSessionId : str ( state . testSessionId ) } : { } ) ,
. . . ( str ( state . reviewSessionId ) ? { reviewSessionId : str ( state . reviewSessionId ) } : { } ) ,
}
const profiles : HandoffProfiles = {
. . . ( str ( state . profilePath ) ? { profilePath : str ( state . profilePath ) } : { } ) ,
. . . ( str ( state . brainstormProfilePath ) ? { brainstormProfilePath : str ( state . brainstormProfilePath ) } : { } ) ,
. . . ( str ( state . testProfilePath ) ? { testProfilePath : str ( state . testProfilePath ) } : { } ) ,
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
const worktreeAbs = worktreePath ? resolveWorktreePath ( worktreePath , workspaceRoot ) : undefined
if ( worktreeAbs && fs . existsSync ( worktreeAbs ) ) {
if ( ! branch ) {
toast ( panel , 'error' , ` 无法移交 # ${ issueNumber } : state 缺少 branch,无法校验是否已 push` )
finish ( )
return
}
const status = await git ( worktreeAbs , [ 'status' , '--porcelain' ] )
const fetched = await git ( worktreeAbs , [ 'fetch' , 'origin' , branch ] )
if ( ! status . ok || ! fetched . ok ) {
toast ( panel , 'error' , ` 检查 worktree 失败: ${ ( status . stderr || fetched . stderr ) . trim ( ) } ` )
finish ( )
return
}
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 ) {
toast ( panel , 'error' , ` 无法移交 # ${ issueNumber } : ${ check . reason } ` )
finish ( )
return
}
}
const spinner = toast ( panel , 'info' , ` 正在移交 # ${ issueNumber } 给 ${ to } … ` , { spinner : true , dismissOnTimer : 120_000 } )
// ② 关终端,等 jsonl 刷盘
let disposedAny = false
for ( const [ terminal , origin ] of panel . terminalOrigin ) {
if ( origin . issueNumber === issueNumber ) {
try {
terminal . dispose ( )
disposedAny = true
}
// 终端已经死了也无所谓,目的只是确保不再有活跃会话
catch { }
}
}
for ( const t of window . terminals ) {
if ( t . name === ` issue- ${ issueNumber } -冲突解决 ` ) {
try {
t . dispose ( )
disposedAny = true
}
// 同上:冲突解决终端不进 terminalOrigin,按名字关,关不掉不影响移交
catch { }
}
}
if ( disposedAny )
await new Promise < void > ( r = > setTimeout ( r , 600 ) )
// ③ 打包 + 上传
let attachmentId : number
let manifest : HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir ( 'spx-handoff-' )
const outDir = await scratchDir ( 'spx-handoff-out-' )
try {
manifest = await stageHandoffBundle (
{ issueNumber , from : me , branch , sessions , profiles } ,
staging ,
{ projectsRoot : claudeProjectsRoot ( ) , codexRoot : defaultCodexSessionsDir ( ) } ,
)
const name = handoffAttachmentName ( issueNumber )
const archive = path . join ( outDir , name )
await packHandoff ( staging , archive )
for ( const old of await listIssueAttachments ( { host , token , owner , repo , index : issueNumber } ) ) {
if ( old . name === name )
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId : old.id } )
}
const uploaded = await uploadIssueAttachment ( { host , token , owner , repo , index : issueNumber , name , data : await fsp . readFile ( archive ) } )
attachmentId = uploaded . id
logger . add ( { level : 'info' , source : 'panel' , message : ` 移交 # ${ issueNumber } :会话包已上传 (attachment ${ attachmentId } , ${ uploaded . size } bytes) ` , details : JSON.stringify ( manifest ) } )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'error' , source : 'panel' , message : ` 移交 # ${ issueNumber } :打包/上传失败 ` , details : message } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(打包/上传): ${ message } ` )
finish ( )
return
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
await fsp . rm ( staging , { recursive : true , force : true } ) . catch ( ( ) = > { } )
await fsp . rm ( outDir , { recursive : true , force : true } ) . catch ( ( ) = > { } )
}
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
// ⑤ 重新指派。失败回滚远端改动。
try {
await panel . mergeIssueState ( issueNumber , handoffStartedStateExtra ( attachmentId , me ) )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
// 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId } ) . catch ( ( ) = > { } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(写状态): ${ message } ` )
finish ( )
return
}
try {
await updateIssueAssignees ( { host , token , owner , repo , index : issueNumber , assignees : [ to ] } )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
// 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。
await panel . mergeIssueState ( issueNumber , { handoffAttachmentId : '' , handoffFrom : '' } ) . catch ( ( ) = > { } )
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId } ) . catch ( ( ) = > { } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(指派 ${ to } ): ${ message } ` )
finish ( )
return
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
// 远端移交已经生效,这里不回滚。
let worktreeRemovalFailed = false
if ( worktreeAbs && fs . existsSync ( worktreeAbs ) ) {
const settings = getSettings ( panel . context )
try {
await panel . dispatchWorktreeHook ( 'pre-remove' , {
workspaceRoot ,
worktreePath : worktreeAbs ,
branch : branch ? ? '' ,
issueNumber ,
mainBranch : settings.devBranch || 'main' ,
customScriptPath : settings.worktreePreRemoveScript ,
} )
killProcessesUsingWorktree ( worktreeAbs )
await removeWorktreeDir ( workspaceRoot , worktreeAbs )
}
catch ( err ) {
worktreeRemovalFailed = true
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'warn' , source : 'panel' , message : ` 移交 # ${ issueNumber } :清理 worktree 失败 ` , details : message } )
toast ( panel , 'error' , ` 已移交,但本机 worktree 清理失败: ${ message } ` )
}
}
if ( branch ) {
const settings = getSettings ( panel . context )
await cleanupFeatureBranch ( { workspaceRoot , branch , issueNumber , devBranch : settings.devBranch , autoBuildBranch : settings.autoBuildBranch } )
}
// ⑦ 本机墓碑:sid/profile/worktreePath 归零。worktree 没删掉时保留
// worktreePath,别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑
// 失败只告警——远端移交已经生效,不回滚。
await panel . mergeIssueState ( issueNumber , handoffLocalTombstones ( { keepWorktreePath : worktreeRemovalFailed } ) ) . catch ( ( err ) = > {
const message = err instanceof Error ? err.message : String ( err )
logger . add ( { level : 'warn' , source : 'panel' , message : ` 移交 # ${ issueNumber } :写本机墓碑失败 ` , details : message } )
} )
dismiss ( panel , spinner )
panel . postMessage ( { type : 'issue/patch' , issueNumber , patch : handoffStartedUiPatch ( attachmentId , me , to ) } )
toast ( panel , 'success' , ` # ${ issueNumber } 已移交给 ${ to } ( ${ manifest . claude . length } 个 claude 会话, ${ manifest . codex . length } 个 codex 会话) ` )
finish ( )
}
/**
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept ( panel : KanbanWebviewPanel , issueNumber : number ) : Promise < void > {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = ( ) : void = > panel . postMessage ( { type : 'handoff/done' , issueNumber } )
const ctx = await resolveRepoCtx ( panel )
if ( ! ctx ) {
finish ( )
return
}
const { workspaceRoot , host , owner , repo , token } = ctx
let state : Record < string , unknown >
try {
state = await panel . readIssueState ( issueNumber )
@@ -350,6 +386,15 @@ 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-' )
try {