@open-mercato/cezar 0.9.1 → 0.9.2

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 (300) hide show
  1. package/README.md +19 -11
  2. package/dist/agent-config/catalog.d.ts +1 -1
  3. package/dist/agent-config/files.d.ts +1 -1
  4. package/dist/agent-config/service.d.ts +1 -1
  5. package/dist/agent-config/validate.d.ts +1 -1
  6. package/dist/automations/coordinator.d.ts +26 -0
  7. package/dist/automations/coordinator.js +66 -0
  8. package/dist/automations/coordinator.js.map +1 -0
  9. package/dist/automations/github-poller.d.ts +74 -0
  10. package/dist/automations/github-poller.js +234 -0
  11. package/dist/automations/github-poller.js.map +1 -0
  12. package/dist/automations/scheduler.d.ts +42 -0
  13. package/dist/automations/scheduler.js +192 -0
  14. package/dist/automations/scheduler.js.map +1 -0
  15. package/dist/automations/store.d.ts +65 -0
  16. package/dist/automations/store.js +298 -0
  17. package/dist/automations/store.js.map +1 -0
  18. package/dist/automations/task-template.d.ts +18 -0
  19. package/dist/automations/task-template.js +95 -0
  20. package/dist/automations/task-template.js.map +1 -0
  21. package/dist/automations/types.d.ts +251 -0
  22. package/dist/automations/types.js +158 -0
  23. package/dist/automations/types.js.map +1 -0
  24. package/dist/config.d.ts +9 -111
  25. package/dist/contract/agent-config.d.ts +151 -0
  26. package/dist/contract/automations.d.ts +911 -0
  27. package/dist/contract/events.d.ts +45 -0
  28. package/dist/contract/github.d.ts +467 -0
  29. package/dist/contract/health.d.ts +96 -0
  30. package/dist/contract/index.d.ts +15 -0
  31. package/dist/contract/index.js +1421 -0
  32. package/dist/contract/projects.d.ts +182 -0
  33. package/dist/contract/repo.d.ts +234 -0
  34. package/dist/contract/runs.d.ts +1226 -0
  35. package/dist/contract/skills.d.ts +225 -0
  36. package/dist/contract/workflows.d.ts +212 -0
  37. package/dist/contract/workspace.d.ts +442 -0
  38. package/dist/core/agent-env.d.ts +1 -1
  39. package/dist/core/agent-runner.d.ts +12 -1
  40. package/dist/core/agent-runner.js +13 -0
  41. package/dist/core/agent-runner.js.map +1 -1
  42. package/dist/core/ask.d.ts +45 -124
  43. package/dist/core/ask.js +103 -13
  44. package/dist/core/ask.js.map +1 -1
  45. package/dist/core/claude-cli-runner.d.ts +2 -2
  46. package/dist/core/claude-cli-runner.js +52 -9
  47. package/dist/core/claude-cli-runner.js.map +1 -1
  48. package/dist/core/claude-ui-mapper.d.ts +1 -1
  49. package/dist/core/codex-app-server-runner.d.ts +1 -1
  50. package/dist/core/codex-app-server-runner.js +55 -3
  51. package/dist/core/codex-app-server-runner.js.map +1 -1
  52. package/dist/core/codex-app-server-transport.d.ts +6 -1
  53. package/dist/core/codex-app-server-transport.js +12 -3
  54. package/dist/core/codex-app-server-transport.js.map +1 -1
  55. package/dist/core/codex-model-catalog.d.ts +1 -1
  56. package/dist/core/codex-ui-mapper.d.ts +5 -1
  57. package/dist/core/codex-ui-mapper.js +74 -17
  58. package/dist/core/codex-ui-mapper.js.map +1 -1
  59. package/dist/core/model-identity.d.ts +1 -1
  60. package/dist/core/model-presets.d.ts +2 -2
  61. package/dist/core/model-presets.js +1 -1
  62. package/dist/core/opencode-server-runner.d.ts +2 -2
  63. package/dist/core/opencode-ui-mapper.d.ts +4 -1
  64. package/dist/core/opencode-ui-mapper.js +45 -7
  65. package/dist/core/opencode-ui-mapper.js.map +1 -1
  66. package/dist/core/provider-auth.d.ts +62 -0
  67. package/dist/core/provider-auth.js +290 -0
  68. package/dist/core/provider-auth.js.map +1 -0
  69. package/dist/core/provider-availability.d.ts +3 -0
  70. package/dist/core/provider-availability.js +13 -0
  71. package/dist/core/provider-availability.js.map +1 -0
  72. package/dist/core/runner-factory.d.ts +1 -1
  73. package/dist/core/runner-model-catalog.d.ts +1 -1
  74. package/dist/core/tool-display.d.ts +1 -1
  75. package/dist/core/ui-events.d.ts +1 -1
  76. package/dist/core/ui-events.js +1 -1
  77. package/dist/git-diff-base.d.ts +60 -0
  78. package/dist/git-diff-base.js +54 -0
  79. package/dist/git-diff-base.js.map +1 -0
  80. package/dist/git-worktree.d.ts +37 -6
  81. package/dist/git-worktree.js +39 -9
  82. package/dist/git-worktree.js.map +1 -1
  83. package/dist/handoff.d.ts +2 -2
  84. package/dist/handoff.js +1 -1
  85. package/dist/index.js +65 -8
  86. package/dist/index.js.map +1 -1
  87. package/dist/paths.d.ts +16 -1
  88. package/dist/paths.js +24 -1
  89. package/dist/paths.js.map +1 -1
  90. package/dist/planner.d.ts +2 -2
  91. package/dist/planner.js +1 -1
  92. package/dist/planner.js.map +1 -1
  93. package/dist/release/manifests.d.ts +81 -0
  94. package/dist/release/manifests.js +74 -0
  95. package/dist/release/manifests.js.map +1 -0
  96. package/dist/release/snapshot.d.ts +9 -23
  97. package/dist/release/snapshot.js +9 -22
  98. package/dist/release/snapshot.js.map +1 -1
  99. package/dist/release/stable.d.ts +15 -29
  100. package/dist/release/stable.js +14 -27
  101. package/dist/release/stable.js.map +1 -1
  102. package/dist/runs/auto-name.d.ts +1 -1
  103. package/dist/runs/retention.d.ts +1 -1
  104. package/dist/runs/store.d.ts +142 -336
  105. package/dist/runs/store.js +136 -9
  106. package/dist/runs/store.js.map +1 -1
  107. package/dist/runs/ui-event-sink.d.ts +1 -1
  108. package/dist/server/app-type.d.ts +21 -0
  109. package/dist/server/app-type.js +2 -0
  110. package/dist/server/app-type.js.map +1 -0
  111. package/dist/server/capabilities.d.ts +6 -5
  112. package/dist/server/capabilities.js +11 -0
  113. package/dist/server/capabilities.js.map +1 -1
  114. package/dist/server/forge/github.d.ts +58 -151
  115. package/dist/server/forge/github.js +530 -6
  116. package/dist/server/forge/github.js.map +1 -1
  117. package/dist/server/forge/index.d.ts +10 -3
  118. package/dist/server/forge/index.js +14 -1
  119. package/dist/server/forge/index.js.map +1 -1
  120. package/dist/server/forge/types.d.ts +86 -1
  121. package/dist/server/git-changes.d.ts +8 -8
  122. package/dist/server/git-changes.js +14 -17
  123. package/dist/server/git-changes.js.map +1 -1
  124. package/dist/server/git.d.ts +2 -0
  125. package/dist/server/git.js +9 -0
  126. package/dist/server/git.js.map +1 -1
  127. package/dist/server/github.d.ts +3 -3
  128. package/dist/server/github.js +1 -1
  129. package/dist/server/github.js.map +1 -1
  130. package/dist/server/pr.d.ts +2 -2
  131. package/dist/server/project-context.d.ts +20 -3
  132. package/dist/server/project-context.js +30 -1
  133. package/dist/server/project-context.js.map +1 -1
  134. package/dist/server/provider-action-gate.d.ts +8 -0
  135. package/dist/server/provider-action-gate.js +54 -0
  136. package/dist/server/provider-action-gate.js.map +1 -0
  137. package/dist/server/provider-auth-runtime.d.ts +21 -0
  138. package/dist/server/provider-auth-runtime.js +66 -0
  139. package/dist/server/provider-auth-runtime.js.map +1 -0
  140. package/dist/server/server.d.ts +11142 -47
  141. package/dist/server/server.js +1714 -806
  142. package/dist/server/server.js.map +1 -1
  143. package/dist/server/validators.d.ts +97 -0
  144. package/dist/server/validators.js +86 -0
  145. package/dist/server/validators.js.map +1 -0
  146. package/dist/server/ws.d.ts +2 -2
  147. package/dist/server/ws.js +2 -2
  148. package/dist/server/ws.js.map +1 -1
  149. package/dist/server-install/engine.d.ts +1 -1
  150. package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
  151. package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
  152. package/dist/server-install/platforms/ubuntu-vps.js +83 -1
  153. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
  154. package/dist/server-install/state.d.ts +1 -1
  155. package/dist/server-install/steps.d.ts +2 -2
  156. package/dist/server-install/strategies.d.ts +1 -1
  157. package/dist/server-install/types.d.ts +38 -424
  158. package/dist/server-install/ui.d.ts +1 -1
  159. package/dist/skills-banner.d.ts +7 -6
  160. package/dist/skills-banner.js +7 -6
  161. package/dist/skills-banner.js.map +1 -1
  162. package/dist/skills-remote.d.ts +2 -2
  163. package/dist/skills-update.d.ts +88 -0
  164. package/dist/skills-update.js +392 -0
  165. package/dist/skills-update.js.map +1 -0
  166. package/dist/skills.d.ts +2 -0
  167. package/dist/skills.js +3 -2
  168. package/dist/skills.js.map +1 -1
  169. package/dist/todos.d.ts +2 -28
  170. package/dist/todos.js +1 -1
  171. package/dist/ui-state.d.ts +16 -2
  172. package/dist/ui-state.js +14 -1
  173. package/dist/ui-state.js.map +1 -1
  174. package/dist/workflows/load.d.ts +1 -1
  175. package/dist/workflows/run.d.ts +53 -12
  176. package/dist/workflows/run.js +436 -64
  177. package/dist/workflows/run.js.map +1 -1
  178. package/dist/workflows/types.d.ts +59 -224
  179. package/dist/workflows/types.js +14 -0
  180. package/dist/workflows/types.js.map +1 -1
  181. package/dist/workspace/config.d.ts +68 -206
  182. package/dist/workspace/config.js +145 -14
  183. package/dist/workspace/config.js.map +1 -1
  184. package/dist/workspace/migrations.d.ts +1 -1
  185. package/dist/workspace/migrations.js +19 -4
  186. package/dist/workspace/migrations.js.map +1 -1
  187. package/dist/workspace/projects.d.ts +8 -2
  188. package/dist/workspace/projects.js +9 -3
  189. package/dist/workspace/projects.js.map +1 -1
  190. package/dist/workspace/semaphore.d.ts +6 -0
  191. package/dist/workspace/semaphore.js +14 -1
  192. package/dist/workspace/semaphore.js.map +1 -1
  193. package/dist/workspace/ui-state.d.ts +13 -3
  194. package/dist/workspace/ui-state.js +15 -5
  195. package/dist/workspace/ui-state.js.map +1 -1
  196. package/package.json +17 -39
  197. package/scripts/inline-contract.mjs +112 -0
  198. package/scripts/mock-claude.mjs +39 -1
  199. package/scripts/sync-readme.mjs +20 -0
  200. package/web/dist/assets/alert-dialog-ghffK6g9.js +1 -0
  201. package/web/dist/assets/arrow-down-Cz8qb71e.js +1 -0
  202. package/web/dist/assets/arrow-left-wcYKQXI_.js +1 -0
  203. package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
  204. package/web/dist/assets/centered-state-ZtnFHA-n.js +43 -0
  205. package/web/dist/assets/chevron-right-G4caF2QV.js +1 -0
  206. package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
  207. package/web/dist/assets/circle-check--KvBxXWp.js +1 -0
  208. package/web/dist/assets/circle-x-C3EFZ3xf.js +1 -0
  209. package/web/dist/assets/collapsible-B0JVzd0B.js +1 -0
  210. package/web/dist/assets/commit-list-wm6DPHNs.js +1 -0
  211. package/web/dist/assets/compare-variants-DbdP4Y5T.js +1 -0
  212. package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
  213. package/web/dist/assets/diff-Bizc96qe.js +3 -0
  214. package/web/dist/assets/diff-stat-Dyfg3ltF.js +1 -0
  215. package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-CG8ek6tC.js} +4 -4
  216. package/web/dist/assets/dropdown-menu-C1Dlf35P.js +1 -0
  217. package/web/dist/assets/editable-title-5rC_QRBZ.js +1 -0
  218. package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-DpGTRqC2.js} +1 -1
  219. package/web/dist/assets/{file-D4GBh1ao.js → file-DlUGpUlb.js} +1 -1
  220. package/web/dist/assets/folder-BAY6Vddy.js +1 -0
  221. package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-agOG0iGM.js} +1 -1
  222. package/web/dist/assets/git-toolbar-DGiNMmtY.js +1 -0
  223. package/web/dist/assets/github-Vp7lxYGQ.js +1 -0
  224. package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
  225. package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
  226. package/web/dist/assets/image-preview-BWGk5fPX.js +1 -0
  227. package/web/dist/assets/index-DSxyJpuK.css +2 -0
  228. package/web/dist/assets/index-DiawI6RX.js +6 -0
  229. package/web/dist/assets/lib-BQXq3kEf.js +1 -0
  230. package/web/dist/assets/lib-BxQXEXDF.js +1 -0
  231. package/web/dist/assets/markdown-BGBOGCA_.js +2 -0
  232. package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
  233. package/web/dist/assets/new-task-form-BpAw_Dks.js +1 -0
  234. package/web/dist/assets/pill-Dvs-P0xy.js +1 -0
  235. package/web/dist/assets/project-router-Cx8NPoqn.js +1 -0
  236. package/web/dist/assets/prompt-templates-CSBYBR1I.js +15 -0
  237. package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
  238. package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-BoGdAykg.js} +1 -1
  239. package/web/dist/assets/repo-git-PmX-BNMo.js +1 -0
  240. package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  241. package/web/dist/assets/run-diff-EcOkf3ti.js +3 -0
  242. package/web/dist/assets/run-header-BGiv6_qX.js +1 -0
  243. package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-X3EpVhgs.js} +1 -1
  244. package/web/dist/assets/skill-empty-hint-CZLALx6l.js +1 -0
  245. package/web/dist/assets/skills-BiWfHmCR.js +1 -0
  246. package/web/dist/assets/skills-CC91ouGH.js +1 -0
  247. package/web/dist/assets/sparkles-Dt4q7pbG.js +1 -0
  248. package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-hwBL8XsC.js} +1 -1
  249. package/web/dist/assets/tab-link-DIlR5T07.js +1 -0
  250. package/web/dist/assets/task-changes-XkHDxzQA.js +1 -0
  251. package/web/dist/assets/task-commits-BTTA5k5T.js +1 -0
  252. package/web/dist/assets/task-files-Da8_4C9g.js +2 -0
  253. package/web/dist/assets/task-thread-DoNXUsHr.js +9 -0
  254. package/web/dist/assets/textarea-CUdokhkv.js +1 -0
  255. package/web/dist/assets/thread-loading-B8q8ukXJ.js +1 -0
  256. package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-DhVIdWv8.js} +1 -1
  257. package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-XYT0DGCI.js} +1 -1
  258. package/web/dist/assets/{upload-CDdyPPo_.js → upload-BKjN59-1.js} +1 -1
  259. package/web/dist/assets/use-desktop-Bf5LS_P4.js +1 -0
  260. package/web/dist/assets/use-submit-shortcut-CzVyoAMd.js +1 -0
  261. package/web/dist/assets/utils-DsLuRXwV.js +64 -0
  262. package/web/dist/assets/workflows-o2QU_pvU.js +11 -0
  263. package/web/dist/assets/zoomable-image-7WyUiaVD.js +1 -0
  264. package/web/dist/index.html +32 -13
  265. package/scripts/dev.mjs +0 -74
  266. package/scripts/release-snapshot.mjs +0 -130
  267. package/scripts/release.mjs +0 -119
  268. package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
  269. package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
  270. package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
  271. package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
  272. package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
  273. package/web/dist/assets/dist-B6XQOhgM.js +0 -1
  274. package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
  275. package/web/dist/assets/github-DTTSG0ht.js +0 -1
  276. package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
  277. package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
  278. package/web/dist/assets/index-Cijmlz7q.js +0 -30
  279. package/web/dist/assets/index-DzroxqBG.css +0 -2
  280. package/web/dist/assets/lib-DPEZDBUN.js +0 -1
  281. package/web/dist/assets/lib-uHpeUPzK.js +0 -1
  282. package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
  283. package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
  284. package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
  285. package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
  286. package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
  287. package/web/dist/assets/run-header-Cg5incil.js +0 -1
  288. package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
  289. package/web/dist/assets/skills-PpP2owbM.js +0 -1
  290. package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
  291. package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
  292. package/web/dist/assets/task-commits-COkLaUri.js +0 -1
  293. package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
  294. package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
  295. package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
  296. package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
  297. package/web/dist/assets/utils-B8s5qIcK.js +0 -1
  298. package/web/dist/assets/workflows-BFk8C0co.js +0 -11
  299. package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
  300. /package/web/{open-mercato.svg → dist/open-mercato.svg} +0 -0
