From f208d017480cd3820965acac9e318774cf367b43 Mon Sep 17 00:00:00 2001 From: "zhubowen.cc" Date: Thu, 23 Jul 2026 20:49:40 +0800 Subject: [PATCH] Keep PR review collaboration alive beyond author sessions PR handoffs previously depended on ad-hoc chats and a live author session. Add an idempotent PR Room lifecycle that creates or adopts a Feishu group, prepares local and federated reviewer agents, persists setup intent, and lets the author repair or finish the room safely. Constraint: Feishu bot open_id values are app-scoped, so remote reviewer readiness must be acknowledged by the owning deployment before kickoff. Constraint: Cross-deployment simultaneous open calls do not yet share a distributed uniqueness lock. Rejected: Retry expired or timed-out group creation automatically | could create duplicate review groups after an ambiguous remote success. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve the setup attempt token and finish-pending invariants before adding new PR Room side effects. Tested: pnpm build; tsc --noEmit; 63 targeted unit tests; full unit suite with only 15 baseline environment failures; dual code and architecture review. Not-tested: Live cross-deployment Feishu group creation against two production Botmux deployments. --- README.md | 4 + docs/pr-review-room.md | 91 +++ src/cli.ts | 12 + src/cli/arg-utils.ts | 27 + src/cli/pr-room.ts | 997 ++++++++++++++++++++++++ src/dashboard/federated-group-core.ts | 83 ++ src/dashboard/federation-api.ts | 67 +- src/services/webhook-lifecycle-store.ts | 350 ++++++++- test/cli-arg-utils.test.ts | 29 +- test/federation-api.test.ts | 105 +++ test/pr-room.test.ts | 146 ++++ test/webhook-lifecycle-store.test.ts | 373 +++++++++ 12 files changed, 2275 insertions(+), 9 deletions(-) create mode 100644 docs/pr-review-room.md create mode 100644 src/cli/pr-room.ts create mode 100644 test/pr-room.test.ts diff --git a/README.md b/README.md index 8aff38783..8182a53be 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ botmux 可以管理 CLI 无关的自定义 Skill Registry,并按 bot 配置在 同一台机器上可运行多个飞书机器人,每个机器人可对应不同的 CLI。同一群聊中通过 @mention 路由消息,仅「你 + 1 个机器人」的 1v1 群无需 @ 自动响应,多人群默认必须 @(可通过「群聊 @ 策略」配置话题内免 @ / 全群免 @);多机器人时 `@ @ /t xxx` 可让每个被 @ 的机器人在同一条消息上各自独立开新话题。先发一次 `@ @ /introduce` 让它们互相登记 open_id,之后各 bot 就能在自己的会话里显式 @mention 对方协作(命令详见 [📖 文档 · 斜杠命令](https://deepcoldy.github.io/botmux/slash-commands))。 +针对一次 PR 的持续协作可用 [`botmux pr-room`](docs/pr-review-room.md):提交 PR +后幂等创建作者/Owner agent review 群,或接管 Owner 已先创建的群;合并、关闭或 +废弃时显式结束生命周期并保留审查记录。 + ### 多话题协作模式 「多机器人协作」的升级版:主 bot(**编排者**)把一个大任务拆成多个**子项目**,在群里**自动开多条话题**,每条话题派一组 bot 并行推进(常见「一个写代码 + 一个 review」),用一张**飞书任务清单**当所有人共享的进度板,最后由主 bot 收齐汇总。一个普通群就是一个并行工作台,你在飞书任务面板一眼看完成度。 diff --git a/docs/pr-review-room.md b/docs/pr-review-room.md new file mode 100644 index 000000000..2833f6a0a --- /dev/null +++ b/docs/pr-review-room.md @@ -0,0 +1,91 @@ +# PR Review Room + +`botmux pr-room` 把一次 Pull Request 变成一个有生命周期的飞书协作群。作者 +agent、显式选定的 Owner/reviewer agent,以及这些 agent 已绑定的人类 Owner 会被 +拉进同一个群;群内对话继续走 Botmux 原有的 agent 会话、工作目录和消息路由。 + +## 在提交 PR 后建群 + +作者 agent 创建并 push PR 后执行: + +```bash +botmux pr-room open https://github.com/acme/service/pull/123 \ + --owner-agent owner-reviewer \ + --working-dir /path/to/service +``` + +- 作者 agent 默认取当前会话的 `BOTMUX_LARK_APP_ID`,也可用 + `--author-agent ` 显式指定。 +- `--owner-agent` 可重复。名称不唯一时命令会拒绝猜测,并要求改用 + `larkAppId`。 +- PR URL 是 team 内的幂等键;重复执行会返回原 `chatId`,不会重复拉群。 +- 建群复用 federation roster 和联邦拉群链路,因此 reviewer agent 在其他 + Botmux 部署上也可以被邀请。远端部署会先确认 reviewer 已入群并写入团队信任, + 再允许作者 agent 发出 @ kickoff;离线或未就绪部署会明确降级,而不会假报成功。 +- 建群后 Botmux 会 @ reviewer agent,要求其独立检查 diff、测试、风险和 + 可维护性;作者 agent 根据评论修改、验证和 push。 +- 建群请求超时或本地连接在提交窗口中断时,Botmux 会把结果标记为“不确定”并阻止自动重试, + 避免远端其实已成功却又创建重复群;找到实际群后用 `pr-room adopt` 接管。若 + 确认没有建成,执行 `pr-room finish` 终止该不确定记录,再用 `--reopen` 重试。 + +## 接管已经创建的群 + +如果 Owner 已经先建了 review 群,不要再开一个: + +```bash +botmux pr-room adopt https://github.com/acme/service/pull/123 \ + --chat-id oc_xxx \ + --owner-agent owner-reviewer \ + --working-dir /path/to/service +``` + +`adopt` 会把现有群绑定到该 PR。若 PR 已绑定另一个群,它会拒绝覆盖。传入 +`--owner-agent` 时会先验证作者/reviewer 都在群中、同步团队信任,再触发 review; +省略时只建立生命周期记录。`--working-dir` 只绑定作者 agent 的工作目录。 + +## 修复降级 setup + +如果建群已经成功,但 reviewer 邀请、远端信任、工作目录或 kickoff 失败,room 会 +保留为 `active/degraded`,重复 `open` 不会假报成功,也不会再建群。修复外部原因后 +显式重做 setup: + +```bash +botmux pr-room repair https://github.com/acme/service/pull/123 \ + --owner-agent owner-reviewer +``` + +`repair` 只重做尚未完成的群内准备,不创建新群。首次 setup 会持久化 reviewer 和 +workdir 意图,所以常规重试不必重复参数;显式传参可替换对应意图。每次 repair 都会 +原子领取 attempt,同一 room 的并发命令不会重复发送 kickoff。人类 Owner 未入群或 +未绑定会作为独立未完成项保留;确认 Owner 已手动入群后执行: + +```bash +botmux pr-room repair https://github.com/acme/service/pull/123 \ + --ack-owner-present +``` + +旧版本遗留、缺少结构化 setup 意图的 pending 记录不会被直接标成 ready,必须显式 +补传 `--owner-agent`、`--working-dir` 或 `--ack-owner-present`。 + +## 结束 + +PR 合并、关闭或明确废弃后执行: + +```bash +botmux pr-room finish https://github.com/acme/service/pull/123 +``` + +这只把生命周期标记为结束。群、消息和审查记录都会保留,不会自动解散或删除。 +若 setup 正在执行,`finish` 会先记录结束请求,由当前 setup attempt 完成后原子结束, +避免在 room 已终结后仍发送 kickoff。 +`botmux pr-room list` 可查看当前 team 的活跃及历史记录。 + +结束后的同一 PR 默认不能覆盖原生命周期;如确需重开,显式传 `--reopen`。幂等 +锁当前落在发起命令的 Botmux 部署上,因此团队约定由 PR 作者所在的 hub/主部署 +执行 `open`;跨部署同时发起尚不提供分布式唯一性保证。 + +## 边界 + +该命令负责 PR 创建后的协作编排,不代替 GitHub/SCM 创建、审批或合并 PR。 +当前版本要求创建 PR 的 agent 紧接着调用 `pr-room open`;未来可由 SCM webhook +调用同一套生命周期与联邦建群能力,而不改变群内协作模型。 diff --git a/src/cli.ts b/src/cli.ts index 79198fd61..96416b156 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4570,6 +4570,13 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接 新建飞书群: create-group --bot [--bot ...] [--name "群名"] 用指定 bot 起新群;详见 \`botmux create-group --help\` + pr-room open --owner-agent + 为 PR 建作者/Owner agent 协作群;同一 PR 幂等复用 + adopt --chat-id + 接管 Owner 已创建的 review 群,避免重复拉群 + repair 修复降级的 workdir / 远端信任 / kickoff + finish 结束生命周期(保留群与审查记录) + list 列出 PR Room 预设分享(导出某 bot 的可分享配置给同事,绝不含密钥): preset export [--from-chat ] [--out ] [--yes] @@ -9165,6 +9172,11 @@ switch (command) { case 'dispatch': await cmdDispatch(process.argv.slice(3)); break; case 'report': await cmdReport(process.argv.slice(3)); break; case 'create-group': await cmdCreateGroup(process.argv.slice(3)); break; + case 'pr-room': { + const { cmdPrRoom } = await import('./cli/pr-room.js'); + await cmdPrRoom(process.argv[3] ?? '', process.argv.slice(4)); + break; + } case 'bots': await cmdBots(process.argv[3] ?? 'list', process.argv.slice(4)); break; case 'preset': await cmdPreset(process.argv[3] ?? '', process.argv.slice(4)); break; case 'history': await cmdHistory(process.argv.slice(3)); break; diff --git a/src/cli/arg-utils.ts b/src/cli/arg-utils.ts index 8cd2ba921..8758d61db 100644 --- a/src/cli/arg-utils.ts +++ b/src/cli/arg-utils.ts @@ -18,3 +18,30 @@ export function firstPositional(args: string[], flagsWithValue: string[]): strin } return undefined; } + +/** Collect values from repeatable `--flag value` / `--flag=value` options. */ +export function argValues(args: string[], ...flags: string[]): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const equalsFlag = flags.find(flag => arg.startsWith(`${flag}=`)); + if (equalsFlag) { + const value = arg.slice(equalsFlag.length + 1).trim(); + if (value) values.push(value); + continue; + } + if (!flags.includes(arg)) continue; + const value = args[i + 1]; + if (value !== undefined && !value.startsWith('--')) { + const trimmed = value.trim(); + if (trimmed) values.push(trimmed); + i++; + } + } + return values; +} + +/** Pick the first value from a set of equivalent option names. */ +export function argValue(args: string[], ...flags: string[]): string | undefined { + return argValues(args, ...flags)[0]; +} diff --git a/src/cli/pr-room.ts b/src/cli/pr-room.ts new file mode 100644 index 000000000..8aeb69cac --- /dev/null +++ b/src/cli/pr-room.ts @@ -0,0 +1,997 @@ +import { resolve } from 'node:path'; + +import { loadBotConfigs, registerBot } from '../bot-registry.js'; +import { argValue, argValues, firstPositional } from './arg-utils.js'; +import { resolveBotmuxDataDir } from '../core/data-dir.js'; +import { validateWorkingDir } from '../core/working-dir.js'; +import { + orchestrateFederatedGroup, + prepareFederatedGroupReviewers, + type TeamGroupCreateResult, +} from '../dashboard/federated-group-core.js'; +import { buildFederatedRoster, type AggregatedRosterBot } from '../services/federation-roster.js'; +import { createGroupWithBots } from '../services/group-creator.js'; +import { isInChat } from '../services/groups-store.js'; +import { bindOncall } from '../services/oncall-store.js'; +import { DEFAULT_TEAM_ID } from '../services/team-store.js'; +import { recordTeamGroup } from '../services/team-groups-store.js'; +import { + activateWebhookLifecycleGroup, + beginWebhookLifecycleSetupRepair, + beginWebhookLifecycleFiring, + completeWebhookLifecycleSetupRepair, + failWebhookLifecycleGroup, + isWebhookLifecycleSetupRepairCurrent, + listWebhookLifecycleRecords, + markWebhookLifecycleIndeterminate, + resolveWebhookLifecycleGroup, +} from '../services/webhook-lifecycle-store.js'; +import { + listChatBotMembers, + sendMessage, + type ChatBotMember, +} from '../im/lark/client.js'; + +export interface PullRequestRef { + url: string; + host: string; + owner: string; + repo: string; + number: number; + key: string; +} + +const VALUE_FLAGS = [ + '--pr', + '--owner-agent', + '--reviewer-agent', + '--reviewer-bot', + '--author-agent', + '--author-bot', + '--team', + '--team-id', + '--working-dir', + '--workdir', + '--cwd', + '--name', + '--chat-id', +]; + +export function parsePullRequestRef(raw: string): PullRequestRef { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('PR 地址必须是完整的 http(s) URL'); + } + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('PR 地址必须使用 http(s)'); + const parts = url.pathname.split('/').filter(Boolean); + const marker = parts.findIndex(part => part === 'pull' || part === 'pulls'); + if (marker !== 2 || !/^\d+$/.test(parts[marker + 1] ?? '')) { + throw new Error('当前仅支持形如 https://host/owner/repo/pull/123 的 PR 地址'); + } + const owner = parts[0]; + const repo = parts[1].replace(/\.git$/i, ''); + const number = Number(parts[marker + 1]); + const host = url.host.toLowerCase(); + const canonical = `${url.protocol}//${url.host}/${owner}/${repo}/pull/${number}`; + return { + url: canonical, + host, + owner, + repo, + number, + key: `${host}/${owner}/${repo}#${number}`.toLowerCase(), + }; +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +export function resolveRosterBotRef( + bots: AggregatedRosterBot[], + ref: string, +): AggregatedRosterBot { + const needle = normalized(ref); + const exactId = bots.find(bot => normalized(bot.larkAppId) === needle); + if (exactId) return exactId; + + const matches = bots.filter(bot => + normalized(bot.name) === needle + || normalized(bot.cliId) === needle, + ); + if (matches.length === 0) throw new Error(`未在团队 roster 中找到 agent: ${ref}`); + if (matches.length > 1) { + throw new Error( + `agent 引用不唯一: ${ref};请改用 larkAppId(候选: ${matches.map(bot => `${bot.name}=${bot.larkAppId}`).join(', ')})`, + ); + } + return matches[0]; +} + +function uniqueBots(bots: AggregatedRosterBot[]): AggregatedRosterBot[] { + const seen = new Set(); + return bots.filter(bot => { + if (seen.has(bot.larkAppId)) return false; + seen.add(bot.larkAppId); + return true; + }); +} + +export function resolveReviewerMentionOpenIds( + members: Pick[], + reviewers: Pick[], +): { openIds: string[]; missing: string[] } { + const mentionable = members.filter(member => member.mentionable && member.openId); + const used = new Set(); + const openIds: string[] = []; + const missing: string[] = []; + + for (const reviewer of reviewers) { + const exact = mentionable.filter(member => member.larkAppId === reviewer.larkAppId); + const name = normalized(reviewer.name); + const byDisplayName = mentionable.filter(member => + normalized(member.displayName) === name || normalized(member.name) === name, + ); + const candidates = exact.length > 0 ? exact : byDisplayName; + if (candidates.length !== 1 || used.has(candidates[0].openId)) { + missing.push(reviewer.name); + continue; + } + used.add(candidates[0].openId); + openIds.push(candidates[0].openId); + } + + return { openIds, missing }; +} + +export function isAmbiguousGroupCreateFailure(status: number, reason: string): boolean { + return status === 408 + || status === 504 + || reason.startsWith('group_create_indeterminate'); +} + +function isAmbiguousTransportError(error: any): boolean { + const code = String(error?.code ?? '').toUpperCase(); + if (['ECONNABORTED', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ERR_NETWORK'].includes(code)) return true; + if (error?.name === 'AbortError') return true; + return /timed?\s*out|timeout|socket hang up|connection reset/i.test( + String(error?.message ?? error ?? ''), + ); +} + +function roomScope(teamId: string): string { + return `pr-room:${teamId}`; +} + +function roomName(pr: PullRequestRef, explicit?: string): string { + const preferred = explicit?.trim() || `[PR] ${pr.repo}#${pr.number} review`; + return Array.from(preferred).slice(0, 58).join(''); +} + +export function buildPrRoomKickoff( + pr: PullRequestRef, + reviewerMentions: string[], +): string { + const mentions = reviewerMentions.join(' '); + return [ + mentions, + `PR Review Room 已建立:${pr.url}`, + 'Reviewer agent:先独立检查 diff、测试、风险与可维护性,给出带 file:line 的结论;不要直接改作者分支。', + 'Author agent:接收评论后在项目 worktree 中做最小修改、验证并 push,逐条回复直到 APPROVE / 合并 / 明确废弃。', + '本群以该 PR 为唯一上下文;合并、关闭或废弃后执行 botmux pr-room finish 结束生命周期,群记录保留。', + ].filter(Boolean).join('\n'); +} + +async function createLocalTeamGroup( + authorAppId: string, + args: { + name: string; + larkAppIds: string[]; + ownerUnionIds?: string[]; + transferOwnerUnionId?: string; + }, +): Promise { + const configs = loadBotConfigs(); + const author = configs.find(config => config.larkAppId === authorAppId); + if (!author || !args.larkAppIds.includes(authorAppId)) { + return { ok: false, error: 'author_agent_not_local' }; + } + for (const config of configs) { + if (args.larkAppIds.includes(config.larkAppId)) registerBot(config); + } + try { + const result = await createGroupWithBots({ + creatorLarkAppId: authorAppId, + larkAppIds: args.larkAppIds, + name: args.name, + ownerUnionIds: args.ownerUnionIds, + transferOwnerUnionId: args.transferOwnerUnionId, + }); + return { ...result, shareLink: result.shareLink ?? undefined }; + } catch (error: any) { + const message = error?.message ?? String(error); + return { + ok: false, + error: isAmbiguousTransportError(error) + ? `group_create_indeterminate:${message}` + : message, + }; + } +} + +async function sendKickoff( + authorAppId: string, + chatId: string, + reviewers: AggregatedRosterBot[], + pr: PullRequestRef, +): Promise<{ messageId: string; missing: string[] }> { + const members = (await listChatBotMembers(authorAppId, chatId)) + .filter(member => member.larkAppId !== authorAppId); + const resolved = resolveReviewerMentionOpenIds(members, reviewers); + if (resolved.openIds.length === 0) { + throw new Error(`reviewer_agent_not_mentionable: ${resolved.missing.join(', ')}`); + } + const mentions = resolved.openIds.map(openId => ``); + const messageId = await sendMessage(authorAppId, chatId, buildPrRoomKickoff(pr, mentions), 'text'); + return { messageId, missing: resolved.missing }; +} + +async function assertSetupRepairCurrent( + scope: string, + pr: PullRequestRef, + lifecycleId: string, + repairId: string, + dataDir: string, +): Promise { + if (!await isWebhookLifecycleSetupRepairCurrent( + scope, + pr.key, + lifecycleId, + repairId, + dataDir, + )) { + throw new Error('setup repair claim 已失效;已停止外部副作用'); + } +} + +async function openPrRoom(args: string[]): Promise { + const rawPr = firstPositional(args, VALUE_FLAGS) ?? argValue(args, '--pr'); + if (!rawPr) throw new Error('用法: botmux pr-room open --owner-agent '); + const pr = parsePullRequestRef(rawPr); + const dataDir = resolveBotmuxDataDir(); + const teamId = argValue(args, '--team', '--team-id') ?? DEFAULT_TEAM_ID; + const authorRef = argValue(args, '--author-agent', '--author-bot') + ?? process.env.BOTMUX_LARK_APP_ID?.trim(); + if (!authorRef) { + throw new Error('无法推断作者 agent;请在 Botmux 会话中运行,或传 --author-agent '); + } + const reviewerRefs = argValues(args, '--owner-agent', '--reviewer-agent', '--reviewer-bot'); + if (reviewerRefs.length === 0) throw new Error('至少传一个 --owner-agent '); + + const roster = buildFederatedRoster(dataDir, teamId); + const author = resolveRosterBotRef(roster.bots, authorRef); + if (!author.deployment.local) throw new Error('作者 agent 必须属于当前 Botmux 部署'); + const reviewers = uniqueBots(reviewerRefs.map(ref => resolveRosterBotRef(roster.bots, ref))); + if (reviewers.some(bot => bot.larkAppId === author.larkAppId)) { + throw new Error('Owner/reviewer agent 不能与作者 agent 相同'); + } + const stale = reviewers.filter(bot => bot.deployment.stale); + if (stale.length > 0) { + throw new Error(`Owner agent 所在部署已离线: ${stale.map(bot => bot.name).join(', ')}`); + } + const selectedBotIds = Array.from(new Set([author.larkAppId, ...reviewers.map(bot => bot.larkAppId)])); + + const workdirRaw = argValue(args, '--working-dir', '--workdir', '--cwd'); + let bindWorkingDir: string | undefined; + if (workdirRaw) { + const checked = validateWorkingDir(workdirRaw); + if (!checked.ok) throw new Error(`--working-dir ${checked.error}`); + bindWorkingDir = resolve(checked.resolvedPath); + } + + const scope = roomScope(teamId); + const begun = await beginWebhookLifecycleFiring( + scope, + pr.key, + dataDir, + { + blockResolvedReopen: !args.includes('--reopen'), + blockIndeterminateRetry: true, + }, + ); + if (begun.action === 'resolved') { + throw new Error(`该 PR Room 已结束;如确需重开,请显式传 --reopen(原群 ${begun.record.chatId ?? '-'})`); + } + if (begun.action === 'indeterminate' || begun.action === 'reconcile') { + throw new Error( + `上次建群结果不确定,已阻止自动重试以免重复建群;请找到实际群后执行 pr-room adopt(原因: ${begun.record.indeterminateReason ?? 'unknown'})`, + ); + } + if (begun.action === 'reuse' && begun.record.chatId) { + process.stdout.write(`${begun.record.chatId}\n`); + if ( + begun.record.setupStatus === 'pending' + || begun.record.setupStatus === 'repairing' + || begun.record.setupStatus === 'degraded' + ) { + console.error( + `⚠️ PR Room 已存在但 setup 未完成:${pr.url} → ${begun.record.chatId}` + + `(${begun.record.setupError ?? begun.record.setupStatus};执行 botmux pr-room repair)`, + ); + process.exitCode = 3; + } else { + console.error(`✅ PR Room 已存在:${pr.url} → ${begun.record.chatId}`); + } + return; + } + if (begun.action === 'creating') { + console.error(`PR Room 正在由另一进程创建:${pr.url}`); + process.exitCode = 2; + return; + } + + const requestId = `pr-room-${begun.record.lifecycleId}`; + const result = await orchestrateFederatedGroup(dataDir, { + name: roomName(pr, argValue(args, '--name')), + larkAppIds: selectedBotIds, + operatorUnionId: author.owner?.unionId, + requestId, + teamId, + }, { + createTeamGroup: groupArgs => createLocalTeamGroup(author.larkAppId, groupArgs), + fetcher: fetch, + }); + if (result.status !== 200 || !result.body?.ok || typeof result.body.chatId !== 'string') { + const reason = result.body?.error ?? `group_create_failed_${result.status}`; + const ambiguous = isAmbiguousGroupCreateFailure(result.status, reason); + if (ambiguous) { + await markWebhookLifecycleIndeterminate( + scope, + pr.key, + begun.record.lifecycleId, + reason, + dataDir, + ); + } else { + await failWebhookLifecycleGroup(scope, pr.key, begun.record.lifecycleId, dataDir); + } + throw new Error( + ambiguous + ? `${reason};建群结果可能已提交,已阻止自动重试,请先查找实际群并用 pr-room adopt 接管` + : reason, + ); + } + + const chatId = String(result.body.chatId); + const creator = typeof result.body.creator === 'string' ? result.body.creator : author.larkAppId; + const ownerIssues = [ + ...(result.body.invalidOwnerUnionIds?.length ? ['owner_not_in_group'] : []), + ...(result.body.missingOperatorIdentity ? ['author_owner_unbound'] : []), + ]; + const activated = await activateWebhookLifecycleGroup( + scope, + pr.key, + begun.record.lifecycleId, + chatId, + { + creatorLarkAppId: creator, + setup: { + reviewerLarkAppIds: reviewers.map(bot => bot.larkAppId), + workingDir: bindWorkingDir, + ownerIssues, + }, + }, + dataDir, + ); + if (activated.status === 'stale') { + console.error(`⚠️ 群已创建但生命周期记录被替换,请勿重试建群:${chatId}`); + process.stdout.write(`${chatId}\n`); + return; + } + if (activated.status === 'pending_resolved') { + console.error(`⚠️ 群已创建,但 PR Room 在建群期间已被结束:${chatId}`); + process.stdout.write(`${chatId}\n`); + return; + } + const repairId = activated.record?.setupRepairId; + if (!repairId) throw new Error('PR Room 已激活,但未取得 setup repair claim'); + + let degraded = false; + const degradedReasons: string[] = []; + let workdirError: string | undefined; + if (bindWorkingDir) { + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + const bound = await bindOncall(author.larkAppId, chatId, bindWorkingDir); + if (!bound.ok) { + degraded = true; + workdirError = bound.reason; + degradedReasons.push(`workdir:${bound.reason}`); + } + } + + const rejectedReviewerIds = new Set([ + ...(Array.isArray(result.body.invalidBotIds) ? result.body.invalidBotIds : []), + ...(Array.isArray(result.body.skippedNoOwner) ? result.body.skippedNoOwner : []), + ]); + const joinedReviewers = reviewers.filter(bot => !rejectedReviewerIds.has(bot.larkAppId)); + if (joinedReviewers.length !== reviewers.length) { + degraded = true; + degradedReasons.push('reviewer_not_in_group'); + } + + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + const prepared = await prepareFederatedGroupReviewers( + dataDir, + teamId, + chatId, + joinedReviewers.map(bot => bot.larkAppId), + `${requestId}:prepare:${chatId}`, + fetch, + ); + const preparedRemote = new Set(prepared.ready); + const readyReviewers = joinedReviewers.filter( + bot => bot.deployment.local || preparedRemote.has(bot.larkAppId), + ); + if (prepared.failed.length > 0) { + degraded = true; + degradedReasons.push(`remote_prepare:${prepared.failed.map(failure => failure.error).join(',')}`); + } + + let kickoffMessageId: string | undefined; + let kickoffError: string | undefined; + let unmentionedReviewers: string[] = []; + if (readyReviewers.length === 0) { + degraded = true; + kickoffError = 'no_ready_reviewer_agents'; + degradedReasons.push(kickoffError); + } else { + try { + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + const kickoff = await sendKickoff(author.larkAppId, chatId, readyReviewers, pr); + kickoffMessageId = kickoff.messageId; + unmentionedReviewers = kickoff.missing; + if (unmentionedReviewers.length > 0) { + degraded = true; + degradedReasons.push(`unmentioned:${unmentionedReviewers.join(',')}`); + } + } catch (error: any) { + degraded = true; + kickoffError = error?.message ?? String(error); + degradedReasons.push(`kickoff:${kickoffError}`); + } + } + + process.stdout.write(`${chatId}\n`); + console.error(`✅ PR Room 已创建:${pr.url} → ${chatId}`); + console.error(` agents: ${[author, ...joinedReviewers].map(bot => bot.name).join(', ')}`); + if (bindWorkingDir && !workdirError) console.error(` workdir: ${bindWorkingDir}`); + if (workdirError) console.error(`⚠️ 作者工作目录绑定失败: ${workdirError}`); + const rejectedReviewers = reviewers.filter(bot => rejectedReviewerIds.has(bot.larkAppId)); + if (rejectedReviewers.length > 0) { + console.error(`⚠️ 未加入群的 reviewer agent: ${rejectedReviewers.map(bot => bot.name).join(', ')}`); + } + for (const failure of prepared.failed) { + console.error(`⚠️ reviewer 部署未就绪 ${failure.deploymentName}: ${failure.error}`); + } + if (kickoffMessageId) console.error(`✅ Owner agent review 已触发:${kickoffMessageId}`); + if (unmentionedReviewers.length > 0) { + console.error(`⚠️ 以下 Owner agent 未取得可用 @ handle: ${unmentionedReviewers.join(', ')}`); + } + if (kickoffError) console.error(`⚠️ 群已创建,但触发 Owner agent 失败:${kickoffError}`); + if (result.body.invalidOwnerUnionIds?.length) { + degraded = true; + degradedReasons.push('owner_not_in_group'); + console.error(`⚠️ 未能自动拉入的 Owner: ${result.body.invalidOwnerUnionIds.join(', ')}`); + } + if (result.body.missingOperatorIdentity) { + degraded = true; + degradedReasons.push('author_owner_unbound'); + console.error('⚠️ 作者部署尚未绑定人类 Owner,无法自动把作者本人拉入群。'); + } + const reviewersReady = joinedReviewers.length === reviewers.length + && readyReviewers.length === reviewers.length + && prepared.failed.length === 0 + && !!kickoffMessageId + && unmentionedReviewers.length === 0 + && !kickoffError; + const completion = await completeWebhookLifecycleSetupRepair( + scope, + pr.key, + begun.record.lifecycleId, + repairId, + { + error: degraded ? degradedReasons.join('; ') : undefined, + reviewersReady, + workingDirReady: !bindWorkingDir || !workdirError, + ownerIssues, + }, + dataDir, + ); + if (completion.status === 'stale') { + console.error('⚠️ setup 结果未写入:repair claim 已被替换或生命周期已结束'); + process.exitCode = 3; + return; + } + if (completion.status === 'pending_resolved') { + console.error('⚠️ setup 已完成,同时处理了期间收到的 finish 请求;PR Room 已结束。'); + return; + } + if (degraded) process.exitCode = 3; +} + +async function adoptPrRoom(args: string[]): Promise { + const rawPr = firstPositional(args, VALUE_FLAGS) ?? argValue(args, '--pr'); + const chatId = argValue(args, '--chat-id'); + if (!rawPr || !chatId) { + throw new Error('用法: botmux pr-room adopt --chat-id [--owner-agent ]'); + } + const pr = parsePullRequestRef(rawPr); + const dataDir = resolveBotmuxDataDir(); + const teamId = argValue(args, '--team', '--team-id') ?? DEFAULT_TEAM_ID; + const authorRef = argValue(args, '--author-agent', '--author-bot') + ?? process.env.BOTMUX_LARK_APP_ID?.trim(); + if (!authorRef) { + throw new Error('无法推断接管 agent;请在 Botmux 会话中运行,或传 --author-agent '); + } + const roster = buildFederatedRoster(dataDir, teamId); + const author = resolveRosterBotRef(roster.bots, authorRef); + if (!author.deployment.local) throw new Error('接管 agent 必须属于当前 Botmux 部署'); + const reviewerRefs = argValues(args, '--owner-agent', '--reviewer-agent', '--reviewer-bot'); + const reviewers = uniqueBots(reviewerRefs.map(ref => resolveRosterBotRef(roster.bots, ref))); + if (reviewers.some(bot => bot.larkAppId === author.larkAppId)) { + throw new Error('Owner/reviewer agent 不能与作者 agent 相同'); + } + const stale = reviewers.filter(bot => bot.deployment.stale); + if (stale.length > 0) { + throw new Error(`Owner agent 所在部署已离线: ${stale.map(bot => bot.name).join(', ')}`); + } + const workdirRaw = argValue(args, '--working-dir', '--workdir', '--cwd'); + let bindWorkingDir: string | undefined; + if (workdirRaw) { + const checked = validateWorkingDir(workdirRaw); + if (!checked.ok) throw new Error(`--working-dir ${checked.error}`); + bindWorkingDir = resolve(checked.resolvedPath); + } + const configs = loadBotConfigs(); + const authorConfig = configs.find(config => config.larkAppId === author.larkAppId); + if (!authorConfig) throw new Error('接管 agent 的本地 bot 配置不存在'); + const localParticipantIds = new Set([ + author.larkAppId, + ...reviewers.filter(bot => bot.deployment.local).map(bot => bot.larkAppId), + ]); + for (const config of configs) { + if (localParticipantIds.has(config.larkAppId)) registerBot(config); + } + if (!await isInChat(author.larkAppId, chatId)) { + throw new Error(`接管 agent 不在群中,或 chatId 不可访问: ${chatId}`); + } + + if (reviewers.length > 0) { + const members = (await listChatBotMembers(author.larkAppId, chatId)) + .filter(member => member.larkAppId !== author.larkAppId); + const resolved = resolveReviewerMentionOpenIds(members, reviewers); + if (resolved.missing.length > 0) { + throw new Error(`reviewer agent 不在群中、不可 @ 或名称不唯一: ${resolved.missing.join(', ')}`); + } + } + + const scope = roomScope(teamId); + const begun = await beginWebhookLifecycleFiring( + scope, + pr.key, + dataDir, + { + blockResolvedReopen: !args.includes('--reopen'), + adoptIndeterminate: true, + }, + ); + if (begun.action === 'resolved') { + throw new Error(`该 PR Room 已结束;如确需重开,请显式传 --reopen(原群 ${begun.record.chatId ?? '-'})`); + } + if (begun.action === 'reuse' && begun.record.chatId !== chatId) { + throw new Error(`该 PR 已绑定其他群 ${begun.record.chatId};拒绝覆盖为 ${chatId}`); + } + if (begun.action === 'reuse') { + process.stdout.write(`${chatId}\n`); + if ( + begun.record.setupStatus === 'pending' + || begun.record.setupStatus === 'repairing' + || begun.record.setupStatus === 'degraded' + ) { + console.error( + `⚠️ PR Room 已接管但 setup 未完成:${pr.url} → ${chatId}` + + `(${begun.record.setupError ?? begun.record.setupStatus};执行 botmux pr-room repair)`, + ); + process.exitCode = 3; + } else { + console.error(`✅ PR Room 已接管:${pr.url} → ${chatId}`); + } + return; + } + if (begun.action === 'creating') { + console.error(`PR Room 正在由另一进程创建或接管:${pr.url}`); + process.exitCode = 2; + return; + } + if (begun.action === 'indeterminate') { + throw new Error('PR Room 处于结果不确定状态,当前接管未能取得 reconcile 权限'); + } + + const activated = await activateWebhookLifecycleGroup( + scope, + pr.key, + begun.record.lifecycleId, + chatId, + { + creatorLarkAppId: author.larkAppId, + setup: { + reviewerLarkAppIds: reviewers.map(bot => bot.larkAppId), + workingDir: bindWorkingDir, + ownerIssues: [], + }, + }, + dataDir, + ); + if (activated.status === 'stale') throw new Error('PR Room 生命周期记录在接管期间被替换'); + if (activated.status === 'pending_resolved') { + process.stdout.write(`${chatId}\n`); + console.error(`⚠️ 群已接管,但 PR Room 在接管期间已被结束:${chatId}`); + return; + } + const repairId = activated.record?.setupRepairId; + if (!repairId) throw new Error('PR Room 已接管,但未取得 setup repair claim'); + + const setupErrors: string[] = []; + let workingDirReady = !bindWorkingDir; + if (bindWorkingDir) { + try { + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + const bound = await bindOncall(author.larkAppId, chatId, bindWorkingDir); + if (!bound.ok) throw new Error(bound.reason); + workingDirReady = true; + } catch (error: any) { + setupErrors.push(`workdir:${error?.message ?? String(error)}`); + console.error(`⚠️ 作者工作目录绑定失败:${error?.message ?? String(error)}`); + } + } + + let reviewersReady = reviewers.length === 0; + if (reviewers.length > 0) { + try { + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + recordTeamGroup(dataDir, teamId, chatId); + const prepared = await prepareFederatedGroupReviewers( + dataDir, + teamId, + chatId, + reviewers.map(bot => bot.larkAppId), + `pr-room-adopt:${begun.record.lifecycleId}:${chatId}`, + fetch, + ); + if (prepared.failed.length > 0) { + throw new Error( + `远端 reviewer 部署未就绪: ${prepared.failed.map(failure => `${failure.deploymentName}(${failure.error})`).join(', ')}`, + ); + } + await assertSetupRepairCurrent(scope, pr, begun.record.lifecycleId, repairId, dataDir); + const kickoff = await sendKickoff( + author.larkAppId, + chatId, + reviewers, + pr, + ); + console.error(`✅ Owner agent review 已触发:${kickoff.messageId}`); + if (kickoff.missing.length > 0) { + throw new Error(`以下 Owner agent 未取得可用 @ handle: ${kickoff.missing.join(', ')}`); + } + reviewersReady = true; + } catch (error: any) { + setupErrors.push(`reviewer:${error?.message ?? String(error)}`); + console.error(`⚠️ 群已接管,但触发 Owner agent 失败:${error?.message ?? String(error)}`); + } + } + + const completion = await completeWebhookLifecycleSetupRepair( + scope, + pr.key, + begun.record.lifecycleId, + repairId, + { + error: setupErrors.length > 0 ? setupErrors.join('; ') : undefined, + reviewersReady, + workingDirReady, + ownerIssues: [], + }, + dataDir, + ); + process.stdout.write(`${chatId}\n`); + console.error(`✅ PR Room 已接管:${pr.url} → ${chatId}`); + if (bindWorkingDir) console.error(` workdir: ${bindWorkingDir}`); + if (completion.status === 'stale') { + console.error('⚠️ setup 结果未写入:repair claim 已被替换或生命周期已结束'); + process.exitCode = 3; + return; + } + if (completion.status === 'pending_resolved') { + console.error('⚠️ setup 已完成,同时处理了期间收到的 finish 请求;PR Room 已结束。'); + return; + } + if (setupErrors.length > 0) process.exitCode = 3; +} + +async function repairPrRoom(args: string[]): Promise { + const rawPr = firstPositional(args, VALUE_FLAGS) ?? argValue(args, '--pr'); + if (!rawPr) { + throw new Error('用法: botmux pr-room repair [--owner-agent ] [--working-dir ]'); + } + const pr = parsePullRequestRef(rawPr); + const dataDir = resolveBotmuxDataDir(); + const teamId = argValue(args, '--team', '--team-id') ?? DEFAULT_TEAM_ID; + const scope = roomScope(teamId); + const snapshot = listWebhookLifecycleRecords({ connectorId: scope }, dataDir) + .find(record => record.dedupKey === pr.key); + if (!snapshot || snapshot.status !== 'active' || !snapshot.chatId) { + throw new Error('未找到可修复的 active PR Room;结果不确定时先用 adopt,已结束时用 open --reopen'); + } + + const authorRef = argValue(args, '--author-agent', '--author-bot') + ?? snapshot.creatorLarkAppId + ?? process.env.BOTMUX_LARK_APP_ID?.trim(); + if (!authorRef) throw new Error('无法推断作者 agent;请传 --author-agent '); + const roster = buildFederatedRoster(dataDir, teamId); + const author = resolveRosterBotRef(roster.bots, authorRef); + if (!author.deployment.local) throw new Error('作者 agent 必须属于当前 Botmux 部署'); + const reviewerRefs = argValues(args, '--owner-agent', '--reviewer-agent', '--reviewer-bot'); + const reviewerOverride = reviewerRefs.length > 0 + ? uniqueBots(reviewerRefs.map(ref => resolveRosterBotRef(roster.bots, ref))) + : undefined; + if (reviewerOverride?.some(bot => bot.larkAppId === author.larkAppId)) { + throw new Error('Owner/reviewer agent 不能与作者 agent 相同'); + } + const workdirRaw = argValue(args, '--working-dir', '--workdir', '--cwd'); + let workingDirOverride: string | undefined; + if (workdirRaw) { + const checked = validateWorkingDir(workdirRaw); + if (!checked.ok) throw new Error(`--working-dir ${checked.error}`); + workingDirOverride = resolve(checked.resolvedPath); + } + + const claimed = await beginWebhookLifecycleSetupRepair( + scope, + pr.key, + { + reviewerLarkAppIds: reviewerOverride?.map(bot => bot.larkAppId), + workingDir: workingDirOverride, + acknowledgeOwnerIssues: args.includes('--ack-owner-present'), + }, + dataDir, + ); + if (claimed.action === 'inactive') { + throw new Error('PR Room 在 repair 领取期间已结束'); + } + if (claimed.action === 'busy') { + console.error('PR Room setup 正在由另一进程执行;未重复发送 kickoff。'); + process.exitCode = 2; + return; + } + if (claimed.action === 'ready') { + process.stdout.write(`${claimed.record?.chatId ?? snapshot.chatId}\n`); + console.error(`✅ PR Room setup 已就绪:${pr.url}`); + return; + } + const room = claimed.record; + const repairId = claimed.repairId; + let reviewersReady = room.setupReviewersReady ?? false; + let workingDirReady = room.setupWorkingDirReady ?? false; + + try { + const chatId = room.chatId; + if (!chatId) throw new Error('active PR Room 缺少 chatId'); + if (room.setupIntentVersion !== 1) { + throw new Error( + 'legacy setup 缺少结构化意图;请显式传 --owner-agent、--working-dir 或 --ack-owner-present 后重试', + ); + } + if (!room.setupReviewersReady && !(room.setupReviewerLarkAppIds?.length)) { + throw new Error('setup 仍需 reviewer,但未记录 reviewer;请显式传 --owner-agent'); + } + if (!room.setupWorkingDirReady && !room.setupWorkingDir) { + throw new Error('setup 仍需 workdir,但未记录路径;请显式传 --working-dir'); + } + + const reviewers = room.setupReviewersReady + ? [] + : uniqueBots((room.setupReviewerLarkAppIds ?? []) + .map(ref => resolveRosterBotRef(roster.bots, ref))); + if (reviewers.some(bot => bot.larkAppId === author.larkAppId)) { + throw new Error('Owner/reviewer agent 不能与作者 agent 相同'); + } + const stale = reviewers.filter(bot => bot.deployment.stale); + if (stale.length > 0) { + throw new Error(`Owner agent 所在部署已离线: ${stale.map(bot => bot.name).join(', ')}`); + } + + const configs = loadBotConfigs(); + const localParticipantIds = new Set([ + author.larkAppId, + ...reviewers.filter(bot => bot.deployment.local).map(bot => bot.larkAppId), + ]); + for (const config of configs) { + if (localParticipantIds.has(config.larkAppId)) registerBot(config); + } + if (!await isInChat(author.larkAppId, chatId)) { + throw new Error(`作者 agent 不在群中,或 chatId 不可访问: ${chatId}`); + } + + if (!room.setupWorkingDirReady && room.setupWorkingDir) { + await assertSetupRepairCurrent(scope, pr, room.lifecycleId, repairId, dataDir); + const bound = await bindOncall(author.larkAppId, chatId, room.setupWorkingDir); + if (!bound.ok) throw new Error(`作者工作目录绑定失败: ${bound.reason}`); + workingDirReady = true; + } + + if (reviewers.length > 0) { + const members = (await listChatBotMembers(author.larkAppId, chatId)) + .filter(member => member.larkAppId !== author.larkAppId); + const mentionable = resolveReviewerMentionOpenIds(members, reviewers); + if (mentionable.missing.length > 0) { + throw new Error(`reviewer agent 不在群中、不可 @ 或名称不唯一: ${mentionable.missing.join(', ')}`); + } + await assertSetupRepairCurrent(scope, pr, room.lifecycleId, repairId, dataDir); + recordTeamGroup(dataDir, teamId, chatId); + const prepared = await prepareFederatedGroupReviewers( + dataDir, + teamId, + chatId, + reviewers.map(bot => bot.larkAppId), + `pr-room-repair:${room.lifecycleId}:${chatId}`, + fetch, + ); + if (prepared.failed.length > 0) { + throw new Error( + `远端 reviewer 部署未就绪: ${prepared.failed.map(failure => `${failure.deploymentName}(${failure.error})`).join(', ')}`, + ); + } + await assertSetupRepairCurrent(scope, pr, room.lifecycleId, repairId, dataDir); + const kickoff = await sendKickoff(author.larkAppId, chatId, reviewers, pr); + if (kickoff.missing.length > 0) { + throw new Error(`以下 reviewer 未取得可用 @ handle: ${kickoff.missing.join(', ')}`); + } + reviewersReady = true; + console.error(`✅ Owner agent review 已重新触发:${kickoff.messageId}`); + } + + const ownerIssues = room.setupOwnerIssues ?? []; + const completion = await completeWebhookLifecycleSetupRepair( + scope, + pr.key, + room.lifecycleId, + repairId, + { + error: ownerIssues.length > 0 ? ownerIssues.join('; ') : undefined, + reviewersReady, + workingDirReady, + ownerIssues, + }, + dataDir, + ); + if (completion.status === 'stale') { + throw new Error('setup repair claim 已被替换或生命周期已结束'); + } + process.stdout.write(`${chatId}\n`); + if (completion.status === 'pending_resolved') { + console.error('⚠️ setup 已完成,同时处理了期间收到的 finish 请求;PR Room 已结束。'); + return; + } + if (completion.record?.setupStatus === 'degraded') { + console.error( + `⚠️ agent setup 已修复,但人类 Owner 问题仍需处理:${ownerIssues.join('; ')}` + + '(确认 Owner 已在群中后传 --ack-owner-present)', + ); + process.exitCode = 3; + return; + } + console.error(`✅ PR Room setup 已修复:${pr.url} → ${chatId}`); + } catch (error: any) { + const message = error?.message ?? String(error); + const category = /工作目录|working-dir|workdir/.test(message) + ? 'workdir' + : (/reviewer|Owner agent|远端/.test(message) ? 'reviewer' : 'repair'); + await completeWebhookLifecycleSetupRepair( + scope, + pr.key, + room.lifecycleId, + repairId, + { + error: `${category}:${message}`, + reviewersReady, + workingDirReady, + }, + dataDir, + ); + throw error; + } +} + +async function finishPrRoom(args: string[]): Promise { + const rawPr = firstPositional(args, VALUE_FLAGS) ?? argValue(args, '--pr'); + if (!rawPr) throw new Error('用法: botmux pr-room finish [--team ]'); + const pr = parsePullRequestRef(rawPr); + const dataDir = resolveBotmuxDataDir(); + const teamId = argValue(args, '--team', '--team-id') ?? DEFAULT_TEAM_ID; + const result = await resolveWebhookLifecycleGroup(roomScope(teamId), pr.key, dataDir); + if (result.action === 'noop') { + console.error(`未找到活跃 PR Room:${pr.url}`); + process.exitCode = 2; + return; + } + process.stdout.write(`${result.record?.chatId ?? ''}\n`); + if (result.action === 'pending') { + console.error(`⏳ PR Room 已请求结束;当前 setup 完成后会原子进入 ended:${pr.url}`); + } else { + console.error(`✅ PR Room 生命周期已结束(群记录保留):${pr.url}`); + } +} + +function listPrRooms(args: string[]): void { + const dataDir = resolveBotmuxDataDir(); + const teamId = argValue(args, '--team', '--team-id') ?? DEFAULT_TEAM_ID; + const scope = roomScope(teamId); + const rooms = listWebhookLifecycleRecords({ connectorId: scope }, dataDir); + if (rooms.length === 0) { + console.log('暂无 PR Room。'); + return; + } + for (const room of rooms) { + console.log( + `${room.status.padEnd(8)} ${(room.setupStatus ?? '-').padEnd(8)} ${room.dedupKey} ${room.chatId ?? '-'}`, + ); + } +} + +function help(): void { + console.log(` +botmux pr-room — 为一个 PR 建立作者/Owner agent 协作群 + +用法: + botmux pr-room open --owner-agent [--owner-agent ...] + [--author-agent ] [--team ] + [--working-dir ] [--name <群名>] [--reopen] + botmux pr-room adopt --chat-id [--owner-agent ] + [--working-dir ] [--reopen] + botmux pr-room repair [--owner-agent ] + [--working-dir ] [--ack-owner-present] + botmux pr-room finish [--team ] + botmux pr-room list [--team ] + +行为: + - PR URL 是幂等键:同一个 team 内重复 open 复用原群,不重复拉群。 + - 作者 agent 默认取 BOTMUX_LARK_APP_ID;Owner agent 必须显式选择,避免同一 Owner + 有多个 agent 时误拉错机器人。 + - 复用团队联邦拉群,把所选 agent 及各自已绑定 Owner 拉进群。 + - 远端部署确认 reviewer 已入群并写入团队信任后,才自动 @ 它发起 code review。 + - Owner 已先建群时,用 adopt 绑定现有群,避免创建重复群。 + - setup 降级时用 repair 重做未完成的 workdir、远端信任与 kickoff;不会重新建群。 + - reviewer/workdir 意图会持久化;人类 Owner 已手动入群后用 --ack-owner-present 确认。 + - repair 使用原子 claim;并发 repair 不会重复发送 kickoff,finish 会等待当前 setup 收口。 + - 已结束的 PR Room 默认不能覆盖;确需重开时显式传 --reopen。 + - finish 只标记生命周期结束,保留群与审查记录,不自动解散。 +`); +} + +export async function cmdPrRoom(subcommand: string, args: string[]): Promise { + try { + process.env.SESSION_DATA_DIR ??= resolveBotmuxDataDir(); + if (subcommand === 'open') await openPrRoom(args); + else if (subcommand === 'adopt') await adoptPrRoom(args); + else if (subcommand === 'repair' || subcommand === 'retry') await repairPrRoom(args); + else if (subcommand === 'finish' || subcommand === 'close' || subcommand === 'abandon') await finishPrRoom(args); + else if (subcommand === 'list' || subcommand === 'status') listPrRooms(args); + else help(); + } catch (error: any) { + console.error(`botmux pr-room: ${error?.message ?? String(error)}`); + process.exitCode = 1; + } +} diff --git a/src/dashboard/federated-group-core.ts b/src/dashboard/federated-group-core.ts index 6208a82d5..95791caa0 100644 --- a/src/dashboard/federated-group-core.ts +++ b/src/dashboard/federated-group-core.ts @@ -91,6 +91,89 @@ export interface OrchestrateGroupArgs { teamId?: string; } +export interface PreparedFederatedReviewers { + ready: string[]; + failed: Array<{ deploymentId: string; deploymentName: string; reviewerIds: string[]; error: string }>; +} + +/** + * Push a newly created/adopted team-group trust binding to every deployment + * that owns a selected reviewer before the author @mentions it. Periodic sync + * eventually mirrors the same binding, but review kickoff cannot wait for it. + */ +export async function prepareFederatedGroupReviewers( + dataDir: string, + teamId: string, + chatId: string, + reviewerIds: string[], + requestId: string, + fetcher: Fetcher = fetch, +): Promise { + const requested = new Set(reviewerIds); + const ready: string[] = []; + const failed: PreparedFederatedReviewers['failed'] = []; + + for (const dep of listFederatedDeployments(dataDir, teamId)) { + const mine = dep.bots.map(bot => bot.larkAppId).filter(id => requested.has(id)); + if (mine.length === 0) continue; + if (!dep.callbackUrl || !dep.delegationToken) { + failed.push({ + deploymentId: dep.deploymentId, + deploymentName: dep.name, + reviewerIds: mine, + error: 'deployment_callback_unavailable', + }); + continue; + } + try { + const response = await fetchWithTimeout( + fetcher, + `${dep.callbackUrl}/api/federation/delegate-prepare-review`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${dep.delegationToken}`, + }, + body: JSON.stringify({ chatId, reviewerLarkAppIds: mine, requestId }), + }, + ); + const body = await response.json().catch(() => ({} as any)); + if (response.ok && body?.ok) { + const acknowledged = new Set( + (Array.isArray(body.ready) ? body.ready : []).filter((id: unknown): id is string => typeof id === 'string'), + ); + ready.push(...mine.filter(id => acknowledged.has(id))); + const missing = mine.filter(id => !acknowledged.has(id)); + if (missing.length > 0) { + failed.push({ + deploymentId: dep.deploymentId, + deploymentName: dep.name, + reviewerIds: missing, + error: 'reviewer_not_acknowledged', + }); + } + } else { + failed.push({ + deploymentId: dep.deploymentId, + deploymentName: dep.name, + reviewerIds: mine, + error: body?.error || `prepare_review_http_${response.status}`, + }); + } + } catch (error) { + failed.push({ + deploymentId: dep.deploymentId, + deploymentName: dep.name, + reviewerIds: mine, + error: hubError(error).error, + }); + } + } + + return { ready: Array.from(new Set(ready)), failed }; +} + /** * Validate selection against the team's aggregated roster, collect invitees * (operator + selected bots' owners), then create locally if a local online bot diff --git a/src/dashboard/federation-api.ts b/src/dashboard/federation-api.ts index b44e0d69f..8d89f67d6 100644 --- a/src/dashboard/federation-api.ts +++ b/src/dashboard/federation-api.ts @@ -19,7 +19,7 @@ import { type FederatedBot, } from '../services/federation-store.js'; import { buildFederatedRoster } from '../services/federation-roster.js'; -import { listTeamGroups } from '../services/team-groups-store.js'; +import { listTeamGroups, recordTeamGroup } from '../services/team-groups-store.js'; import { listTeamReports, readTeamBoard, @@ -36,7 +36,7 @@ import { type TeamGroupCreateResult, type TeamGroupOwnerTransferResult, } from './federated-group-core.js'; -import { addUsersToChatByUnionId } from '../services/groups-store.js'; +import { addUsersToChatByUnionId, isInChat } from '../services/groups-store.js'; import { loadBotConfigs, registerBot, getBot } from '../bot-registry.js'; /** Ensure a Lark client exists for larkAppId in THIS (dashboard) process, which @@ -145,6 +145,8 @@ export interface FederationApiDeps { /** Add owners to an existing chat via one of OUR local bots (defaults to * ensure-client + addUsersToChatByUnionId). Test seam. Returns the rejected ids. */ addOwners?: (viaLarkAppId: string, chatId: string, ownerUnionIds: string[]) => Promise<{ invalidUserIds: string[] }>; + /** Test seam for authenticated PR-review group preparation. */ + isBotInChat?: (larkAppId: string, chatId: string) => Promise; } export async function handleFederationApi( @@ -467,6 +469,67 @@ export async function handleFederationApi( return true; } + // Hub asks THIS spoke to trust a newly created/adopted review group before a + // remote reviewer is @mentioned there. Periodic federation sync eventually + // mirrors team groups, but an immediate kickoff would otherwise hit the + // inbound bot-to-bot auth gate first. The authenticated membership determines + // the team; every requested reviewer must be one of OUR bots and in the chat. + if (path === '/api/federation/delegate-prepare-review' && method === 'POST') { + let body: any; + try { body = await readBody(req); } catch { jsonRes(res, 400, { ok: false, error: 'bad_json' }); return true; } + const token = bearerOnly(req); + const membership = findMembershipByDelegationToken(dataDir, token); + if (!membership) { jsonRes(res, 403, { ok: false, error: 'unknown_token' }); return true; } + const requestId = String(body?.requestId ?? '').trim(); + if (!requestId) { jsonRes(res, 400, { ok: false, error: 'request_id_required' }); return true; } + const idemKey = `prepare-review:${token}:${requestId}`; + const cached = idemGet(idemKey) as { status: number; body: any } | undefined; + if (cached) { jsonRes(res, cached.status, cached.body); return true; } + + const chatId = String(body?.chatId ?? '').trim(); + const reviewerLarkAppIds: string[] = Array.from(new Set( + (Array.isArray(body?.reviewerLarkAppIds) ? body.reviewerLarkAppIds : []) + .filter((id: unknown): id is string => typeof id === 'string') + .map((id: string) => id.trim()) + .filter(Boolean), + )); + if (!chatId || reviewerLarkAppIds.length === 0) { + jsonRes(res, 400, { ok: false, error: 'bad_request' }); + return true; + } + if (reviewerLarkAppIds.length > MAX_BOTS) { + jsonRes(res, 400, { ok: false, error: 'too_many' }); + return true; + } + const localIds = new Set( + buildTeamRoster(dataDir, undefined, undefined, deps.liveBots?.()).bots.map(bot => bot.larkAppId), + ); + const notLocal = reviewerLarkAppIds.filter(id => !localIds.has(id)); + if (notLocal.length > 0) { + jsonRes(res, 400, { ok: false, error: 'reviewer_not_local', reviewerIds: notLocal }); + return true; + } + + const check = deps.isBotInChat ?? (async (larkAppId: string, targetChatId: string) => { + if (!ensureLocalClient(larkAppId)) return false; + return isInChat(larkAppId, targetChatId); + }); + const present = await Promise.all( + reviewerLarkAppIds.map(async id => ({ id, present: await check(id, chatId) })), + ); + const missing = present.filter(row => !row.present).map(row => row.id); + if (missing.length > 0) { + jsonRes(res, 409, { ok: false, error: 'reviewers_not_in_chat', reviewerIds: missing }); + return true; + } + + recordTeamGroup(dataDir, membership.teamId, chatId); + const result = { status: 200, body: { ok: true, ready: reviewerLarkAppIds } }; + idemSet(idemKey, result); + jsonRes(res, result.status, result.body); + return true; + } + jsonRes(res, 404, { ok: false, error: 'not_found' }); return true; } diff --git a/src/services/webhook-lifecycle-store.ts b/src/services/webhook-lifecycle-store.ts index 464aaa9b7..107e79a17 100644 --- a/src/services/webhook-lifecycle-store.ts +++ b/src/services/webhook-lifecycle-store.ts @@ -5,6 +5,7 @@ import { config } from '../config.js'; import { withFileLock } from '../utils/file-lock.js'; export type WebhookLifecycleStatus = 'creating' | 'active' | 'resolved'; +export type WebhookLifecycleSetupStatus = 'pending' | 'repairing' | 'ready' | 'degraded'; export interface WebhookLifecycleRecord { lifecycleId: string; @@ -14,6 +15,18 @@ export interface WebhookLifecycleRecord { chatId?: string; creatorLarkAppId?: string; pendingResolved?: boolean; + indeterminate?: boolean; + indeterminateReason?: string; + setupStatus?: WebhookLifecycleSetupStatus; + setupError?: string; + setupIntentVersion?: 1; + setupReviewerLarkAppIds?: string[]; + setupReviewersReady?: boolean; + setupWorkingDir?: string; + setupWorkingDirReady?: boolean; + setupOwnerIssues?: string[]; + setupRepairId?: string; + setupRepairExpiresAt?: string; creatingExpiresAt?: string; createdAt: string; updatedAt: string; @@ -25,11 +38,29 @@ export type BeginLifecycleFiringResult = | { action: 'reuse'; record: WebhookLifecycleRecord } | { action: 'creating'; record: WebhookLifecycleRecord }; +export type GuardedBeginLifecycleFiringResult = + | BeginLifecycleFiringResult + | { action: 'resolved'; record: WebhookLifecycleRecord } + | { action: 'indeterminate'; record: WebhookLifecycleRecord } + | { action: 'reconcile'; record: WebhookLifecycleRecord }; + export interface WebhookLifecycleStoreFile { version: 1; records: WebhookLifecycleRecord[]; } +export interface WebhookLifecycleSetupIntent { + reviewerLarkAppIds: string[]; + workingDir?: string; + ownerIssues?: string[]; +} + +export type BeginWebhookLifecycleSetupRepairResult = + | { action: 'repair'; record: WebhookLifecycleRecord; repairId: string } + | { action: 'ready'; record: WebhookLifecycleRecord } + | { action: 'busy'; record: WebhookLifecycleRecord } + | { action: 'inactive'; record?: WebhookLifecycleRecord }; + function storePath(dataDir: string = config.session.dataDir): string { return join(dataDir, 'webhook-lifecycle.json'); } @@ -85,8 +116,14 @@ function findIndex(store: WebhookLifecycleStoreFile, connectorId: string, dedupK } const CREATING_TTL_MS = 10 * 60 * 1000; +const SETUP_REPAIR_TTL_MS = 30 * 60 * 1000; + +function uniqueStrings(values: string[] | undefined): string[] { + return Array.from(new Set((values ?? []).map(value => value.trim()).filter(Boolean))); +} function creatingExpired(record: WebhookLifecycleRecord, nowMs: number): boolean { + if (record.indeterminate) return false; const raw = record.creatingExpiresAt ?? record.createdAt; const ms = Date.parse(raw); const expiresAt = record.creatingExpiresAt ? ms : ms + CREATING_TTL_MS; @@ -102,11 +139,31 @@ export function listWebhookLifecycleRecords( && (!opts.status || r.status === opts.status)); } +export function beginWebhookLifecycleFiring( + connectorId: string, + dedupKey: string, + dataDir?: string, +): Promise; +export function beginWebhookLifecycleFiring( + connectorId: string, + dedupKey: string, + dataDir: string, + opts: { + blockResolvedReopen: boolean; + blockIndeterminateRetry?: boolean; + adoptIndeterminate?: boolean; + }, +): Promise; export async function beginWebhookLifecycleFiring( connectorId: string, dedupKey: string, dataDir: string = config.session.dataDir, -): Promise { + opts: { + blockResolvedReopen?: boolean; + blockIndeterminateRetry?: boolean; + adoptIndeterminate?: boolean; + } = {}, +): Promise { const fp = storePath(dataDir); return withFileLock(fp, async () => { const nowMs = Date.now(); @@ -114,9 +171,33 @@ export async function beginWebhookLifecycleFiring( const idx = findIndex(store, connectorId, dedupKey); const existing = idx >= 0 ? store.records[idx] : undefined; if (existing?.status === 'active' && existing.chatId) return { action: 'reuse', record: existing }; + if (existing?.status === 'resolved' && opts.blockResolvedReopen) { + return { action: 'resolved', record: existing }; + } + if (existing?.status === 'creating' && existing.indeterminate) { + if (opts.adoptIndeterminate) return { action: 'reconcile', record: existing }; + if (opts.blockIndeterminateRetry) return { action: 'indeterminate', record: existing }; + } if (existing?.status === 'creating' && !creatingExpired(existing, nowMs)) { return { action: 'creating', record: existing }; } + if ( + existing?.status === 'creating' + && (opts.blockIndeterminateRetry || opts.adoptIndeterminate) + ) { + const indeterminate: WebhookLifecycleRecord = { + ...existing, + indeterminate: true, + indeterminateReason: existing.indeterminateReason ?? 'creating_claim_expired', + creatingExpiresAt: undefined, + updatedAt: new Date(nowMs).toISOString(), + }; + store.records[idx] = indeterminate; + writeStore(dataDir, store); + return opts.adoptIndeterminate + ? { action: 'reconcile', record: indeterminate } + : { action: 'indeterminate', record: indeterminate }; + } const now = new Date(nowMs).toISOString(); const record: WebhookLifecycleRecord = { @@ -135,12 +216,40 @@ export async function beginWebhookLifecycleFiring( }); } +export async function markWebhookLifecycleIndeterminate( + connectorId: string, + dedupKey: string, + lifecycleId: string, + reason: string, + dataDir: string = config.session.dataDir, +): Promise { + const fp = storePath(dataDir); + await withFileLock(fp, async () => { + const store = readStore(dataDir); + const idx = findIndex(store, connectorId, dedupKey); + const existing = idx >= 0 ? store.records[idx] : undefined; + if (existing?.lifecycleId !== lifecycleId || existing.status !== 'creating') return; + store.records[idx] = { + ...existing, + indeterminate: true, + indeterminateReason: reason, + creatingExpiresAt: undefined, + updatedAt: new Date().toISOString(), + }; + writeStore(dataDir, store); + }); +} + export async function activateWebhookLifecycleGroup( connectorId: string, dedupKey: string, lifecycleId: string, chatId: string, - opts: { creatorLarkAppId?: string } = {}, + opts: { + creatorLarkAppId?: string; + setupStatus?: WebhookLifecycleRecord['setupStatus']; + setup?: WebhookLifecycleSetupIntent; + } = {}, dataDir: string = config.session.dataDir, ): Promise<{ status: 'active' | 'pending_resolved' | 'stale'; record?: WebhookLifecycleRecord }> { const fp = storePath(dataDir); @@ -151,7 +260,33 @@ export async function activateWebhookLifecycleGroup( if (!existing || existing.lifecycleId !== lifecycleId || existing.status !== 'creating') { return { status: 'stale' }; } - const now = new Date().toISOString(); + const nowMs = Date.now(); + const now = new Date(nowMs).toISOString(); + const setupRepairId = opts.setup ? randomUUID() : undefined; + const reviewerLarkAppIds = opts.setup + ? uniqueStrings(opts.setup.reviewerLarkAppIds) + : existing.setupReviewerLarkAppIds; + const workingDir = opts.setup?.workingDir?.trim() || existing.setupWorkingDir; + const ownerIssues = opts.setup + ? uniqueStrings(opts.setup.ownerIssues) + : existing.setupOwnerIssues; + const setupFields = opts.setup + ? { + setupStatus: 'repairing' as const, + setupError: undefined, + setupIntentVersion: 1 as const, + setupReviewerLarkAppIds: reviewerLarkAppIds, + setupReviewersReady: (reviewerLarkAppIds ?? []).length === 0, + setupWorkingDir: workingDir, + setupWorkingDirReady: !workingDir, + setupOwnerIssues: ownerIssues, + setupRepairId, + setupRepairExpiresAt: new Date(nowMs + SETUP_REPAIR_TTL_MS).toISOString(), + } + : { + setupStatus: opts.setupStatus, + setupError: undefined, + }; const next: WebhookLifecycleRecord = existing.pendingResolved ? { ...existing, @@ -160,6 +295,13 @@ export async function activateWebhookLifecycleGroup( creatorLarkAppId: opts.creatorLarkAppId, creatingExpiresAt: undefined, pendingResolved: false, + indeterminate: false, + indeterminateReason: undefined, + ...setupFields, + setupStatus: opts.setup ? 'degraded' : setupFields.setupStatus, + setupError: opts.setup ? 'finished_before_setup' : undefined, + setupRepairId: undefined, + setupRepairExpiresAt: undefined, updatedAt: now, resolvedAt: now, } @@ -169,6 +311,9 @@ export async function activateWebhookLifecycleGroup( chatId, creatorLarkAppId: opts.creatorLarkAppId, creatingExpiresAt: undefined, + indeterminate: false, + indeterminateReason: undefined, + ...setupFields, updatedAt: now, }; store.records[idx] = next; @@ -177,6 +322,176 @@ export async function activateWebhookLifecycleGroup( }); } +function setupRepairExpired(record: WebhookLifecycleRecord, nowMs: number): boolean { + if (!record.setupRepairExpiresAt) return true; + const expiresAt = Date.parse(record.setupRepairExpiresAt); + return !Number.isFinite(expiresAt) || expiresAt <= nowMs; +} + +export async function beginWebhookLifecycleSetupRepair( + connectorId: string, + dedupKey: string, + opts: { + reviewerLarkAppIds?: string[]; + workingDir?: string; + acknowledgeOwnerIssues?: boolean; + } = {}, + dataDir: string = config.session.dataDir, +): Promise { + const fp = storePath(dataDir); + return withFileLock(fp, async () => { + const store = readStore(dataDir); + const idx = findIndex(store, connectorId, dedupKey); + const existing = idx >= 0 ? store.records[idx] : undefined; + if (!existing || existing.status !== 'active') { + return { action: 'inactive', record: existing }; + } + const nowMs = Date.now(); + if (existing.pendingResolved) { + if ( + existing.setupStatus === 'repairing' + && existing.setupRepairId + && !setupRepairExpired(existing, nowMs) + ) { + return { action: 'busy', record: existing }; + } + const now = new Date(nowMs).toISOString(); + const resolved: WebhookLifecycleRecord = { + ...existing, + status: 'resolved', + pendingResolved: false, + setupStatus: existing.setupStatus === 'repairing' ? 'degraded' : existing.setupStatus, + setupError: existing.setupStatus === 'repairing' + ? (existing.setupError ?? 'setup_repair_expired_after_finish') + : existing.setupError, + setupRepairId: undefined, + setupRepairExpiresAt: undefined, + updatedAt: now, + resolvedAt: now, + }; + store.records[idx] = resolved; + writeStore(dataDir, store); + return { action: 'inactive', record: resolved }; + } + if (existing.setupStatus === 'ready') return { action: 'ready', record: existing }; + if ( + existing.setupStatus === 'repairing' + && existing.setupRepairId + && !setupRepairExpired(existing, nowMs) + ) { + return { action: 'busy', record: existing }; + } + + const repairId = randomUUID(); + const reviewerOverride = opts.reviewerLarkAppIds === undefined + ? undefined + : uniqueStrings(opts.reviewerLarkAppIds); + const workingDirOverride = opts.workingDir?.trim() || undefined; + const next: WebhookLifecycleRecord = { + ...existing, + setupStatus: 'repairing', + setupIntentVersion: existing.setupIntentVersion + ?? (reviewerOverride !== undefined || workingDirOverride || opts.acknowledgeOwnerIssues ? 1 : undefined), + setupReviewerLarkAppIds: reviewerOverride ?? existing.setupReviewerLarkAppIds, + setupReviewersReady: reviewerOverride === undefined + ? existing.setupReviewersReady + : reviewerOverride.length === 0, + setupWorkingDir: workingDirOverride ?? existing.setupWorkingDir, + setupWorkingDirReady: workingDirOverride ? false : existing.setupWorkingDirReady, + setupOwnerIssues: opts.acknowledgeOwnerIssues ? [] : existing.setupOwnerIssues, + setupRepairId: repairId, + setupRepairExpiresAt: new Date(nowMs + SETUP_REPAIR_TTL_MS).toISOString(), + updatedAt: new Date(nowMs).toISOString(), + }; + store.records[idx] = next; + writeStore(dataDir, store); + return { action: 'repair', record: next, repairId }; + }); +} + +export async function isWebhookLifecycleSetupRepairCurrent( + connectorId: string, + dedupKey: string, + lifecycleId: string, + repairId: string, + dataDir: string = config.session.dataDir, +): Promise { + const fp = storePath(dataDir); + return withFileLock(fp, async () => { + const store = readStore(dataDir); + const idx = findIndex(store, connectorId, dedupKey); + const existing = idx >= 0 ? store.records[idx] : undefined; + return existing?.status === 'active' + && existing.lifecycleId === lifecycleId + && existing.setupStatus === 'repairing' + && existing.setupRepairId === repairId + && !setupRepairExpired(existing, Date.now()); + }); +} + +export async function completeWebhookLifecycleSetupRepair( + connectorId: string, + dedupKey: string, + lifecycleId: string, + repairId: string, + result: { + error?: string; + reviewersReady?: boolean; + workingDirReady?: boolean; + ownerIssues?: string[]; + }, + dataDir: string = config.session.dataDir, +): Promise<{ status: 'active' | 'pending_resolved' | 'stale'; record?: WebhookLifecycleRecord }> { + const fp = storePath(dataDir); + return withFileLock(fp, async () => { + const store = readStore(dataDir); + const idx = findIndex(store, connectorId, dedupKey); + const existing = idx >= 0 ? store.records[idx] : undefined; + if ( + !existing + || existing.status !== 'active' + || existing.lifecycleId !== lifecycleId + || existing.setupStatus !== 'repairing' + || existing.setupRepairId !== repairId + ) { + return { status: 'stale' }; + } + + const reviewersReady = result.reviewersReady ?? existing.setupReviewersReady ?? false; + const workingDirReady = result.workingDirReady ?? existing.setupWorkingDirReady ?? false; + const ownerIssues = result.ownerIssues === undefined + ? uniqueStrings(existing.setupOwnerIssues) + : uniqueStrings(result.ownerIssues); + const setupError = result.error?.trim() || undefined; + const setupReady = !setupError + && existing.setupIntentVersion === 1 + && reviewersReady + && workingDirReady + && ownerIssues.length === 0; + const now = new Date().toISOString(); + const next: WebhookLifecycleRecord = { + ...existing, + status: existing.pendingResolved ? 'resolved' : 'active', + pendingResolved: false, + setupStatus: setupReady ? 'ready' : 'degraded', + setupError, + setupReviewersReady: reviewersReady, + setupWorkingDirReady: workingDirReady, + setupOwnerIssues: ownerIssues, + setupRepairId: undefined, + setupRepairExpiresAt: undefined, + updatedAt: now, + resolvedAt: existing.pendingResolved ? now : existing.resolvedAt, + }; + store.records[idx] = next; + writeStore(dataDir, store); + return { + status: next.status === 'resolved' ? 'pending_resolved' : 'active', + record: next, + }; + }); +} + export async function failWebhookLifecycleGroup( connectorId: string, dedupKey: string, @@ -208,11 +523,34 @@ export async function resolveWebhookLifecycleGroup( if (!existing || existing.status === 'resolved') return { action: 'noop' }; const now = new Date().toISOString(); - const next: WebhookLifecycleRecord = existing.status === 'creating' + const nowMs = Date.now(); + const waitsForActivation = existing.status === 'creating' && !existing.indeterminate; + const waitsForSetup = existing.status === 'active' + && existing.setupStatus === 'repairing' + && !!existing.setupRepairId + && !setupRepairExpired(existing, nowMs); + const waitsForCompletion = waitsForActivation || waitsForSetup; + const abandonedSetup = existing.status === 'active' + && existing.setupStatus === 'repairing' + && !waitsForSetup; + const next: WebhookLifecycleRecord = waitsForCompletion ? { ...existing, pendingResolved: true, updatedAt: now } - : { ...existing, status: 'resolved', updatedAt: now, resolvedAt: now }; + : { + ...existing, + status: 'resolved', + indeterminate: false, + indeterminateReason: undefined, + setupStatus: abandonedSetup ? 'degraded' : existing.setupStatus, + setupError: abandonedSetup + ? (existing.setupError ?? 'setup_repair_expired_after_finish') + : existing.setupError, + setupRepairId: abandonedSetup ? undefined : existing.setupRepairId, + setupRepairExpiresAt: abandonedSetup ? undefined : existing.setupRepairExpiresAt, + updatedAt: now, + resolvedAt: now, + }; store.records[idx] = next; writeStore(dataDir, store); - return { action: existing.status === 'creating' ? 'pending' : 'close', record: next }; + return { action: waitsForCompletion ? 'pending' : 'close', record: next }; }); } diff --git a/test/cli-arg-utils.test.ts b/test/cli-arg-utils.test.ts index c2c196ad7..3e7bf0978 100644 --- a/test/cli-arg-utils.test.ts +++ b/test/cli-arg-utils.test.ts @@ -6,7 +6,7 @@ * Run: pnpm vitest run test/cli-arg-utils.test.ts */ import { describe, it, expect } from 'vitest'; -import { firstPositional } from '../src/cli/arg-utils.js'; +import { argValue, argValues, firstPositional } from '../src/cli/arg-utils.js'; describe('firstPositional', () => { it('returns the first non-flag token in a plain positional list', () => { @@ -34,3 +34,30 @@ describe('firstPositional', () => { expect(firstPositional([], ['--session-id'])).toBeUndefined(); }); }); + +describe('argValues', () => { + it('collects repeated flags in argv order across aliases', () => { + expect(argValues( + ['--owner-agent', 'alice', '--reviewer-agent=bob', '--owner-agent', 'carol'], + '--owner-agent', + '--reviewer-agent', + )).toEqual(['alice', 'bob', 'carol']); + }); + + it('ignores missing and blank values without consuming the next flag', () => { + expect(argValues( + ['--owner-agent', '--team', 'default', '--owner-agent='], + '--owner-agent', + )).toEqual([]); + }); +}); + +describe('argValue', () => { + it('returns the first matching alias value', () => { + expect(argValue( + ['--reviewer-agent=bob', '--owner-agent', 'alice'], + '--owner-agent', + '--reviewer-agent', + )).toBe('bob'); + }); +}); diff --git a/test/federation-api.test.ts b/test/federation-api.test.ts index be1ed4c25..854e7e2c4 100644 --- a/test/federation-api.test.ts +++ b/test/federation-api.test.ts @@ -13,6 +13,7 @@ vi.mock('../src/config.js', () => ({ })); import { handleFederationApi } from '../src/dashboard/federation-api.js'; +import { prepareFederatedGroupReviewers } from '../src/dashboard/federated-group-core.js'; import { buildFederatedRoster } from '../src/services/federation-roster.js'; import { registerDeployment } from '../src/services/federation-store.js'; import { ensureDefaultTeam, addMember, DEFAULT_TEAM_ID } from '../src/services/team-store.js'; @@ -20,6 +21,7 @@ import { createInvite } from '../src/services/invite-store.js'; import { addMembership } from '../src/services/federation-membership-store.js'; import { getDeploymentIdentity, setDeploymentOwner } from '../src/services/deployment-identity.js'; import { setBotOwner } from '../src/services/bot-owner-store.js'; +import { listTeamGroups } from '../src/services/team-groups-store.js'; let dataDir: string; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), 'botmux-fedapi-')); state.dataDir = dataDir; }); @@ -434,6 +436,109 @@ describe('handleFederationApi', () => { expect(tried).toEqual(['cli_x', 'cli_y']); // exhausted all candidates }); + it('delegate-prepare-review authenticates, verifies local membership, records trust, and deduplicates', async () => { + writeBots([{ larkAppId: 'cli_reviewer', botOpenId: null, botName: 'Reviewer', cliId: 'codex' }]); + addMembership(dataDir, { + hubUrl: 'http://hub:7891', + teamId: 'default', + teamName: 'T', + syncToken: 'st', + deploymentId: 'dep_me', + delegationToken: 'DTOK-PREPARE', + }); + const inChat = vi.fn(async (_larkAppId: string, chatId: string) => chatId === 'oc_ready'); + + let res = makeRes(); + await handleFederationApi( + makeReq('POST', '/api/federation/delegate-prepare-review', { + chatId: 'oc_ready', + reviewerLarkAppIds: ['cli_reviewer'], + requestId: 'prepare-1', + }, bearer('NOPE')), + res, + new URL('http://x/api/federation/delegate-prepare-review'), + { dataDir, isBotInChat: inChat }, + ); + expect(res.statusCode).toBe(403); + + res = makeRes(); + await handleFederationApi( + makeReq('POST', '/api/federation/delegate-prepare-review', { + chatId: 'oc_missing', + reviewerLarkAppIds: ['cli_reviewer'], + requestId: 'prepare-missing', + }, bearer('DTOK-PREPARE')), + res, + new URL('http://x/api/federation/delegate-prepare-review'), + { dataDir, isBotInChat: inChat }, + ); + expect(res.statusCode).toBe(409); + expect(json(res).error).toBe('reviewers_not_in_chat'); + + res = makeRes(); + await handleFederationApi( + makeReq('POST', '/api/federation/delegate-prepare-review', { + chatId: 'oc_ready', + reviewerLarkAppIds: ['cli_reviewer'], + requestId: 'prepare-1', + }, bearer('DTOK-PREPARE')), + res, + new URL('http://x/api/federation/delegate-prepare-review'), + { dataDir, isBotInChat: inChat }, + ); + expect(res.statusCode).toBe(200); + expect(json(res).ready).toEqual(['cli_reviewer']); + expect(listTeamGroups(dataDir, 'default').map(row => row.chatId)).toContain('oc_ready'); + + const callsAfterSuccess = inChat.mock.calls.length; + res = makeRes(); + await handleFederationApi( + makeReq('POST', '/api/federation/delegate-prepare-review', { + chatId: 'oc_ready', + reviewerLarkAppIds: ['cli_reviewer'], + requestId: 'prepare-1', + }, bearer('DTOK-PREPARE')), + res, + new URL('http://x/api/federation/delegate-prepare-review'), + { dataDir, isBotInChat: inChat }, + ); + expect(res.statusCode).toBe(200); + expect(inChat).toHaveBeenCalledTimes(callsAfterSuccess); + }); + + it('prepareFederatedGroupReviewers calls each owning deployment and reports readiness', async () => { + registerDeployment(dataDir, DEFAULT_TEAM_ID, { + deploymentId: 'dep_review', + name: 'Review deployment', + ownerUnionId: 'on_review', + bots: [{ larkAppId: 'cli_review', botName: 'Reviewer', cliId: 'codex', ownerUnionId: 'on_review' }], + callbackUrl: 'http://review:7891', + delegationToken: 'DT-REVIEW', + }); + const fetcher = vi.fn(async (url: any, init: any) => { + expect(String(url)).toBe('http://review:7891/api/federation/delegate-prepare-review'); + expect(init.headers.authorization).toBe('Bearer DT-REVIEW'); + expect(JSON.parse(init.body)).toMatchObject({ + chatId: 'oc_pr', + reviewerLarkAppIds: ['cli_review'], + requestId: 'room-1', + }); + return { + ok: true, + status: 200, + json: async () => ({ ok: true, ready: ['cli_review'] }), + } as any; + }); + await expect(prepareFederatedGroupReviewers( + dataDir, + DEFAULT_TEAM_ID, + 'oc_pr', + ['cli_review'], + 'room-1', + fetcher as any, + )).resolves.toEqual({ ready: ['cli_review'], failed: [] }); + }); + it('federation/group: remote creator retries owner adds across deployments, then transfers ownership', async () => { registerDeployment(dataDir, DEFAULT_TEAM_ID, { deploymentId: 'dep_a', name: 'A', ownerUnionId: 'on_a', bots: [{ larkAppId: 'cli_a', botName: 'A1', cliId: 'codex', ownerUnionId: 'on_a' } as any], callbackUrl: 'http://a:7891', delegationToken: 'DTA' }); registerDeployment(dataDir, DEFAULT_TEAM_ID, { deploymentId: 'dep_b', name: 'B', ownerUnionId: 'on_b', bots: [{ larkAppId: 'cli_b', botName: 'B1', cliId: 'codex', ownerUnionId: 'on_b' } as any], callbackUrl: 'http://b:7891', delegationToken: 'DTB' }); diff --git a/test/pr-room.test.ts b/test/pr-room.test.ts new file mode 100644 index 000000000..a1f25e3ff --- /dev/null +++ b/test/pr-room.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildPrRoomKickoff, + isAmbiguousGroupCreateFailure, + parsePullRequestRef, + resolveReviewerMentionOpenIds, + resolveRosterBotRef, +} from '../src/cli/pr-room.js'; +import type { AggregatedRosterBot } from '../src/services/federation-roster.js'; + +function bot( + larkAppId: string, + name: string, + cliId: string, + local = false, +): AggregatedRosterBot { + return { + larkAppId, + name, + cliId, + capability: null, + hasTeamRole: false, + deployment: { + id: local ? 'local' : 'remote', + name: local ? 'Local' : 'Remote', + local, + stale: false, + }, + }; +} + +describe('pr-room', () => { + it('canonicalizes a GitHub pull request URL into a stable room key', () => { + expect(parsePullRequestRef('https://github.com/DeepColdy/botmux/pull/570/files')).toEqual({ + url: 'https://github.com/DeepColdy/botmux/pull/570', + host: 'github.com', + owner: 'DeepColdy', + repo: 'botmux', + number: 570, + key: 'github.com/deepcoldy/botmux#570', + }); + }); + + it('rejects issue and repository URLs so unrelated work cannot share a room', () => { + expect(() => parsePullRequestRef('https://github.com/deepcoldy/botmux/issues/570')).toThrow( + '当前仅支持', + ); + expect(() => parsePullRequestRef('https://github.com/deepcoldy/botmux')).toThrow( + '当前仅支持', + ); + }); + + it('keeps SCM ports in the room key', () => { + expect(parsePullRequestRef('https://scm.example:8443/acme/service/pull/7').key) + .toBe('scm.example:8443/acme/service#7'); + expect(parsePullRequestRef('https://scm.example:9443/acme/service/pull/7').key) + .toBe('scm.example:9443/acme/service#7'); + }); + + it('resolves an exact app id or unique display name and rejects ambiguous cli ids', () => { + const bots = [ + bot('cli_author', '呀哈哈', 'coco', true), + bot('cli_owner_codex', 'Botmux开发者(Codex)', 'codex'), + bot('cli_other_codex', 'LastResort(Codex)', 'codex'), + ]; + expect(resolveRosterBotRef(bots, 'cli_owner_codex').name).toBe('Botmux开发者(Codex)'); + expect(resolveRosterBotRef(bots, '呀哈哈').larkAppId).toBe('cli_author'); + expect(() => resolveRosterBotRef(bots, 'codex')).toThrow('不唯一'); + }); + + it('builds a kickoff that assigns independent reviewer and author responsibilities', () => { + const pr = parsePullRequestRef('https://github.com/deepcoldy/botmux/pull/570'); + const text = buildPrRoomKickoff(pr, ['']); + expect(text).toContain(''); + expect(text).toContain(pr.url); + expect(text).toContain('Reviewer agent'); + expect(text).toContain('Author agent'); + expect(text).toContain('botmux pr-room finish'); + }); + + it('resolves local reviewers by app id and federated reviewers by display name', () => { + expect(resolveReviewerMentionOpenIds( + [ + { + larkAppId: 'cli_local', + openId: 'ou_local', + name: 'codex', + displayName: 'Local Reviewer', + mentionable: true, + }, + { + larkAppId: '', + openId: 'ou_remote', + name: 'Remote Reviewer', + displayName: 'Remote Reviewer', + mentionable: true, + }, + ], + [ + { larkAppId: 'cli_local', name: 'Local Reviewer' }, + { larkAppId: 'cli_remote', name: 'Remote Reviewer' }, + ], + )).toEqual({ openIds: ['ou_local', 'ou_remote'], missing: [] }); + }); + + it('fails closed when a reviewer display name is ambiguous or not mentionable', () => { + expect(resolveReviewerMentionOpenIds( + [ + { + larkAppId: '', + openId: 'ou_one', + name: 'Reviewer', + displayName: 'Reviewer', + mentionable: true, + }, + { + larkAppId: '', + openId: 'ou_two', + name: 'Reviewer', + displayName: 'Reviewer', + mentionable: true, + }, + { + larkAppId: 'cli_hidden', + openId: 'ou_hidden', + name: 'Hidden', + displayName: 'Hidden', + mentionable: false, + }, + ], + [ + { larkAppId: 'cli_remote', name: 'Reviewer' }, + { larkAppId: 'cli_hidden', name: 'Hidden' }, + ], + )).toEqual({ openIds: [], missing: ['Reviewer', 'Hidden'] }); + }); + + it('only classifies timeout or explicit local transport ambiguity as maybe-created', () => { + expect(isAmbiguousGroupCreateFailure(504, 'delegation_timeout')).toBe(true); + expect(isAmbiguousGroupCreateFailure(408, 'request_timeout')).toBe(true); + expect(isAmbiguousGroupCreateFailure(502, 'group_create_indeterminate:socket hang up')).toBe(true); + expect(isAmbiguousGroupCreateFailure(502, 'hub_unreachable')).toBe(false); + expect(isAmbiguousGroupCreateFailure(502, 'no_creator_available')).toBe(false); + }); +}); diff --git a/test/webhook-lifecycle-store.test.ts b/test/webhook-lifecycle-store.test.ts index 52ca8057d..c99f6474a 100644 --- a/test/webhook-lifecycle-store.test.ts +++ b/test/webhook-lifecycle-store.test.ts @@ -5,8 +5,11 @@ import { describe, expect, it } from 'vitest'; import { activateWebhookLifecycleGroup, beginWebhookLifecycleFiring, + beginWebhookLifecycleSetupRepair, + completeWebhookLifecycleSetupRepair, failWebhookLifecycleGroup, listWebhookLifecycleRecords, + markWebhookLifecycleIndeterminate, resolveWebhookLifecycleGroup, } from '../src/services/webhook-lifecycle-store.js'; @@ -58,4 +61,374 @@ describe('webhook-lifecycle-store', () => { expect(retry.action).toBe('create'); expect(retry.record.lifecycleId).not.toBe(create.record.lifecycleId); }); + + it('holds an expired guarded creation for reconciliation instead of risking a duplicate group', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#expired', dir); + expect(create.action).toBe('create'); + const fp = join(dir, 'webhook-lifecycle.json'); + const raw = JSON.parse(readFileSync(fp, 'utf-8')); + raw.records[0].creatingExpiresAt = '2020-01-01T00:00:00.000Z'; + writeFileSync(fp, JSON.stringify(raw, null, 2) + '\n'); + + const blocked = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#expired', + dir, + { blockResolvedReopen: true, blockIndeterminateRetry: true }, + ); + expect(blocked.action).toBe('indeterminate'); + expect(blocked.record).toMatchObject({ + lifecycleId: create.record.lifecycleId, + indeterminate: true, + indeterminateReason: 'creating_claim_expired', + }); + + const adopted = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#expired', + dir, + { blockResolvedReopen: true, adoptIndeterminate: true }, + ); + expect(adopted.action).toBe('reconcile'); + expect(adopted.record.lifecycleId).toBe(create.record.lifecycleId); + }); + + it('atomically blocks resolved replacement unless reopen is explicit', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#1', dir); + expect(create.action).toBe('create'); + await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#1', + create.record.lifecycleId, + 'oc_original', + {}, + dir, + ); + await resolveWebhookLifecycleGroup('pr-room', 'repo#1', dir); + + const blocked = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#1', + dir, + { blockResolvedReopen: true }, + ); + expect(blocked.action).toBe('resolved'); + expect(blocked.record.chatId).toBe('oc_original'); + + const reopened = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#1', + dir, + { blockResolvedReopen: false }, + ); + expect(reopened.action).toBe('create'); + expect(reopened.record.lifecycleId).not.toBe(create.record.lifecycleId); + }); + + it('holds ambiguous group creation for explicit adoption instead of retrying', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#2', dir); + expect(create.action).toBe('create'); + await markWebhookLifecycleIndeterminate( + 'pr-room', + 'repo#2', + create.record.lifecycleId, + 'delegation_timeout', + dir, + ); + + const retry = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#2', + dir, + { blockResolvedReopen: true, blockIndeterminateRetry: true }, + ); + expect(retry.action).toBe('indeterminate'); + expect(retry.record.indeterminateReason).toBe('delegation_timeout'); + + const adopt = await beginWebhookLifecycleFiring( + 'pr-room', + 'repo#2', + dir, + { blockResolvedReopen: true, adoptIndeterminate: true }, + ); + expect(adopt.action).toBe('reconcile'); + const active = await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#2', + adopt.record.lifecycleId, + 'oc_recovered', + {}, + dir, + ); + expect(active.status).toBe('active'); + }); + + it('lets finish terminally clear an indeterminate no-group result', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#3', dir); + expect(create.action).toBe('create'); + await markWebhookLifecycleIndeterminate( + 'pr-room', + 'repo#3', + create.record.lifecycleId, + 'delegation_timeout', + dir, + ); + const finished = await resolveWebhookLifecycleGroup('pr-room', 'repo#3', dir); + expect(finished.action).toBe('close'); + expect(finished.record).toMatchObject({ status: 'resolved', indeterminate: false }); + }); + + it('persists setup intent and only one process can repair it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#4', dir); + expect(create.action).toBe('create'); + const active = await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#4', + create.record.lifecycleId, + 'oc_setup', + { + setup: { + reviewerLarkAppIds: ['cli_owner'], + workingDir: '/workspace/repo', + ownerIssues: ['owner_not_in_group'], + }, + }, + dir, + ); + expect(active.record).toMatchObject({ + setupStatus: 'repairing', + setupReviewerLarkAppIds: ['cli_owner'], + setupReviewersReady: false, + setupWorkingDir: '/workspace/repo', + setupWorkingDirReady: false, + setupOwnerIssues: ['owner_not_in_group'], + }); + + const busy = await beginWebhookLifecycleSetupRepair('pr-room', 'repo#4', {}, dir); + expect(busy.action).toBe('busy'); + const initialRepairId = active.record?.setupRepairId; + expect(initialRepairId).toBeTruthy(); + const degraded = await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#4', + create.record.lifecycleId, + initialRepairId!, + { + error: 'kickoff:timeout', + workingDirReady: true, + reviewersReady: false, + }, + dir, + ); + expect(degraded.status).toBe('active'); + expect(listWebhookLifecycleRecords({}, dir)[0]).toMatchObject({ + chatId: 'oc_setup', + setupStatus: 'degraded', + setupError: 'kickoff:timeout', + setupWorkingDirReady: true, + setupReviewersReady: false, + setupOwnerIssues: ['owner_not_in_group'], + }); + + const claimed = await beginWebhookLifecycleSetupRepair('pr-room', 'repo#4', {}, dir); + expect(claimed.action).toBe('repair'); + if (claimed.action !== 'repair') throw new Error('expected repair claim'); + const repaired = await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#4', + create.record.lifecycleId, + claimed.repairId, + { reviewersReady: true }, + dir, + ); + expect(repaired.record).toMatchObject({ + setupStatus: 'degraded', + setupReviewersReady: true, + setupWorkingDirReady: true, + setupOwnerIssues: ['owner_not_in_group'], + }); + }); + + it('defers finish while setup is claimed, then resolves atomically on completion', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#5', dir); + expect(create.action).toBe('create'); + const active = await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#5', + create.record.lifecycleId, + 'oc_finish_race', + { setup: { reviewerLarkAppIds: [], ownerIssues: [] } }, + dir, + ); + const finish = await resolveWebhookLifecycleGroup('pr-room', 'repo#5', dir); + expect(finish.action).toBe('pending'); + expect(finish.record).toMatchObject({ status: 'active', pendingResolved: true }); + + const completed = await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#5', + create.record.lifecycleId, + active.record!.setupRepairId!, + {}, + dir, + ); + expect(completed.status).toBe('pending_resolved'); + expect(completed.record).toMatchObject({ + status: 'resolved', + setupStatus: 'ready', + pendingResolved: false, + }); + }); + + it('never issues a new setup claim after finish when the original claim expires', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#finish-expired', dir); + expect(create.action).toBe('create'); + await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#finish-expired', + create.record.lifecycleId, + 'oc_finish_expired', + { setup: { reviewerLarkAppIds: ['cli_owner'], ownerIssues: [] } }, + dir, + ); + const finish = await resolveWebhookLifecycleGroup('pr-room', 'repo#finish-expired', dir); + expect(finish.action).toBe('pending'); + + const fp = join(dir, 'webhook-lifecycle.json'); + const raw = JSON.parse(readFileSync(fp, 'utf-8')); + raw.records[0].setupRepairExpiresAt = '2020-01-01T00:00:00.000Z'; + writeFileSync(fp, JSON.stringify(raw, null, 2) + '\n'); + + const retry = await beginWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#finish-expired', + {}, + dir, + ); + expect(retry.action).toBe('inactive'); + expect(retry.record).toMatchObject({ + status: 'resolved', + pendingResolved: false, + setupStatus: 'degraded', + setupError: 'setup_repair_expired_after_finish', + }); + expect(retry.record?.setupRepairId).toBeUndefined(); + }); + + it('clears an already-expired setup claim when finish closes the room directly', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#expired-finish', dir); + expect(create.action).toBe('create'); + await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#expired-finish', + create.record.lifecycleId, + 'oc_expired_finish', + { setup: { reviewerLarkAppIds: ['cli_owner'], ownerIssues: [] } }, + dir, + ); + const fp = join(dir, 'webhook-lifecycle.json'); + const raw = JSON.parse(readFileSync(fp, 'utf-8')); + raw.records[0].setupRepairExpiresAt = '2020-01-01T00:00:00.000Z'; + writeFileSync(fp, JSON.stringify(raw, null, 2) + '\n'); + + const finish = await resolveWebhookLifecycleGroup( + 'pr-room', + 'repo#expired-finish', + dir, + ); + expect(finish.action).toBe('close'); + expect(finish.record).toMatchObject({ + status: 'resolved', + setupStatus: 'degraded', + setupError: 'setup_repair_expired_after_finish', + }); + expect(finish.record?.setupRepairId).toBeUndefined(); + expect(finish.record?.setupRepairExpiresAt).toBeUndefined(); + }); + + it('does not promote a legacy pending record without structured setup intent', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#6', dir); + expect(create.action).toBe('create'); + await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#6', + create.record.lifecycleId, + 'oc_legacy', + { setupStatus: 'pending' }, + dir, + ); + const claimed = await beginWebhookLifecycleSetupRepair('pr-room', 'repo#6', {}, dir); + expect(claimed.action).toBe('repair'); + if (claimed.action !== 'repair') throw new Error('expected repair claim'); + const completed = await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#6', + create.record.lifecycleId, + claimed.repairId, + { reviewersReady: true, workingDirReady: true }, + dir, + ); + expect(completed.record).toMatchObject({ setupStatus: 'degraded' }); + expect(completed.record?.setupIntentVersion).toBeUndefined(); + }); + + it('does not mark setup ready when an acknowledged-owner repair still errors', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-webhook-life-')); + const create = await beginWebhookLifecycleFiring('pr-room', 'repo#7', dir); + expect(create.action).toBe('create'); + const active = await activateWebhookLifecycleGroup( + 'pr-room', + 'repo#7', + create.record.lifecycleId, + 'oc_ack_error', + { + setup: { + reviewerLarkAppIds: [], + ownerIssues: ['owner_not_in_group'], + }, + }, + dir, + ); + await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#7', + create.record.lifecycleId, + active.record!.setupRepairId!, + { error: 'owner_not_in_group' }, + dir, + ); + const claimed = await beginWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#7', + { acknowledgeOwnerIssues: true }, + dir, + ); + expect(claimed.action).toBe('repair'); + if (claimed.action !== 'repair') throw new Error('expected repair claim'); + + const failed = await completeWebhookLifecycleSetupRepair( + 'pr-room', + 'repo#7', + create.record.lifecycleId, + claimed.repairId, + { error: 'repair:author_not_in_chat' }, + dir, + ); + expect(failed.record).toMatchObject({ + setupStatus: 'degraded', + setupError: 'repair:author_not_in_chat', + setupReviewersReady: true, + setupWorkingDirReady: true, + setupOwnerIssues: [], + }); + }); });