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