✨ feat(vscode): webhook 副作用按 assignee 门禁,适配多人协作
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 会话身份:当前 Gitea token 对应的用户,按 host 缓存。
|
||||
*
|
||||
* 多人协作的三个消费方:
|
||||
* - webhook 副作用门禁(coordinator)用 login 判断"这是不是我的工单"
|
||||
* - 实施认领(handleImplement)把 assignee 设为当前用户
|
||||
* - 状态栏常驻展示身份,token 失效/用错同事 token 时第一眼就能看出来
|
||||
*/
|
||||
|
||||
import type { ExtensionContext, StatusBarItem } from 'vscode'
|
||||
import type { GiteaUser } from '../gitea/api'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { StatusBarAlignment, ThemeColor, window, workspace } from 'vscode'
|
||||
import { detectRepo } from '../git/remote'
|
||||
import { getCurrentUser } from '../gitea/api'
|
||||
import { logger } from '../logging/logger'
|
||||
import { getToken } from './secrets'
|
||||
|
||||
const userByHost = new Map<string, GiteaUser>()
|
||||
let statusBar: StatusBarItem | undefined
|
||||
|
||||
/**
|
||||
* 解析并缓存 token 对应的 login。失败(网络/token 失效)返回 undefined,
|
||||
* 由调用方决定降级行为——门禁侧的语义是"身份未知 → observe"。
|
||||
*/
|
||||
export async function loginForToken(host: string, token: string): Promise<string | undefined> {
|
||||
const hit = userByHost.get(host)
|
||||
if (hit)
|
||||
return hit.login
|
||||
try {
|
||||
const user = await getCurrentUser({ host, token })
|
||||
userByHost.set(host, user)
|
||||
return user.login
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'error',
|
||||
source: 'identity',
|
||||
message: `解析 Gitea 身份失败 host=${host}`,
|
||||
details: message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** token 变更后必须调用,否则状态栏和门禁还认旧身份。 */
|
||||
export function clearIdentityCache(host?: string): void {
|
||||
if (host)
|
||||
userByHost.delete(host)
|
||||
else
|
||||
userByHost.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活时(以及 token 保存后)调用:解析当前工作区的 Gitea 身份,
|
||||
* 展示到状态栏,并与 git 提交邮箱比对——token 被同步/复制到同事机器
|
||||
* 是已发生过的真实事故(chw 机器),这里是最后一道人眼防线。
|
||||
*/
|
||||
export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
||||
if (!statusBar) {
|
||||
statusBar = window.createStatusBarItem(StatusBarAlignment.Left, 0)
|
||||
ctx.subscriptions.push(statusBar)
|
||||
}
|
||||
|
||||
const ws = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!ws) {
|
||||
statusBar.hide()
|
||||
return
|
||||
}
|
||||
const remote = await detectRepo(ws)
|
||||
if (!remote) {
|
||||
statusBar.hide()
|
||||
return
|
||||
}
|
||||
|
||||
const token = await getToken(ctx, remote.host)
|
||||
if (!token) {
|
||||
statusBar.text = '$(account) spx: 未配置 token'
|
||||
statusBar.tooltip = `尚未为 ${remote.host} 配置 Gitea token,点击配置`
|
||||
statusBar.command = 'superpowers.setGiteaToken'
|
||||
statusBar.backgroundColor = new ThemeColor('statusBarItem.warningBackground')
|
||||
statusBar.show()
|
||||
return
|
||||
}
|
||||
|
||||
const login = await loginForToken(remote.host, token)
|
||||
if (!login) {
|
||||
statusBar.text = '$(account) spx: token 失效'
|
||||
statusBar.tooltip = `${remote.host} 的 token 无法解析身份,点击重新配置`
|
||||
statusBar.command = 'superpowers.setGiteaToken'
|
||||
statusBar.backgroundColor = new ThemeColor('statusBarItem.errorBackground')
|
||||
statusBar.show()
|
||||
return
|
||||
}
|
||||
|
||||
statusBar.text = `$(account) spx: ${login}`
|
||||
statusBar.tooltip = `Gitea 身份 ${login} @ ${remote.host}`
|
||||
statusBar.command = 'superpowers.setGiteaToken'
|
||||
statusBar.backgroundColor = undefined
|
||||
statusBar.show()
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'identity',
|
||||
message: `当前 Gitea 身份 ${login} @ ${remote.host}`,
|
||||
})
|
||||
|
||||
checkGitIdentityMatch(ws, remote.host, login)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea 账号邮箱 vs `git config user.email`:不一致基本等于"用了别人的
|
||||
* token"或"git 身份没配"。只警告不拦截——同名多邮箱的合法场景存在。
|
||||
*/
|
||||
function checkGitIdentityMatch(workspaceRoot: string, host: string, login: string): void {
|
||||
const giteaEmail = userByHost.get(host)?.email?.trim().toLowerCase()
|
||||
if (!giteaEmail)
|
||||
return
|
||||
const res = spawnSync('git', ['-C', workspaceRoot, 'config', 'user.email'], { encoding: 'utf-8' })
|
||||
const gitEmail = (res.stdout ?? '').trim().toLowerCase()
|
||||
if (!gitEmail || gitEmail === giteaEmail)
|
||||
return
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'identity',
|
||||
message: `Gitea 身份与 git 提交邮箱不一致: ${login}(${giteaEmail}) vs git(${gitEmail})`,
|
||||
})
|
||||
void window.showWarningMessage(
|
||||
`Gitea token 身份是 ${login}(${giteaEmail}),但 git 提交邮箱是 ${gitEmail}。确认没有用错同事的 token。`,
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
TreeDataProvider,
|
||||
TreeItem,
|
||||
} from 'vscode'
|
||||
import { EventEmitter, commands, window } from 'vscode'
|
||||
import { commands, EventEmitter, window } from 'vscode'
|
||||
import { initIdentity } from './auth/identity'
|
||||
import { setProfilesDirOverride } from './cc/profiles'
|
||||
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
||||
import { getSettings } from './settings/store'
|
||||
@@ -36,6 +37,8 @@ export function activate(context: ExtensionContext): void {
|
||||
context.subscriptions.push({
|
||||
dispose: () => { void webhookCoordinator.dispose() },
|
||||
})
|
||||
// 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。
|
||||
void initIdentity(context)
|
||||
|
||||
const treeView = window.createTreeView('superpowers.kanban', {
|
||||
treeDataProvider: new EmptyTreeProvider(),
|
||||
|
||||
+33
-8
@@ -16,6 +16,7 @@ const PAGE_SIZE = 50
|
||||
export interface GiteaUser {
|
||||
login: string
|
||||
id: number
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface GiteaIssue {
|
||||
@@ -57,8 +58,8 @@ function baseUrl(host: string): string {
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/json',
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ export async function listIssuesByFilter(opts: {
|
||||
const out: GiteaIssue[] = []
|
||||
let page = 1
|
||||
// Gitea returns a JSON array of issues; we accumulate until we see a short page.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
|
||||
url.searchParams.set('type', 'issues')
|
||||
@@ -127,7 +128,7 @@ export async function listAllRepoComments(opts: {
|
||||
}): Promise<GiteaComment[]> {
|
||||
const out: GiteaComment[] = []
|
||||
let page = 1
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/comments`)
|
||||
url.searchParams.set('limit', String(PAGE_SIZE))
|
||||
@@ -161,7 +162,7 @@ export async function listIssueComments(opts: {
|
||||
}): Promise<GiteaComment[]> {
|
||||
const out: GiteaComment[] = []
|
||||
let page = 1
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const url = new URL(
|
||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
|
||||
@@ -204,7 +205,6 @@ export async function postIssueComment(opts: {
|
||||
await ensureOk(res)
|
||||
}
|
||||
|
||||
|
||||
export interface GiteaPullRequest {
|
||||
number: number
|
||||
merged: boolean
|
||||
@@ -285,6 +285,32 @@ export async function getIssue(opts: {
|
||||
return await res.json() as GiteaIssue
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖式设置工单 assignee(实施即认领的多人协作语义:单一责任人,
|
||||
* 重新实施会把责任转移给新实施者)。
|
||||
*/
|
||||
export async function updateIssueAssignees(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
index: number
|
||||
assignees: string[]
|
||||
}): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...authHeaders(opts.token),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ assignees: opts.assignees }),
|
||||
},
|
||||
)
|
||||
await ensureOk(res)
|
||||
}
|
||||
|
||||
export async function getDependencies(opts: {
|
||||
host: string
|
||||
token: string
|
||||
@@ -430,7 +456,6 @@ export async function removeDependency(opts: {
|
||||
await ensureOk(res)
|
||||
}
|
||||
|
||||
|
||||
// no longer auto-invoked by the implement flow; kept for future manual ops
|
||||
export async function createWebhook(opts: {
|
||||
host: string
|
||||
@@ -550,7 +575,7 @@ export async function listPullRequestFiles(opts: {
|
||||
}): Promise<PrFileDto[]> {
|
||||
const out: PrFileDto[] = []
|
||||
let page = 1
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const url = new URL(
|
||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`,
|
||||
|
||||
@@ -7,20 +7,22 @@ import { promises as fsp } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { loginForToken } from '../../auth/identity'
|
||||
import { getToken } from '../../auth/secrets'
|
||||
import { buildCcCommand } from '../../cc/ccCommand'
|
||||
import { buildCodexCommandString } from '../../cc/codexCommand'
|
||||
import { watchForNewCodexSession } from '../../cc/codexSessionWatcher'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts'
|
||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
import { createWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||
import { updateIssueAssignees } from '../../gitea/api'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
|
||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
||||
@@ -647,9 +649,12 @@ function deriveSlug(planFile: string): string {
|
||||
*/
|
||||
function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string {
|
||||
let p = template
|
||||
.split('$project_name').join(path.basename(workspaceRoot))
|
||||
.split('$project_root').join(workspaceRoot)
|
||||
.split('$feature_name').join(slug)
|
||||
.split('$project_name')
|
||||
.join(path.basename(workspaceRoot))
|
||||
.split('$project_root')
|
||||
.join(workspaceRoot)
|
||||
.split('$feature_name')
|
||||
.join(slug)
|
||||
if (p.startsWith('~')) {
|
||||
p = path.join(os.homedir(), p.slice(1))
|
||||
}
|
||||
@@ -832,6 +837,39 @@ export async function handleImplement(
|
||||
return
|
||||
}
|
||||
|
||||
// 实施即认领:assignee 是 webhook 副作用门禁的路由依据(谁实施,谁的
|
||||
// 机器负责审查/写状态)。覆盖式单人指派,重新实施会转移责任。失败不阻塞
|
||||
// 实施——门禁侧会退回本地终端兜底。
|
||||
void (async () => {
|
||||
try {
|
||||
const me = await loginForToken(remote.host, token)
|
||||
if (!me)
|
||||
return
|
||||
await updateIssueAssignees({
|
||||
host: remote.host,
|
||||
token,
|
||||
owner: remote.owner,
|
||||
repo: remote.repo,
|
||||
index: issueNumber,
|
||||
assignees: [me],
|
||||
})
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'implement',
|
||||
message: `#${issueNumber} assignee → ${me}`,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'implement',
|
||||
message: `设置 #${issueNumber} assignee 失败(不阻塞实施)`,
|
||||
details: message,
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
// Run the post-create lifecycle hook. Fire-and-await so subsequent
|
||||
// steps (mkdir projects dir, terminal spawn, cc launch) see whatever
|
||||
// setup the user script performed (e.g. .env copy). Failures are
|
||||
@@ -1223,7 +1261,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
|
||||
// worktree 里:代码就在当前工作目录,不用 tea 拉取,直接测;
|
||||
// 主 worktree:PR 已合并,先用 tea 熟悉改动再测。
|
||||
const prompt = "/pr-acceptance-testing"
|
||||
const prompt = '/pr-acceptance-testing'
|
||||
if (prompt.includes('\'')) {
|
||||
void window.showErrorMessage('启动测试失败:prompt 含单引号,拒绝执行')
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { commands, env, Uri, workspace } from 'vscode'
|
||||
import { clearIdentityCache, initIdentity } from '../../auth/identity'
|
||||
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
||||
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
@@ -142,8 +143,12 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
// 保存后立刻让 listClaudeProfiles / getDefaultProfilePath 吃到新目录。
|
||||
setProfilesDirOverride(trimmedProfilesDirectory || undefined)
|
||||
void handleProfilesList(panel)
|
||||
if (!keepExisting)
|
||||
if (!keepExisting) {
|
||||
await setToken(panel.context, trimmedHost, trimmedToken)
|
||||
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
|
||||
clearIdentityCache(trimmedHost)
|
||||
void initIdentity(panel.context)
|
||||
}
|
||||
// YouTrack token: stored under the base URL's host. Empty input keeps the
|
||||
// existing token (placeholder semantics), matching the gitea flow above.
|
||||
if (trimmedYtBase && trimmedYtToken)
|
||||
|
||||
@@ -71,6 +71,22 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对
|
||||
* 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端
|
||||
* 仍活着,归属判定不能因此失明。前缀匹配策略与 injectIntoImplTerminal
|
||||
* 一致(shell OSC 会在名字后追加分支后缀)。
|
||||
*/
|
||||
export function hasLiveIssueSessionTerminal(
|
||||
issueNumber: number,
|
||||
kinds: readonly string[] = ['实施', '测试'],
|
||||
): boolean {
|
||||
const prefixes = kinds.map(k => `issue-${issueNumber}-${k}`)
|
||||
return window.terminals.some(t =>
|
||||
t.exitStatus === undefined && prefixes.some(p => t.name.startsWith(p)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `vscode.window.terminals` for an existing terminal whose name
|
||||
* matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')`
|
||||
|
||||
@@ -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