diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index f7f6fb4..baa4940 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -21,6 +21,7 @@ import { closeIssue, deleteBranch, deleteIssue, + getDependencies, getPullRequest, GiteaApiError, mergePullRequest, @@ -35,6 +36,7 @@ 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 { if (toColumn === 'in-progress') { @@ -42,6 +44,11 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: return } + if (toColumn === 'todo') { + await handleResetToTodo(panel, issueNumber) + return + } + if (toColumn !== 'done') { logger.add({ level: 'info', @@ -554,6 +561,290 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: }) } +export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const rollback = (fromColumn: IssueColumn | undefined): void => { + panel.postMessage({ + type: 'issue/patch', + issueNumber, + patch: { column: fromColumn ?? 'in-progress' }, + }) + } + + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '请先打开一个工作区文件夹', + dismissOnTimer: 5000, + }) + rollback(undefined) + return + } + + const remote = await detectRepo(workspaceRoot) + if (!remote) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '当前工作区没有 Gitea 远程仓库', + dismissOnTimer: 5000, + }) + rollback(undefined) + return + } + + const token = await getToken(panel.context, remote.host) + if (!token) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '请先完成 Gitea 配置', + dismissOnTimer: 5000, + }) + rollback(undefined) + return + } + + // ① 读 state → 守卫(todo no-op / done 拒绝)→ 确认;取消 rollback 必须先有 fromColumn + let prStr: string | undefined + let worktreePath: string | undefined + let fromColumn: IssueColumn | undefined + let featureBranchFromState: string | undefined + try { + const stateObj = await panel.readIssueState(issueNumber) + if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0) + prStr = stateObj.pr + if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0) + worktreePath = stateObj.worktreePath + if ( + typeof stateObj.column === 'string' + && ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column) + ) { + fromColumn = stateObj.column as IssueColumn + } + if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0) + featureBranchFromState = stateObj.branch + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ + level: 'error', + source: 'panel', + message: `读取工单 #${issueNumber} 状态失败`, + details: message, + }) + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `读取工单 #${issueNumber} 状态失败: ${message}`, + dismissOnTimer: 6000, + }) + rollback(undefined) + return + } + + if (fromColumn === 'todo') + return + + if (!canResetToTodo(fromColumn ?? 'in-progress')) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `已完成的工单 #${issueNumber} 不能重置为待办`, + dismissOnTimer: 5000, + }) + rollback(fromColumn) + return + } + + const choice = await window.showWarningMessage( + `确定将工单 #${issueNumber} 重置为待办?将停止会话、删除 worktree / 未合并 PR / feature 分支,实施进度不可恢复。`, + { modal: true }, + '重置为待办', + ) + if (choice !== '重置为待办') { + rollback(fromColumn) + return + } + + // ② 停会话 → 删 worktree(失败即停)→ 未合并 PR 关掉 → 清 feature 分支 + for (const [terminal, origin] of panel.terminalOrigin) { + if (origin.issueNumber === issueNumber) { + try { + terminal.dispose() + } + catch { + // dispose 失败也无所谓,VS Code 自己会清掉关闭事件 + } + } + } + + if (worktreePath) { + const abs = resolveWorktreePath(worktreePath, workspaceRoot) + if (fs.existsSync(abs)) { + const settingsForHook = getSettings(panel.context) + await panel.dispatchWorktreeHook('pre-remove', { + workspaceRoot, + worktreePath: abs, + branch: resolveFeatureBranch({ stateBranch: featureBranchFromState }), + issueNumber, + mainBranch: settingsForHook.devBranch || 'main', + customScriptPath: settingsForHook.worktreePreRemoveScript, + }) + const killed = killProcessesUsingWorktree(abs) + if (killed.length > 0) { + const list = killed + .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) + .join('\n') + logger.add({ + level: 'warn', + source: 'panel', + message: `重置待办前已杀掉占用进程 (issue #${issueNumber})`, + details: list, + }) + } + try { + await removeWorktreeDir(workspaceRoot, abs) + logger.add({ + level: 'info', + source: 'panel', + message: `已清理 worktree ${worktreePath} (issue #${issueNumber})`, + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ + level: 'error', + source: 'panel', + message: `清理 worktree 失败 (issue #${issueNumber})`, + details: message, + }) + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `删除 worktree 失败 #${issueNumber}: ${message}`, + dismissOnTimer: 6000, + }) + rollback(fromColumn) + return + } + } + } + + let prHeadRef: string | undefined + if (prStr) { + const prIndex = Number.parseInt(prStr, 10) + if (!Number.isFinite(prIndex)) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `工单 #${issueNumber} 的 PR 字段无效: ${prStr}`, + dismissOnTimer: 6000, + }) + rollback(fromColumn) + return + } + try { + const pullRequest = await getPullRequest({ + host: remote.host, + token, + owner: remote.owner, + repo: remote.repo, + index: prIndex, + }) + prHeadRef = pullRequest.headRef + if (!pullRequest.merged) { + await closeIssue({ + host: remote.host, + token, + owner: remote.owner, + repo: remote.repo, + issueNumber: prIndex, + }) + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ + level: 'error', + source: 'panel', + message: `处理 PR #${prIndex} 失败 (issue #${issueNumber})`, + details: message, + }) + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `处理 PR #${prIndex} 失败 (issue #${issueNumber}): ${message}`, + dismissOnTimer: 6000, + }) + rollback(fromColumn) + return + } + } + + const featureBranch = resolveFeatureBranch({ + stateBranch: featureBranchFromState, + prHeadRef, + }) + if (featureBranch) { + const settingsForBranchCleanup = getSettings(panel.context) + await cleanupFeatureBranch({ + workspaceRoot, + branch: featureBranch, + issueNumber, + devBranch: settingsForBranchCleanup.devBranch, + autoBuildBranch: settingsForBranchCleanup.autoBuildBranch, + 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. * @@ -1401,14 +1692,36 @@ export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber } try { - await addDependency({ + // Gitea 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。 + const deps = await getDependencies({ host: remote.host, token, owner: remote.owner, repo: remote.repo, index: issueNumber, - dependencyIndex: prerequisiteNumber, }) + 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) diff --git a/vscode/src/panel/handlers/todoReset.test.ts b/vscode/src/panel/handlers/todoReset.test.ts new file mode 100644 index 0000000..731f80e --- /dev/null +++ b/vscode/src/panel/handlers/todoReset.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset' + +describe('canResetToTodo', () => { + it('允许从 in-progress 重置', () => { + expect(canResetToTodo('in-progress')).toBe(true) + }) + + it('允许从 review 重置', () => { + expect(canResetToTodo('review')).toBe(true) + }) + + it('已在 todo 不允许(handler 走 no-op)', () => { + expect(canResetToTodo('todo')).toBe(false) + }) + + it('done 不允许(handler 拒绝并 rollback)', () => { + expect(canResetToTodo('done')).toBe(false) + }) +}) + +describe('todoResetStateExtra', () => { + it('清空实施痕迹并回到 todo,保留规划侧字段不在 extra 里', () => { + const extra = todoResetStateExtra() + expect(extra).toEqual({ + column: 'todo', + branch: '', + implementStatus: '', + pr: '', + prMerged: false, + prMergedAt: '', + worktreePath: '', + implementSessionId: '', + reviewSessionId: '', + }) + expect(extra).not.toHaveProperty('sessionId') + expect(extra).not.toHaveProperty('planFile') + expect(extra).not.toHaveProperty('specFile') + expect(extra).not.toHaveProperty('profilePath') + expect(extra).not.toHaveProperty('brainstormProfilePath') + expect(extra).not.toHaveProperty('testProfilePath') + expect(extra).not.toHaveProperty('color') + expect(extra).not.toHaveProperty('autoReview') + }) +}) + +describe('todoResetUiPatch', () => { + it('用 null 清空 UI 字段,避免 postMessage 丢掉 undefined', () => { + expect(todoResetUiPatch()).toEqual({ + column: 'todo', + branch: null, + implementStatus: null, + pr: null, + prMerged: false, + prMergedAt: null, + worktreePath: null, + implementSessionId: null, + reviewSessionId: null, + worktreeExists: false, + }) + }) +}) diff --git a/vscode/src/panel/handlers/todoReset.ts b/vscode/src/panel/handlers/todoReset.ts new file mode 100644 index 0000000..cb33beb --- /dev/null +++ b/vscode/src/panel/handlers/todoReset.ts @@ -0,0 +1,45 @@ +import type { IssueColumn } from '../../gitea/types' + +export function canResetToTodo(fromColumn: IssueColumn | undefined): boolean { + return fromColumn === 'in-progress' || fromColumn === 'review' +} + +export function todoResetStateExtra(): Record { + return { + column: 'todo', + branch: '', + implementStatus: '', + pr: '', + prMerged: false, + prMergedAt: '', + worktreePath: '', + implementSessionId: '', + reviewSessionId: '', + } +} + +export function todoResetUiPatch(): { + column: 'todo' + branch: null + implementStatus: null + pr: null + prMerged: false + prMergedAt: null + worktreePath: null + implementSessionId: null + reviewSessionId: null + worktreeExists: false +} { + return { + column: 'todo', + branch: null, + implementStatus: null, + pr: null, + prMerged: false, + prMergedAt: null, + worktreePath: null, + implementSessionId: null, + reviewSessionId: null, + worktreeExists: false, + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 4dbc360..8c402f2 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -42,7 +42,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 7286313..7bdbc15 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -366,6 +366,7 @@ export function App() { onMergeBranch={mergeBranch} onDeleteIssue={deleteIssue} onCloseIssue={closeIssue} + onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)} onCloseSessionTab={closeSessionTab} onStartBrainstormSession={startBrainstormSession} onUpdateAutoReview={updateIssueAutoReview} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index f7c7648..7b93d31 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -42,6 +42,7 @@ interface BottomTabsProps { onMergeBranch: (issueNumber: number, branch: string) => void onDeleteIssue?: (issueNumber: number) => void onCloseIssue: (issueNumber: number) => void + onResetToTodo: (issueNumber: number) => void onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void onStartBrainstormSession: (issueNumber: number) => void onUpdateAutoReview: (issueNumber: number, value: boolean) => void @@ -128,6 +129,7 @@ export function BottomTabs(props: BottomTabsProps) { onMergeBranch={props.onMergeBranch} onDeleteIssue={props.onDeleteIssue} onCloseIssue={props.onCloseIssue} + onResetToTodo={props.onResetToTodo} onCloseSessionTab={props.onCloseSessionTab} onStartBrainstormSession={props.onStartBrainstormSession} onUpdateAutoReview={props.onUpdateAutoReview} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 906433f..512d58a 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { useMemo, useRef } from 'react' -import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react' +import { CircleSlash, ExternalLink, GitMerge, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react' import type { ClaudeProfile } from '../hooks/useIssues' import type { Issue, IssueColumn } from '../types' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' @@ -68,6 +68,8 @@ interface IssueDetailPanelProps { onDeleteIssue?: (issueNumber: number) => void /** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */ onCloseIssue: (issueNumber: number) => void + /** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */ + onResetToTodo: (issueNumber: number) => void /** Close the matching session terminal tab. The extension watches * onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which * makes the X button vanish on its own. */ @@ -117,6 +119,7 @@ export function IssueDetailPanel({ onMergeBranch, onDeleteIssue, onCloseIssue, + onResetToTodo, onCloseSessionTab, onStartBrainstormSession, onUpdateAutoReview, @@ -572,6 +575,17 @@ export function IssueDetailPanel({ avoid acting on a gitea issue of the same number. */} {issue.source !== 'youtrack' && ( <> + {(issue.column === 'in-progress' || issue.column === 'review') && ( + + )}