feat(vscode): webhook 副作用按 assignee 门禁,适配多人协作

This commit is contained in:
2026-08-18 23:57:21 +08:00
parent c20bd86370
commit c370214d12
11 changed files with 450 additions and 22 deletions
+108 -4
View File
@@ -14,14 +14,17 @@ import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server'
import { promises as fsp } from 'node:fs'
import { env, Uri, window, workspace } from 'vscode'
import { loginForToken } from '../auth/identity'
import { getToken } from '../auth/secrets'
import { getReviewPrompt } from '../cc/prompts'
import { detectRepo } from '../git/remote'
import { deleteWebhook, getPullRequest, listIssueComments } from '../gitea/api'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership'
import { WebhookServer } from './server'
/**
@@ -241,6 +244,46 @@ class WebhookCoordinator {
return { hookId: undefined, host: remote.host, owner: remote.owner, repo: remote.repo, token: tok }
}
/**
* 多人协作门禁:webhook 广播到每位开发者的机器,只有"责任人机器"执行
* 副作用(写 state JSON / 触发 codex 审查 / 注入终端),其余机器只刷
* 本机 UI。判定规则见 {@link decideEventOwnership};任何一步失败都退
* 化为 observe——宁可漏做(可人工补)也不能多机重复做。
*
* `evidenceKinds` 是无 assignee 存量工单的兜底证据(本机终端类型);
* PR 事件默认只认实施/测试终端,issue edited 额外认规划终端。
*/
private async resolveOwnership(
issueNumber: number,
ctx: { host: string, owner: string, repo: string, token: string },
evidenceKinds?: readonly string[],
): Promise<'own' | 'observe'> {
let assignees: string[] = []
try {
const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber })
assignees = (issue?.assignees ?? []).map(a => a.login)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 #${issueNumber} assignee 失败,按无 assignee 兜底`,
details: message,
})
}
const me = await loginForToken(ctx.host, ctx.token)
const hasLocalSessionEvidence = hasLiveIssueSessionTerminal(issueNumber, evidenceKinds)
const decision = decideEventOwnership({ assignees, me, hasLocalSessionEvidence })
logger.add({
level: 'info',
source: 'webhook',
message: `归属判定 #${issueNumber}: ${decision}`,
details: `assignees=[${assignees.join(',')}] me=${me ?? '<未知>'} localTab=${hasLocalSessionEvidence}`,
})
return decision
}
/**
* Process a single `pull_request` webhook delivery: merge the PR number
* into the issue's state-JSON comment, delete the gitea webhook, and (if
@@ -628,8 +671,8 @@ class WebhookCoordinator {
}
// 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `<!-- spx:spec=... -->` 占位被当真。
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s>]*\/[^\s>]*\.md)\s*-->/)
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s>]*\/[^\s>]*\.md)\s*-->/)
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const specFile = specMatch ? specMatch[1] : undefined
const planFile = planMatch ? planMatch[1] : undefined
@@ -695,6 +738,29 @@ class WebhookCoordinator {
return
}
// spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
// 额外认规划终端。observe 机器只把新值推给本机 webview。
const ownership = await this.resolveOwnership(
event.issueNumber,
{ host: remote.host, owner: remote.owner, repo: remote.repo, token },
['实施', '测试', '规划'],
)
if (ownership === 'observe') {
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (specFile)
patch.specFile = specFile
if (planFile)
patch.planFile = planFile
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
return
}
const extra: Record<string, unknown> = {}
if (specFile)
extra.specFile = specFile
@@ -762,6 +828,16 @@ class WebhookCoordinator {
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// 别人的工单:只刷本机看板,不写共享状态、不弹 toast、不触发审查。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { pr: event.pr, implementStatus: 'done' },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
@@ -887,6 +963,9 @@ class WebhookCoordinator {
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe')
return
// Per-issue override takes precedence over the global setting.
let effectiveAutoReview = getSettings(this.ctx).autoReview
let autoReviewSource: 'issue' | 'global' = 'global'
@@ -976,6 +1055,16 @@ class WebhookCoordinator {
if (!merged)
return
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// 合并状态由责任人机器落库,本机只刷看板。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { prMerged: true },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
@@ -1038,6 +1127,15 @@ class WebhookCoordinator {
})
return
}
if (await this.resolveOwnership(event.issueNumber, ctx) === 'observe') {
// pr 字段清空由责任人机器落库,本机只刷看板。
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { pr: null },
})
return
}
try {
await mergeStateJsonComment({
host: ctx.host,
@@ -1146,7 +1244,7 @@ class WebhookCoordinator {
})
return
}
const text = body.replace(/<!--\s*spx:review=1\s*-->\s*\n?/i, '').trim()
const text = body.replace(/<!--\s*spx:review=1\s*-->\s*/i, '').trim()
if (!text) {
logger.add({
level: 'warn',
@@ -1210,6 +1308,12 @@ class WebhookCoordinator {
}
}
if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') {
// 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到
// 该终端,提前拦截省掉后面的评论计数 API 往返。
return
}
// Count marker comments to determine isFirstReview. This call also
// includes the just-posted comment, so a first review yields count=1.
let reviewCount = 1
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { decideEventOwnership } from './eventOwnership'
describe('decideEventOwnership', () => {
it('assignee 是我 → own', () => {
expect(decideEventOwnership({
assignees: ['cruldra'],
me: 'cruldra',
hasLocalSessionEvidence: false,
})).toBe('own')
})
it('assignee 是别人 → observe,即使本机有终端(重新指派会转移责任)', () => {
expect(decideEventOwnership({
assignees: ['chehongwei'],
me: 'cruldra',
hasLocalSessionEvidence: true,
})).toBe('observe')
})
it('多 assignee 含我 → own', () => {
expect(decideEventOwnership({
assignees: ['chehongwei', 'cruldra'],
me: 'cruldra',
hasLocalSessionEvidence: false,
})).toBe('own')
})
it('有 assignee 但身份未知 → observe(无法证明归属时不动作)', () => {
expect(decideEventOwnership({
assignees: ['cruldra'],
me: undefined,
hasLocalSessionEvidence: true,
})).toBe('observe')
})
it('无 assignee(存量工单)+ 本机有实施/测试终端 → own', () => {
expect(decideEventOwnership({
assignees: [],
me: 'cruldra',
hasLocalSessionEvidence: true,
})).toBe('own')
})
it('无 assignee + 无本地证据 → observe', () => {
expect(decideEventOwnership({
assignees: [],
me: 'cruldra',
hasLocalSessionEvidence: false,
})).toBe('observe')
})
it('无 assignee 时本地证据不依赖身份(token 失效也能兜底)', () => {
expect(decideEventOwnership({
assignees: [],
me: undefined,
hasLocalSessionEvidence: true,
})).toBe('own')
})
})
+24
View File
@@ -0,0 +1,24 @@
/**
* 多人协作事件门禁:Gitea webhook 广播到每位开发者的机器,写状态、触发
* codex 审查、注入终端这类副作用只允许"责任人机器"执行,其余机器只做
* UI 刷新。归属以 Gitea 原生 assignee 为准;存量无 assignee 的工单退回
* 本地证据(本机是否开着该工单的实施/测试终端)。
*/
export type EventOwnership = 'own' | 'observe'
export function decideEventOwnership(opts: {
/** 工单 assignee 的 login 列表,空数组表示未指派。 */
assignees: string[]
/** 本机 token 对应的 Gitea login;身份解析失败时为 undefined。 */
me: string | undefined
/** 本机是否存在该工单的实施/测试终端(存量工单的兜底证据)。 */
hasLocalSessionEvidence: boolean
}): EventOwnership {
// ① 有 assignee → 指派即真相,本地终端不作数(重新指派会转移责任);
// 身份未知时无法自证归属,宁可不动作也不重复动作。
if (opts.assignees.length > 0)
return opts.me !== undefined && opts.assignees.includes(opts.me) ? 'own' : 'observe'
// ② 无 assignee(存量工单)→ 本地终端在,说明实施发生在这台机器。
return opts.hasLocalSessionEvidence ? 'own' : 'observe'
}