diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts new file mode 100644 index 0000000..71bbc23 --- /dev/null +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -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, + }) + }) +}) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts new file mode 100644 index 0000000..7b17713 --- /dev/null +++ b/vscode/src/panel/handlers/handoff.ts @@ -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 { + const extra: Record = { 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 { + return { + handoffAttachmentId: String(attachmentId), + handoffFrom: from, + assignees: [to], + sessionId: null, + implementSessionId: null, + reviewSessionId: null, + testSessionId: null, + worktreePath: null, + worktreeExists: false, + } +} + +function definedEntries(o: Record): Record { + const out: Record = {} + 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 { + 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 { + return { + handoffAttachmentId: null, + handoffFrom: null, + ...definedEntries(local.sessions), + ...definedEntries(local.profiles), + ...(local.worktreePath ? { worktreePath: local.worktreePath } : {}), + worktreeExists: local.worktreeExists, + } +}