✨ 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
|
||||
|
||||
Reference in New Issue
Block a user