✨ feat(vscode): 移交/接管扩展端流程与消息分发
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -27,6 +27,7 @@ import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
|
|||||||
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
|
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
|
||||||
import { webhookCoordinator } from '../webhook/coordinator'
|
import { webhookCoordinator } from '../webhook/coordinator'
|
||||||
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
||||||
|
import * as handoffFlow from './handlers/handoffFlow'
|
||||||
import * as issues from './handlers/issues'
|
import * as issues from './handlers/issues'
|
||||||
import * as managedSessions from './handlers/managedSessions'
|
import * as managedSessions from './handlers/managedSessions'
|
||||||
import * as prFiles from './handlers/prFiles'
|
import * as prFiles from './handlers/prFiles'
|
||||||
@@ -427,6 +428,18 @@ export class KanbanWebviewPanel {
|
|||||||
void issues.handleColumnChange(this, msg.issueNumber, msg.toColumn)
|
void issues.handleColumnChange(this, msg.issueNumber, msg.toColumn)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'handoff/users') {
|
||||||
|
void handoffFlow.handleHandoffUsers(this, msg.issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'handoff/start') {
|
||||||
|
void handoffFlow.handleHandoffStart(this, msg.issueNumber, msg.to)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'handoff/accept') {
|
||||||
|
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg.type === 'youtrack/list-projects') {
|
if (msg.type === 'youtrack/list-projects') {
|
||||||
void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token)
|
void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
|
||||||
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { window, workspace } from 'vscode'
|
||||||
|
import { loginForToken } from '../../auth/identity'
|
||||||
|
import { getToken } from '../../auth/secrets'
|
||||||
|
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
|
||||||
|
import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
|
||||||
|
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
|
||||||
|
import { claudeProjectsRoot } from '../../cc/sessionBundle'
|
||||||
|
import { projectsDirFor } from '../../cc/sessionWatcher'
|
||||||
|
import { detectRepo } from '../../git/remote'
|
||||||
|
import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||||
|
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath'
|
||||||
|
import {
|
||||||
|
deleteIssueAttachment,
|
||||||
|
downloadAttachment,
|
||||||
|
getIssueAttachment,
|
||||||
|
listIssueAttachments,
|
||||||
|
listRepoAssignees,
|
||||||
|
updateIssueAssignees,
|
||||||
|
uploadIssueAttachment,
|
||||||
|
} from '../../gitea/api'
|
||||||
|
import { logger } from '../../logging/logger'
|
||||||
|
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
|
||||||
|
import { getSettings } from '../../settings/store'
|
||||||
|
import { makeNonce } from '../KanbanPanel'
|
||||||
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
|
import {
|
||||||
|
checkWorktreePushed,
|
||||||
|
handoffAcceptedStateExtra,
|
||||||
|
handoffAcceptedUiPatch,
|
||||||
|
handoffStartedStateExtra,
|
||||||
|
handoffStartedUiPatch,
|
||||||
|
} from './handoff'
|
||||||
|
|
||||||
|
interface RepoCtx {
|
||||||
|
workspaceRoot: string
|
||||||
|
host: string
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
token: string
|
||||||
|
me: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
|
||||||
|
const id = extra?.id ?? makeNonce()
|
||||||
|
panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(panel: KanbanWebviewPanel, id: string): void {
|
||||||
|
panel.postMessage({ type: 'toast/dismiss', id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
toast(panel, 'error', '请先打开一个工作区文件夹')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const remote = await detectRepo(workspaceRoot)
|
||||||
|
if (!remote) {
|
||||||
|
toast(panel, 'error', '当前工作区没有 Gitea 远程仓库')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const token = await getToken(panel.context, remote.host)
|
||||||
|
if (!token) {
|
||||||
|
toast(panel, 'error', '请先完成 Gitea 配置')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const me = await loginForToken(remote.host, token)
|
||||||
|
if (!me) {
|
||||||
|
toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me }
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => {
|
||||||
|
resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string | undefined {
|
||||||
|
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function scratchDir(prefix: string): Promise<string> {
|
||||||
|
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
|
const ctx = await resolveRepoCtx(panel)
|
||||||
|
if (!ctx)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
const users = await listRepoAssignees(ctx)
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'handoff/users-result',
|
||||||
|
issueNumber,
|
||||||
|
users: users.map(u => u.login).filter(l => l !== ctx.me).sort(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
toast(panel, 'error', `读取可指派用户失败:${message}`)
|
||||||
|
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 →
|
||||||
|
* 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;
|
||||||
|
* 本机清理失败只告警,移交在远端已经生效。
|
||||||
|
*/
|
||||||
|
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
|
||||||
|
const ctx = await resolveRepoCtx(panel)
|
||||||
|
if (!ctx)
|
||||||
|
return
|
||||||
|
const { workspaceRoot, host, owner, repo, token, me } = ctx
|
||||||
|
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||||||
|
|
||||||
|
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 守卫:干净 + 与远端一致
|
||||||
|
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
||||||
|
if (worktreeAbs && fs.existsSync(worktreeAbs) && branch) {
|
||||||
|
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 {
|
||||||
|
await fsp.rm(staging, { recursive: true, force: true })
|
||||||
|
await fsp.rm(outDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。
|
||||||
|
try {
|
||||||
|
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
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)
|
||||||
|
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 + 本地分支(远端分支留给接管方)
|
||||||
|
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) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const ctx = await resolveRepoCtx(panel)
|
||||||
|
if (!ctx)
|
||||||
|
return
|
||||||
|
const { workspaceRoot, host, owner, repo, token } = ctx
|
||||||
|
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||||||
|
|
||||||
|
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 attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10)
|
||||||
|
if (!Number.isFinite(attachmentId)) {
|
||||||
|
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||||
|
const extracted = await scratchDir('spx-takeover-')
|
||||||
|
try {
|
||||||
|
// ① 下载 + 解包 + 校验清单
|
||||||
|
const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
|
||||||
|
const data = await downloadAttachment({ token, url: meta.browser_download_url })
|
||||||
|
const archive = path.join(extracted, meta.name)
|
||||||
|
await fsp.writeFile(archive, data)
|
||||||
|
await unpackHandoff(archive, extracted)
|
||||||
|
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber)
|
||||||
|
|
||||||
|
// ② 重建 worktree
|
||||||
|
const branch = manifest.branch ?? str(state.branch)
|
||||||
|
let worktreeAbs: string | undefined
|
||||||
|
if (branch) {
|
||||||
|
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
|
||||||
|
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
|
||||||
|
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
|
||||||
|
const settings = getSettings(panel.context)
|
||||||
|
await panel.dispatchWorktreeHook('post-create', {
|
||||||
|
workspaceRoot,
|
||||||
|
worktreePath: worktreeAbs,
|
||||||
|
branch,
|
||||||
|
issueNumber,
|
||||||
|
mainBranch: settings.devBranch || 'main',
|
||||||
|
customScriptPath: settings.worktreePostCreateScript,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 装会话文件
|
||||||
|
await installHandoffBundle(extracted, manifest, {
|
||||||
|
projectsRoot: claudeProjectsRoot(),
|
||||||
|
codexRoot: defaultCodexSessionsDir(),
|
||||||
|
workspaceProjectsDir: projectsDirFor(workspaceRoot),
|
||||||
|
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
||||||
|
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
||||||
|
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
||||||
|
try {
|
||||||
|
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(panel, spinner)
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber,
|
||||||
|
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs }),
|
||||||
|
})
|
||||||
|
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
||||||
|
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) })
|
||||||
|
}
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await fsp.rm(extracted, { recursive: true, force: true })
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, 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, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, 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, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
|
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||||||
|
| { type: 'handoff/done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
| {
|
| {
|
||||||
@@ -168,6 +170,9 @@ export type WebviewToExtension
|
|||||||
| { type: 'worktree/delete', issueNumber: number, path: string }
|
| { type: 'worktree/delete', issueNumber: number, path: string }
|
||||||
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
||||||
| { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
|
| { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
|
||||||
|
| { type: 'handoff/users', issueNumber: number }
|
||||||
|
| { type: 'handoff/start', issueNumber: number, to: string }
|
||||||
|
| { type: 'handoff/accept', issueNumber: number }
|
||||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||||
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, 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, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, 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, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
|
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||||||
|
| { type: 'handoff/done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
| {
|
| {
|
||||||
@@ -183,6 +185,9 @@ export type WebviewToExtension
|
|||||||
| { type: 'worktree/delete', issueNumber: number, path: string }
|
| { type: 'worktree/delete', issueNumber: number, path: string }
|
||||||
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
||||||
| { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string }
|
| { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string }
|
||||||
|
| { type: 'handoff/users', issueNumber: number }
|
||||||
|
| { type: 'handoff/start', issueNumber: number, to: string }
|
||||||
|
| { type: 'handoff/accept', issueNumber: number }
|
||||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||||
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
||||||
|
|||||||
Reference in New Issue
Block a user