import type { IssueColumn } from '../../gitea/types' import type { LockedReason } from '../issueLock' import type { KanbanWebviewPanel } from '../KanbanPanel' import type { SessionTabKind } from './terminals' import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' 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 { commands, env, ThemeColor, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { buildCcArgv } from '../../cc/ccCommand' import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt } from '../../cc/prompts' import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle' import { sessionLaunch } from '../../cc/sessionTerminal' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' import { gitFetch, resolveFeatureBranch } from '../../git/branches' import { detectRepo } from '../../git/remote' import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { addDependency, closeIssue, deleteBranch, deleteIssue, getDependencies, getPullRequest, GiteaApiError, mergePullRequest, removeDependency, } from '../../gitea/api' import { isValidSpxFilePath, loadIssues } from '../../gitea/issueLoader' import { mergeStateJsonComment } from '../../gitea/stateJson' import { mergeLocalIssueState } from '../../issues/localState' import { logger } from '../../logging/logger' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' import { makeNonce } from '../../webview/html' import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor' import { evaluateLockedReason } from '../issueLock' import { cleanupFeatureBranch } from './branchCleanup' import { clearSessionConfirmText, clearSessionDoneText, SESSION_KINDS } from './sessionKinds' import * as sessions from './sessions' import { issueNumberForNewIssueTerminal } from './terminals' import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset' export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise { if (toColumn !== 'review') { try { const requestState = await panel.readIssueState(issueNumber) if (typeof requestState.handoffRequestFrom === 'string' && requestState.handoffRequestFrom.length > 0) { await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null }, }) } } catch { // 清申请失败不拦换列 } } if (toColumn === 'in-progress') { await handleDropToInProgress(panel, issueNumber) return } if (toColumn === 'todo') { await handleResetToTodo(panel, issueNumber) return } if (toColumn !== 'done') { logger.add({ level: 'info', source: 'panel', message: `暂不处理 toColumn=${toColumn} 的拖放持久化 (issue #${issueNumber})`, }) return } // 失败回滚时使用的"原列"。读 state JSON 时尽量带出,最终读不到时 // 兜底到 'in-progress'(绝大多数被拖到 done 的工单来自 in-progress / review)。 const rollback = (fromColumn: IssueColumn | undefined): void => { panel.postMessage({ type: 'issue/patch', issueNumber, patch: { column: fromColumn ?? 'in-progress' }, }) } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) rollback(undefined) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) rollback(undefined) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) rollback(undefined) return } // 1. 读工单状态(共享评论叠加本机 workspaceState 记录)。 let prStr: string | undefined let worktreePath: string | undefined let fromColumn: IssueColumn | undefined let implementSessionId: string | undefined let testSessionId: string | undefined let prototypeSessionId: string | undefined let featureBranchFromState: string | undefined try { const stateObj = await panel.readIssueState(issueNumber) if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0) prStr = stateObj.pr if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0) worktreePath = stateObj.worktreePath if ( typeof stateObj.column === 'string' && ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column) ) { fromColumn = stateObj.column as IssueColumn } if (typeof stateObj.implementSessionId === 'string' && stateObj.implementSessionId.length > 0) implementSessionId = stateObj.implementSessionId if (typeof stateObj.testSessionId === 'string' && stateObj.testSessionId.length > 0) testSessionId = stateObj.testSessionId if (typeof stateObj.prototypeSessionId === 'string' && stateObj.prototypeSessionId.length > 0) prototypeSessionId = stateObj.prototypeSessionId if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0) featureBranchFromState = stateObj.branch } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 状态失败`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `读取工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) rollback(undefined) return } // 2. 无关联 PR 的工单(讨论 / 文档 / 运维类)也允许拖到完成列, // 跳过 PR 合并 + worktree 清理,仅持久化 column='done'。 if (!prStr) { try { await mergeStateJsonComment({ host: remote.host, owner: remote.owner, repo: remote.repo, token, issueNumber, extra: { column: 'done' }, }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} 无 PR,直接标记完成`, }) await syncCloseGiteaIssue(panel, { host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber, }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { column: 'done' }, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: `工单 #${issueNumber} 已完成`, dismissOnTimer: 4000, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 column=done 失败 (#${issueNumber}, no PR)`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) } return } const prIndex = Number.parseInt(prStr, 10) if (!Number.isFinite(prIndex)) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `工单 #${issueNumber} 的 PR 字段无效: ${prStr}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } // 3. Look up PR state on gitea. let pullRequest: Awaited> try { pullRequest = await getPullRequest({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: prIndex, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `读取 PR #${prIndex} 状态失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `无法读取 PR #${prIndex} 状态: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } if (!pullRequest.merged) { // PR 还没合并 → 由插件代为合并(用户拖到"完成"列即表示放行)。 // 合并失败(冲突 / 已关闭 / 权限)回滚拖动到原列。 try { await mergePullRequest({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: prIndex, }) logger.add({ level: 'info', source: 'panel', message: `已合并 PR #${prIndex} (issue #${issueNumber})`, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'info', message: `已合并 PR #${prIndex}`, dismissOnTimer: 4000, }) // 合并成功后做一次 fetch,让本地 main 能感知远端的 merge commit // (best-effort,失败不影响合并流程,只 log)。 try { const fetched = await gitFetch(workspaceRoot) if (fetched.ok) { logger.add({ level: 'info', source: 'panel', message: `git fetch origin 成功 (PR #${prIndex} 合并后)`, }) } else { logger.add({ level: 'warn', source: 'panel', message: `git fetch origin 失败 (PR #${prIndex} 合并后)`, details: fetched.stderr, }) } } catch (err) { const m = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `git fetch origin 抛错 (PR #${prIndex} 合并后)`, details: m, }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) const isConflict = err instanceof GiteaApiError && (err.status === 405 || /conflict/i.test(err.message)) logger.add({ level: 'error', source: 'panel', message: `合并 PR #${prIndex} 失败 (issue #${issueNumber})${isConflict ? ' [冲突]' : ''}`, details: message, }) const conflictBranch = resolveFeatureBranch({ stateBranch: featureBranchFromState, prHeadRef: pullRequest.headRef, }) if (isConflict && conflictBranch && worktreePath) { // 走冲突解决分支:直接在实施 worktree 里 merge dev 制造冲突落地, // 再开一个临时 cc 会话让 cc 解决。fire-and-forget,不进任何 map / state JSON。 await sessions.startConflictResolution(panel, { issueNumber, prIndex, workspaceRoot, featureBranch: conflictBranch, worktreePath, }) } else { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: isConflict ? `合并 PR #${prIndex} 失败 [冲突]:state JSON 缺少 branch 或 worktreePath 字段,无法自动解决` : `合并 PR #${prIndex} 失败: ${message}`, dismissOnTimer: 6000, }) } rollback(fromColumn) return } } // 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath。 // 走 mergeIssueState 漏斗:worktreePath 是本机字段落 workspaceState, // 其余共享字段进 state JSON(空字符串清空,loader 把 length===0 视为 unset)。 try { await panel.mergeIssueState(issueNumber, { column: 'done', worktreePath: '', branch: '', prMerged: true, prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} column=done 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 column=done 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } await syncCloseGiteaIssue(panel, { host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber, }) // 删 worktree 前把实施/测试/原型会话整包(jsonl + 子目录)搬到主仓库 projects 目录, // 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。 if ((implementSessionId || testSessionId || prototypeSessionId) && worktreePath) { const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot) const srcProjectsDir = projectsDirFor(worktreeAbs) const dstProjectsDir = projectsDirFor(workspaceRoot) for (const sid of [implementSessionId, testSessionId, prototypeSessionId]) { if (typeof sid !== 'string' || sid.length === 0) continue try { await installClaudeSession({ sid, srcDir: srcProjectsDir, dstProjectsDir, projectsRoot: claudeProjectsRoot() }) logger.add({ level: 'info', source: 'panel', message: `cc 会话 ${sid} 已搬到主仓库 projects 目录 (issue #${issueNumber})` }) } catch (err) { logger.add({ level: 'warn', source: 'panel', message: `搬迁 cc 会话 ${sid} 失败 (issue #${issueNumber}),worktree 清理仍继续`, details: err instanceof Error ? err.message : String(err), }) } } } // 5. Best-effort cleanup of the worktree. Failures are non-fatal — the // state JSON already records done, user can manually clean later. if (worktreePath) { const abs = resolveWorktreePath(worktreePath, workspaceRoot) if (fs.existsSync(abs)) { // 删前先 dispose 该工单的全部会话终端并等一小会:实施/测试会话可能仍在 // worktree 里跑(写构建产物、持有 cwd),先让它们退出,避免边删边被写。 let disposedAny = false for (const [terminal, origin] of panel.terminalOrigin) { if (origin.issueNumber === issueNumber) { try { terminal.dispose() disposedAny = true } catch { // dispose 失败无所谓,VS Code 会清掉关闭事件 } } } if (disposedAny) await new Promise(r => setTimeout(r, 600)) // Pre-remove hook — same best-effort contract as the other call // sites. Run before the actual remove so user scripts can still // touch the worktree dir. const settingsForHook = getSettings(panel.context) await panel.dispatchWorktreeHook('pre-remove', { workspaceRoot, worktreePath: abs, branch: resolveFeatureBranch({ stateBranch: featureBranchFromState, prHeadRef: pullRequest.headRef, }), issueNumber, mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) // dispose 终端后仍可能有 dev server 等残留;杀掉再删,占用不再回滚拖拽。 const killed = killProcessesUsingWorktree(abs) if (killed.length > 0) { const list = killed .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') logger.add({ level: 'warn', source: 'panel', message: `清理 worktree 前已杀掉占用进程 (issue #${issueNumber})`, details: list, }) } try { await removeWorktreeDir(workspaceRoot, abs) logger.add({ level: 'info', source: 'panel', message: `已清理 worktree ${worktreePath} (issue #${issueNumber})`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `清理 worktree 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `工单 #${issueNumber} 已完成,但 worktree 清理失败: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } } } const featureBranch = resolveFeatureBranch({ stateBranch: featureBranchFromState, prHeadRef: pullRequest.headRef, }) if (featureBranch) { const settingsForBranchCleanup = getSettings(panel.context) await cleanupFeatureBranch({ workspaceRoot, branch: featureBranch, issueNumber, devBranch: settingsForBranchCleanup.devBranch, remote, token, }) } else { logger.add({ level: 'warn', source: 'panel', message: `工单 #${issueNumber} 完成时未解析到 feature 分支,跳过分支清理`, details: `state.branch=${featureBranchFromState ?? ''} pr.head.ref=${pullRequest.headRef || ''}`, }) } // 6. 全部成功 → 增量推 done + 用 null 清空 worktreePath/branch(postMessage 会丢 undefined key) // 并带 worktreeExists: false,详情「工作树」行即时变为只读/—。 panel.postMessage({ type: 'issue/patch', issueNumber, patch: { column: 'done', worktreePath: null, branch: null, worktreeExists: false, prMerged: true, prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), }, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: featureBranch ? `工单 #${issueNumber} 已完成,worktree 与分支 ${featureBranch} 已清理` : `工单 #${issueNumber} 已完成,worktree 已清理`, dismissOnTimer: 5000, }) } export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: number): Promise { const rollback = (fromColumn: IssueColumn | undefined): void => { panel.postMessage({ type: 'issue/patch', issueNumber, patch: { column: fromColumn ?? 'in-progress' }, }) } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) rollback(undefined) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) rollback(undefined) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) rollback(undefined) return } // ① 读 state → 守卫(todo no-op / done 拒绝)→ 确认;取消 rollback 必须先有 fromColumn let prStr: string | undefined let worktreePath: string | undefined let fromColumn: IssueColumn | undefined let featureBranchFromState: string | undefined try { const stateObj = await panel.readIssueState(issueNumber) if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0) prStr = stateObj.pr if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0) worktreePath = stateObj.worktreePath if ( typeof stateObj.column === 'string' && ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column) ) { fromColumn = stateObj.column as IssueColumn } if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0) featureBranchFromState = stateObj.branch } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 状态失败`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `读取工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) rollback(undefined) return } if (fromColumn === 'todo') return if (!canResetToTodo(fromColumn ?? 'in-progress')) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `已完成的工单 #${issueNumber} 不能重置为待办`, dismissOnTimer: 5000, }) rollback(fromColumn) return } const choice = await window.showWarningMessage( `确定将工单 #${issueNumber} 重置为待办?将停止会话、删除 worktree / 未合并 PR / feature 分支,实施进度不可恢复。`, { modal: true }, '重置为待办', ) if (choice !== '重置为待办') { rollback(fromColumn) return } // ② 停会话 → 删 worktree(失败即停)→ 未合并 PR 关掉 → 清 feature 分支 for (const [terminal, origin] of panel.terminalOrigin) { if (origin.issueNumber === issueNumber) { try { terminal.dispose() } catch { // dispose 失败也无所谓,VS Code 自己会清掉关闭事件 } } } if (worktreePath) { const abs = resolveWorktreePath(worktreePath, workspaceRoot) if (fs.existsSync(abs)) { const settingsForHook = getSettings(panel.context) await panel.dispatchWorktreeHook('pre-remove', { workspaceRoot, worktreePath: abs, branch: resolveFeatureBranch({ stateBranch: featureBranchFromState }), issueNumber, mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) const killed = killProcessesUsingWorktree(abs) if (killed.length > 0) { const list = killed .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') logger.add({ level: 'warn', source: 'panel', message: `重置待办前已杀掉占用进程 (issue #${issueNumber})`, details: list, }) } try { await removeWorktreeDir(workspaceRoot, abs) logger.add({ level: 'info', source: 'panel', message: `已清理 worktree ${worktreePath} (issue #${issueNumber})`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `清理 worktree 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `删除 worktree 失败 #${issueNumber}: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } } } let prHeadRef: string | undefined if (prStr) { const prIndex = Number.parseInt(prStr, 10) if (!Number.isFinite(prIndex)) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `工单 #${issueNumber} 的 PR 字段无效: ${prStr}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } try { const pullRequest = await getPullRequest({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: prIndex, }) prHeadRef = pullRequest.headRef if (!pullRequest.merged) { await closeIssue({ host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber: prIndex, }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `处理 PR #${prIndex} 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `处理 PR #${prIndex} 失败 (issue #${issueNumber}): ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } } const featureBranch = resolveFeatureBranch({ stateBranch: featureBranchFromState, prHeadRef, }) if (featureBranch) { const settingsForBranchCleanup = getSettings(panel.context) await cleanupFeatureBranch({ workspaceRoot, branch: featureBranch, issueNumber, devBranch: settingsForBranchCleanup.devBranch, remote, token, }) } // ③ 落盘清空实施痕迹 → UI patch 用 null(postMessage 会丢 undefined) try { await panel.mergeIssueState(issueNumber, todoResetStateExtra()) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 column=todo 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) rollback(fromColumn) return } panel.postMessage({ type: 'issue/patch', issueNumber, patch: todoResetUiPatch(), }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: `工单 #${issueNumber} 已重置为待办`, dismissOnTimer: 5000, }) } /** * Handle a "drag to in-progress" kanban move. * * Validates the issue has a valid `planFile` recorded in its state, * and that the file actually exists on disk, then delegates the full * implementation pipeline to `handleImplement` (which owns issue locking, * tab reuse, column/branch/worktreePath state writes, gitea webhook * registration, cc spawn). On any pre-flight failure, rolls the optimistic * column move back to the source column. */ export async function handleDropToInProgress(panel: KanbanWebviewPanel, issueNumber: number): Promise { // Failed pre-flight → rollback optimistic move to source column. // Drags into in-progress typically originate from 'todo', so fall back // there when state JSON has no usable column field. const rollback = (fromColumn: IssueColumn | undefined): void => { panel.postMessage({ type: 'issue/patch', issueNumber, patch: { column: fromColumn ?? 'todo' }, }) } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { rollback(undefined) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { rollback(undefined) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { rollback(undefined) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // 读工单状态(共享评论叠加本机记录)拿 source column + planFile + // 可选的 profilePath / sessionId。 let stateObj: Record = {} try { stateObj = await panel.readIssueState(issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 状态失败`, details: message, }) rollback(undefined) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `读取工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) return } const fromColumn: IssueColumn | undefined = (typeof stateObj.column === 'string' && ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column)) ? stateObj.column as IssueColumn : undefined // No-op if state JSON already says in-progress (drag-on-self). if (fromColumn === 'in-progress') { logger.add({ level: 'info', source: 'panel', message: `#${issueNumber} 已在 in-progress,跳过拖放触发`, }) return } // planFile must be a valid spx path and actually exist on disk. const planFileRaw = stateObj.planFile if (!isValidSpxFilePath(planFileRaw)) { rollback(fromColumn) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `工单 #${issueNumber} 无合法 planFile,无法启动实施`, dismissOnTimer: 5000, }) return } const planFile: string = planFileRaw const absPlan = path.isAbsolute(planFile) ? planFile : path.join(workspaceRoot, planFile) if (!fs.existsSync(absPlan)) { rollback(fromColumn) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `计划文件不存在 #${issueNumber}: ${planFile}`, dismissOnTimer: 5000, }) return } const profilePath = typeof stateObj.profilePath === 'string' && stateObj.profilePath.length > 0 ? stateObj.profilePath : undefined const sessionId = typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0 ? stateObj.sessionId : undefined try { await panel.handleImplement(issueNumber, planFile, profilePath, sessionId) } catch (err) { const message = err instanceof Error ? err.message : String(err) rollback(fromColumn) logger.add({ level: 'error', source: 'panel', message: `拖到 in-progress 触发实施失败 #${issueNumber}`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `启动实施失败 #${issueNumber}`, dismissOnTimer: 5000, }) } } /** * 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 * 成功后复用 issue/remove 消息,让 open issues 看板立即移除该工单。 */ export async function handleCloseIssue(panel: KanbanWebviewPanel, issueNumber: number): Promise { logger.add({ level: 'info', source: 'panel', message: `收到关闭工单请求 #${issueNumber}`, }) const choice = await window.showWarningMessage( `确定关闭工单 #${issueNumber}?此操作只会关闭 Gitea 工单,不会清理本地会话、worktree、PR 或分支。`, { modal: true }, '关闭工单', ) if (choice !== '关闭工单') return const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } try { await closeIssue({ host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `关闭工单 #${issueNumber} 失败`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `关闭工单 #${issueNumber} 失败: ${message}`, dismissOnTimer: 6000, }) return } logger.add({ level: 'info', source: 'panel', message: `已关闭工单 #${issueNumber}`, }) panel.postMessage({ type: 'issue/remove', issueNumber }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: `已关闭工单 #${issueNumber}`, dismissOnTimer: 4000, }) } /** * 硬删 Gitea 工单 + 关联资源(worktree / PR / feature branch / cc session * tabs)。每步独立 try/catch,任一步失败立即停下并 toast 报错,让用户手动 * 处理。前端在最后一步收到 `issue/remove` 后从 issues 数组移除该工单。 */ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: number): Promise { // 1. modal confirm — 用户没点"删除"就 abort const choice = await window.showWarningMessage( `确定删除工单 #${issueNumber}?此操作不可撤销,将清理 worktree / 关闭 PR / 删除 feature branch / 删除 issue。`, { modal: true }, '删除', ) if (choice !== '删除') return // 2. workspace / repo / token 前置 const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // 3. 读工单状态(共享评论叠加本机记录)拿 pr / branch / worktreePath let stateObj: Record = {} try { stateObj = await panel.readIssueState(issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `读取工单 #${issueNumber} 状态失败: ${message}`, dismissOnTimer: 6000, }) return } const prStr = typeof stateObj.pr === 'string' ? stateObj.pr : '' const branch = typeof stateObj.branch === 'string' ? stateObj.branch : '' const worktreePath = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : '' // 4. 关 cc/codex tab:扫 terminalOrigin 找该工单的所有 terminal 全部 dispose for (const [terminal, origin] of panel.terminalOrigin) { if (origin.issueNumber === issueNumber) { try { terminal.dispose() } catch { // dispose 失败也无所谓,VS Code 自己会清掉关闭事件 } } } // 5. 删 worktree(如有)— 递归强删目录(removeWorktreeDir),失败立即停 if (worktreePath) { const absWorktree = resolveWorktreePath(worktreePath, workspaceRoot) if (fs.existsSync(absWorktree)) { // Pre-remove hook before nuking the worktree. Best-effort — // hook failure does NOT block the destructive delete. const settingsForHook = getSettings(panel.context) await panel.dispatchWorktreeHook('pre-remove', { workspaceRoot, worktreePath: absWorktree, branch, issueNumber, mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) // 先杀占用再删,与拖到完成 / 删 worktree 同一路径。 const killed = killProcessesUsingWorktree(absWorktree) if (killed.length > 0) { const list = killed .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') logger.add({ level: 'warn', source: 'panel', message: `删除工单前已杀掉占用 worktree 的进程 (issue #${issueNumber})`, details: list, }) } try { await removeWorktreeDir(workspaceRoot, absWorktree) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `删除 worktree 失败 #${issueNumber}: ${message}`, dismissOnTimer: 6000, }) return } } } // 6. 关 PR(gitea 把 PR 当 issue subtype 处理,PATCH /issues/{prNumber} 即可) if (prStr) { const prIndex = Number.parseInt(prStr, 10) if (Number.isFinite(prIndex)) { try { await closeIssue({ host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber: prIndex, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `关闭 PR #${prIndex} 失败 (issue #${issueNumber}): ${message}`, dismissOnTimer: 6000, }) return } } } // 7. 删 feature branch(如有) if (branch) { try { await deleteBranch({ host: remote.host, token, owner: remote.owner, repo: remote.repo, branch, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `删除分支 ${branch} 失败 (issue #${issueNumber}): ${message}`, dismissOnTimer: 6000, }) return } } // 8. 硬删 issue 本身 try { await deleteIssue({ host: remote.host, token, owner: remote.owner, repo: remote.repo, issueNumber, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `删除工单 #${issueNumber} 失败: ${message}`, dismissOnTimer: 6000, }) return } logger.add({ level: 'info', source: 'panel', message: `已硬删工单 #${issueNumber}(含 worktree / PR / branch / tab)`, }) // 9. 前端推 remove + success toast panel.postMessage({ type: 'issue/remove', issueNumber }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: `工单 #${issueNumber} 已删除`, dismissOnTimer: 4000, }) } export async function handleIssueCreate( panel: KanbanWebviewPanel, userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string, ): Promise { const trimmed = userRequest.trim() if (!trimmed) { // Webview already disables the submit button when empty, but be defensive. return } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // Ensure the always-on webhook listener is up so we can receive the // `issues opened` callback that drives state-JSON fill-in. const settings = getSettings(panel.context) try { await webhookCoordinator.ensurePort(settings.webhookPort) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `webhook 服务启动失败: ${message}`, dismissOnTimer: 8000, }) return } const nonce = randomUUID() const shortNonce = nonce.slice(0, 8) const inboxDir = path.join(os.tmpdir(), 'spx-inbox', nonce) try { await fsp.mkdir(inboxDir, { recursive: true }) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `创建临时目录失败: ${message}`, dismissOnTimer: 8000, }) return } // Persist any pasted images to the inbox tmpdir so cc can Read them. // The `[Image #N]` tokens in `trimmed` stay in place — the prompt also // lists absolute paths at the end so cc can correlate. const mediaTypeToExt: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', } const imagePaths: string[] = [] if (images && images.length > 0) { for (let i = 0; i < images.length; i++) { const img = images[i] const ext = mediaTypeToExt[img.mediaType.toLowerCase()] ?? 'png' const abs = path.join(inboxDir, `${i + 1}.${ext}`) try { await fsp.writeFile(abs, Buffer.from(img.base64, 'base64')) imagePaths.push(abs) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `图片落盘失败 (${i + 1}/${images.length})`, details: message, }) } } } const color = pickRandomIssueColor() // 工单还不存在,模板上下文里没有 issueNumber / state —— 模板据此走新建工单分支。 const prompt = renderBrainstormPrompt(panel.context, { userRequest: trimmed, nonce, imagePaths: imagePaths.length > 0 ? imagePaths : undefined, }) // 新建工单弹窗里选的 profile > 设置里的「头脑风暴默认配置文件」> DEFAULT。 const effectiveProfilePath = sessions.resolveBrainstormProfilePath(panel, brainstormProfilePath) // Ensure the claude projects subdir exists *before* spawning the // terminal so the watcher can't miss the create event. const projDir = projectsDirFor(workspaceRoot) try { await fsp.mkdir(projDir, { recursive: true }) } catch (err) { console.warn('[superpowers] failed to mkdir claude projects dir:', err) } const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) const terminalName = `issue-new-${shortNonce}-规划` const themeColor = new ThemeColor(color) const iconUri = themeColorIdToIconUri(color) const terminal = window.createTerminal({ name: terminalName, cwd: workspaceRoot, location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt })), }) terminal.show(false) logger.add({ level: 'info', source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) // 新建工单会话是头脑风暴;只把用户选中的 brainstorm profile 写入 pending, // 空串不落盘(webhook 侧也会跳过空值),实施/测试 profile 各自独立配置。 const storedBrainstormProfile = brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath.trim() : undefined panel.pendingIssueCreations.set(nonce, { brainstormProfilePath: storedBrainstormProfile, color, workspaceRoot, inboxDir, terminalName, terminal, createdAt: Date.now(), }) logger.add({ level: 'info', source: 'panel', message: `已启动新建工单会话 nonce=${shortNonce}`, }) panel.postMessage({ type: 'toast/show', id: `issue-new-${shortNonce}`, level: 'info', message: '正在打开新工单会话…', spinner: true, dismissOnTimer: 4000, }) // 工单往往比会话文件先出现。webhook 把 pending 取走时 sessionId 还空, // 这里在文件落地后补写本机会话 id。 watchPromise.then(async (sid) => { if (!sid) { logger.add({ level: 'warn', source: 'panel', message: `新建工单会话监听超时 nonce=${shortNonce}`, }) return } const pending = panel.pendingIssueCreations.get(nonce) if (pending) { pending.sessionId = sid logger.add({ level: 'info', source: 'panel', message: `已捕获新建工单会话 ${sid} nonce=${shortNonce}`, }) return } const issueNumber = issueNumberForNewIssueTerminal(panel, terminal) if (issueNumber === undefined) { logger.add({ level: 'warn', source: 'panel', message: `新建工单会话 ${sid} 已捕获,但 nonce=${shortNonce} 还没对上工单`, }) return } try { const state = await panel.readIssueState(issueNumber) if (typeof state.sessionId === 'string' && state.sessionId.length > 0) return } catch { // 本机状态还没有,照写 } await mergeLocalIssueState(panel.context, 'gitea', issueNumber, { sessionId: sid }) panel.terminals.set(sid, terminal) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { sessionId: sid } }) logger.add({ level: 'info', source: 'panel', message: `补写工单 #${issueNumber} 头脑风暴会话 ${sid}`, }) }).catch((err) => { console.warn('[superpowers] new-issue session watch failed:', err) }) } /** * 把 Gitea 工单状态改成 closed。failure non-fatal —— state JSON 已写、PR 已合、 * 看板已切到 done 列,rollback 意义不大。失败时打 warn 日志 + warning toast, * 用户可手动关闭工单。 */ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: { host: string token: string owner: string repo: string issueNumber: number }): Promise { try { await closeIssue(opts) logger.add({ level: 'info', source: 'panel', message: `已关闭 Gitea 工单 #${opts.issueNumber}`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `同步关闭 Gitea 工单 #${opts.issueNumber} 失败`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `工单 #${opts.issueNumber} 状态同步失败,请手动关闭: ${message}`, dismissOnTimer: 6000, }) } } /** * Server-side lock check for prerequisite gating(不信 webview 传来的状态)。 * 优先用主进程自己算好的工单快照——开会话是热路径,全量 loadIssues 要拉工单 * 列表、每张工单的评论、依赖和 PR,几秒起步;快照没有该工单(面板还没加载 * 完)时才退回一次全量拉取。 * * Fail-open: any error fetching issues is logged and we return * `{ locked: false }` so a transient Gitea hiccup can't lock the user out * of starting a session. */ export async function resolveLockedReason( panel: KanbanWebviewPanel, issueNumber: number, ): Promise { try { const snapshot = panel.snapshotIssues() if (snapshot?.some(i => i.number === issueNumber)) return evaluateLockedReason(snapshot, issueNumber) const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return { locked: false } const remote = await detectRepo(workspaceRoot) if (!remote) return { locked: false } const token = await getToken(panel.context, remote.host) if (!token) return { locked: false } const { issues } = await loadIssues({ host: remote.host, token, owner: remote.owner, repo: remote.repo, workspaceRoot, }) return evaluateLockedReason(issues, issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `锁定检查失败,放行 #${issueNumber}`, details: message, }) return { locked: false } } } export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber: number, prerequisiteNumber: number): Promise { // Gitea-only: dependency links don't map to YouTrack. Ignore for youtrack // cards rather than calling the gitea API with a synthetic number. if (panel.isYouTrackIssue(issueNumber) || panel.isYouTrackIssue(prerequisiteNumber)) return const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } try { // Gitea 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。 const deps = await getDependencies({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: issueNumber, }) for (const dep of deps) { if (dep.number !== prerequisiteNumber) { await removeDependency({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: issueNumber, dependencyIndex: dep.number, }) } } if (!deps.some(d => d.number === prerequisiteNumber)) { await addDependency({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: issueNumber, dependencyIndex: prerequisiteNumber, }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `设置工单 #${issueNumber} 依赖 #${prerequisiteNumber} 失败`, details: message, }) void window.showWarningMessage(`设置工单 #${issueNumber} 依赖 #${prerequisiteNumber} 失败: ${message}`) return } logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} 已设置前置依赖 #${prerequisiteNumber}`, }) void panel.loadAndPush() } export async function handleClearDependency(panel: KanbanWebviewPanel, issueNumber: number, prerequisiteNumber: number): Promise { if (panel.isYouTrackIssue(issueNumber) || panel.isYouTrackIssue(prerequisiteNumber)) return const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } try { await removeDependency({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: issueNumber, dependencyIndex: prerequisiteNumber, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `清除工单 #${issueNumber} 依赖 #${prerequisiteNumber} 失败`, details: message, }) void window.showWarningMessage(`清除工单 #${issueNumber} 依赖 #${prerequisiteNumber} 失败: ${message}`) return } logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} 已清除前置依赖 #${prerequisiteNumber}`, }) void panel.loadAndPush() } /** * Persist the per-issue `autoReview` override into the issue's state-JSON * comment. The webhook coordinator reads this on every PR `opened` / * `synchronize` to decide whether to fire a review. Set true/false → use * that value; we never write back `undefined` (the user explicitly chose). */ export async function handleUpdateAutoReview(panel: KanbanWebviewPanel, issueNumber: number, value: boolean): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // Snapshot the previous value so we can roll the webview back on failure // without re-broadcasting the whole issues list. let previousValue: boolean | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.autoReview === 'boolean' ? existingState.autoReview : undefined } catch { // If we can't read the previous state, leave previousValue undefined — // the rollback patch will clear the override, which is the safest // recovery (webview will fall back to the global autoReview default). previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { autoReview: value }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} autoReview=${value} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 autoReview 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 自动审查开关失败: ${message}`, dismissOnTimer: 6000, }) // Roll back the optimistic update in the webview. panel.postMessage({ type: 'issue/patch', issueNumber, patch: { autoReview: previousValue }, }) } } export async function handleUpdateReviewPassed(panel: KanbanWebviewPanel, issueNumber: number, value: boolean): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } let previousValue: boolean | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.reviewPassed === 'boolean' ? existingState.reviewPassed : undefined } catch { previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { reviewPassed: value }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} reviewPassed=${value} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 reviewPassed 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 审查通过标记失败: ${message}`, dismissOnTimer: 6000, }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { reviewPassed: previousValue }, }) } } /** * Persist a per-issue `profilePath` override into the issue's state JSON * comment. Implement / implement-resume sessions use this profile * (see resolveImplementProfilePath); empty = 设置里的「实施默认配置文件」,再空 = getDefaultProfilePath()。 * Mirrors handleUpdateAutoReview: optimistic update on the webview, rolled * back via `issue/patch` if the persist fails. */ export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNumber: number, profilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // Snapshot the previous value so we can roll the webview back on failure // without re-broadcasting the whole issues list. let previousValue: string | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.profilePath === 'string' ? existingState.profilePath : undefined } catch { // If we can't read the previous state, leave previousValue undefined — // the rollback patch will clear the override, falling back to the default. previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { profilePath }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} profilePath=${profilePath} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 profilePath 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 配置文件失败: ${message}`, dismissOnTimer: 6000, }) // Roll back the optimistic update in the webview. panel.postMessage({ type: 'issue/patch', issueNumber, patch: { profilePath: previousValue }, }) } } /** * 清掉工单记的某种会话 id,之后该行可以新起一个会话。 * * 用处:会话 id 是别的机器上开的、本机没有会话文件,resume 不了又挡着新起; * 或者旧会话历史太杂(头脑风暴里还留着几个版本之前的提示词和报错),想从干净 * 的上下文重来。只写本机记录('' 墓碑盖住共享值),别的机器不受影响;会话文件 * 本身不动,手上有 id 仍可 resume 找回。 */ export async function handleClearSession(panel: KanbanWebviewPanel, issueNumber: number, kind: SessionTabKind): Promise { const { field, role } = SESSION_KINDS[kind] let previousValue: string | undefined try { const existingState = await panel.readIssueState(issueNumber) const value = existingState[field] previousValue = typeof value === 'string' && value.length > 0 ? value : undefined } catch { previousValue = undefined } if (!previousValue) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'info', message: `工单 #${issueNumber} 本来就没有${role}会话`, dismissOnTimer: 4000, }) return } const choice = await window.showWarningMessage( clearSessionConfirmText(issueNumber, kind, previousValue), { modal: true }, '清除', ) if (choice !== '清除') return try { if (kind === 'review') await sessions.cancelReviewSessionCapture(panel, issueNumber) await panel.mergeIssueState(issueNumber, { [field]: '' }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `清除 ${field} 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `清除工单 #${issueNumber} ${role}会话失败: ${message}`, dismissOnTimer: 6000, }) return } panel.postMessage({ type: 'issue/patch', issueNumber, patch: { [field]: null }, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'success', message: clearSessionDoneText(issueNumber, kind), dismissOnTimer: 5000, }) } /** * Persist a per-issue `brainstormProfilePath` override into the issue's state * JSON. Brainstorm start/resume use this profile (see resolveBrainstormProfilePath); * empty = 设置里的「头脑风暴默认配置文件」,再空 = getDefaultProfilePath()。与实施/测试 profile 互不回退。 */ export async function handleUpdateBrainstormProfilePath(panel: KanbanWebviewPanel, issueNumber: number, brainstormProfilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } let previousValue: string | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.brainstormProfilePath === 'string' ? existingState.brainstormProfilePath : undefined } catch { previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { brainstormProfilePath }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} brainstormProfilePath=${brainstormProfilePath} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 brainstormProfilePath 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 头脑风暴配置文件失败: ${message}`, dismissOnTimer: 6000, }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { brainstormProfilePath: previousValue }, }) } } /** * Persist a per-issue `testProfilePath` override into the issue's state JSON * comment. Test start/resume use this profile (see resolveTestProfilePath); * empty = 设置里的「测试默认配置文件」,再空 = getDefaultProfilePath(),不回退实施 profile。 */ export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, issueNumber: number, testProfilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // Snapshot the previous value so we can roll the webview back on failure // without re-broadcasting the whole issues list. let previousValue: string | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.testProfilePath === 'string' ? existingState.testProfilePath : undefined } catch { // If we can't read the previous state, leave previousValue undefined — // the rollback patch will clear the override, falling back to the default. previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { testProfilePath }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} testProfilePath=${testProfilePath} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 testProfilePath 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 测试配置文件失败: ${message}`, dismissOnTimer: 6000, }) // Roll back the optimistic update in the webview. panel.postMessage({ type: 'issue/patch', issueNumber, patch: { testProfilePath: previousValue }, }) } } /** * Persist a per-issue `prototypeProfilePath` override into the issue's state JSON * comment. 原型 start/resume 用它(见 resolvePrototypeProfilePath); * 空 = 设置里的「原型默认配置文件」,再空 = getDefaultProfilePath(),与其它会话互不回退。 */ export async function handleUpdatePrototypeProfilePath(panel: KanbanWebviewPanel, issueNumber: number, prototypeProfilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } const remote = await detectRepo(workspaceRoot) if (!remote) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '当前工作区没有 Gitea 远程仓库', dismissOnTimer: 5000, }) return } const token = await getToken(panel.context, remote.host) if (!token) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先完成 Gitea 配置', dismissOnTimer: 5000, }) return } // Snapshot the previous value so we can roll the webview back on failure // without re-broadcasting the whole issues list. let previousValue: string | undefined try { const existingState = await panel.readIssueState(issueNumber) previousValue = typeof existingState.prototypeProfilePath === 'string' ? existingState.prototypeProfilePath : undefined } catch { // If we can't read the previous state, leave previousValue undefined — // the rollback patch will clear the override, falling back to the default. previousValue = undefined } try { await panel.mergeIssueState(issueNumber, { prototypeProfilePath }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} prototypeProfilePath=${prototypeProfilePath} 已持久化`, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `持久化 prototypeProfilePath 失败 (issue #${issueNumber})`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `保存工单 #${issueNumber} 原型配置文件失败: ${message}`, dismissOnTimer: 6000, }) // Roll back the optimistic update in the webview. panel.postMessage({ type: 'issue/patch', issueNumber, patch: { prototypeProfilePath: previousValue }, }) } } export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '请先打开一个工作区文件夹', dismissOnTimer: 5000, }) return } // 多数字段存的是仓库相对路径,但 prototypeFile 允许绝对路径(插件可能在仓库外)。 const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath) const isMarkdown = abs.toLowerCase().endsWith('.md') try { if (isMarkdown) { await commands.executeCommand('markdown.showPreview', Uri.file(abs)) } else { await commands.executeCommand('vscode.open', Uri.file(abs)) } } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: `打开文件失败: ${message}`, dismissOnTimer: 6000, }) } } /** * Resolve the gitea PR URL for the current workspace's remote and open it * in the user's default browser. Called from the webview's pr-link * button. */ export async function handleOpenPr(panel: KanbanWebviewPanel, pr: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } const remote = await detectRepo(workspaceRoot) if (!remote) { void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') return } const url = `https://${remote.host}/${remote.owner}/${remote.repo}/pulls/${pr}` void env.openExternal(Uri.parse(url)) } export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } const remote = await detectRepo(workspaceRoot) if (!remote) { void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') return } const token = await getToken(panel.context, remote.host) if (!token) { void window.showErrorMessage('请先完成 Gitea 配置') return } try { const state = await panel.readIssueState(issueNumber) const pr = typeof state.pr === 'string' && /^\d+$/.test(state.pr) ? state.pr : undefined if (!pr) { panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'error', message: `#${issueNumber} 尚未关联有效 PR`, dismissOnTimer: 8000, }) void window.showErrorMessage(`#${issueNumber} 尚未关联有效 PR`) return } const stateWorktreePath = typeof state.worktreePath === 'string' && state.worktreePath.length > 0 ? state.worktreePath : undefined if (!stateWorktreePath) { panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'error', message: `#${issueNumber} 尚未记录 worktree 路径`, dismissOnTimer: 8000, }) void window.showErrorMessage(`#${issueNumber} 尚未记录 worktree 路径`) return } const worktreePath = resolveWorktreePath(stateWorktreePath, workspaceRoot) let worktreeStat: fs.Stats try { worktreeStat = await fsp.stat(worktreePath) } catch { void window.showErrorMessage(`#${issueNumber} 的 worktree 不存在: ${worktreePath}`) return } if (!worktreeStat.isDirectory()) { void window.showErrorMessage(`#${issueNumber} 的 worktree 不是目录: ${worktreePath}`) return } const outputRelPath = `docs/pr-diff/pr-${pr}-issue-${issueNumber}.md` const outputAbsPath = path.join(workspaceRoot, outputRelPath) panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'info', message: `已启动 PR #${pr} 变更摘要生成`, dismissOnTimer: 5000, }) const rawProfile = await getEffectivePrDiffSummaryProfilePath(panel.context) const resolution = resolveCommitProfileForRun( rawProfile, resolveProfilePath, (p) => { try { fs.accessSync(p, fs.constants.R_OK) return true } catch { return false } }, ) if (!resolution.ok) { const message = resolution.reason === 'empty' ? '未配置改动说明 profile,请在设置中选择' : `改动说明 profile 不可用:${resolution.profilePath}` panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'error', message, dismissOnTimer: 8000, }) return } const profilePath = resolution.profilePath const prompt = `你在一个 VS Code 扩展启动的后台 Claude 会话中工作。\n\n硬性限制:\n- 不要修改代码。\n- 不要创建或切换分支。\n- 不要提交。\n- 不要 push。\n- 不要写任何文件。\n- 只分析当前 PR 的代码变动,并把 Markdown 摘要正文直接输出到 stdout。\n\n任务:\n- 当前工作目录是工单 #\${issueNumber} 的 worktree。\n- 分析 PR #\${pr} 与主分支的代码差异。\n- 用 git 拿差异:先 git fetch origin main,再 git diff origin/main...HEAD(概览可加 --stat)。注意 tea 没有 diff 子命令,不要尝试 tea pulls diff。\n\n输出必须是合法 Markdown,预览器要能渲染出标题层级与代码块高亮,结构如下:\n1. 第一行是一级标题:# PR #\${pr} 代码变更摘要\n2. 紧接着用 1~2 句话概述本次变更:做了什么、新增了哪些包/模块、关键依赖方向(例如「stockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入」)。\n3. 然后写一行图例:图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n4. 然后是一棵统一的目录树,放进单个围栏代码块(语言标注用 text):树前一行写三个反引号紧跟 text,树后一行写三个反引号。\n\n目录树规则:\n- 用 box-drawing 连线字符画树:├──、└──、│,缩进对齐,像 tree 命令的输出,不要只用纯空格缩进糊一棵没有连线的树。\n- 从仓库根开始保留真实目录层级。\n- 每个文件行以标记开头:新建文件用「+ 」、修改文件用「✎ 」,后接文件名 + 若干空格对齐 + 一句中文目的描述(简短,点出关键符号/端点/职责)。\n- 目录节点行尾可加可选的阶段/分组标签(如 [Phase N]),仅当本次变更本身有清晰阶段时才加;没有就不加,不要硬编。\n- 关键:整棵树一定要写在围栏代码块之内,否则 Markdown 预览器会折叠空白、丢掉层级与连线。\n\n排序与重点:\n- 按架构重要性排列:核心业务/领域代码(实现逻辑、入口、API routes、数据模型、service/repository)靠前。\n- alembic 数据库迁移文件、测试文件不算重点:放在各自模块或整棵树的靠后位置,照常用 +/✎ 标记列出,但不要放在最前、不要强调。\n- 测试文件判定:文件名形如 test_* / *_test.* / *.test.* / *.spec.*,或路径包含 tests/、__tests__/ 目录,或 conftest.py、fixture、mock 文件。\n- 迁移文件判定:路径在 alembic/、migrations/、versions/ 之下的文件。\n\n格式示例(请照此结构输出,不要照抄内容):\n\n# PR #123 代码变更摘要\n\nstockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入。\n\n图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n\n\\\`\\\`\\\`text\nbackend/\n└── src/luffy_agent/commerce/\n ├── models.py ✎ +StockPool;ProductCatalog 加 pool_id\n ├── stockpool/\n │ ├── service.py + reserve_pool_or_raise + compute_pool_views\n │ └── routes.py + /admin/commerce/stock-pools\n ├── orders/service.py ✎ create_order 绑定池分支\n └── alembic/versions/ + stock_pool 建表 + pool_id 加列\n\\\`\\\`\\\`\n\n要求:\n- 每个新增/修改文件一行,一句话说明新增/修改了什么及其目的。\n- 整棵树只用一个围栏代码块,用 box-drawing 连线保留真实目录层级。\n- 只输出上述 Markdown 正文本身,不要写文件,不要任何前导说明/寒暄/结尾总结,第一行就是 # PR #\${pr} 代码变更摘要 标题。` const result = await spawnClaude({ prompt, cwd: worktreePath, profilePath, timeoutMs: 600_000, }) const summary = result.resultText.trim() if (!summary) { panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'error', message: 'Claude 未返回摘要内容', dismissOnTimer: 8000, }) void window.showErrorMessage('Claude 未返回摘要内容') return } fs.mkdirSync(path.dirname(outputAbsPath), { recursive: true }) fs.writeFileSync(outputAbsPath, summary, 'utf8') // prDiffFile 是本机生成的文件路径,经漏斗落 workspaceState,不进共享评论。 await panel.mergeIssueState(issueNumber, { prDiffFile: outputRelPath }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { prDiffFile: outputRelPath }, }) panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'success', message: `PR #${pr} 变更摘要已生成`, dismissOnTimer: 5000, }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `生成 PR 变更摘要失败 #${issueNumber}`, details: message, }) panel.postMessage({ type: 'toast/show', id: randomUUID(), level: 'error', message: `生成 PR 变更摘要失败: ${message}`, dismissOnTimer: 8000, }) void window.showErrorMessage(`生成 PR 变更摘要失败: ${message}`) } } finally { panel.postMessage({ type: 'issue/pr-diff-summary-done', issueNumber }) } }