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

2620 lines
82 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 { 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<void> {
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<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 || 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<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()
// 工单还不存在,模板上下文里没有 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<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(不信 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<LockedReason> {
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<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 },
})
}
}
export async function handleUpdateReviewPassed(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
}
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<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 },
})
}
}
/**
* 清掉工单记的某种会话 id,之后该行可以新起一个会话。
*
* 用处:会话 id 是别的机器上开的、本机没有会话文件,resume 不了又挡着新起;
* 或者旧会话历史太杂(头脑风暴里还留着几个版本之前的提示词和报错),想从干净
* 的上下文重来。只写本机记录('' 墓碑盖住共享值),别的机器不受影响;会话文件
* 本身不动,手上有 id 仍可 resume 找回。
*/
export async function handleClearSession(panel: KanbanWebviewPanel, issueNumber: number, kind: SessionTabKind): Promise<void> {
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<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 },
})
}
}
/**
* 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<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.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<void> {
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<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 })
}
}