✨ 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

@@ -90,6 +90,8 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、检查会话大小、压缩检查、关闭终端、收集会话、打包、上传、写入状态、指派、清理本机)。移交结束前不自动消失。 进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、检查会话大小、压缩检查、关闭终端、收集会话、打包、上传、写入状态、指派、清理本机)。移交结束前不自动消失。
审查列里,负责人不是自己、也没有待接管包的 Gitea 工单,卡片上可以「申请移交」。同一张卡同时只有一条申请。同事收到提示,卡片上可以同意或拒绝。同意走上面的移交,对象就是申请人;申请人仍自己点接管。拒绝后申请消失,申请人看到被拒绝。申请人可以在回应前取消。同意后若移交失败,申请留着,两边都看到原因,修好可以再点同意。工单离开审查列时清掉未处理的申请。共享字段 `handoffRequestFrom` / `handoffRequestError`(拒绝时 error 为 `rejected`)。
回滚:大小检查失败 → 什么都不改;打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删附件;指派失败 → 清回两个共享字段、删附件。结束 `toast/dismiss`。 回滚:大小检查失败 → 什么都不改;打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删附件;指派失败 → 清回两个共享字段、删附件。结束 `toast/dismiss`。
## 接管方:接管 ## 接管方:接管
+2
View File
@@ -29,6 +29,8 @@ var knownStateFields = map[string]bool{
"implementStatus": true, "implementStatus": true,
"color": true, "color": true,
"autoReview": true, "autoReview": true,
"handoffRequestFrom": true,
"handoffRequestError": true,
} }
// ExtractStateJSON scans comments tail-first and returns the first comment // ExtractStateJSON scans comments tail-first and returns the first comment
+2
View File
@@ -427,6 +427,8 @@ var knownStateFields = map[string]struct{}{
"autoReview": {}, "autoReview": {},
"handoffAttachmentId": {}, "handoffAttachmentId": {},
"handoffFrom": {}, "handoffFrom": {},
"handoffRequestFrom": {},
"handoffRequestError": {},
} }
// parseStateComment returns the parsed map when body is a JSON object carrying // parseStateComment returns the parsed map when body is a JSON object carrying
+8
View File
@@ -125,6 +125,14 @@
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
"description": "发起移交的 Gitea login;清空时写空串。" "description": "发起移交的 Gitea login;清空时写空串。"
},
"handoffRequestFrom": {
"type": "string",
"description": "谁在申请把审查中的工单移交给自己;清空时写空串。"
},
"handoffRequestError": {
"type": "string",
"description": "最近一次同意移交失败的原因;对方拒绝时写 rejected;清空时写空串。"
} }
} }
} }
+8
View File
@@ -125,6 +125,14 @@
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
"description": "发起移交的 Gitea login;清空时写空串。" "description": "发起移交的 Gitea login;清空时写空串。"
},
"handoffRequestFrom": {
"type": "string",
"description": "谁在申请把审查中的工单移交给自己;清空时写空串。"
},
"handoffRequestError": {
"type": "string",
"description": "最近一次同意移交失败的原因;对方拒绝时写 rejected;清空时写空串。"
} }
} }
} }
+16
View File
@@ -102,6 +102,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview?: boolean autoReview?: boolean
handoffAttachmentId?: string handoffAttachmentId?: string
handoffFrom?: string handoffFrom?: string
handoffRequestFrom?: string
handoffRequestError?: string
} { } {
if (comments.length === 0) if (comments.length === 0)
return { column: null } return { column: null }
@@ -151,6 +153,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview?: unknown autoReview?: unknown
handoffAttachmentId?: unknown handoffAttachmentId?: unknown
handoffFrom?: unknown handoffFrom?: unknown
handoffRequestFrom?: unknown
handoffRequestError?: unknown
} }
if (isIssueColumn(obj.column)) { if (isIssueColumn(obj.column)) {
const sessionId = typeof obj.sessionId === 'string' && obj.sessionId.length > 0 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 const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
? obj.handoffFrom ? obj.handoffFrom
: undefined : 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 { return {
column: obj.column, column: obj.column,
sessionId, sessionId,
@@ -237,6 +247,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
autoReview, autoReview,
handoffAttachmentId, handoffAttachmentId,
handoffFrom, handoffFrom,
handoffRequestFrom,
handoffRequestError,
} }
} }
} }
@@ -381,6 +393,8 @@ async function buildIssue(opts: {
autoReview, autoReview,
handoffAttachmentId, handoffAttachmentId,
handoffFrom, handoffFrom,
handoffRequestFrom,
handoffRequestError,
} = parseColumnFromComments(comments) } = parseColumnFromComments(comments)
// 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。 // 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。
const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged
@@ -449,6 +463,8 @@ async function buildIssue(opts: {
...(autoReview !== undefined ? { autoReview } : {}), ...(autoReview !== undefined ? { autoReview } : {}),
...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}), ...(handoffFrom ? { handoffFrom } : {}),
...(handoffRequestFrom ? { handoffRequestFrom } : {}),
...(handoffRequestError ? { handoffRequestError } : {}),
assignees: (issue.assignees ?? []).map(a => a.login), assignees: (issue.assignees ?? []).map(a => a.login),
...(issue.user?.login ? { poster: issue.user.login } : {}), ...(issue.user?.login ? { poster: issue.user.login } : {}),
htmlUrl: issue.html_url, htmlUrl: issue.html_url,
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 * 扫描时会被跳过,避免它们插队后下一次 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。 * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+4
View File
@@ -139,6 +139,10 @@ export interface Issue {
handoffAttachmentId?: string handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */
handoffFrom?: string handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */
handoffRequestFrom?: string
/** 同意移交失败的原因。对方拒绝时为 rejected。 */
handoffRequestError?: string
/** Whether the brainstorm terminal tab is currently alive in this VS Code window. */ /** Whether the brainstorm terminal tab is currently alive in this VS Code window. */
brainstormTabOpen?: boolean brainstormTabOpen?: boolean
/** Whether the implementation terminal tab is currently alive in this VS Code window. */ /** 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) void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
return 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') { if (msg.type === 'session-handoff/users') {
void sessionHandoff.handleSessionHandoffUsers(this) void sessionHandoff.handleSessionHandoffUsers(this)
return return
+110 -24
View File
@@ -43,6 +43,7 @@ import {
handoffStartedStateExtra, handoffStartedStateExtra,
handoffStartedUiPatch, handoffStartedUiPatch,
} from './handoff' } from './handoff'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest'
export interface RepoCtx { export interface RepoCtx {
workspaceRoot: string workspaceRoot: string
@@ -134,14 +135,16 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住 * 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。 * 未预期异常并保证 `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 }) const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。 // spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined let spinner: string | undefined
try { try {
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) if (!ctx)
return return { ok: false, message: '无法开始移交' }
const { workspaceRoot, host, owner, repo, token, me } = ctx const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown> let state: Record<string, unknown>
@@ -149,8 +152,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
state = await panel.readIssueState(issueNumber) state = await panel.readIssueState(issueNumber)
} }
catch (err) { catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) const message = `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`
return toast(panel, 'error', message)
return { ok: false, message }
} }
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到 // 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
@@ -158,11 +162,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) { if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return return { ok: false, message: `工单 #${issueNumber} 不存在或已删除` }
} }
if (issue.state === 'closed') { if (issue.state === 'closed') {
toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`) toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`)
return return { ok: false, message: `#${issueNumber} 已关闭,不能移交` }
} }
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到 // 前置复查: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 column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
const assignees = (issue.assignees ?? []).map(a => a.login) const assignees = (issue.assignees ?? []).map(a => a.login)
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) { if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) {
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`) const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
return toast(panel, 'error', message)
return { ok: false, message }
} }
const branch = str(state.branch) 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 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) if (spinner)
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', message) toast(panel, 'error', message)
return { ok: false, message }
} }
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时 // ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
@@ -206,21 +212,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) { if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) { if (!branch) {
fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) return fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
return
} }
const status = await git(worktreeAbs, ['status', '--porcelain']) const status = await git(worktreeAbs, ['status', '--porcelain'])
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch]) const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
if (!status.ok || !fetched.ok) { if (!status.ok || !fetched.ok) {
fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) return fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
return
} }
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD']) const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`]) const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout }) const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
if (!check.ok) { if (!check.ok) {
fail(`无法移交 #${issueNumber}:${check.reason}`) return fail(`无法移交 #${issueNumber}:${check.reason}`)
return
} }
} }
else if (branch) { 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 localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`]) const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) { if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`) return fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
return
} }
} }
} }
@@ -254,12 +256,10 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
} }
catch (err) { catch (err) {
if (err instanceof HandoffSourcesTooLargeError) { if (err instanceof HandoffSourcesTooLargeError) {
fail(`#${issueNumber} ${err.message}`) return fail(`#${issueNumber} ${err.message}`)
return
} }
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
fail(`移交失败(检查会话大小):${message}`) return fail(`移交失败(检查会话大小):${message}`)
return
} }
// ② 关终端,等 jsonl 刷盘 // ② 关终端,等 jsonl 刷盘
@@ -318,7 +318,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message }) logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', `移交失败(${prefix}):${message}`) toast(panel, 'error', `移交失败(${prefix}):${message}`)
return return { ok: false, message: `移交失败(${prefix}):${message}` }
} }
finally { finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 // 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(() => {}) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`) toast(panel, 'error', `移交失败(写状态):${message}`)
return return { ok: false, message: `移交失败(写状态):${message}` }
} }
say('指派') say('指派')
try { try {
@@ -351,7 +351,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`) toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
return return { ok: false, message: `移交失败(指派 ${to}):${message}` }
} }
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警—— // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
@@ -396,6 +396,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
dismiss(panel, spinner) dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) }) 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 会话)`) 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) { catch (err) {
const message = err instanceof Error ? err.message : String(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}`) toast(panel, 'error', `移交失败:${message}`)
if (spinner) if (spinner)
dismiss(panel, spinner) dismiss(panel, spinner)
return { ok: false, message: `移交失败:${message}` }
} }
finally { finally {
finish() finish()
@@ -414,6 +418,88 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 → * 下载解包 → 从远端重建 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> { export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。 // finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) 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' import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> { 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') { if (toColumn === 'in-progress') {
await handleDropToInProgress(panel, issueNumber) await handleDropToInProgress(panel, issueNumber)
return return
+5 -1
View File
@@ -52,7 +52,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -214,6 +214,10 @@ export type WebviewToExtension
| { type: 'handoff/users', issueNumber: number } | { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string } | { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number } | { 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/users' }
| { type: 'session-handoff/start', sessionId: string, to: string } | { type: 'session-handoff/start', sessionId: string, to: string }
| { type: 'session-handoff/accept', attachmentId: number } | { 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', 'dependency/clear',
'handoff/users', 'handoff/users',
'handoff/start', 'handoff/start',
'handoff/request-accept',
'handoff/request-reject',
// 分支与 worktree // 分支与 worktree
'git/merge-preview', 'git/merge-preview',
'worktree/delete', 'worktree/delete',
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->' export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */ /** 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 * Parse a comment body into a state object, or null if it isn't a state
+8
View File
@@ -94,6 +94,10 @@ export function App() {
requestHandoffUsers, requestHandoffUsers,
startHandoff, startHandoff,
acceptHandoff, acceptHandoff,
askHandoff,
cancelHandoffRequest,
acceptHandoffRequest,
rejectHandoffRequest,
isHandoffRunning, isHandoffRunning,
} = useIssues() } = useIssues()
const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles() const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles()
@@ -389,6 +393,10 @@ export function App() {
onDependencyClear={clearDependency} onDependencyClear={clearDependency}
me={me} me={me}
avatars={avatars} avatars={avatars}
onAskHandoff={askHandoff}
onCancelHandoffRequest={cancelHandoffRequest}
onAcceptHandoffRequest={acceptHandoffRequest}
onRejectHandoffRequest={rejectHandoffRequest}
/> />
</div> </div>
<div className="h-[40vh] shrink-0"> <div className="h-[40vh] shrink-0">
+77 -2
View File
@@ -1,10 +1,11 @@
import type { Ref } from 'react' import type { ReactNode, Ref } from 'react'
import type { Issue } from '../types' import type { Issue } from '../types'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { Lock } from 'lucide-react' import { Lock } from 'lucide-react'
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from '../../../src/panel/handlers/handoffRequest'
import { Card } from './ui/card' import { Card } from './ui/card'
interface IssueCardProps { interface IssueCardProps {
@@ -26,6 +27,11 @@ interface IssueCardProps {
ownerLogin?: string ownerLogin?: string
/** 归属人头像 data URI;缺失时回退首字母圆点。 */ /** 归属人头像 data URI;缺失时回退首字母圆点。 */
ownerAvatar?: string ownerAvatar?: string
me?: string
onAskHandoff?: (issueNumber: number) => void
onCancelHandoffRequest?: (issueNumber: number) => void
onAcceptHandoffRequest?: (issueNumber: number) => void
onRejectHandoffRequest?: (issueNumber: number) => void
} }
/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */ /** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */
@@ -51,8 +57,69 @@ function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
) )
} }
function HandoffRequestActions({
issue,
me,
onAsk,
onCancel,
onAccept,
onReject,
}: {
issue: Issue
me?: string
onAsk?: (issueNumber: number) => void
onCancel?: (issueNumber: number) => void
onAccept?: (issueNumber: number) => void
onReject?: (issueNumber: number) => void
}) {
const button = 'rounded border border-[var(--vscode-contrastBorder)] px-1.5 py-0.5 text-[10px] hover:bg-[var(--vscode-toolbar-hoverBackground)]'
let body: ReactNode = null
if (canAnswerHandoffRequest(issue, me)) {
body = (
<>
<span className="text-[10px] opacity-80">
{issue.handoffRequestFrom}
{' '}
想要
</span>
{issue.handoffRequestError && issue.handoffRequestError !== 'rejected'
? <span className="text-[10px] text-[var(--vscode-errorForeground)]">{issue.handoffRequestError}</span>
: null}
<button type="button" className={button} onClick={() => onAccept?.(issue.number)}>同意</button>
<button type="button" className={button} onClick={() => onReject?.(issue.number)}>拒绝</button>
</>
)
}
else if (canCancelHandoffRequest(issue, me)) {
body = (
<>
<span className="text-[10px] opacity-80">
{issue.handoffRequestError && issue.handoffRequestError !== 'rejected' ? issue.handoffRequestError : '已申请'}
</span>
<button type="button" className={button} onClick={() => onCancel?.(issue.number)}>取消</button>
</>
)
}
else if (canAskHandoff(issue, me)) {
body = <button type="button" className={button} onClick={() => onAsk?.(issue.number)}>申请移交</button>
}
else if (issue.column === 'review' && issue.handoffRequestFrom && issue.source !== 'youtrack' && !issue.handoffAttachmentId) {
body = <span className="text-[10px] opacity-60">申请中</span>
}
if (!body)
return null
return (
<div
className="mb-1 flex flex-wrap items-center gap-1"
onPointerDown={event => event.stopPropagation()}
>
{body}
</div>
)
}
export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>( export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => { ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar, me, onAskHandoff, onCancelHandoffRequest, onAcceptHandoffRequest, onRejectHandoffRequest }, ref) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } const { attributes, listeners, setNodeRef, transform, transition, isDragging }
= useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly }) = useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly })
@@ -111,6 +178,14 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
</span> </span>
) )
: null} : null}
<HandoffRequestActions
issue={issue}
me={me}
onAsk={onAskHandoff}
onCancel={onCancelHandoffRequest}
onAccept={onAcceptHandoffRequest}
onReject={onRejectHandoffRequest}
/>
<div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}> <div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}>
<span className="font-mono opacity-60"> <span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
@@ -45,6 +45,10 @@ interface KanbanBoardProps {
me?: string me?: string
/** Gitea login → 头像 data URI。 */ /** Gitea login → 头像 data URI。 */
avatars?: Record<string, string> avatars?: Record<string, string>
onAskHandoff?: (issueNumber: number) => void
onCancelHandoffRequest?: (issueNumber: number) => void
onAcceptHandoffRequest?: (issueNumber: number) => void
onRejectHandoffRequest?: (issueNumber: number) => void
} }
const kanbanCollisionDetection: CollisionDetection = (args) => { const kanbanCollisionDetection: CollisionDetection = (args) => {
@@ -119,6 +123,10 @@ export function KanbanBoard({
onDependencyClear, onDependencyClear,
me, me,
avatars, avatars,
onAskHandoff,
onCancelHandoffRequest,
onAcceptHandoffRequest,
onRejectHandoffRequest,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
@@ -281,6 +289,11 @@ export function KanbanBoard({
readOnly={readOnly} readOnly={readOnly}
ownerLogin={ownerLogin} ownerLogin={ownerLogin}
ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined} ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined}
me={me}
onAskHandoff={onAskHandoff}
onCancelHandoffRequest={onCancelHandoffRequest}
onAcceptHandoffRequest={onAcceptHandoffRequest}
onRejectHandoffRequest={onRejectHandoffRequest}
/> />
) )
})} })}
+46 -1
View File
@@ -20,6 +20,7 @@ import type { ToastItem } from '../components/ToastStack'
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages' import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
import type { Issue, IssueColumn } from '../types' import type { Issue, IssueColumn } from '../types'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { handoffRequestNotices } from '../../../src/panel/handlers/handoffRequest'
import { compareIssuesInColumn } from '../lib/issueSort' import { compareIssuesInColumn } from '../lib/issueSort'
import { onMessage, postMessage } from '../lib/vscode' import { onMessage, postMessage } from '../lib/vscode'
@@ -320,6 +321,11 @@ export interface UseIssuesResult {
startHandoff: (issueNumber: number, to: string) => void startHandoff: (issueNumber: number, to: string) => void
/** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */ /** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */
acceptHandoff: (issueNumber: number) => void acceptHandoff: (issueNumber: number) => void
/** 审查列里向同事申请把工单移交给自己。 */
askHandoff: (issueNumber: number) => void
cancelHandoffRequest: (issueNumber: number) => void
acceptHandoffRequest: (issueNumber: number) => void
rejectHandoffRequest: (issueNumber: number) => void
/** 是否有移交/接管流程正在该工单上跑,直到扩展端回 `handoff/done` 才清除。 */ /** 是否有移交/接管流程正在该工单上跑,直到扩展端回 `handoff/done` 才清除。 */
isHandoffRunning: (issueNumber: number) => boolean isHandoffRunning: (issueNumber: number) => boolean
} }
@@ -632,6 +638,18 @@ export function useIssues(): UseIssuesResult {
postMessage({ type: 'handoff/start', issueNumber, to }) postMessage({ type: 'handoff/start', issueNumber, to })
}, [markHandoffRunning]) }, [markHandoffRunning])
const askHandoff = useCallback((issueNumber: number): void => {
postMessage({ type: 'handoff/request', issueNumber })
}, [])
const cancelHandoffRequest = useCallback((issueNumber: number): void => {
postMessage({ type: 'handoff/request-cancel', issueNumber })
}, [])
const acceptHandoffRequest = useCallback((issueNumber: number): void => {
postMessage({ type: 'handoff/request-accept', issueNumber })
}, [])
const rejectHandoffRequest = useCallback((issueNumber: number): void => {
postMessage({ type: 'handoff/request-reject', issueNumber })
}, [])
const acceptHandoff = useCallback((issueNumber: number): void => { const acceptHandoff = useCallback((issueNumber: number): void => {
if (handoffRunningRef.current.has(issueNumber)) if (handoffRunningRef.current.has(issueNumber))
return return
@@ -1042,5 +1060,32 @@ export function useIssues(): UseIssuesResult {
return cleanup return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning]) }, [clearPrDiffSummaryRunning, markHandoffRunning])
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } const seenIssues = useRef<Issue[] | null>(null)
useEffect(() => {
if (state.status !== 'ready')
return
const prev = seenIssues.current
seenIssues.current = state.issues
if (!prev || !me)
return
const messages: string[] = []
for (const next of state.issues) {
const before = prev.find(issue => issue.number === next.number)
if (before)
messages.push(...handoffRequestNotices(before, next, me))
}
if (messages.length === 0)
return
setToasts(current => [
...current,
...messages.map(message => ({
id: `handoff-request-${message}`,
level: 'info' as const,
message,
dismissOnTimer: 8000,
})),
])
}, [state, me])
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
} }
+5 -1
View File
@@ -83,7 +83,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -245,6 +245,10 @@ export type WebviewToExtension
| { type: 'handoff/users', issueNumber: number } | { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string } | { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number } | { 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/users' }
| { type: 'session-handoff/start', sessionId: string, to: string } | { type: 'session-handoff/start', sessionId: string, to: string }
| { type: 'session-handoff/accept', attachmentId: number } | { type: 'session-handoff/accept', attachmentId: number }
+4
View File
@@ -103,6 +103,10 @@ export interface Issue {
handoffAttachmentId?: string handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */
handoffFrom?: string handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */
handoffRequestFrom?: string
/** 同意移交失败的原因。对方拒绝时为 rejected。 */
handoffRequestError?: string
/** /**
* 各类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。 * 各类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。
* 仅用于详情面板控制是否在会话 id 行右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。 * 仅用于详情面板控制是否在会话 id 行右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。