Files
superwork/vscode/src/panel/handlers/issues.ts
T

2367 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IssueColumn } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
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 { buildCcCommand } from '../../cc/ccCommand'
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
import { getDefaultProfilePath, resolveProfilePath } from '../../cc/profiles'
import { getBrainstormPrompt } from '../../cc/prompts'
import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'
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 { logger } from '../../logging/logger'
import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import * as sessions from './sessions'
import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
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 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.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<ReturnType<typeof getPullRequest>>
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) && worktreePath) {
const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot)
const srcProjectsDir = projectsDirFor(worktreeAbs)
const dstProjectsDir = projectsDirFor(workspaceRoot)
for (const sid of [implementSessionId, testSessionId]) {
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<void>(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<void> {
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<void> {
// 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<string, unknown> = {}
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<void> {
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<void> {
// 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<string, unknown> = {}
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<void> {
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<string, string> = {
'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()
const prompt = getBrainstormPrompt(panel.context, {
userRequest: trimmed,
nonce,
imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
})
if (prompt.includes('\'')) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '创建失败:prompt 含单引号,拒绝执行',
dismissOnTimer: 8000,
})
try {
await fsp.rm(inboxDir, { recursive: true, force: true })
}
catch {}
return
}
const effectiveProfilePath
= brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath : getDefaultProfilePath()
if (effectiveProfilePath.includes('\'')) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `创建失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
dismissOnTimer: 8000,
})
try {
await fsp.rm(inboxDir, { recursive: true, force: true })
}
catch {}
return
}
// 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,
})
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(),
})
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt })
terminal.sendText(cmd)
logger.add({
level: 'info',
source: 'panel',
message: `已发送新建工单 prompt nonce=${shortNonce}`,
})
panel.postMessage({
type: 'toast/show',
id: `issue-new-${shortNonce}`,
level: 'info',
message: '正在打开新工单会话…',
spinner: true,
dismissOnTimer: 4000,
})
// Background: fill in sessionId once the jsonl materializes. If the
// matching webhook fires before this resolves, the pending entry has
// already been deleted and we silently no-op — the state JSON just
// lacks sessionId in that (rare) race case, and the user can resume
// from a later session id via the terminal.
watchPromise.then((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}`,
})
}
}).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<void> {
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. Fetches a fresh issues
* snapshot (we don't trust webview state) and reports whether
* `issueNumber` is blocked by an unfinished prerequisite. Mirrors the
* webview-side `isIssueLocked` predicate: locked iff the issue has a
* prerequisite that exists in the current snapshot and is not yet in the
* `done` column.
*
* 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<{ locked: boolean, prerequisiteNumber?: number, prerequisiteColumn?: string }> {
try {
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,
})
const issue = issues.find(i => i.number === issueNumber)
if (!issue)
return { locked: false }
if (issue.prerequisite === undefined)
return { locked: false }
const prereq = issues.find(i => i.number === issue.prerequisite)
if (!prereq)
return { locked: false }
if (prereq.column === 'done')
return { locked: false }
return {
locked: true,
prerequisiteNumber: prereq.number,
prerequisiteColumn: prereq.column,
}
}
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<void> {
// 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<void> {
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<void> {
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 },
})
}
}
/**
* 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<void> {
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 },
})
}
}
/**
* 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<void> {
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<void> {
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 },
})
}
}
export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
const abs = 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<void> {
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<void> {
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 })
}
}