✨ 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
@@ -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',