✨ feat(vscode): 同事改工单所在列、开关工单时,本机看板实时跟着动

此前只有新建工单、改标题、改指派会实时上卡;列变化存在 state JSON 评论里,
而 issue_comment 处理只认带 spx:review 标识的审查评论,别人拖卡这边毫无反应,
issues closed/reopened 也没接。抽出 refreshIssueCard 作为统一刷卡入口:重拉
该工单、叠加本机运行态、按 mine 可见性门禁决定上卡还是撤卡,只刷 UI,不写
远端状态也不起会话,观察者机器执行同样安全。state 评论(解析出 column)与
工单开关都接到这个入口上。
This commit is contained in:
cruldra committed 2026-09-21 00:37:04 +08:00
1 parent 6603e76fe1
commit fb122b76ce
3 files changed
+105 -17

No files matched your search

+9
View File
@@ -43,6 +43,15 @@ function parseStateComment(body: string | undefined | null): Record<string, unkn
return hasKnownField ? obj : null return hasKnownField ? obj : null
} }
/**
* 评论是否是一条带 `column` 的 state JSON——即「这张卡换列了」的信号。
* webhook 收到这种评论时据此刷新对应卡片。
*/
export function hasStateColumn(body: string | undefined | null): boolean {
const parsed = parseStateComment(body)
return parsed !== null && 'column' in parsed
}
/** /**
* 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。 * 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。
* 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。 * 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。
+70 -17
View File
@@ -22,7 +22,7 @@ import { isMailboxIssue } from '../cc/sessionHandoff'
import { detectRepo } from '../git/remote' import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
@@ -442,6 +442,9 @@ class WebhookCoordinator {
else if (event.action === 'edited') { else if (event.action === 'edited') {
await this.handleIssueEdited(event) await this.handleIssueEdited(event)
} }
else if (event.action === 'closed' || event.action === 'reopened') {
await this.handleIssueStateChanged(event)
}
else if (event.action === 'assigned' || event.action === 'unassigned') { else if (event.action === 'assigned' || event.action === 'unassigned') {
// Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠 // Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠
// action 区分,不会走到独立的事件分支。 // action 区分,不会走到独立的事件分支。
@@ -720,10 +723,19 @@ class WebhookCoordinator {
} }
/** /**
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板 * 把一张工单的最新状态推给看板:重拉这张工单、叠加本机运行态,再按
* 要实时上卡/撤卡,不能等下次手动刷新。 * 'mine' 可见性门禁决定上卡还是撤卡。指派变化、列变化、开关工单都走
* 这里。它只刷 UI——不写远端状态、不起会话,所以在"观察者"机器上执行
* 同样安全,同事把卡拖到哪一列,这边就跟着动到哪一列。
*/ */
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> { private async refreshIssueCard(opts: {
issueNumber: number
/** 原始 webhook payload,'mine' 门禁从里面取 poster / assignees。 */
raw: unknown
/** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */
reason: string
}): Promise<void> {
const { issueNumber, raw, reason } = opts
if (!this.ctx) if (!this.ctx)
return return
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。 // 没面板就没什么可更新,下次打开看板的批量加载会兜底。
@@ -735,7 +747,7 @@ class WebhookCoordinator {
logger.add({ logger.add({
level: 'warn', level: 'warn',
source: 'webhook', source: 'webhook',
message: 'issue assigned 事件忽略:未打开工作区', message: `${reason} 事件忽略:未打开工作区`,
}) })
return return
} }
@@ -744,7 +756,7 @@ class WebhookCoordinator {
logger.add({ logger.add({
level: 'warn', level: 'warn',
source: 'webhook', source: 'webhook',
message: 'issue assigned 事件忽略:工作区未关联 gitea', message: `${reason} 事件忽略:工作区未关联 gitea`,
}) })
return return
} }
@@ -753,12 +765,12 @@ class WebhookCoordinator {
logger.add({ logger.add({
level: 'warn', level: 'warn',
source: 'webhook', source: 'webhook',
message: 'issue assigned 事件忽略:未配置 token', message: `${reason} 事件忽略:未配置 token`,
}) })
return return
} }
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡, // 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡,
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
// 省一次身份请求。 // 省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
@@ -766,20 +778,20 @@ class WebhookCoordinator {
let assignees: string[] = [] let assignees: string[] = []
let me: string | undefined let me: string | undefined
if (scope === 'mine') { if (scope === 'mine') {
const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
poster = raw?.issue?.user?.login poster = payload?.issue?.user?.login
assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
me = await loginForToken(remote.host, token) me = await loginForToken(remote.host, token)
} }
if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) { if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) {
logger.add({ logger.add({
level: 'info', level: 'info',
source: 'webhook', source: 'webhook',
message: `issue #${event.issueNumber} ${event.action === 'unassigned' ? '取消指派' : '指派'}后与我无关,scope=mine 撤卡`, message: `issue #${issueNumber} ${reason} 后与我无关,scope=mine 撤卡`,
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`, details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
}) })
// 卡本来不在板上时前端 remove 是无害 no-op。 // 卡本来不在板上时前端 remove 是无害 no-op。
this.activePanel.postMessage({ type: 'issue/remove', issueNumber: event.issueNumber }) this.activePanel.postMessage({ type: 'issue/remove', issueNumber })
return return
} }
try { try {
@@ -789,11 +801,11 @@ class WebhookCoordinator {
repo: remote.repo, repo: remote.repo,
token, token,
workspaceRoot: ws, workspaceRoot: ws,
issueNumber: event.issueNumber, issueNumber,
}) })
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新 // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
// assignee,不在板上的新增——指派给我的工单借此实时上卡。 // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
if (issue) { if (issue) {
this.activePanel.noteIssueOwnership(issue) this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue }) this.activePanel.postMessage({ type: 'issue/append', issue })
@@ -804,12 +816,36 @@ class WebhookCoordinator {
logger.add({ logger.add({
level: 'warn', level: 'warn',
source: 'webhook', source: 'webhook',
message: 'loadSingleIssue 失败(issue assigned)', message: `loadSingleIssue 失败(${reason})`,
details: msg, details: msg,
}) })
} }
} }
/**
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
* 要实时上卡/撤卡,不能等下次手动刷新。
*/
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> {
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: event.action === 'unassigned' ? '取消指派' : '指派',
})
}
/**
* issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着
* 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。
*/
private async handleIssueStateChanged(event: IssueWebhookEvent): Promise<void> {
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
})
}
/** /**
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->` * Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。 * / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
@@ -1386,6 +1422,23 @@ class WebhookCoordinator {
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`, message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
details: body.slice(0, 200), details: body.slice(0, 200),
}) })
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
if (event.prNumber === undefined && hasStateColumn(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
})
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: '列变化',
})
return
}
if (!/<!--\s*spx:review=1\s*-->/i.test(body)) { if (!/<!--\s*spx:review=1\s*-->/i.test(body)) {
logger.add({ logger.add({
level: 'info', level: 'info',
+26
View File
@@ -394,3 +394,29 @@ describe('deleteLocalBranch', () => {
]) ])
}) })
}) })
describe('hasStateColumn', () => {
it('带 column 的 state JSON → true', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('{"column":"done","pr":"318"}')).toBe(true)
expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true)
})
it('没有 column 的 state JSON → false', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('{"pr":"318","branch":"feature/x"}')).toBe(false)
})
it('普通文本、审查评论、空值 → false', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('<!-- spx:review=1 -->\n看着没问题')).toBe(false)
expect(hasStateColumn('拖到完成列了')).toBe(false)
expect(hasStateColumn('')).toBe(false)
expect(hasStateColumn(undefined)).toBe(false)
})
it('json 数组不算 state 评论', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('[{"column":"done"}]')).toBe(false)
})
})