✨ feat(vscode): 审查列可以申请同事移交工单
This commit is contained in:
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`。
|
||||||
|
|
||||||
## 接管方:接管
|
## 接管方:接管
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;清空时写空串。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;清空时写空串。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
Reference in new issue
Block a user