✨ feat(vscode): webhook 副作用按 assignee 门禁,适配多人协作
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user