claude-code-best/src/commands/autofix-pr/prOutcomeCheck.ts
unraid 31433ac958 feat(autofix-pr): 注册 completionChecker 用 gh CLI 探测 PR 完成
Phase 2 of remote-agent completion loop。Phase 1 修了 monitor lock
dangling,但完成信号仍然只能等 CCR session 自然 archive(timing 不可
预测,且不知道 PR 究竟有没有被修好)。Phase 2 加上主动完成探测。

实现:
- 新增 prOutcomeCheck.ts(纯决策矩阵):summariseAutofixOutcome 给定
  PR 快照 + 基线 SHA 返回 completed/summary。8 个决策分支单元测试。
- 新增 prFetch.ts(spawn 层):runGhPrView 调 gh CLI,fetchPrHeadSha
  在 launch 时捕获基线 SHA,checkPrAutofixOutcome 组合两者。
- AutofixPrRemoteTaskMetadata 加 initialHeadSha?: string 字段,survive
  --resume。
- launchAutofixPr.ts 模块顶部 registerCompletionChecker('autofix-pr',
  ...),5s throttle 防 gh CLI 调用爆。callAutofixPr 启动时调
  fetchPrHeadSha 传入 metadata。

决策矩阵:
  MERGED                  → done(merged)
  CLOSED 未 merge          → done(closed without fix)
  OPEN 无 baseline        → 继续轮询
  OPEN head 未变           → 继续轮询(agent 还没 push)
  OPEN head 变 + CI pending → 继续轮询
  OPEN head 变 + CI failure → done(surface red,user 决定 retry)
  OPEN head 变 + CI success → done(clean fix)

设计:
- gh CLI 而非 Octokit:复用用户已有 auth,不引入 token 管理
- 决策与 spawn 分文件:prOutcomeCheck 纯函数易测,prFetch 单独 mock
  避免 Bun mock.module 进程级污染(已在 launchAutofixPr.test 注释说明)
- 5s throttle:framework 每 1s 轮询,gh CLI subprocess 太重不能跟上
- 失败兜底:fetchPrHeadSha/checkPrAutofixOutcome 失败均不抛,returns
  null/false,framework 继续走原路径

测试:
- prOutcomeCheck 9 个单测覆盖决策矩阵
- launchAutofixPr 5 个新测试:checker 注册 / fetchPrHeadSha 调用 /
  initialHeadSha 传 metadata / SHA 失败仍能 launch / SHA null 处理

完整方案见 docs/features/remote-agent-completion-analysis.md。
2026-05-19 10:38:48 +08:00

124 lines
3.9 KiB
TypeScript

// Pure decision matrix for autofix-pr completion detection.
//
// Given a snapshot of the PR (state, head SHA, CI rollup) and a baseline
// head SHA captured at /autofix-pr launch, decide whether autofix has
// finished. No side effects — extracted from the gh CLI invocation in
// prFetch.ts so unit tests can exercise every branch without spawning
// subprocesses.
export type AutofixOutcomeProbeResult =
| { completed: true; summary: string }
| { completed: false }
export interface PrViewPayload {
headRefOid: string
state: 'OPEN' | 'CLOSED' | 'MERGED'
statusCheckRollup?: Array<{
conclusion?: string | null
status?: string | null
name?: string
}>
}
export interface AutofixOutcomeIdentity {
owner: string
repo: string
prNumber: number
/**
* Head commit SHA captured at /autofix-pr launch. When this differs from
* the current head, autofix has pushed at least one commit. Optional —
* absence means we can only finish on terminal PR states (merged/closed).
*/
initialHeadSha?: string
}
/**
* Pure judgement of whether autofix has finished, given a PR snapshot and
* the baseline head SHA. Decision matrix:
* - MERGED → done (merged)
* - CLOSED (not merged) → done (closed without fix)
* - OPEN, no baseline → keep polling
* - OPEN, head unchanged → keep polling (agent hasn't pushed)
* - OPEN, head changed, CI pending → keep polling (wait for CI)
* - OPEN, head changed, CI failure → done (surface red so user can retry)
* - OPEN, head changed, CI success → done (clean fix)
*/
export function summariseAutofixOutcome(
payload: PrViewPayload,
identity: AutofixOutcomeIdentity,
): AutofixOutcomeProbeResult {
const { owner, repo, prNumber, initialHeadSha } = identity
if (payload.state === 'MERGED') {
return {
completed: true,
summary: `${owner}/${repo}#${prNumber} merged. Autofix monitoring complete.`,
}
}
if (payload.state === 'CLOSED') {
return {
completed: true,
summary: `${owner}/${repo}#${prNumber} closed without merge. Autofix monitoring complete.`,
}
}
if (!initialHeadSha) return { completed: false }
if (payload.headRefOid === initialHeadSha) return { completed: false }
const ciState = summariseCiRollup(payload.statusCheckRollup)
if (ciState.state === 'pending') return { completed: false }
if (ciState.state === 'failure') {
return {
completed: true,
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber} but CI is failing (${ciState.detail}).`,
}
}
return {
completed: true,
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber}, CI green.`,
}
}
interface CiSummary {
state: 'success' | 'pending' | 'failure'
detail: string
}
function summariseCiRollup(
rollup: PrViewPayload['statusCheckRollup'],
): CiSummary {
if (!rollup || rollup.length === 0) {
// No checks configured on this repo — treat as success so completion
// can fire on push alone. PRs without CI are perfectly valid.
return { state: 'success', detail: 'no checks configured' }
}
let pending = 0
let failed = 0
const total = rollup.length
for (const check of rollup) {
const status = (check.status ?? '').toUpperCase()
const conclusion = (check.conclusion ?? '').toUpperCase()
if (status && status !== 'COMPLETED') {
pending++
continue
}
if (
conclusion === 'SUCCESS' ||
conclusion === 'NEUTRAL' ||
conclusion === 'SKIPPED'
) {
continue
}
if (conclusion === '') {
pending++
continue
}
failed++
}
if (pending > 0)
return { state: 'pending', detail: `${pending}/${total} checks pending` }
if (failed > 0)
return { state: 'failure', detail: `${failed}/${total} checks failing` }
return { state: 'success', detail: `${total}/${total} checks passing` }
}