feat(vscode): handoff 纯决策与 state/UI payload

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 16:09:14 +08:00
parent bffdcb4d42
commit f7b97bbe9e
2 changed files with 206 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
canAcceptHandoff,
canStartHandoff,
checkWorktreePushed,
handoffAcceptedStateExtra,
handoffAcceptedUiPatch,
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
describe('canStartHandoff', () => {
it('gitea 工单、非 done、无待接管 → true', () => {
expect(canStartHandoff({ column: 'in-progress' })).toBe(true)
expect(canStartHandoff({ column: 'todo', source: 'gitea' })).toBe(true)
})
it('done / youtrack / 已在移交中 → false', () => {
expect(canStartHandoff({ column: 'done' })).toBe(false)
expect(canStartHandoff({ column: 'review', source: 'youtrack' })).toBe(false)
expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' })).toBe(false)
})
})
describe('canAcceptHandoff', () => {
it('有附件且 assignee 含我 → true', () => {
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(true)
})
it('无附件 / 不是我的 / 身份未知 / youtrack → false', () => {
expect(canAcceptHandoff({ assignees: ['me'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['x'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me'] }, undefined)).toBe(false)
expect(canAcceptHandoff({ source: 'youtrack', handoffAttachmentId: '9', assignees: ['me'] }, 'me')).toBe(false)
})
})
describe('checkWorktreePushed', () => {
it('干净且 HEAD 与远端一致 → ok', () => {
expect(checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'abc' })).toEqual({ ok: true })
})
it('有未提交改动 → 拒绝', () => {
const r = checkWorktreePushed({ statusPorcelain: ' M a.ts\n', localHead: 'abc', remoteHead: 'abc' })
expect(r.ok).toBe(false)
if (!r.ok)
expect(r.reason).toMatch(/未提交/)
})
it('本地领先远端 → 拒绝', () => {
const r = checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'def' })
expect(r.ok).toBe(false)
if (!r.ok)
expect(r.reason).toMatch(/push/)
})
})
describe('payloads', () => {
it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => {
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
handoffAttachmentId: '9',
handoffFrom: 'chw',
sessionId: '',
implementSessionId: '',
reviewSessionId: '',
testSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
worktreePath: '',
prDiffFile: '',
})
})
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({
handoffAttachmentId: '9',
handoffFrom: 'chw',
assignees: ['me'],
sessionId: null,
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
worktreePath: null,
worktreeExists: false,
})
})
it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => {
expect(handoffAcceptedStateExtra({
sessions: { implementSessionId: 'i', reviewSessionId: 'r' },
profiles: { profilePath: '/p.json' },
worktreePath: '/wt',
})).toEqual({
handoffAttachmentId: '',
handoffFrom: '',
implementSessionId: 'i',
reviewSessionId: 'r',
profilePath: '/p.json',
worktreePath: '/wt',
})
})
it('接管 UI patch', () => {
expect(handoffAcceptedUiPatch({
sessions: { sessionId: 's' },
profiles: {},
worktreePath: '/wt',
worktreeExists: true,
})).toEqual({
handoffAttachmentId: null,
handoffFrom: null,
sessionId: 's',
worktreePath: '/wt',
worktreeExists: true,
})
})
})
+95
View File
@@ -0,0 +1,95 @@
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import { LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack'
column: IssueColumn
handoffAttachmentId?: string
}): boolean {
return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId
}
export function canAcceptHandoff(issue: {
source?: 'gitea' | 'youtrack'
handoffAttachmentId?: string
assignees?: string[]
}, me: string | undefined): boolean {
return issue.source !== 'youtrack'
&& !!issue.handoffAttachmentId
&& me !== undefined
&& (issue.assignees ?? []).includes(me)
}
/** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */
export function checkWorktreePushed(input: {
statusPorcelain: string
localHead: string
remoteHead: string
}): { ok: true } | { ok: false, reason: string } {
if (input.statusPorcelain.trim().length > 0)
return { ok: false, reason: '工作区有未提交改动,请先提交并 push' }
if (input.localHead.trim() !== input.remoteHead.trim())
return { ok: false, reason: '本地分支与远端不一致,请先 push' }
return { ok: true }
}
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
const extra: Record<string, unknown> = { handoffAttachmentId: String(attachmentId), handoffFrom: from }
for (const f of LOCAL_STATE_FIELDS)
extra[f] = ''
return extra
}
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown> {
return {
handoffAttachmentId: String(attachmentId),
handoffFrom: from,
assignees: [to],
sessionId: null,
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
worktreePath: null,
worktreeExists: false,
}
}
function definedEntries(o: Record<string, any>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(o)) {
if (v)
out[k] = v
}
return out
}
export function handoffAcceptedStateExtra(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
}): Record<string, unknown> {
return {
handoffAttachmentId: '',
handoffFrom: '',
...definedEntries(local.sessions),
...definedEntries(local.profiles),
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
}
}
export function handoffAcceptedUiPatch(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
worktreeExists: boolean
}): Record<string, unknown> {
return {
handoffAttachmentId: null,
handoffFrom: null,
...definedEntries(local.sessions),
...definedEntries(local.profiles),
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
worktreeExists: local.worktreeExists,
}
}