✨ feat(vscode): 审查列可以申请同事移交工单

This commit is contained in:
cruldra committed 2026-09-28 00:17:09 +08:00
1 parent 65583a001c
commit 6342f39de0
22 files changed
+450 -31

No files matched your search

+16
View File
@@ -102,6 +102,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview?: boolean
handoffAttachmentId?: string
handoffFrom?: string
handoffRequestFrom?: string
handoffRequestError?: string
} {
if (comments.length === 0)
return { column: null }
@@ -151,6 +153,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview?: unknown
handoffAttachmentId?: unknown
handoffFrom?: unknown
handoffRequestFrom?: unknown
handoffRequestError?: unknown
}
if (isIssueColumn(obj.column)) {
const sessionId = typeof obj.sessionId === 'string' && obj.sessionId.length > 0
@@ -212,6 +216,12 @@ function parseColumnFromComments(comments: GiteaComment[]): {
const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
? obj.handoffFrom
: undefined
const handoffRequestFrom = typeof obj.handoffRequestFrom === 'string' && obj.handoffRequestFrom.length > 0
? obj.handoffRequestFrom
: undefined
const handoffRequestError = typeof obj.handoffRequestError === 'string' && obj.handoffRequestError.length > 0
? obj.handoffRequestError
: undefined
return {
column: obj.column,
sessionId,
@@ -237,6 +247,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview,
handoffAttachmentId,
handoffFrom,
handoffRequestFrom,
handoffRequestError,
}
}
}
@@ -381,6 +393,8 @@ async function buildIssue(opts: {
autoReview,
handoffAttachmentId,
handoffFrom,
handoffRequestFrom,
handoffRequestError,
} = parseColumnFromComments(comments)
// 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。
const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged
@@ -449,6 +463,8 @@ async function buildIssue(opts: {
...(autoReview !== undefined ? { autoReview } : {}),
...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}),
...(handoffRequestFrom ? { handoffRequestFrom } : {}),
...(handoffRequestError ? { handoffRequestError } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
...(issue.user?.login ? { poster: issue.user.login } : {}),
htmlUrl: issue.html_url,
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
*/
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/**
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+4
View File
@@ -139,6 +139,10 @@ export interface Issue {
handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */
handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */
handoffRequestFrom?: string
/** 同意移交失败的原因。对方拒绝时为 rejected。 */
handoffRequestError?: string
/** Whether the brainstorm terminal tab is currently alive in this VS Code window. */
brainstormTabOpen?: boolean
/** Whether the implementation terminal tab is currently alive in this VS Code window. */
+16
View File
@@ -525,6 +525,22 @@ export class KanbanWebviewPanel {
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
return
}
if (msg.type === 'handoff/request') {
void handoffFlow.handleHandoffRequest(this, msg.issueNumber)
return
}
if (msg.type === 'handoff/request-cancel') {
void handoffFlow.handleHandoffRequestCancel(this, msg.issueNumber)
return
}
if (msg.type === 'handoff/request-accept') {
void handoffFlow.handleHandoffRequestAccept(this, msg.issueNumber)
return
}
if (msg.type === 'handoff/request-reject') {
void handoffFlow.handleHandoffRequestReject(this, msg.issueNumber)
return
}
if (msg.type === 'session-handoff/users') {
void sessionHandoff.handleSessionHandoffUsers(this)
return
+110 -24
View File
@@ -43,6 +43,7 @@ import {
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest'
export interface RepoCtx {
workspaceRoot: string
@@ -134,14 +135,16 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
*/
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
export type HandoffStartResult = { ok: true } | { ok: false, message: string }
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<HandoffStartResult> {
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
return { ok: false, message: '无法开始移交' }
const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown>
@@ -149,8 +152,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
return
const message = `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`
toast(panel, 'error', message)
return { ok: false, message }
}
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
@@ -158,11 +162,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
return { ok: false, message: `工单 #${issueNumber} 不存在或已删除` }
}
if (issue.state === 'closed') {
toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`)
return
return { ok: false, message: `#${issueNumber} 已关闭,不能移交` }
}
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到
@@ -172,8 +176,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
const assignees = (issue.assignees ?? []).map(a => a.login)
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) {
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`)
return
const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
toast(panel, 'error', message)
return { ok: false, message }
}
const branch = str(state.branch)
@@ -193,10 +198,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
const say = (step: string): string => toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}:${step}`, { id: spinner, spinner: true, dismissOnTimer: 0 })
const fail = (message: string): void => {
const fail = (message: string): HandoffStartResult => {
if (spinner)
dismiss(panel, spinner)
toast(panel, 'error', message)
return { ok: false, message }
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
@@ -206,21 +212,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) {
fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
return
return fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
}
const status = await git(worktreeAbs, ['status', '--porcelain'])
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
if (!status.ok || !fetched.ok) {
fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
return
return fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
}
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
if (!check.ok) {
fail(`无法移交 #${issueNumber}:${check.reason}`)
return
return fail(`无法移交 #${issueNumber}:${check.reason}`)
}
}
else if (branch) {
@@ -231,8 +234,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
return
return fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
}
}
}
@@ -254,12 +256,10 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
catch (err) {
if (err instanceof HandoffSourcesTooLargeError) {
fail(`#${issueNumber} ${err.message}`)
return
return fail(`#${issueNumber} ${err.message}`)
}
const message = err instanceof Error ? err.message : String(err)
fail(`移交失败(检查会话大小):${message}`)
return
return fail(`移交失败(检查会话大小):${message}`)
}
// ② 关终端,等 jsonl 刷盘
@@ -318,7 +318,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(${prefix}):${message}`)
return
return { ok: false, message: `移交失败(${prefix}):${message}` }
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
@@ -338,7 +338,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`)
return
return { ok: false, message: `移交失败(写状态):${message}` }
}
say('指派')
try {
@@ -351,7 +351,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
return
return { ok: false, message: `移交失败(指派 ${to}):${message}` }
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
@@ -396,6 +396,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
return { ok: true }
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -403,6 +406,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
return { ok: false, message: `移交失败:${message}` }
}
finally {
finish()
@@ -414,6 +418,88 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffAttachmentId?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
return
}
const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber })
if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
}
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
return {
me: ctx.me,
facts: {
source: 'gitea',
column,
...(str(state.handoffAttachmentId) ? { handoffAttachmentId: str(state.handoffAttachmentId) } : {}),
...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
},
}
}
export async function handleHandoffRequest(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAskHandoff(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 现在不能申请移交`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: loaded.me, handoffRequestError: '' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: loaded.me, handoffRequestError: null } })
}
export async function handleHandoffRequestCancel(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canCancelHandoffRequest(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 没有你的移交申请`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
}
export async function handleHandoffRequestReject(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAnswerHandoffRequest(loaded.facts, loaded.me)) {
toast(panel, 'error', `#${issueNumber} 不能拒绝这个申请`)
return
}
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: 'rejected' })
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: 'rejected' } })
}
export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const loaded = await requestFacts(panel, issueNumber)
if (!loaded)
return
if (!canAnswerHandoffRequest(loaded.facts, loaded.me) || !loaded.facts.handoffRequestFrom) {
toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`)
return
}
const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom)
if (result.ok)
return
await panel.mergeIssueState(issueNumber, { handoffRequestError: result.message }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestError: result.message } })
}
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest, handoffRequestNotices } from './handoffRequest'
const review = {
source: 'gitea' as const,
column: 'review' as const,
assignees: ['chw'],
}
describe('移交申请', () => {
it('审查列里同事的工单可以申请', () => {
expect(canAskHandoff(review, 'cruldra')).toBe(true)
})
it('自己的、没人负责的、不在审查列的、YouTrack 的、已在移交中的,不能申请', () => {
expect(canAskHandoff(review, 'chw')).toBe(false)
expect(canAskHandoff({ ...review, assignees: [] }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, column: 'in-progress' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, source: 'youtrack' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, handoffAttachmentId: '9' }, 'cruldra')).toBe(false)
expect(canAskHandoff(review, undefined)).toBe(false)
})
it('已经有一条申请时,别人不能再申请,申请人可以取消', () => {
const pending = { ...review, handoffRequestFrom: 'cruldra' }
expect(canAskHandoff(pending, 'other')).toBe(false)
expect(canCancelHandoffRequest(pending, 'cruldra')).toBe(true)
expect(canCancelHandoffRequest(pending, 'other')).toBe(false)
})
it('负责人看到申请,申请人看到拒绝和失败原因', () => {
const pending = { ...review, number: 435, handoffRequestFrom: 'cruldra' }
expect(handoffRequestNotices({ ...review, number: 435 }, pending, 'chw')).toEqual(['cruldra 想要 #435'])
expect(handoffRequestNotices(pending, { ...pending, handoffRequestFrom: undefined, handoffRequestError: 'rejected' }, 'cruldra'))
.toEqual(['#435 的移交申请被拒绝了'])
expect(handoffRequestNotices(pending, { ...pending, handoffRequestError: '会话太大' }, 'cruldra'))
.toEqual(['#435 没交出来:会话太大'])
})
it('只有负责人能同意或拒绝', () => {
const pending = { ...review, handoffRequestFrom: 'cruldra' }
expect(canAnswerHandoffRequest(pending, 'chw')).toBe(true)
expect(canAnswerHandoffRequest(pending, 'cruldra')).toBe(false)
expect(canAnswerHandoffRequest({ ...pending, column: 'todo' }, 'chw')).toBe(false)
})
})
@@ -0,0 +1,58 @@
export interface HandoffRequestFacts {
source?: 'gitea' | 'youtrack'
column: 'todo' | 'in-progress' | 'review' | 'done'
handoffAttachmentId?: string
handoffRequestFrom?: string
handoffRequestError?: string
assignees?: string[]
}
function openReview(issue: HandoffRequestFacts): boolean {
return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffAttachmentId
}
/** 审查列里同事负责的工单,且当前没有别的申请。 */
export function canAskHandoff(issue: HandoffRequestFacts, me: string | undefined): boolean {
if (!me || !openReview(issue))
return false
const assignees = issue.assignees ?? []
if (assignees.length === 0 || assignees.includes(me))
return false
return !issue.handoffRequestFrom
}
/** 申请人在对方回应前可以取消。 */
export function canCancelHandoffRequest(issue: HandoffRequestFacts, me: string | undefined): boolean {
return !!me && openReview(issue) && issue.handoffRequestFrom === me
}
/** 负责人同意或拒绝。同意会走现有移交。 */
export function handoffRequestNotices(before: HandoffRequestFacts & { number: number }, next: HandoffRequestFacts & { number: number }, me: string): string[] {
const notices: string[] = []
if (
next.handoffRequestFrom
&& next.handoffRequestFrom !== before.handoffRequestFrom
&& next.handoffRequestFrom !== me
&& (next.assignees ?? []).includes(me)
) {
notices.push(`${next.handoffRequestFrom} 想要 #${next.number}`)
}
if (before.handoffRequestFrom === me && next.handoffRequestError === 'rejected' && before.handoffRequestError !== 'rejected')
notices.push(`#${next.number} 的移交申请被拒绝了`)
if (
next.handoffRequestFrom === me
&& next.handoffRequestError
&& next.handoffRequestError !== 'rejected'
&& next.handoffRequestError !== before.handoffRequestError
) {
notices.push(`#${next.number} 没交出来:${next.handoffRequestError}`)
}
return notices
}
export function canAnswerHandoffRequest(issue: HandoffRequestFacts, me: string | undefined): boolean {
return !!me
&& openReview(issue)
&& !!issue.handoffRequestFrom
&& (issue.assignees ?? []).includes(me)
}
+16
View File
@@ -45,6 +45,22 @@ 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 !== '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
+5 -1
View File
@@ -52,7 +52,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -214,6 +214,10 @@ export type WebviewToExtension
| { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number }
| { type: 'handoff/request', issueNumber: number }
| { type: 'handoff/request-cancel', issueNumber: number }
| { type: 'handoff/request-accept', issueNumber: number }
| { type: 'handoff/request-reject', issueNumber: number }
| { type: 'session-handoff/users' }
| { type: 'session-handoff/start', sessionId: string, to: string }
| { type: 'session-handoff/accept', attachmentId: number }
+2
View File
@@ -36,6 +36,8 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
'dependency/clear',
'handoff/users',
'handoff/start',
'handoff/request-accept',
'handoff/request-reject',
// 分支与 worktree
'git/merge-preview',
'worktree/delete',
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/**
* Parse a comment body into a state object, or null if it isn't a state