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
+9 -1
View File
@@ -164,7 +164,15 @@ func buildIssueCmd() *cobra.Command {
}
}
rc := fromCtx(cmd)
issue, err := rc.Client.CreateIssue(rc.Owner, rc.Repo, icTitle, body)
// 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。
// 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。
var assignees []string
if login, loginErr := rc.Client.CurrentUserLogin(); loginErr == nil && login != "" {
assignees = []string{login}
} else if loginErr != nil {
fmt.Fprintf(os.Stderr, "警告: 解析当前用户失败,工单将无 assignee: %v\n", loginErr)
}
issue, err := rc.Client.CreateIssue(rc.Owner, rc.Repo, icTitle, body, assignees)
if err != nil {
return err
}
+15 -1
View File
@@ -81,10 +81,24 @@ func (c *Client) do(method, path string, payload any, out any) error {
return nil
}
// CurrentUserLogin resolves the token owner's login.
func (c *Client) CurrentUserLogin() (string, error) {
var out struct {
Login string `json:"login"`
}
if err := c.do(http.MethodGet, "/api/v1/user", nil, &out); err != nil {
return "", err
}
return out.Login, nil
}
// CreateIssue creates a new issue and returns the created object.
func (c *Client) CreateIssue(owner, repo, title, body string) (*Issue, error) {
func (c *Client) CreateIssue(owner, repo, title, body string, assignees []string) (*Issue, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload := map[string]any{"title": title, "body": body}
if len(assignees) > 0 {
payload["assignees"] = assignees
}
var out Issue
if err := c.do(http.MethodPost, path, payload, &out); err != nil {
return nil, err
+131
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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`,
+44 -6
View File
@@ -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 拉取,直接测;
// 主 worktreePR 已合并,先用 tea 熟悉改动再测。
const prompt = "/pr-acceptance-testing"
const prompt = '/pr-acceptance-testing'
if (prompt.includes('\'')) {
void window.showErrorMessage('启动测试失败:prompt 含单引号,拒绝执行')
return
+6 -1
View File
@@ -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)
+16
View File
@@ -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 + ' ')`
+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'
}