✨ feat(vscode): 支持重置回待办并修好待办依赖拖拽
This commit is contained in:
@@ -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<void> {
|
||||
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<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,
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user