From c370214d122ea1476186b2c205450ff55a482c1a Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 18 Aug 2026 23:57:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20webhook=20=E5=89=AF?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E6=8C=89=20assignee=20=E9=97=A8=E7=A6=81,?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=A4=9A=E4=BA=BA=E5=8D=8F=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/cli/cmd/spx/main.go | 10 +- vscode/cli/internal/gitea/client.go | 16 ++- vscode/src/auth/identity.ts | 131 ++++++++++++++++++++++ vscode/src/extension.ts | 5 +- vscode/src/gitea/api.ts | 41 +++++-- vscode/src/panel/handlers/sessions.ts | 50 ++++++++- vscode/src/panel/handlers/settings.ts | 7 +- vscode/src/panel/handlers/terminals.ts | 16 +++ vscode/src/webhook/coordinator.ts | 112 +++++++++++++++++- vscode/src/webhook/eventOwnership.test.ts | 60 ++++++++++ vscode/src/webhook/eventOwnership.ts | 24 ++++ 11 files changed, 450 insertions(+), 22 deletions(-) create mode 100644 vscode/src/auth/identity.ts create mode 100644 vscode/src/webhook/eventOwnership.test.ts create mode 100644 vscode/src/webhook/eventOwnership.ts diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index f1adb11..cf41bf5 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -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 } diff --git a/vscode/cli/internal/gitea/client.go b/vscode/cli/internal/gitea/client.go index dac8cde..3542444 100644 --- a/vscode/cli/internal/gitea/client.go +++ b/vscode/cli/internal/gitea/client.go @@ -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 diff --git a/vscode/src/auth/identity.ts b/vscode/src/auth/identity.ts new file mode 100644 index 0000000..7987f1c --- /dev/null +++ b/vscode/src/auth/identity.ts @@ -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() +let statusBar: StatusBarItem | undefined + +/** + * 解析并缓存 token 对应的 login。失败(网络/token 失效)返回 undefined, + * 由调用方决定降级行为——门禁侧的语义是"身份未知 → observe"。 + */ +export async function loginForToken(host: string, token: string): Promise { + 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 { + 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。`, + ) +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index b2e7f8e..8745438 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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(), diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index d92c673..841ad3e 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -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 { 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 { 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 { 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 { + 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 { 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`, diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index d0ba0ef..cf3dc29 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -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 { // 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 diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index d181bd5..5ecc682 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -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) diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 3be4a32..e74ce93 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -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 + ' ')` diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 41b7012..1606afb 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -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 写的 `` 占位被当真。 - const specMatch = event.body.match(//) - const planMatch = event.body.match(//) + const specMatch = event.body.match(//) + const planMatch = event.body.match(//) 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 = {} 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*\n?/i, '').trim() + const text = body.replace(/\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 diff --git a/vscode/src/webhook/eventOwnership.test.ts b/vscode/src/webhook/eventOwnership.test.ts new file mode 100644 index 0000000..63f270c --- /dev/null +++ b/vscode/src/webhook/eventOwnership.test.ts @@ -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') + }) +}) diff --git a/vscode/src/webhook/eventOwnership.ts b/vscode/src/webhook/eventOwnership.ts new file mode 100644 index 0000000..4e5327e --- /dev/null +++ b/vscode/src/webhook/eventOwnership.ts @@ -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' +}