@defrex/autobuild 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * GitHubForge (SPEC §3.2): the Forge adapter for GitHub, speaking the GitHub
3
+ * REST API through an injectable transport seam. All remote access is
4
+ * kernel-side plumbing (SPEC §8.6 [D7]) — agents never touch the remote, so
5
+ * forge credentials never enter the sandbox.
6
+ *
7
+ * The adapter never routes API calls through `gh` or reads a local checkout
8
+ * for them: the same adapter serves a checkout-bound dispatcher and a
9
+ * checkout-less (`--repository`) one. Credentials come from `GITHUB_TOKEN` /
10
+ * `GH_TOKEN`, falling back to the gh CLI's stored login (`gh auth token`) so
11
+ * a local operator needs only `gh auth login`. The one deliberate subprocess
12
+ * exception on the API side is that probe; `pushBranch` remains `git push`
13
+ * from the workspace — it is only ever called from worktree-side terminals
14
+ * and contract fixtures; `vercel-sandbox` builds publish through the
15
+ * sandbox's receive-pack proxy and the host never pushes.
16
+ *
17
+ * Native auto-merge is the one operation GitHub exposes only through its
18
+ * GraphQL schema (no REST route exists), so its enable/disable mutations
19
+ * travel over the same transport to `POST /graphql`.
20
+ *
21
+ * Repository coordinates (owner/name) resolve lazily and memoized: explicit
22
+ * constructor option → `AB_REPOSITORY` (normalized) → `git remote get-url
23
+ * origin` in `repoRoot` (checkout mode) → hard error at first use. In origin
24
+ * mode the dispatcher exports `AB_REPOSITORY`, so no git call ever runs.
25
+ */
26
+ import { z } from 'zod'
27
+ import { isValidGitBranchName, normalizeGitRemoteUrl } from '../../kernel/origin'
28
+ import type { Exec } from '../workspace/git-worktree'
29
+ import {
30
+ GitHubApiError,
31
+ createGitHubFetchTransport,
32
+ githubTokenSource,
33
+ restPlanLimitation,
34
+ type GitHubRequest,
35
+ type GitHubTokenSource,
36
+ type GitHubRequestOpts,
37
+ type GitHubResponse,
38
+ } from './github-transport'
39
+ import {
40
+ classifyAutoMergeEnable,
41
+ mergeStateStatuses,
42
+ type MergeGatePresence,
43
+ type MergeStateStatus,
44
+ } from '../../kernel/auto-merge'
45
+ import type {
46
+ AutoMergeDeferralReason,
47
+ AutoMergeResult,
48
+ ClosePrResult,
49
+ PrAttachmentHosting,
50
+ Forge,
51
+ PrRef,
52
+ PrState,
53
+ } from '../types'
54
+ import { GitHubPrAttachmentHosting } from './github-pr-attachments'
55
+
56
+ /** One subprocess seam for every forge command: argv array, optional cwd,
57
+ * no shell. Git commands name their cwd; the gh credential probe needs none.
58
+ * The contract is the workspace module's, re-exported so the forge and its
59
+ * callers name one type. */
60
+ export type { Exec, ExecResult } from '../workspace/git-worktree'
61
+
62
+ export const bunExec: Exec = async (cmd, opts) => {
63
+ if (opts.signal?.aborted === true) {
64
+ throw new Error(`forge command aborted before launch: ${cmd.join(' ')}`)
65
+ }
66
+ const proc = Bun.spawn(cmd, {
67
+ cwd: opts.cwd,
68
+ stdout: 'pipe',
69
+ stderr: 'pipe',
70
+ stdin: 'ignore',
71
+ })
72
+ const abort = (): void => {
73
+ try {
74
+ proc.kill('SIGKILL')
75
+ } catch {
76
+ // A process that exited concurrently is already cancelled sufficiently.
77
+ }
78
+ }
79
+ opts.signal?.addEventListener('abort', abort, { once: true })
80
+ try {
81
+ const [stdout, stderr, exitCode] = await Promise.all([
82
+ new Response(proc.stdout).text(),
83
+ new Response(proc.stderr).text(),
84
+ proc.exited,
85
+ ])
86
+ return { stdout, stderr, exitCode }
87
+ } finally {
88
+ opts.signal?.removeEventListener('abort', abort)
89
+ }
90
+ }
91
+
92
+ // ── Repository coordinates ───────────────────────────────────────────────────
93
+
94
+ export interface RepoCoordinates {
95
+ owner: string
96
+ name: string
97
+ }
98
+
99
+ /** Owner/name from an `owner/name` slug or any remote URL spelling. */
100
+ export function parseRepoCoordinates(input: string): RepoCoordinates | null {
101
+ const normalized = normalizeGitRemoteUrl(input)
102
+ try {
103
+ const url = new URL(normalized)
104
+ if (url.hostname !== '') {
105
+ const parts = url.pathname.split('/').filter((part) => part !== '')
106
+ if (parts.length === 2) return { owner: parts[0]!, name: parts[1]! }
107
+ return null
108
+ }
109
+ } catch {
110
+ // Slug fallback below.
111
+ }
112
+ const parts = normalized.split('/')
113
+ if (parts.length === 2 && parts[0] !== '' && parts[1] !== '') {
114
+ return { owner: parts[0]!, name: parts[1]! }
115
+ }
116
+ return null
117
+ }
118
+
119
+ // ── Response shapes ──────────────────────────────────────────────────────────
120
+ //
121
+ // REST responses are the forge's full objects, so shapes validate every field
122
+ // these methods rely on and pass unknown fields through — an added GitHub
123
+ // field cannot break a poll, but a changed relied-on field still fails loudly.
124
+
125
+ const restPrRef = z
126
+ .object({
127
+ number: z.number().int().positive(),
128
+ html_url: z.string().min(1),
129
+ head: z.object({ sha: z.string().min(1) }).passthrough(),
130
+ })
131
+ .passthrough()
132
+
133
+ const restPrState = z
134
+ .object({
135
+ state: z.enum(['open', 'closed']),
136
+ merged: z.boolean(),
137
+ mergeable: z.boolean().nullable(),
138
+ merge_commit_sha: z.string().min(1).nullable(),
139
+ })
140
+ .passthrough()
141
+
142
+ const restNativeAutoMerge = z
143
+ .object({
144
+ node_id: z.string().min(1),
145
+ auto_merge: z.object({}).passthrough().nullable(),
146
+ })
147
+ .passthrough()
148
+
149
+ const restAutoMergeView = z
150
+ .object({
151
+ node_id: z.string().min(1),
152
+ auto_merge: z.object({}).passthrough().nullable(),
153
+ mergeable_state: z.string().min(1),
154
+ head: z.object({ ref: z.string().min(1), sha: z.string().min(1) }).passthrough(),
155
+ base: z.object({ ref: z.string().min(1) }).passthrough(),
156
+ })
157
+ .passthrough()
158
+
159
+ const restRepositoryAutoMerge = z.object({ allow_auto_merge: z.boolean() }).passthrough()
160
+
161
+ const restBranchHead = z
162
+ .object({ commit: z.object({ sha: z.string().min(1) }).passthrough() })
163
+ .passthrough()
164
+
165
+ const restRulesetRules = z.array(
166
+ z
167
+ .object({
168
+ type: z.string().min(1),
169
+ parameters: z.unknown().optional(),
170
+ })
171
+ .passthrough(),
172
+ )
173
+
174
+ // ── Ruleset gate classification (unchanged from the gh implementation) ───────
175
+
176
+ const pullRequestRuleParameters = z
177
+ .object({
178
+ required_approving_review_count: z.number().int().nonnegative(),
179
+ require_code_owner_review: z.boolean(),
180
+ require_last_push_approval: z.boolean(),
181
+ required_review_thread_resolution: z.boolean(),
182
+ })
183
+ .passthrough()
184
+
185
+ const requiredStatusRuleParameters = z
186
+ .object({ required_status_checks: z.array(z.unknown()) })
187
+ .passthrough()
188
+ const requiredDeploymentsRuleParameters = z
189
+ .object({ required_deployment_environments: z.array(z.string()) })
190
+ .passthrough()
191
+ const requiredWorkflowsRuleParameters = z.object({ workflows: z.array(z.unknown()) }).passthrough()
192
+ const requiredCodeScanningRuleParameters = z
193
+ .object({
194
+ code_scanning_tools: z.array(z.unknown()).optional(),
195
+ required_code_scanning_tools: z.array(z.unknown()).optional(),
196
+ })
197
+ .refine(
198
+ (value) =>
199
+ value.code_scanning_tools !== undefined || value.required_code_scanning_tools !== undefined,
200
+ { message: 'a code-scanning tools array is required' },
201
+ )
202
+ .passthrough()
203
+ const mergeQueueRuleParameters = z.object({}).passthrough()
204
+
205
+ const STRUCTURAL_RULE_TYPES = new Set([
206
+ 'creation',
207
+ 'update',
208
+ 'deletion',
209
+ 'required_linear_history',
210
+ 'non_fast_forward',
211
+ 'commit_message_pattern',
212
+ 'commit_author_email_pattern',
213
+ 'committer_email_pattern',
214
+ 'branch_name_pattern',
215
+ 'tag_name_pattern',
216
+ 'file_path_restriction',
217
+ 'max_file_path_length',
218
+ 'file_extension_restriction',
219
+ 'max_file_size',
220
+ ])
221
+
222
+ function parseRuleParameters<S extends z.ZodType>(
223
+ schema: S,
224
+ parameters: unknown,
225
+ type: string,
226
+ ): z.infer<S> {
227
+ const parsed = schema.safeParse(parameters)
228
+ if (!parsed.success) {
229
+ throw new Error(`unexpected parameters for active GitHub ruleset rule ${type}: ${parsed.error}`)
230
+ }
231
+ return parsed.data
232
+ }
233
+
234
+ /** Whether any active repository/organization rule matching one branch
235
+ * carries a real merge-blocking requirement. Unknown future types fail closed. */
236
+ export function rulesetsHaveMergeGate(rules: z.infer<typeof restRulesetRules>): boolean {
237
+ let present = false
238
+ for (const rule of rules) {
239
+ switch (rule.type) {
240
+ case 'merge_queue':
241
+ parseRuleParameters(mergeQueueRuleParameters, rule.parameters, rule.type)
242
+ present = true
243
+ break
244
+ case 'required_signatures':
245
+ present = true
246
+ break
247
+ case 'required_status_checks': {
248
+ const parameters = parseRuleParameters(
249
+ requiredStatusRuleParameters,
250
+ rule.parameters,
251
+ rule.type,
252
+ )
253
+ present = parameters.required_status_checks.length > 0 || present
254
+ break
255
+ }
256
+ case 'required_deployments': {
257
+ const parameters = parseRuleParameters(
258
+ requiredDeploymentsRuleParameters,
259
+ rule.parameters,
260
+ rule.type,
261
+ )
262
+ present = parameters.required_deployment_environments.length > 0 || present
263
+ break
264
+ }
265
+ case 'workflows':
266
+ case 'required_workflows': {
267
+ const parameters = parseRuleParameters(
268
+ requiredWorkflowsRuleParameters,
269
+ rule.parameters,
270
+ rule.type,
271
+ )
272
+ present = parameters.workflows.length > 0 || present
273
+ break
274
+ }
275
+ case 'required_code_scanning':
276
+ case 'code_scanning': {
277
+ const parameters = parseRuleParameters(
278
+ requiredCodeScanningRuleParameters,
279
+ rule.parameters,
280
+ rule.type,
281
+ )
282
+ const tools =
283
+ parameters.code_scanning_tools ?? parameters.required_code_scanning_tools ?? []
284
+ present = tools.length > 0 || present
285
+ break
286
+ }
287
+ case 'pull_request': {
288
+ const parameters = parseRuleParameters(
289
+ pullRequestRuleParameters,
290
+ rule.parameters,
291
+ rule.type,
292
+ )
293
+ present =
294
+ parameters.required_approving_review_count > 0 ||
295
+ parameters.require_code_owner_review ||
296
+ parameters.require_last_push_approval ||
297
+ parameters.required_review_thread_resolution ||
298
+ present
299
+ break
300
+ }
301
+ default:
302
+ if (!STRUCTURAL_RULE_TYPES.has(rule.type)) {
303
+ throw new Error(
304
+ `unknown active GitHub ruleset rule type ${JSON.stringify(rule.type)}; ` +
305
+ 'cannot prove the branch has no merge-blocking gate',
306
+ )
307
+ }
308
+ }
309
+ }
310
+ return present
311
+ }
312
+
313
+ // ── Native auto-merge mutations (GraphQL) ────────────────────────────────────
314
+ //
315
+ // GitHub's REST API has no auto-merge route: PUT/DELETE
316
+ // /repos/{o}/{r}/pulls/{n}/auto-merge respond 404 (route-not-found) while
317
+ // adjacent PR routes on the same PR respond 401 without auth. `gh` itself
318
+ // drives native auto-merge through the GraphQL mutations below, so the forge
319
+ // does too — same transport seam, same token.
320
+
321
+ const ENABLE_AUTO_MERGE_MUTATION = `
322
+ mutation ($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) {
323
+ enablePullRequestAutoMerge(
324
+ input: {pullRequestId: $pullRequestId, mergeMethod: $mergeMethod}
325
+ ) {
326
+ pullRequest { id }
327
+ }
328
+ }
329
+ `
330
+
331
+ const DISABLE_AUTO_MERGE_MUTATION = `
332
+ mutation ($pullRequestId: ID!) {
333
+ disablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId}) {
334
+ pullRequest { id }
335
+ }
336
+ }
337
+ `
338
+
339
+ /** A successful mutation payload: the mutated PR's node id. A null payload
340
+ * (mutation executed but returned nothing) fails the strict parse. */
341
+ const autoMergeMutationPayload = z.object({
342
+ pullRequest: z.object({ id: z.string().min(1) }),
343
+ })
344
+ const enableAutoMergeData = z.object({ enablePullRequestAutoMerge: autoMergeMutationPayload })
345
+ const disableAutoMergeData = z.object({ disablePullRequestAutoMerge: autoMergeMutationPayload })
346
+
347
+ // ── Classic branch protection (REST) ─────────────────────────────────────────
348
+ //
349
+ // The documented Branch-protection `protection` object (GET
350
+ // /repos/{o}/{r}/branches/{b}). Subsections are present-or-null; a subsection
351
+ // missing entirely from the response is UNPROVEN, not absent — the fail-closed
352
+ // posture the GraphQL probe had via its hard parse errors.
353
+
354
+ type ClassicGateResult = { kind: 'proved'; gate: boolean } | { kind: 'unproven'; detail: string }
355
+
356
+ const requiredStatusChecksKey = 'required_status_checks'
357
+ const reviewsKey = 'required_pull_request_reviews'
358
+ const restrictionsKey = 'restrictions'
359
+
360
+ function classifyClassicProtection(protection: unknown): ClassicGateResult {
361
+ if (protection === null) return { kind: 'proved', gate: false }
362
+ if (typeof protection !== 'object') {
363
+ return { kind: 'unproven', detail: `protection is ${JSON.stringify(protection)}` }
364
+ }
365
+ const raw = protection as Record<string, unknown>
366
+ // An unprotected branch is not rendered as `protection: null`: GitHub
367
+ // answers `{ enabled: false, required_status_checks: { enforcement_level:
368
+ // "off", ... } }` with the other subsections omitted. `enabled: false` is
369
+ // the documented proof that classic protection is absent.
370
+ if (raw.enabled === false) return { kind: 'proved', gate: false }
371
+ const statusChecks = raw.required_status_checks
372
+ const reviews = raw.required_pull_request_reviews
373
+ const restrictions = raw.restrictions
374
+ // Type-validate every subsection that IS present: GitHub renders unset
375
+ // subsections as null, so anything else here is a shape this classifier
376
+ // cannot read. Malformed-but-present never proves presence or absence.
377
+ if (statusChecks !== undefined && statusChecks !== null && typeof statusChecks !== 'object') {
378
+ return { kind: 'unproven', detail: 'required_status_checks is neither null nor an object' }
379
+ }
380
+ if (restrictions !== undefined && restrictions !== null && typeof restrictions !== 'object') {
381
+ return { kind: 'unproven', detail: 'restrictions is neither null nor an object' }
382
+ }
383
+ if (reviews !== undefined && reviews !== null && typeof reviews !== 'object') {
384
+ return {
385
+ kind: 'unproven',
386
+ detail: 'required_pull_request_reviews is neither null nor an object',
387
+ }
388
+ }
389
+ // Presence proofs — one subsection that demonstrably carries a requirement
390
+ // settles presence regardless of what the response did with the others.
391
+ if (statusChecks !== undefined && statusChecks !== null) {
392
+ return { kind: 'proved', gate: true }
393
+ }
394
+ if (restrictions !== undefined && restrictions !== null) {
395
+ return { kind: 'proved', gate: true }
396
+ }
397
+ // Absence proofs — to conclude "no gate" every subsection that could carry
398
+ // one must have been rendered; a missing subsection is auth- or plan-scoped
399
+ // in a way we cannot prove, so each names itself precisely.
400
+ if (!(requiredStatusChecksKey in raw)) {
401
+ return {
402
+ kind: 'unproven',
403
+ detail: 'protection.required_status_checks missing from response',
404
+ }
405
+ }
406
+ if (!(restrictionsKey in raw)) {
407
+ return { kind: 'unproven', detail: 'protection.restrictions missing from response' }
408
+ }
409
+ if (!(reviewsKey in raw)) {
410
+ return {
411
+ kind: 'unproven',
412
+ detail:
413
+ 'protection.required_pull_request_reviews missing from response; ' +
414
+ 'a classic reviews requirement could not be ruled out',
415
+ }
416
+ }
417
+ if (reviews === null) return { kind: 'proved', gate: false }
418
+ const reviewFields = reviews as Record<string, unknown>
419
+ for (const field of [
420
+ 'required_approving_review_count',
421
+ 'require_code_owner_reviews',
422
+ 'require_last_push_approval',
423
+ ] as const) {
424
+ if (!(field in reviewFields)) {
425
+ return { kind: 'unproven', detail: `required_pull_request_reviews.${field} missing` }
426
+ }
427
+ }
428
+ const count = reviewFields.required_approving_review_count
429
+ const codeOwners = reviewFields.require_code_owner_reviews
430
+ const lastPush = reviewFields.require_last_push_approval
431
+ if (
432
+ typeof count !== 'number' ||
433
+ typeof codeOwners !== 'boolean' ||
434
+ typeof lastPush !== 'boolean'
435
+ ) {
436
+ return {
437
+ kind: 'unproven',
438
+ detail: 'required_pull_request_reviews fields have unexpected types',
439
+ }
440
+ }
441
+ return { kind: 'proved', gate: count > 0 || codeOwners || lastPush }
442
+ }
443
+
444
+ /** REST `mergeable_state` → the kernel's canonical GraphQL-spelling enum.
445
+ * The value sets differ in spelling only; unrecognized values throw so the
446
+ * caller's fail-closed handling defers instead of ever mapping to DIRECT. */
447
+ function restMergeState(raw: string): MergeStateStatus {
448
+ const mapped = (
449
+ {
450
+ behind: 'BEHIND',
451
+ blocked: 'BLOCKED',
452
+ clean: 'CLEAN',
453
+ dirty: 'DIRTY',
454
+ draft: 'DRAFT',
455
+ has_hooks: 'HAS_HOOKS',
456
+ unknown: 'UNKNOWN',
457
+ unstable: 'UNSTABLE',
458
+ } as Record<string, MergeStateStatus>
459
+ )[raw]
460
+ if (mapped === undefined) {
461
+ throw new Error(
462
+ `unknown GitHub mergeable_state ${JSON.stringify(raw)}; ` +
463
+ `known states: ${mergeStateStatuses.join(', ')}`,
464
+ )
465
+ }
466
+ return mapped
467
+ }
468
+
469
+ // ── Forge adapter ────────────────────────────────────────────────────────────
470
+
471
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
472
+
473
+ /** Hard cap on the janitor poll's per-PR ETag cache. One small entry per PR
474
+ * ever polled in a dispatcher process lifetime makes hitting this in practice
475
+ * implausible; the cap is hygiene, not a load-bearing bound. */
476
+ const PR_STATE_CACHE_CAP = 512
477
+
478
+ export class GitHubForge implements Forge {
479
+ readonly name = 'github'
480
+ readonly prAttachments: PrAttachmentHosting
481
+
482
+ private readonly transport: GitHubRequest
483
+ /** Only `pushBranch` still runs a local command (`git push` from the
484
+ * workspace); every API operation goes through the transport. */
485
+ private readonly exec: Exec
486
+ private readonly explicitRepository?: string
487
+ private readonly repoRoot?: string
488
+ private readonly env: Readonly<Record<string, string | undefined>>
489
+ private coordinates?: RepoCoordinates | null
490
+ /** Per-PR ETag cache for the janitor poll, keyed `owner/name#number`.
491
+ * Conditional revalidation per GitHub's documented best practices: every
492
+ * REST response carries an `ETag`, and re-GETting with `If-None-Match`
493
+ * answers 304 Not Modified — which does not count against the primary rate
494
+ * limit — whenever the representation is unchanged. The cache lives only
495
+ * for this adapter instance (the dispatcher constructs one forge and holds
496
+ * it across janitor ticks), so a process restart is cache loss: exactly
497
+ * today's first-poll behavior. Capped with oldest-inserted eviction; a miss
498
+ * is an ordinary unconditional poll, so eviction is harmless by
499
+ * construction. */
500
+ private readonly prStateCache = new Map<string, { etag: string; state: PrState }>()
501
+ /** Bounded waits between mergeability re-queries on the UNKNOWN path. */
502
+ private readonly mergeabilityRetryDelaysMs: number[]
503
+
504
+ constructor(
505
+ opts: {
506
+ transport?: GitHubRequest
507
+ exec?: Exec
508
+ /** Literal token, or a resolver. Absent: the adapter resolves
509
+ * `GITHUB_TOKEN`, `GH_TOKEN`, then the gh CLI login lazily through its
510
+ * own exec seam. */
511
+ token?: GitHubTokenSource
512
+ repository?: string
513
+ repoRoot?: string
514
+ env?: Readonly<Record<string, string | undefined>>
515
+ /** Bounded waits between mergeability re-queries when GitHub reports
516
+ * `mergeable_state: 'unknown'` (mergeability still being computed).
517
+ * Two retries default; tests inject `[0, 0]` so no test sleeps. */
518
+ mergeabilityRetryDelaysMs?: number[]
519
+ } = {},
520
+ ) {
521
+ this.exec = opts.exec ?? bunExec
522
+ this.env = opts.env ?? {}
523
+ // Credential order: explicit token → GITHUB_TOKEN/GH_TOKEN → the gh CLI's
524
+ // stored login, probed lazily through this adapter's exec seam on the
525
+ // first request. A request that still goes out anonymously carries the
526
+ // miss reason in its error, so nothing is probed at construction time.
527
+ this.transport =
528
+ opts.transport ??
529
+ createGitHubFetchTransport({
530
+ token: opts.token !== undefined ? opts.token : githubTokenSource(this.env, this.exec),
531
+ })
532
+ if (opts.repository !== undefined && opts.repository !== '') {
533
+ this.explicitRepository = opts.repository
534
+ } else if (this.env.AB_REPOSITORY !== undefined && this.env.AB_REPOSITORY !== '') {
535
+ this.explicitRepository = this.env.AB_REPOSITORY
536
+ }
537
+ this.repoRoot = opts.repoRoot
538
+ this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? [500, 1500]
539
+ this.prAttachments = new GitHubPrAttachmentHosting({ transport: this.transport })
540
+ }
541
+
542
+ /** Memoized owner/name. `null` means "could not prove coordinates" — the
543
+ * caller decides whether that is fatal (direct calls) or a deferral
544
+ * (auto-merge gate probes). */
545
+ private async repoCoordinates(): Promise<RepoCoordinates | null> {
546
+ this.coordinates ??= await this.resolveCoordinates()
547
+ return this.coordinates
548
+ }
549
+
550
+ private async resolveCoordinates(): Promise<RepoCoordinates | null> {
551
+ if (this.explicitRepository !== undefined) return parseRepoCoordinates(this.explicitRepository)
552
+ // Worktree/checkout mode only: the last-resort probe reads the checkout's
553
+ // origin. Origin mode always exports AB_REPOSITORY, so this never runs.
554
+ if (this.repoRoot !== undefined) {
555
+ try {
556
+ const result = await this.exec(['git', 'remote', 'get-url', 'origin'], {
557
+ cwd: this.repoRoot,
558
+ signal: undefined,
559
+ })
560
+ if (result.exitCode === 0) {
561
+ const raw = result.stdout.trim()
562
+ if (raw !== '') return parseRepoCoordinates(raw)
563
+ }
564
+ } catch {
565
+ // Fall through to the error.
566
+ }
567
+ }
568
+ return null
569
+ }
570
+
571
+ private requireCoordinates(): Promise<RepoCoordinates> {
572
+ return this.repoCoordinates().then((coordinates) => {
573
+ if (coordinates === null) {
574
+ throw new Error(
575
+ 'GitHub forge could not resolve the repository owner/name — pass ' +
576
+ "--repository <origin>, export AB_REPOSITORY, or set the checkout's origin remote",
577
+ )
578
+ }
579
+ return coordinates
580
+ })
581
+ }
582
+
583
+ private async request(
584
+ method: string,
585
+ path: string,
586
+ opts?: GitHubRequestOpts,
587
+ ): Promise<GitHubResponse> {
588
+ return this.transport(method, path, opts)
589
+ }
590
+
591
+ private parse<T>(schema: z.ZodType<T>, response: GitHubResponse, operation: string): T {
592
+ const parsed = schema.safeParse(response.json)
593
+ if (!parsed.success) {
594
+ throw new Error(`unexpected GitHub response for ${operation}: ${parsed.error.message}`)
595
+ }
596
+ return parsed.data
597
+ }
598
+
599
+ private async getJson<T>(
600
+ schema: z.ZodType<T>,
601
+ path: string,
602
+ operation: string,
603
+ opts?: GitHubRequestOpts,
604
+ ): Promise<T> {
605
+ return this.parse(schema, await this.request('GET', path, opts), operation)
606
+ }
607
+
608
+ /** One GraphQL operation over the shared transport: POST the document,
609
+ * validate the envelope, and surface the API-level `errors` array as a
610
+ * thrown error — GitHub answers HTTP 200 even when the mutation failed. */
611
+ private async graphql<T>(
612
+ operation: string,
613
+ schema: z.ZodType<T>,
614
+ query: string,
615
+ variables: Record<string, unknown>,
616
+ ): Promise<T> {
617
+ const response = await this.request('POST', 'graphql', { body: { query, variables } })
618
+ const envelope = z
619
+ .object({
620
+ data: z.unknown().optional(),
621
+ errors: z.array(z.object({ message: z.string() }).passthrough()).optional(),
622
+ })
623
+ .safeParse(response.json)
624
+ if (!envelope.success) {
625
+ throw new Error(
626
+ `unexpected GitHub GraphQL envelope for ${operation}: ${envelope.error.message}`,
627
+ )
628
+ }
629
+ const errors = envelope.data.errors
630
+ if (errors !== undefined && errors.length > 0) {
631
+ throw new Error(
632
+ `GitHub GraphQL ${operation} failed: ${errors.map((e) => e.message).join('; ')}`,
633
+ )
634
+ }
635
+ if (envelope.data.data === undefined || envelope.data.data === null) {
636
+ throw new Error(`GitHub GraphQL ${operation} returned no data`)
637
+ }
638
+ const parsed = schema.safeParse(envelope.data.data)
639
+ if (!parsed.success) {
640
+ throw new Error(`unexpected GitHub GraphQL data for ${operation}: ${parsed.error.message}`)
641
+ }
642
+ return parsed.data
643
+ }
644
+
645
+ // ── Forge operations ───────────────────────────────────────────────────────
646
+
647
+ /** [D1]: rebase is banned and branches are never rewritten — never force.
648
+ * Only ever called from worktree-side terminals and contract fixtures. */
649
+ async pushBranch(workspacePath: string, branch: string): Promise<void> {
650
+ const result = await this.exec(['git', 'push', '-u', 'origin', `HEAD:refs/heads/${branch}`], {
651
+ cwd: workspacePath,
652
+ signal: undefined,
653
+ })
654
+ if (result.exitCode !== 0) {
655
+ throw new Error(
656
+ `forge command failed (exit ${result.exitCode}): git push -u origin HEAD:refs/heads/${branch}\n${result.stderr.trim()}`,
657
+ )
658
+ }
659
+ }
660
+
661
+ async openPr(opts: {
662
+ workspacePath: string
663
+ head: string
664
+ base: string
665
+ title: string
666
+ body: string
667
+ mergeMessage?: string
668
+ }): Promise<PrRef> {
669
+ // Idempotent by head branch (SPEC §8.7 crash paths): finalize's `ab done`
670
+ // opens the PR BEFORE appending finalize.completed, so a crash or store
671
+ // failure between the two makes the retry call openPr again. Adopting the
672
+ // open PR instead of erroring on the duplicate makes the re-run a
673
+ // harmless retry — the same rationale that makes push-before-event safe
674
+ // for implement.
675
+ const { owner, name } = await this.requireCoordinates()
676
+ const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
677
+ const listed = await this.getJson(
678
+ z.array(restPrRef),
679
+ `${repoPath}/pulls?head=${encodeURIComponent(`${owner}:${opts.head}`)}&state=open`,
680
+ 'open PR probe',
681
+ )
682
+ const existing = listed[0]
683
+ if (existing !== undefined) {
684
+ return {
685
+ number: existing.number,
686
+ url: existing.html_url,
687
+ headSha: existing.head.sha,
688
+ }
689
+ }
690
+ const created = this.parse(
691
+ restPrRef,
692
+ await this.request('POST', `${repoPath}/pulls`, {
693
+ body: { title: opts.title, head: opts.head, base: opts.base, body: opts.body },
694
+ }),
695
+ 'PR create',
696
+ )
697
+ return { number: created.number, url: created.html_url, headSha: created.head.sha }
698
+ }
699
+
700
+ /** Janitor poll (SPEC §15.7): merged / closed / mergeability for one PR.
701
+ * With a cached ETag the read revalidates via `If-None-Match`: a 304 Not
702
+ * Modified returns the cached previous result — the same result the previous
703
+ * poll produced — and a 200 refreshes the cached ETag alongside the parsed
704
+ * state (terminal `merged`/`closed` results included, uniformly). Without a
705
+ * cached ETag (first poll, cache loss) or when the provider omits an `ETag`
706
+ * response header, this is byte-for-byte the unconditional poll: 304
707
+ * handling only ever applies to requests that carry conditional headers. */
708
+ async getPrState(_workspacePath: string, number: number): Promise<PrState> {
709
+ const { owner, name } = await this.requireCoordinates()
710
+ const prPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`
711
+ const cacheKey = `${owner}/${name}#${number}`
712
+ const cached = this.prStateCache.get(cacheKey)
713
+ const response =
714
+ cached !== undefined
715
+ ? await this.request('GET', prPath, { headers: { 'If-None-Match': cached.etag } })
716
+ : await this.request('GET', prPath)
717
+ if (response.status === 304 && cached !== undefined) {
718
+ return cached.state
719
+ }
720
+ const view = this.parse(restPrState, response, `PR #${number} poll`)
721
+ let state: PrState
722
+ if (view.merged) {
723
+ // §15.7 [D1]: pr.merged records the squash commit as the landing
724
+ // point — a merged PR without one is unusable, not mappable.
725
+ if (view.merge_commit_sha === null) {
726
+ throw new Error(`GitHub reports PR #${number} merged with no merge_commit_sha`)
727
+ }
728
+ state = { state: 'merged', sha: view.merge_commit_sha }
729
+ } else {
730
+ switch (view.state) {
731
+ case 'closed':
732
+ state = { state: 'closed' }
733
+ break
734
+ case 'open':
735
+ state = { state: 'open', mergeable: view.mergeable }
736
+ }
737
+ }
738
+ const etag = response.headers.etag
739
+ if (etag === undefined || etag === '') {
740
+ // No revalidation token on this 200: drop any stale entry so a future
741
+ // poll can never replay it against a changed representation.
742
+ this.prStateCache.delete(cacheKey)
743
+ } else {
744
+ this.prStateCache.set(cacheKey, { etag, state })
745
+ // Hard capacity cap with oldest-inserted eviction (JS Map iteration
746
+ // order). The just-inserted entry is newest, so it survives.
747
+ while (this.prStateCache.size > PR_STATE_CACHE_CAP) {
748
+ const oldest = this.prStateCache.keys().next()
749
+ if (oldest.done === true) break
750
+ this.prStateCache.delete(oldest.value)
751
+ }
752
+ }
753
+ return state
754
+ }
755
+
756
+ async closePr(workspacePath: string, number: number): Promise<ClosePrResult> {
757
+ const before = await this.getPrState(workspacePath, number)
758
+ if (before.state === 'merged' || before.state === 'closed') return before
759
+
760
+ const { owner, name } = await this.requireCoordinates()
761
+ const prPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`
762
+ let closeError: unknown
763
+ try {
764
+ await this.request('PATCH', prPath, { body: { state: 'closed' } })
765
+ } catch (error) {
766
+ closeError = error
767
+ }
768
+ // Re-read even after a mutation failure: GitHub may have merged the PR in
769
+ // the inspection-to-close race, and merged state must never be overwritten.
770
+ const after = await this.getPrState(workspacePath, number)
771
+ if (after.state === 'merged' || after.state === 'closed') return after
772
+ if (closeError !== undefined) throw closeError
773
+ throw new Error(`GitHub PR #${number} remained open after the close request`)
774
+ }
775
+
776
+ async deleteBranch(_workspacePath: string, branch: string): Promise<void> {
777
+ const ref = `refs/heads/${branch}`
778
+ if (!isValidGitBranchName(branch)) {
779
+ throw new Error(`GitHub branch cleanup rejected invalid ref ${ref}`)
780
+ }
781
+ const { owner, name } = await this.requireCoordinates()
782
+ const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
783
+ try {
784
+ await this.request('GET', `${repoPath}/git/ref/heads/${branch}`)
785
+ } catch (error) {
786
+ if (error instanceof GitHubApiError && error.status === 404) return // idempotent
787
+ throw error
788
+ }
789
+ try {
790
+ await this.request('DELETE', `${repoPath}/git/refs/heads/${branch}`)
791
+ } catch (error) {
792
+ // A probe/delete race (branch deleted elsewhere) is still a success.
793
+ if (error instanceof GitHubApiError && error.status === 404) return
794
+ throw error
795
+ }
796
+ }
797
+
798
+ /** Read the provider's projected native desired state, plus the PR's node
799
+ * id — the GraphQL mutations key on the node id, not the PR number.
800
+ * Mutations are not acknowledgements: only an independent follow-up
801
+ * observation can make an `applied` result durable. */
802
+ private async nativeAutoMergeView(number: number): Promise<{ enabled: boolean; nodeId: string }> {
803
+ const { owner, name } = await this.requireCoordinates()
804
+ const view = await this.getJson(
805
+ restNativeAutoMerge,
806
+ `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
807
+ `PR #${number} native auto-merge read`,
808
+ )
809
+ return { enabled: view.auto_merge !== null, nodeId: view.node_id }
810
+ }
811
+
812
+ /** Probe both GitHub gate systems for the PR's exact base branch. The one
813
+ * non-success ruleset response that proves absence is GitHub's documented
814
+ * account-plan limitation; even then classic protection must independently
815
+ * parse successfully. Every other uncertainty is returned as a typed,
816
+ * fail-closed deferral rather than escaping into finalize or the janitor. */
817
+ private async mergeGatePresence(
818
+ baseRefName: string,
819
+ ): Promise<
820
+ | { kind: 'proved'; presence: MergeGatePresence }
821
+ | { kind: 'deferred'; reason: AutoMergeDeferralReason }
822
+ > {
823
+ const unproven = (detail: string) =>
824
+ ({
825
+ kind: 'deferred',
826
+ reason: { code: 'unproven-gate-state', detail },
827
+ }) as const
828
+ const errorMessage = (error: unknown): string =>
829
+ error instanceof Error ? error.message : String(error)
830
+
831
+ const coordinates = await this.repoCoordinates()
832
+ if (coordinates === null) {
833
+ return unproven(
834
+ 'GitHub auto-merge gate repository inspection failed: could not resolve the repository owner/name',
835
+ )
836
+ }
837
+ const { owner, name } = coordinates
838
+ const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
839
+ const branchPath = `${repoPath}/branches/${baseRefName}`
840
+
841
+ let classicGate: boolean | undefined
842
+ let classicError: string | undefined
843
+ try {
844
+ const branch = await this.request('GET', branchPath)
845
+ const view = branch.json as Record<string, unknown> | undefined
846
+ if (view?.protected === false) {
847
+ // The branch view's own flag proves classic protection absent even
848
+ // when the nested protection object is the unprotected stub.
849
+ classicGate = false
850
+ } else {
851
+ const protection = view?.protection
852
+ if (protection === undefined) {
853
+ throw new Error('branch response carries no protection object')
854
+ }
855
+ const classic = classifyClassicProtection(protection)
856
+ if (classic.kind === 'unproven') throw new Error(classic.detail)
857
+ classicGate = classic.gate
858
+ }
859
+ } catch (error) {
860
+ classicError = errorMessage(error)
861
+ }
862
+
863
+ let rulesetGate = false
864
+ let planLimited = false
865
+ try {
866
+ const rules = await this.request('GET', `${repoPath}/rules/branches/${baseRefName}`)
867
+ rulesetGate = rulesetsHaveMergeGate(this.parse(restRulesetRules, rules, 'ruleset probe'))
868
+ } catch (error) {
869
+ if (
870
+ error instanceof GitHubApiError &&
871
+ error.status === 403 &&
872
+ restPlanLimitation.safeParse(error.body).success
873
+ ) {
874
+ planLimited = true
875
+ } else {
876
+ return unproven(`GitHub auto-merge ruleset probe failed: ${errorMessage(error)}`)
877
+ }
878
+ }
879
+
880
+ if (planLimited) {
881
+ if (classicGate === undefined) {
882
+ return {
883
+ kind: 'deferred',
884
+ reason: {
885
+ code: 'github-plan-limitation',
886
+ detail:
887
+ 'GitHub returned its documented rulesets plan-limitation response, but classic ' +
888
+ `branch protection could not be proven absent: ${classicError ?? 'unknown failure'}`,
889
+ },
890
+ }
891
+ }
892
+ return { kind: 'proved', presence: classicGate ? 'present' : 'absent' }
893
+ }
894
+
895
+ if (classicGate === undefined) {
896
+ // The classic response was incomplete, but a ruleset probe that
897
+ // succeeded and found merge-blocking rules proves the branch is gated
898
+ // no matter what the classic response hid — native auto-merge is the
899
+ // correct application either way.
900
+ if (rulesetGate) return { kind: 'proved', presence: 'present' }
901
+ return unproven(
902
+ `GitHub auto-merge classic branch-protection probe failed: ${classicError ?? 'unknown failure'}; ` +
903
+ `the ruleset probe for branch '${baseRefName}' found no merge-blocking rules, ` +
904
+ 'so a classic reviews requirement could not be ruled out',
905
+ )
906
+ }
907
+ return {
908
+ kind: 'proved',
909
+ presence: classicGate || rulesetGate ? 'present' : 'absent',
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Enable-path PR inspection with bounded mergeability re-query. GitHub's
915
+ * `mergeable_state: 'unknown'` means mergeability is still being computed —
916
+ * transient, commonly observed just after required checks finish — so the
917
+ * same view is re-GET after short waits before concluding, stopping early if
918
+ * native auto-merge appears applied (another actor enabled it; the caller
919
+ * acknowledges adoption without a mutation). Unknown *unrecognized* state
920
+ * strings still throw into the caller's fail-closed handling — only the
921
+ * mapped UNKNOWN value re-queries.
922
+ */
923
+ private async inspectAutoMergeView(
924
+ repoPath: string,
925
+ number: number,
926
+ ): Promise<{
927
+ view: z.infer<typeof restAutoMergeView>
928
+ mergeState: MergeStateStatus
929
+ requeries: number
930
+ waitedMs: number
931
+ }> {
932
+ const view = await this.getJson(
933
+ restAutoMergeView,
934
+ `${repoPath}/pulls/${number}`,
935
+ `PR #${number} auto-merge inspection`,
936
+ )
937
+ let current = view
938
+ let requeries = 0
939
+ let waitedMs = 0
940
+ while (
941
+ current.auto_merge === null &&
942
+ restMergeState(current.mergeable_state) === 'UNKNOWN' &&
943
+ requeries < this.mergeabilityRetryDelaysMs.length
944
+ ) {
945
+ const delay = this.mergeabilityRetryDelaysMs[requeries] ?? 0
946
+ await sleep(delay)
947
+ waitedMs += delay
948
+ requeries += 1
949
+ current = await this.getJson(
950
+ restAutoMergeView,
951
+ `${repoPath}/pulls/${number}`,
952
+ `PR #${number} auto-merge inspection`,
953
+ )
954
+ }
955
+ return {
956
+ view: current,
957
+ mergeState: restMergeState(current.mergeable_state),
958
+ requeries,
959
+ waitedMs,
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Reconcile native auto-merge state. A native idempotent hit is acknowledged
965
+ * immediately. Otherwise enabling is classified from authoritative gate
966
+ * existence plus the complete current merge-state enum; only a proved
967
+ * ungated stable PR is returned as a direct candidate. A successful mutation
968
+ * is confirmed with a second native-state read before returning `applied`.
969
+ */
970
+ async setAutoMerge(
971
+ _workspacePath: string,
972
+ number: number,
973
+ enabled: boolean,
974
+ ): Promise<AutoMergeResult> {
975
+ // Cancellation must remain usable when GitHub adds a merge-state enum:
976
+ // inspect only the fields disabling actually needs. The mutation itself
977
+ // sits outside the enable path's catch on purpose — a failed cancellation
978
+ // is a hard janitor-tick error (as `gh pr merge --disable-auto` was
979
+ // before), never a silent deferral of live consent revocation.
980
+ if (!enabled) {
981
+ const native = await this.nativeAutoMergeView(number)
982
+ if (!native.enabled) {
983
+ return { kind: 'applied' }
984
+ }
985
+ await this.graphql(
986
+ 'disablePullRequestAutoMerge',
987
+ disableAutoMergeData,
988
+ DISABLE_AUTO_MERGE_MUTATION,
989
+ { pullRequestId: native.nodeId },
990
+ )
991
+ return (await this.nativeAutoMergeView(number)).enabled
992
+ ? { kind: 'deferred' }
993
+ : { kind: 'applied' }
994
+ }
995
+
996
+ try {
997
+ const { owner, name } = await this.requireCoordinates()
998
+ const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
999
+ const { view, mergeState, requeries, waitedMs } = await this.inspectAutoMergeView(
1000
+ repoPath,
1001
+ number,
1002
+ )
1003
+ if (view.auto_merge !== null) return { kind: 'applied' }
1004
+
1005
+ const gate = await this.mergeGatePresence(view.base.ref)
1006
+ if (gate.kind === 'deferred') return gate
1007
+ const disposition = classifyAutoMergeEnable(mergeState, gate.presence)
1008
+ switch (disposition.kind) {
1009
+ case 'native': {
1010
+ const repository = await this.getJson(
1011
+ restRepositoryAutoMerge,
1012
+ repoPath,
1013
+ 'repository auto-merge read',
1014
+ )
1015
+ if (!repository.allow_auto_merge) {
1016
+ return {
1017
+ kind: 'deferred',
1018
+ reason: {
1019
+ code: 'repository-auto-merge-disabled',
1020
+ detail: 'GitHub reports allow_auto_merge=false; the PR was left open for a human',
1021
+ },
1022
+ }
1023
+ }
1024
+ await this.graphql(
1025
+ 'enablePullRequestAutoMerge',
1026
+ enableAutoMergeData,
1027
+ ENABLE_AUTO_MERGE_MUTATION,
1028
+ { pullRequestId: view.node_id, mergeMethod: 'SQUASH' },
1029
+ )
1030
+ return (await this.nativeAutoMergeView(number)).enabled
1031
+ ? { kind: 'applied' }
1032
+ : {
1033
+ kind: 'deferred',
1034
+ reason: {
1035
+ code: 'unproven-gate-state',
1036
+ detail:
1037
+ 'enablePullRequestAutoMerge was accepted but the follow-up native read ' +
1038
+ `reports auto_merge unset for PR #${number}`,
1039
+ },
1040
+ }
1041
+ }
1042
+ case 'direct':
1043
+ return { kind: 'ungated', headSha: view.head.sha }
1044
+ case 'deferred':
1045
+ if (mergeState === 'UNKNOWN') {
1046
+ // Not a persistent indeterminacy: mergeability was still being
1047
+ // computed (transient) after the bounded re-queries. pendingAutoMerge
1048
+ // keeps the consent pending, so a later janitor tick retries.
1049
+ return {
1050
+ kind: 'deferred',
1051
+ reason: {
1052
+ code: 'mergeability-uncomputed',
1053
+ detail:
1054
+ `GitHub reported mergeable_state 'UNKNOWN' for PR #${number} — mergeability ` +
1055
+ `was still being computed (transient) after ${requeries} re-quer${requeries === 1 ? 'y' : 'ies'} ` +
1056
+ `over ${waitedMs} ms; native auto-merge was not enabled`,
1057
+ },
1058
+ }
1059
+ }
1060
+ if (mergeState === 'DIRTY') {
1061
+ // A persistent, definite state: GitHub has computed merge
1062
+ // conflicts with the base branch, so native auto-merge cannot be
1063
+ // enabled against it. The recovery is updating the branch or
1064
+ // resolving the conflicts — not gate-state investigation — and
1065
+ // pendingAutoMerge keeps the consent pending so a later janitor
1066
+ // tick re-examines it once the conflict is resolved.
1067
+ return {
1068
+ kind: 'deferred',
1069
+ reason: {
1070
+ code: 'merge-conflicts',
1071
+ detail:
1072
+ `GitHub reports mergeable_state 'DIRTY' for PR #${number} — the head branch has ` +
1073
+ `merge conflicts with '${view.base.ref}'; update the branch or resolve the conflicts ` +
1074
+ 'and the pending consent will be re-examined on a later tick. ' +
1075
+ 'Native auto-merge was not enabled',
1076
+ },
1077
+ }
1078
+ }
1079
+ return {
1080
+ kind: 'deferred',
1081
+ reason: {
1082
+ code: 'unproven-gate-state',
1083
+ detail: `GitHub reports mergeable_state '${mergeState}' for PR #${number}; native auto-merge was not enabled`,
1084
+ },
1085
+ }
1086
+ case 'error':
1087
+ throw new Error(disposition.reason)
1088
+ }
1089
+ } catch (error) {
1090
+ return {
1091
+ kind: 'deferred',
1092
+ reason: {
1093
+ code: 'unproven-gate-state',
1094
+ detail:
1095
+ 'GitHub auto-merge gate inspection or native application failed: ' +
1096
+ (error instanceof Error ? error.message : String(error)),
1097
+ },
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ /** Normal guarded squash — no admin, force, rebase, or native-auto flag.
1103
+ * A 409 (head moved) surfaces as a hard error, preserving the contract's
1104
+ * moved-head rejection. */
1105
+ async squashMerge(
1106
+ _workspacePath: string,
1107
+ number: number,
1108
+ expectedHeadSha: string,
1109
+ ): Promise<void> {
1110
+ const { owner, name } = await this.requireCoordinates()
1111
+ await this.request(
1112
+ 'PUT',
1113
+ `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/merge`,
1114
+ {
1115
+ body: { merge_method: 'squash', sha: expectedHeadSha },
1116
+ },
1117
+ )
1118
+ }
1119
+
1120
+ /** The build's summary comment (SPEC §7.5) — links into the store. */
1121
+ async commentOnPr(_workspacePath: string, number: number, body: string): Promise<void> {
1122
+ const { owner, name } = await this.requireCoordinates()
1123
+ await this.request(
1124
+ 'POST',
1125
+ `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`,
1126
+ { body: { body } },
1127
+ )
1128
+ }
1129
+
1130
+ // ── Optional capabilities (checkout-less dispatcher) ──────────────────────
1131
+
1132
+ /** Current tip of a remote branch. Throws (including 404) when the branch
1133
+ * does not exist; the provider seam maps that to `undefined`. */
1134
+ async remoteBranchSha(branch: string): Promise<string> {
1135
+ const { owner, name } = await this.requireCoordinates()
1136
+ const branchHead = await this.getJson(
1137
+ restBranchHead,
1138
+ `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${branch}`,
1139
+ `remote branch ${branch}`,
1140
+ )
1141
+ return branchHead.commit.sha
1142
+ }
1143
+
1144
+ /** Raw file bytes from the repository. Ref omitted → the repository's
1145
+ * default branch. Throws (including 404) when the path does not exist. */
1146
+ async readFile(path: string, ref?: string): Promise<string> {
1147
+ const { owner, name } = await this.requireCoordinates()
1148
+ const query = ref !== undefined ? { ref } : undefined
1149
+ const response = await this.request(
1150
+ 'GET',
1151
+ `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path}`,
1152
+ {
1153
+ headers: { Accept: 'application/vnd.github.raw' },
1154
+ ...(query !== undefined ? { query } : {}),
1155
+ },
1156
+ )
1157
+ if (response.bytes === undefined) {
1158
+ throw new Error(`GitHub contents read of ${path} returned no raw bytes`)
1159
+ }
1160
+ return new TextDecoder().decode(response.bytes)
1161
+ }
1162
+ }