diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index cf1a649..33f8e10 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -43,6 +43,15 @@ function parseStateComment(body: string | undefined | null): Record { + private async refreshIssueCard(opts: { + issueNumber: number + /** 原始 webhook payload,'mine' 门禁从里面取 poster / assignees。 */ + raw: unknown + /** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */ + reason: string + }): Promise { + const { issueNumber, raw, reason } = opts if (!this.ctx) return // 没面板就没什么可更新,下次打开看板的批量加载会兜底。 @@ -735,7 +747,7 @@ class WebhookCoordinator { logger.add({ level: 'warn', source: 'webhook', - message: 'issue assigned 事件忽略:未打开工作区', + message: `${reason} 事件忽略:未打开工作区`, }) return } @@ -744,7 +756,7 @@ class WebhookCoordinator { logger.add({ level: 'warn', source: 'webhook', - message: 'issue assigned 事件忽略:工作区未关联 gitea', + message: `${reason} 事件忽略:工作区未关联 gitea`, }) return } @@ -753,12 +765,12 @@ class WebhookCoordinator { logger.add({ level: 'warn', source: 'webhook', - message: 'issue assigned 事件忽略:未配置 token', + message: `${reason} 事件忽略:未配置 token`, }) return } - // 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡, + // 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, // 省一次身份请求。 const scope = this.ctx.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE @@ -766,20 +778,20 @@ class WebhookCoordinator { let assignees: string[] = [] let me: string | undefined if (scope === 'mine') { - const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null - poster = raw?.issue?.user?.login - assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) + const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null + poster = payload?.issue?.user?.login + assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) me = await loginForToken(remote.host, token) } if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) { logger.add({ level: 'info', 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 ?? '<未知>'}`, }) // 卡本来不在板上时前端 remove 是无害 no-op。 - this.activePanel.postMessage({ type: 'issue/remove', issueNumber: event.issueNumber }) + this.activePanel.postMessage({ type: 'issue/remove', issueNumber }) return } try { @@ -789,11 +801,11 @@ class WebhookCoordinator { repo: remote.repo, token, workspaceRoot: ws, - issueNumber: event.issueNumber, + issueNumber, }) const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null - // 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新 - // assignee,不在板上的新增——指派给我的工单借此实时上卡。 + // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新 + // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。 if (issue) { this.activePanel.noteIssueOwnership(issue) this.activePanel.postMessage({ type: 'issue/append', issue }) @@ -804,12 +816,36 @@ class WebhookCoordinator { logger.add({ level: 'warn', source: 'webhook', - message: 'loadSingleIssue 失败(issue assigned)', + message: `loadSingleIssue 失败(${reason})`, details: msg, }) } } + /** + * issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板 + * 要实时上卡/撤卡,不能等下次手动刷新。 + */ + private async handleIssueAssigned(event: IssueWebhookEvent): Promise { + await this.refreshIssueCard({ + issueNumber: event.issueNumber, + raw: event.raw, + reason: event.action === 'unassigned' ? '取消指派' : '指派', + }) + } + + /** + * issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着 + * 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。 + */ + private async handleIssueStateChanged(event: IssueWebhookEvent): Promise { + await this.refreshIssueCard({ + issueNumber: event.issueNumber, + raw: event.raw, + reason: event.action === 'closed' ? '工单关闭' : '工单重开', + }) + } + /** * Issue `edited` payload: cc 会往 issue body 里追加 `` * / `` / `` 三种 marker。 @@ -1386,6 +1422,23 @@ class WebhookCoordinator { message: `issue_comment body diag #${event.issueNumber} len=${body.length}`, 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 (!//i.test(body)) { logger.add({ level: 'info', diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 2b27e34..95bb7f6 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -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('\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) + }) +})