@@ -10,6 +10,127 @@ import { autosaveCommit } from '../../git-worktree.js';
10
10
  * protected by BACKWARD_COMPATIBILITY.md — additive changes only.
11
11
  */
12
12
  const exec = promisify(execFile);
13
+ export const GH_PR_DIFF_FILE_CAP = 300;
14
+ export const GH_PR_PATCH_CAP = 512 * 1024;
15
+ export const GH_PR_DIFF_JSON_CAP = 4 * 1024 * 1024;
16
+ const ghPrFileSchema = z.object({
17
+ filename: z.string().min(1),
18
+ previous_filename: z.string().optional(),
19
+ status: z.enum(['added', 'modified', 'removed', 'renamed', 'copied', 'changed']),
20
+ additions: z.number().int().nonnegative(),
21
+ deletions: z.number().int().nonnegative(),
22
+ patch: z.string().optional(),
23
+ });
24
+ const ghPrHeadSchema = z.object({ headRefOid: z.string().regex(/^[0-9a-f]{40}$/i) });
25
+ export class GithubPrNotFoundError extends Error {
26
+ }
27
+ const prDiffCache = new Map();
28
+ /** Fetch no more than the three GitHub pages represented by the public 300-file response cap. */
29
+ export async function fetchPrFilePages(runPage) {
30
+ const rows = [];
31
+ for (let page = 1; page <= GH_PR_DIFF_FILE_CAP / 100; page++) {
32
+ const next = z.array(z.unknown()).parse(JSON.parse(await runPage(page)));
33
+ rows.push(...next);
34
+ if (next.length < 100)
35
+ break;
36
+ }
37
+ return rows;
38
+ }
39
+ export async function fetchGithubPrDiff(repoRoot, number, refresh = false) {
40
+ if (process.env.CEZ_DRY_RUN === '1')
41
+ return mockGithubPrDiff(number);
42
+ try {
43
+ const head = ghPrHeadSchema.parse(JSON.parse(await gh(repoRoot, ['pr', 'view', String(number), '--json', 'headRefOid']))).headRefOid;
44
+ const key = `${repoRoot}\0${number}\0${head}`;
45
+ const hit = prDiffCache.get(key);
46
+ if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
47
+ return hit.data;
48
+ const rows = z.array(ghPrFileSchema).parse(await fetchPrFilePages((page) => gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/files?per_page=100&page=${page}`], 30_000)));
49
+ const limited = rows.slice(0, GH_PR_DIFF_FILE_CAP);
50
+ // A full third page may have a successor. Without fetching a 301st file, conservatively call
51
+ // the response partial rather than claiming completeness we cannot prove.
52
+ let responseTruncated = rows.length >= GH_PR_DIFF_FILE_CAP;
53
+ const reasons = responseTruncated ? [`Only the first ${GH_PR_DIFF_FILE_CAP} files are shown.`] : [];
54
+ const files = limited.map((row) => {
55
+ let patch = row.patch;
56
+ let truncated = false;
57
+ let patchUnavailableReason;
58
+ if (patch !== undefined && Buffer.byteLength(patch, 'utf8') > GH_PR_PATCH_CAP) {
59
+ patch = undefined;
60
+ truncated = true;
61
+ patchUnavailableReason = 'too-large';
62
+ responseTruncated = true;
63
+ }
64
+ else if (patch === undefined) {
65
+ patchUnavailableReason = row.additions === 0 && row.deletions === 0 ? 'binary' : 'not-provided';
66
+ }
67
+ return {
68
+ path: row.filename,
69
+ ...(row.previous_filename ? { previousPath: row.previous_filename } : {}),
70
+ status: row.status,
71
+ additions: row.additions,
72
+ deletions: row.deletions,
73
+ ...(patch !== undefined ? { patch } : {}),
74
+ ...(patchUnavailableReason ? { patchUnavailableReason } : {}),
75
+ ...(truncated ? { truncated: true } : {}),
76
+ };
77
+ });
78
+ let kept = files;
79
+ while (kept.length > 0 &&
80
+ Buffer.byteLength(JSON.stringify({ available: true, number, headSha: head, files: kept }), 'utf8') >
81
+ GH_PR_DIFF_JSON_CAP) {
82
+ kept = kept.slice(0, -1);
83
+ responseTruncated = true;
84
+ }
85
+ if (kept.length < files.length)
86
+ reasons.push('The response size limit omitted some files.');
87
+ if (files.some((file) => file.truncated))
88
+ reasons.push('One or more patches exceeded the per-file limit.');
89
+ const data = {
90
+ available: true,
91
+ number,
92
+ headSha: head,
93
+ files: kept,
94
+ additions: rows.reduce((sum, row) => sum + row.additions, 0),
95
+ deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
96
+ truncated: responseTruncated,
97
+ ...(reasons.length ? { reason: reasons.join(' ') } : {}),
98
+ };
99
+ prDiffCache.set(key, { at: Date.now(), data });
100
+ while (prDiffCache.size > 50)
101
+ prDiffCache.delete(prDiffCache.keys().next().value);
102
+ return data;
103
+ }
104
+ catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ if (/HTTP 404|Could not resolve to a PullRequest|no pull requests found/i.test(message)) {
107
+ throw new GithubPrNotFoundError(`Pull request #${number} was not found`);
108
+ }
109
+ return {
110
+ available: false,
111
+ reason: /ENOENT/.test(message)
112
+ ? 'gh CLI not found — install it and run `gh auth login`'
113
+ : firstLine(message),
114
+ };
115
+ }
116
+ }
117
+ function mockGithubPrDiff(number) {
118
+ return {
119
+ available: true,
120
+ number,
121
+ headSha: '0123456789abcdef0123456789abcdef01234567',
122
+ additions: 15,
123
+ deletions: 4,
124
+ truncated: true,
125
+ reason: 'One or more patches were not provided by GitHub.',
126
+ files: [
127
+ { path: 'src/session.ts', status: 'modified', additions: 8, deletions: 3, patch: '@@ -1,3 +1,4 @@\n-old\n+new\n context' },
128
+ { path: 'src/new-name.ts', previousPath: 'src/old-name.ts', status: 'renamed', additions: 7, deletions: 1, patch: '@@ -1 +1 @@\n-old name\n+new name' },
129
+ { path: 'assets/logo.png', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'binary' },
130
+ { path: 'generated/output.txt', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'too-large', truncated: true },
131
+ ],
132
+ };
133
+ }
13
134
  // `gh … --json` output — validated at the boundary, extras stripped.
14
135
  const ghAuthor = z.object({ login: z.string() }).nullish();
15
136
  // `color` is the 6-hex GitHub label color (no `#`), '' when gh omits it.
@@ -31,11 +152,13 @@ export const ghCheckRunSchema = z.object({
31
152
  conclusion: z.string().nullish(),
32
153
  });
33
154
  const ghStatusCheckRollup = z.array(ghCheckRunSchema).nullish();
155
+ // The list tier no longer requests `statusCheckRollup` (#664) — it is hydrated lazily per
156
+ // on-screen PR row (`fetchGithubChecks`). `checks` is set to `null` on list rows; the schema
157
+ // keeps no rollup field because the list call never asks for it.
34
158
  const ghPrSchema = ghIssueSchema.extend({
35
159
  isDraft: z.boolean().default(false),
36
160
  additions: z.number().default(0),
37
161
  deletions: z.number().default(0),
38
- statusCheckRollup: ghStatusCheckRollup,
39
162
  });
40
163
  const ghPrViewSchema = z.object({
41
164
  number: z.number(),
@@ -44,6 +167,39 @@ const ghPrViewSchema = z.object({
44
167
  isDraft: z.boolean().default(false),
45
168
  statusCheckRollup: ghStatusCheckRollup,
46
169
  });
170
+ const mergeCheckSchema = z.object({
171
+ name: z.string().default('Check'),
172
+ state: z.string().nullish(),
173
+ status: z.string().nullish(),
174
+ conclusion: z.string().nullish(),
175
+ detailsUrl: z.string().nullish(),
176
+ });
177
+ const mergePrSchema = z.object({
178
+ number: z.number(),
179
+ title: z.string(),
180
+ url: z.string(),
181
+ state: z.string(),
182
+ isDraft: z.boolean().default(false),
183
+ headRefName: z.string(),
184
+ baseRefName: z.string(),
185
+ headRefOid: z.string().regex(/^[0-9a-f]{40}$/),
186
+ mergeable: z.string().nullish(),
187
+ mergeStateStatus: z.string().nullish(),
188
+ reviewDecision: z.string().nullish(),
189
+ statusCheckRollup: z.array(mergeCheckSchema).nullish(),
190
+ });
191
+ const repoMergePolicySchema = z.object({
192
+ allow_merge_commit: z.boolean().default(false),
193
+ allow_squash_merge: z.boolean().default(false),
194
+ allow_rebase_merge: z.boolean().default(false),
195
+ merge_commit_title: z.string().nullish(),
196
+ squash_merge_commit_title: z.string().nullish(),
197
+ });
198
+ const ghMergeResultSchema = z.object({
199
+ merged: z.boolean(),
200
+ message: z.string().nullish(),
201
+ sha: z.string().nullish(),
202
+ });
47
203
  /** Exported for unit tests (#400) — collapses a zod-validated `statusCheckRollup` array down to
48
204
  * the single enum the GitHub tab (list rows + detail badge) renders. */
49
205
  export function rollupToChecks(rollup) {
@@ -147,9 +303,11 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
147
303
  }
148
304
  try {
149
305
  // No `comments` field — `gh … --json comments` ships full comment bodies.
150
- // Big fetches (the GUI's follow-up "give me everything" shot) get a
151
- // longer wall clock statusCheckRollup on hundreds of PRs is slow.
152
- const timeout = capped > 100 ? 60_000 : 15_000;
306
+ // No `statusCheckRollup` either (#664): the CI rollup for every open PR was the
307
+ // dominant costit forced the 60 s budget below — and is now hydrated lazily per
308
+ // on-screen PR row via `fetchGithubChecks`. A big list still gets a little more wall
309
+ // clock than the default 30, but nothing like the old rollup walk.
310
+ const timeout = capped > 100 ? 30_000 : 15_000;
153
311
  const fields = 'number,title,author,createdAt,labels,body,url';
154
312
  // The repo handle first (cheap) so the counts GraphQL query — which needs owner/name —
155
313
  // can run parallel to the two expensive list calls below.
@@ -168,7 +326,7 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
168
326
  const countsMaxPages = Math.min(GH_COUNTS_MAX_PAGES, Math.max(1, Math.ceil(capped / 100)));
169
327
  const [issuesOut, prsOut, counts] = await Promise.all([
170
328
  gh(repoRoot, ['issue', 'list', '--limit', String(capped), '--json', fields], timeout),
171
- gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions,statusCheckRollup`], timeout),
329
+ gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions`], timeout),
172
330
  // Real comment counts (#499). Degrades to empty maps on its own — a failure here leaves
173
331
  // every count at 0, never fails the tab. Skipped entirely if the handle isn't parseable.
174
332
  ownerName
@@ -210,7 +368,9 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
210
368
  isDraft: p.isDraft,
211
369
  additions: p.additions,
212
370
  deletions: p.deletions,
213
- checks: rollupToChecks(p.statusCheckRollup),
371
+ // Hydrated lazily by `fetchGithubChecks` for on-screen rows (#664) — the list no
372
+ // longer pays for the CI rollup of every open PR.
373
+ checks: null,
214
374
  };
215
375
  });
216
376
  const data = {
@@ -688,6 +848,138 @@ export async function fetchCommitChecks(runGraphql, owner, name, shas, chunkSize
688
848
  }
689
849
  return out;
690
850
  }
851
+ /** PR numbers per checks query. Aliases resolve independently (a failed chunk costs only its own
852
+ * glyphs); bounded so an unbounded number list can't blow the query size limit. Also the route's
853
+ * hard cap on how many PRs one request may ask about. */
854
+ export const GH_CHECKS_MAX = 100;
855
+ /** One aliased `pullRequest(number:)` per PR; the rolled-up CI state lives on the head commit. */
856
+ function prChecksQuery(numbers) {
857
+ const aliases = numbers
858
+ .map((n, i) => ` p${i}: pullRequest(number: ${n}) { commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } }`)
859
+ .join('\n');
860
+ return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
861
+ }
862
+ const ghPrChecksSchema = z.record(z.string(), z
863
+ .object({
864
+ commits: z.object({
865
+ nodes: z.array(z.object({
866
+ commit: z.object({
867
+ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
868
+ }),
869
+ })),
870
+ }),
871
+ })
872
+ .nullish());
873
+ /**
874
+ * Rolled-up CI state per PR number, as a `number → glyph` map.
875
+ *
876
+ * Batched and aliased so a 100-PR window costs one subprocess, not a hundred. Each alias resolves
877
+ * independently and an unknown number comes back `null` (alias resolved null → left absent), so
878
+ * partial results degrade cleanly. Degrades to an empty map on any failure — exactly as
879
+ * `fetchCommitChecks` does. Exported for tests; `runGraphql` is injected so this is testable
880
+ * without shelling out.
881
+ */
882
+ export async function fetchPrChecks(runGraphql, owner, name, numbers, chunkSize = GH_CHECKS_MAX) {
883
+ const out = {};
884
+ if (numbers.length === 0)
885
+ return out;
886
+ for (let i = 0; i < numbers.length; i += chunkSize) {
887
+ const chunk = numbers.slice(i, i + chunkSize);
888
+ try {
889
+ const raw = JSON.parse(await runGraphql(prChecksQuery(chunk), { owner, name }));
890
+ const repository = ghPrChecksSchema.parse(raw?.data?.repository ?? {});
891
+ chunk.forEach((number, index) => {
892
+ const node = repository[`p${index}`];
893
+ if (!node)
894
+ return; // unknown PR → alias resolved null; leave the glyph absent
895
+ const rollup = node.commits.nodes[0]?.commit.statusCheckRollup;
896
+ // Adapt the single rollup state into the array shape `rollupToChecks` expects, reusing the
897
+ // FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
898
+ out[number] = rollup
899
+ ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null
900
+ : null; // no CI configured — distinct from absent
901
+ });
902
+ }
903
+ catch {
904
+ // A failed chunk costs only its own glyphs; the rest still resolve.
905
+ }
906
+ }
907
+ return out;
908
+ }
909
+ // Per-PR checks cache: keyed `repoRoot␀number`, same 60 s TTL as the list cache but BOUNDED. Lets
910
+ // a repeated visible-window hydration within a minute serve cached glyphs instead of re-querying.
911
+ const checksCache = new Map();
912
+ const CHECKS_CACHE_MAX = 500;
913
+ /** Test hook — the per-PR checks cache would otherwise leak state across cases in one process. */
914
+ export function __clearChecksCacheForTests() {
915
+ checksCache.clear();
916
+ }
917
+ /**
918
+ * Lazy checks glyphs for the given PR numbers (route-facing). Resolves the repo handle once,
919
+ * serves fresh cache entries, queries only the misses, and degrades to `{ available: false,
920
+ * reason }` when `gh` or the handle is unavailable — never a throw, never a 5xx (plan rule 7).
921
+ * Numbers are de-duplicated, validated, and capped at `GH_CHECKS_MAX`.
922
+ */
923
+ export async function fetchGithubChecks(repoRoot, numbers) {
924
+ if (process.env.CEZ_DRY_RUN === '1')
925
+ return mockGithubChecks(numbers);
926
+ const wanted = [...new Set(numbers)].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_CHECKS_MAX);
927
+ const checks = {};
928
+ const misses = [];
929
+ const now = Date.now();
930
+ for (const n of wanted) {
931
+ const hit = checksCache.get(`${repoRoot}\0${n}`);
932
+ if (hit && now - hit.at < CACHE_MS)
933
+ checks[n] = hit.glyph;
934
+ else
935
+ misses.push(n);
936
+ }
937
+ if (misses.length === 0)
938
+ return { available: true, checks };
939
+ try {
940
+ const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
941
+ const ownerName = parseOwnerName(repoOut);
942
+ if (!ownerName)
943
+ return { available: false, reason: 'repository handle unavailable' };
944
+ const runGraphql = (query, variables) => {
945
+ const args = ['api', 'graphql', '-f', `query=${query}`];
946
+ for (const [key, value] of Object.entries(variables))
947
+ args.push('-f', `${key}=${value}`);
948
+ return gh(repoRoot, args);
949
+ };
950
+ const fetched = await fetchPrChecks(runGraphql, ownerName.owner, ownerName.name, misses);
951
+ for (const n of misses) {
952
+ // Absent (unknown/closed PR) caches as `null` so a nonexistent number isn't re-queried.
953
+ const glyph = n in fetched ? fetched[n] : null;
954
+ checks[n] = glyph;
955
+ checksCache.set(`${repoRoot}\0${n}`, { at: now, glyph });
956
+ }
957
+ while (checksCache.size > CHECKS_CACHE_MAX) {
958
+ const oldest = checksCache.keys().next().value;
959
+ if (oldest === undefined)
960
+ break;
961
+ checksCache.delete(oldest);
962
+ }
963
+ return { available: true, checks };
964
+ }
965
+ catch (err) {
966
+ const message = err instanceof Error ? err.message : String(err);
967
+ return {
968
+ available: false,
969
+ reason: /ENOENT/.test(message)
970
+ ? 'gh CLI not found — install it and run `gh auth login`'
971
+ : firstLine(message),
972
+ };
973
+ }
974
+ }
975
+ /** CEZ_DRY_RUN=1 — glyphs straight from the mock catalog so the offline demo shows checks. */
976
+ function mockGithubChecks(numbers) {
977
+ const byNumber = new Map(mockGithub().prs.map((p) => [p.number, (p.checks ?? null)]));
978
+ const checks = {};
979
+ for (const n of numbers)
980
+ checks[n] = byNumber.get(n) ?? null;
981
+ return { available: true, checks };
982
+ }
691
983
  /**
692
984
  * The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
693
985
  * filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
@@ -1082,6 +1374,235 @@ export function detectGithubCached(repoRoot) {
1082
1374
  }
1083
1375
  return cached; // last-known value while revalidating; null only until the first probe warms
1084
1376
  }
1377
+ const mergeStateCache = new Map();
1378
+ const mergeInflight = new Set();
1379
+ const MERGE_CACHE_MS = 15_000;
1380
+ function mergeCheckState(check) {
1381
+ const value = (check.conclusion || check.state || check.status || '').toUpperCase();
1382
+ if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(value))
1383
+ return 'passing';
1384
+ if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'CANCELLED'].includes(value))
1385
+ return 'failing';
1386
+ if (['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', 'WAITING', 'REQUESTED'].includes(value))
1387
+ return 'pending';
1388
+ return 'unknown';
1389
+ }
1390
+ export function normalizeMergeState(raw, policyRaw, requirements = {
1391
+ readable: false,
1392
+ requiredChecks: [],
1393
+ }) {
1394
+ const pr = mergePrSchema.parse(raw);
1395
+ const policy = repoMergePolicySchema.parse(policyRaw);
1396
+ const state = pr.state.toUpperCase() === 'MERGED' ? 'merged' : pr.state.toUpperCase() === 'CLOSED' ? 'closed' : 'open';
1397
+ const mergeable = pr.mergeable?.toUpperCase() === 'MERGEABLE'
1398
+ ? 'mergeable'
1399
+ : pr.mergeable?.toUpperCase() === 'CONFLICTING'
1400
+ ? 'conflicting'
1401
+ : 'unknown';
1402
+ const reviewDecision = pr.reviewDecision?.toUpperCase() === 'APPROVED'
1403
+ ? 'approved'
1404
+ : pr.reviewDecision?.toUpperCase() === 'CHANGES_REQUESTED'
1405
+ ? 'changes-requested'
1406
+ : pr.reviewDecision?.toUpperCase() === 'REVIEW_REQUIRED'
1407
+ ? 'review-required'
1408
+ : 'unknown';
1409
+ const checks = (pr.statusCheckRollup ?? []).map((check) => ({
1410
+ name: check.name,
1411
+ state: mergeCheckState(check),
1412
+ required: requirements.readable ? requirements.requiredChecks.includes(check.name) : null,
1413
+ ...(check.detailsUrl?.startsWith('https://') || check.detailsUrl?.startsWith('http://')
1414
+ ? { url: check.detailsUrl }
1415
+ : {}),
1416
+ }));
1417
+ const methods = [];
1418
+ if (policy.allow_squash_merge)
1419
+ methods.push('squash');
1420
+ if (policy.allow_merge_commit)
1421
+ methods.push('merge');
1422
+ if (policy.allow_rebase_merge)
1423
+ methods.push('rebase');
1424
+ const defaultMethod = policy.squash_merge_commit_title && methods.includes('squash')
1425
+ ? 'squash'
1426
+ : policy.merge_commit_title && methods.includes('merge')
1427
+ ? 'merge'
1428
+ : methods[0] ?? null;
1429
+ const blockers = [];
1430
+ let eligibility = 'ready';
1431
+ if (state !== 'open') {
1432
+ eligibility = 'terminal';
1433
+ blockers.push({ code: 'terminal', message: state === 'merged' ? 'This pull request is merged.' : 'This pull request is closed.' });
1434
+ }
1435
+ else if (pr.isDraft) {
1436
+ eligibility = 'blocked';
1437
+ blockers.push({ code: 'draft', message: 'Mark the pull request ready for review before merging.' });
1438
+ }
1439
+ else if (mergeable === 'conflicting') {
1440
+ eligibility = 'blocked';
1441
+ blockers.push({ code: 'conflicts', message: 'Conflicts must be resolved before merging.' });
1442
+ }
1443
+ else if (checks.some((check) => check.state === 'failing')) {
1444
+ eligibility = 'blocked';
1445
+ blockers.push({ code: 'checks-failing', message: 'One or more checks are failing.' });
1446
+ }
1447
+ else if (reviewDecision === 'changes-requested' || reviewDecision === 'review-required') {
1448
+ eligibility = 'blocked';
1449
+ blockers.push({ code: 'reviews', message: reviewDecision === 'changes-requested' ? 'Changes were requested.' : 'A required review is missing.' });
1450
+ }
1451
+ else if (reviewDecision === 'unknown' || !requirements.readable) {
1452
+ eligibility = 'unknown';
1453
+ blockers.push({
1454
+ code: 'rules-unknown',
1455
+ message: 'GitHub could not confirm review and branch-protection requirements.',
1456
+ });
1457
+ }
1458
+ else if (checks.some((check) => check.state === 'pending') || pr.mergeStateStatus?.toUpperCase() === 'UNSTABLE') {
1459
+ eligibility = 'pending';
1460
+ blockers.push({ code: 'pending', message: 'Checks or GitHub mergeability are still pending.' });
1461
+ }
1462
+ else if (mergeable !== 'mergeable' ||
1463
+ !['CLEAN', 'HAS_HOOKS'].includes(pr.mergeStateStatus?.toUpperCase() ?? '') ||
1464
+ methods.length === 0) {
1465
+ eligibility = 'unknown';
1466
+ blockers.push({ code: 'unknown', message: 'GitHub could not confirm every merge requirement.' });
1467
+ }
1468
+ const canMerge = eligibility === 'ready';
1469
+ const canOverride = !canMerge &&
1470
+ state === 'open' &&
1471
+ !pr.isDraft &&
1472
+ mergeable !== 'conflicting' &&
1473
+ methods.length > 0;
1474
+ return {
1475
+ number: pr.number,
1476
+ title: pr.title,
1477
+ url: pr.url,
1478
+ state,
1479
+ isDraft: pr.isDraft,
1480
+ headRef: pr.headRefName,
1481
+ baseRef: pr.baseRefName,
1482
+ headSha: pr.headRefOid,
1483
+ mergeable,
1484
+ reviewDecision,
1485
+ checks,
1486
+ methods,
1487
+ defaultMethod,
1488
+ eligibility,
1489
+ blockers,
1490
+ canMerge,
1491
+ canOverride,
1492
+ };
1493
+ }
1494
+ async function fetchPrMergeState(repoRoot, repoRef, number, refresh = false) {
1495
+ if (process.env.CEZ_DRY_RUN === '1') {
1496
+ return {
1497
+ available: true,
1498
+ mergeState: normalizeMergeState({
1499
+ number,
1500
+ title: 'Dry-run pull request',
1501
+ url: `https://github.com/mock/repo/pull/${number}`,
1502
+ state: 'OPEN',
1503
+ isDraft: false,
1504
+ headRefName: 'feat/dry-run',
1505
+ baseRefName: 'main',
1506
+ headRefOid: '0123456789abcdef0123456789abcdef01234567',
1507
+ mergeable: 'MERGEABLE',
1508
+ mergeStateStatus: 'CLEAN',
1509
+ reviewDecision: 'APPROVED',
1510
+ statusCheckRollup: [{ name: 'test', conclusion: 'SUCCESS', detailsUrl: 'https://github.com/mock/repo/actions' }],
1511
+ }, { allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: true }, { readable: true, requiredChecks: ['test'] }),
1512
+ };
1513
+ }
1514
+ if (!repoRef)
1515
+ return { available: false, reason: 'GitHub remote could not be resolved' };
1516
+ const key = `${repoRoot}:${number}`;
1517
+ const hit = mergeStateCache.get(key);
1518
+ if (!refresh && hit && Date.now() - hit.at < MERGE_CACHE_MS)
1519
+ return hit.value;
1520
+ try {
1521
+ const prOut = await gh(repoRoot, [
1522
+ 'pr', 'view', String(number), '--json',
1523
+ 'number,title,url,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup',
1524
+ ]);
1525
+ const parsedPr = mergePrSchema.parse(JSON.parse(prOut));
1526
+ const [policyOut, requiredChecks] = await Promise.all([
1527
+ gh(repoRoot, ['api', `repos/${repoRef.owner}/${repoRef.repo}`]),
1528
+ gh(repoRoot, [
1529
+ 'api',
1530
+ `repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(parsedPr.baseRefName)}/protection/required_status_checks`,
1531
+ '--jq',
1532
+ '[.contexts[]?, .checks[]?.context] | unique',
1533
+ ])
1534
+ .then((output) => ({ readable: true, requiredChecks: z.array(z.string()).parse(JSON.parse(output)) }))
1535
+ .catch(() => ({ readable: false, requiredChecks: [] })),
1536
+ ]);
1537
+ const value = {
1538
+ available: true,
1539
+ mergeState: normalizeMergeState(parsedPr, JSON.parse(policyOut), requiredChecks),
1540
+ };
1541
+ mergeStateCache.set(key, { at: Date.now(), value });
1542
+ return value;
1543
+ }
1544
+ catch (error) {
1545
+ return { available: false, reason: firstLine(error instanceof Error ? error.message : String(error)) };
1546
+ }
1547
+ }
1548
+ export function evictGithubProjectCaches(repoRoot) {
1549
+ listCache.delete(repoRoot);
1550
+ mergeStateCache.forEach((_value, key) => {
1551
+ if (key.startsWith(`${repoRoot}:`))
1552
+ mergeStateCache.delete(key);
1553
+ });
1554
+ commentsCache.forEach((_value, key) => {
1555
+ if (key.startsWith(`${repoRoot}:`))
1556
+ commentsCache.delete(key);
1557
+ });
1558
+ }
1559
+ async function mergePullRequest(repoRoot, repoRef, number, input) {
1560
+ const key = `${repoRoot}:${number}`;
1561
+ if (mergeInflight.has(key))
1562
+ return { merged: false, status: 409, error: 'A merge is already in progress.', code: 'concurrent' };
1563
+ mergeInflight.add(key);
1564
+ try {
1565
+ const fresh = await fetchPrMergeState(repoRoot, repoRef, number, true);
1566
+ if (!fresh.available)
1567
+ return { merged: false, status: 502, error: fresh.reason };
1568
+ const current = fresh.mergeState;
1569
+ if (current.headSha !== input.expectedHeadSha) {
1570
+ return { merged: false, status: 409, error: 'The pull request head changed. Review the new commits before merging.', code: 'stale-head', current };
1571
+ }
1572
+ if (!current.methods.includes(input.method)) {
1573
+ return { merged: false, status: 409, error: 'That merge method is no longer enabled.', code: 'disabled-method', current };
1574
+ }
1575
+ if (!mergePreflightAllowed(current, input.overrideRules)) {
1576
+ return { merged: false, status: 409, error: current.blockers[0]?.message ?? 'The pull request is not eligible to merge.', code: current.eligibility, current };
1577
+ }
1578
+ if (process.env.CEZ_DRY_RUN === '1') {
1579
+ evictGithubProjectCaches(repoRoot);
1580
+ return { merged: true, number, url: current.url, method: input.method, mergeCommitSha: 'abcdef0123456789abcdef0123456789abcdef01' };
1581
+ }
1582
+ if (!repoRef)
1583
+ return { merged: false, status: 404, error: 'GitHub repository not found.' };
1584
+ const out = await gh(repoRoot, [
1585
+ 'api', '--method', 'PUT', `repos/${repoRef.owner}/${repoRef.repo}/pulls/${number}/merge`,
1586
+ '-f', `merge_method=${input.method}`, '-f', `sha=${input.expectedHeadSha}`,
1587
+ ]);
1588
+ const result = ghMergeResultSchema.parse(JSON.parse(out));
1589
+ if (!result.merged)
1590
+ return { merged: false, status: 409, error: result.message ?? 'GitHub refused the merge.', code: 'github-blocked', current };
1591
+ evictGithubProjectCaches(repoRoot);
1592
+ return { merged: true, number, url: current.url, method: input.method, ...(result.sha ? { mergeCommitSha: result.sha } : {}) };
1593
+ }
1594
+ catch (error) {
1595
+ const message = firstLine(error instanceof Error ? error.message : String(error));
1596
+ const status = /403|permission|forbidden/i.test(message) ? 403 : /404|not found/i.test(message) ? 404 : 502;
1597
+ return { merged: false, status, error: status === 403 ? 'GitHub permission denied.' : status === 404 ? 'Pull request or repository not found.' : 'GitHub could not complete the merge.' };
1598
+ }
1599
+ finally {
1600
+ mergeInflight.delete(key);
1601
+ }
1602
+ }
1603
+ export function mergePreflightAllowed(current, overrideRules = false) {
1604
+ return current.canMerge || (overrideRules && current.canOverride);
1605
+ }
1085
1606
  const GH_PR_STATES = {
1086
1607
  MERGED: 'merged',
1087
1608
  CLOSED: 'closed',
@@ -1093,6 +1614,7 @@ export function createGithubDriver(repoRoot, repoRef) {
1093
1614
  detectCached: () => detectGithubCached(repoRoot),
1094
1615
  listIssues: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).issues,
1095
1616
  listPRs: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).prs,
1617
+ prDiff: (number, opts) => fetchGithubPrDiff(repoRoot, number, opts?.refresh),
1096
1618
  createPR: (input) => createDraftPr(input),
1097
1619
  // Null covers everything from "no PR yet" to "gh missing" — the callers
1098
1620
  // (Create PR → View PR flip) treat all of it as "nothing to link".
@@ -1114,6 +1636,8 @@ export function createGithubDriver(repoRoot, repoRef) {
1114
1636
  return null;
1115
1637
  }
1116
1638
  },
1639
+ prMergeState: (number, opts) => fetchPrMergeState(repoRoot, repoRef, number, opts?.refresh),
1640
+ mergePR: (number, input) => mergePullRequest(repoRoot, repoRef, number, input),
1117
1641
  viewUrl: (kind, ref) => {
1118
1642
  if (!repoRef)
1119
1643
  return null;