@wjarka/cezarion 0.10.0-pr30.54

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 (478) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +838 -0
  3. package/dist/agent-config/account-identity.d.ts +44 -0
  4. package/dist/agent-config/account-identity.js +128 -0
  5. package/dist/agent-config/account-identity.js.map +1 -0
  6. package/dist/agent-config/catalog.d.ts +72 -0
  7. package/dist/agent-config/catalog.js +223 -0
  8. package/dist/agent-config/catalog.js.map +1 -0
  9. package/dist/agent-config/files.d.ts +46 -0
  10. package/dist/agent-config/files.js +119 -0
  11. package/dist/agent-config/files.js.map +1 -0
  12. package/dist/agent-config/model-settings/claude.d.ts +2 -0
  13. package/dist/agent-config/model-settings/claude.js +11 -0
  14. package/dist/agent-config/model-settings/claude.js.map +1 -0
  15. package/dist/agent-config/model-settings/codex.d.ts +2 -0
  16. package/dist/agent-config/model-settings/codex.js +16 -0
  17. package/dist/agent-config/model-settings/codex.js.map +1 -0
  18. package/dist/agent-config/model-settings/opencode.d.ts +2 -0
  19. package/dist/agent-config/model-settings/opencode.js +8 -0
  20. package/dist/agent-config/model-settings/opencode.js.map +1 -0
  21. package/dist/agent-config/model-settings/pi.d.ts +12 -0
  22. package/dist/agent-config/model-settings/pi.js +18 -0
  23. package/dist/agent-config/model-settings/pi.js.map +1 -0
  24. package/dist/agent-config/model-settings/shared.d.ts +9 -0
  25. package/dist/agent-config/model-settings/shared.js +93 -0
  26. package/dist/agent-config/model-settings/shared.js.map +1 -0
  27. package/dist/agent-config/model-settings/types.d.ts +9 -0
  28. package/dist/agent-config/model-settings/types.js +2 -0
  29. package/dist/agent-config/model-settings/types.js.map +1 -0
  30. package/dist/agent-config/models.d.ts +9 -0
  31. package/dist/agent-config/models.js +32 -0
  32. package/dist/agent-config/models.js.map +1 -0
  33. package/dist/agent-config/seed.d.ts +7 -0
  34. package/dist/agent-config/seed.js +90 -0
  35. package/dist/agent-config/seed.js.map +1 -0
  36. package/dist/agent-config/service.d.ts +44 -0
  37. package/dist/agent-config/service.js +71 -0
  38. package/dist/agent-config/service.js.map +1 -0
  39. package/dist/agent-config/validate.d.ts +21 -0
  40. package/dist/agent-config/validate.js +76 -0
  41. package/dist/agent-config/validate.js.map +1 -0
  42. package/dist/automations/coordinator.d.ts +26 -0
  43. package/dist/automations/coordinator.js +66 -0
  44. package/dist/automations/coordinator.js.map +1 -0
  45. package/dist/automations/github-poller.d.ts +74 -0
  46. package/dist/automations/github-poller.js +234 -0
  47. package/dist/automations/github-poller.js.map +1 -0
  48. package/dist/automations/scheduler.d.ts +42 -0
  49. package/dist/automations/scheduler.js +192 -0
  50. package/dist/automations/scheduler.js.map +1 -0
  51. package/dist/automations/store.d.ts +65 -0
  52. package/dist/automations/store.js +298 -0
  53. package/dist/automations/store.js.map +1 -0
  54. package/dist/automations/task-template.d.ts +18 -0
  55. package/dist/automations/task-template.js +95 -0
  56. package/dist/automations/task-template.js.map +1 -0
  57. package/dist/automations/types.d.ts +255 -0
  58. package/dist/automations/types.js +159 -0
  59. package/dist/automations/types.js.map +1 -0
  60. package/dist/config.d.ts +77 -0
  61. package/dist/config.js +242 -0
  62. package/dist/config.js.map +1 -0
  63. package/dist/contract/agent-config.d.ts +153 -0
  64. package/dist/contract/agent-profiles.d.ts +347 -0
  65. package/dist/contract/automations.d.ts +935 -0
  66. package/dist/contract/events.d.ts +104 -0
  67. package/dist/contract/github.d.ts +556 -0
  68. package/dist/contract/health.d.ts +102 -0
  69. package/dist/contract/index.d.ts +16 -0
  70. package/dist/contract/index.js +1895 -0
  71. package/dist/contract/projects.d.ts +210 -0
  72. package/dist/contract/repo.d.ts +234 -0
  73. package/dist/contract/runs.d.ts +1473 -0
  74. package/dist/contract/skills.d.ts +232 -0
  75. package/dist/contract/workflows.d.ts +218 -0
  76. package/dist/contract/workspace.d.ts +579 -0
  77. package/dist/core/agent-env.d.ts +31 -0
  78. package/dist/core/agent-env.js +371 -0
  79. package/dist/core/agent-env.js.map +1 -0
  80. package/dist/core/agent-model-policy.d.ts +10 -0
  81. package/dist/core/agent-model-policy.js +36 -0
  82. package/dist/core/agent-model-policy.js.map +1 -0
  83. package/dist/core/agent-profiles.d.ts +62 -0
  84. package/dist/core/agent-profiles.js +90 -0
  85. package/dist/core/agent-profiles.js.map +1 -0
  86. package/dist/core/agent-runner.d.ts +211 -0
  87. package/dist/core/agent-runner.js +68 -0
  88. package/dist/core/agent-runner.js.map +1 -0
  89. package/dist/core/ask.d.ts +105 -0
  90. package/dist/core/ask.js +187 -0
  91. package/dist/core/ask.js.map +1 -0
  92. package/dist/core/backend-detect.d.ts +16 -0
  93. package/dist/core/backend-detect.js +155 -0
  94. package/dist/core/backend-detect.js.map +1 -0
  95. package/dist/core/claude-cli-runner.d.ts +59 -0
  96. package/dist/core/claude-cli-runner.js +483 -0
  97. package/dist/core/claude-cli-runner.js.map +1 -0
  98. package/dist/core/claude-ui-mapper.d.ts +80 -0
  99. package/dist/core/claude-ui-mapper.js +598 -0
  100. package/dist/core/claude-ui-mapper.js.map +1 -0
  101. package/dist/core/codex-app-server-runner.d.ts +41 -0
  102. package/dist/core/codex-app-server-runner.js +584 -0
  103. package/dist/core/codex-app-server-runner.js.map +1 -0
  104. package/dist/core/codex-app-server-transport.d.ts +35 -0
  105. package/dist/core/codex-app-server-transport.js +150 -0
  106. package/dist/core/codex-app-server-transport.js.map +1 -0
  107. package/dist/core/codex-model-catalog.d.ts +10 -0
  108. package/dist/core/codex-model-catalog.js +110 -0
  109. package/dist/core/codex-model-catalog.js.map +1 -0
  110. package/dist/core/codex-ui-mapper.d.ts +112 -0
  111. package/dist/core/codex-ui-mapper.js +766 -0
  112. package/dist/core/codex-ui-mapper.js.map +1 -0
  113. package/dist/core/model-identity.d.ts +125 -0
  114. package/dist/core/model-identity.js +132 -0
  115. package/dist/core/model-identity.js.map +1 -0
  116. package/dist/core/model-presets.d.ts +21 -0
  117. package/dist/core/model-presets.js +81 -0
  118. package/dist/core/model-presets.js.map +1 -0
  119. package/dist/core/ndjson.d.ts +6 -0
  120. package/dist/core/ndjson.js +23 -0
  121. package/dist/core/ndjson.js.map +1 -0
  122. package/dist/core/opencode-model-catalog.d.ts +32 -0
  123. package/dist/core/opencode-model-catalog.js +153 -0
  124. package/dist/core/opencode-model-catalog.js.map +1 -0
  125. package/dist/core/opencode-server-runner.d.ts +32 -0
  126. package/dist/core/opencode-server-runner.js +882 -0
  127. package/dist/core/opencode-server-runner.js.map +1 -0
  128. package/dist/core/opencode-ui-mapper.d.ts +137 -0
  129. package/dist/core/opencode-ui-mapper.js +800 -0
  130. package/dist/core/opencode-ui-mapper.js.map +1 -0
  131. package/dist/core/pi-model-catalog.d.ts +31 -0
  132. package/dist/core/pi-model-catalog.js +152 -0
  133. package/dist/core/pi-model-catalog.js.map +1 -0
  134. package/dist/core/pi-runner.d.ts +24 -0
  135. package/dist/core/pi-runner.js +403 -0
  136. package/dist/core/pi-runner.js.map +1 -0
  137. package/dist/core/pi-ui-mapper.d.ts +36 -0
  138. package/dist/core/pi-ui-mapper.js +304 -0
  139. package/dist/core/pi-ui-mapper.js.map +1 -0
  140. package/dist/core/process-usage.d.ts +66 -0
  141. package/dist/core/process-usage.js +193 -0
  142. package/dist/core/process-usage.js.map +1 -0
  143. package/dist/core/provider-auth.d.ts +154 -0
  144. package/dist/core/provider-auth.js +509 -0
  145. package/dist/core/provider-auth.js.map +1 -0
  146. package/dist/core/provider-availability.d.ts +3 -0
  147. package/dist/core/provider-availability.js +14 -0
  148. package/dist/core/provider-availability.js.map +1 -0
  149. package/dist/core/runner-factory.d.ts +8 -0
  150. package/dist/core/runner-factory.js +25 -0
  151. package/dist/core/runner-factory.js.map +1 -0
  152. package/dist/core/runner-model-catalog.d.ts +29 -0
  153. package/dist/core/runner-model-catalog.js +78 -0
  154. package/dist/core/runner-model-catalog.js.map +1 -0
  155. package/dist/core/secret-redaction.d.ts +37 -0
  156. package/dist/core/secret-redaction.js +115 -0
  157. package/dist/core/secret-redaction.js.map +1 -0
  158. package/dist/core/shell-env.d.ts +28 -0
  159. package/dist/core/shell-env.js +56 -0
  160. package/dist/core/shell-env.js.map +1 -0
  161. package/dist/core/tool-display.d.ts +31 -0
  162. package/dist/core/tool-display.js +179 -0
  163. package/dist/core/tool-display.js.map +1 -0
  164. package/dist/core/ui-events.d.ts +335 -0
  165. package/dist/core/ui-events.js +25 -0
  166. package/dist/core/ui-events.js.map +1 -0
  167. package/dist/core/usage-limit.d.ts +42 -0
  168. package/dist/core/usage-limit.js +209 -0
  169. package/dist/core/usage-limit.js.map +1 -0
  170. package/dist/core/usage.d.ts +11 -0
  171. package/dist/core/usage.js +15 -0
  172. package/dist/core/usage.js.map +1 -0
  173. package/dist/core/v1-text-coalescer.d.ts +36 -0
  174. package/dist/core/v1-text-coalescer.js +72 -0
  175. package/dist/core/v1-text-coalescer.js.map +1 -0
  176. package/dist/git-diff-base.d.ts +85 -0
  177. package/dist/git-diff-base.js +180 -0
  178. package/dist/git-diff-base.js.map +1 -0
  179. package/dist/git-refs.d.ts +11 -0
  180. package/dist/git-refs.js +14 -0
  181. package/dist/git-refs.js.map +1 -0
  182. package/dist/git-worktree.d.ts +161 -0
  183. package/dist/git-worktree.js +508 -0
  184. package/dist/git-worktree.js.map +1 -0
  185. package/dist/handoff.d.ts +62 -0
  186. package/dist/handoff.js +154 -0
  187. package/dist/handoff.js.map +1 -0
  188. package/dist/index.d.ts +2 -0
  189. package/dist/index.js +680 -0
  190. package/dist/index.js.map +1 -0
  191. package/dist/install-as-command.d.ts +40 -0
  192. package/dist/install-as-command.js +52 -0
  193. package/dist/install-as-command.js.map +1 -0
  194. package/dist/pack-check.d.ts +17 -0
  195. package/dist/pack-check.js +27 -0
  196. package/dist/pack-check.js.map +1 -0
  197. package/dist/paths.d.ts +140 -0
  198. package/dist/paths.js +192 -0
  199. package/dist/paths.js.map +1 -0
  200. package/dist/planner.d.ts +27 -0
  201. package/dist/planner.js +268 -0
  202. package/dist/planner.js.map +1 -0
  203. package/dist/release/manifests.d.ts +81 -0
  204. package/dist/release/manifests.js +74 -0
  205. package/dist/release/manifests.js.map +1 -0
  206. package/dist/release/snapshot.d.ts +67 -0
  207. package/dist/release/snapshot.js +97 -0
  208. package/dist/release/snapshot.js.map +1 -0
  209. package/dist/release/stable.d.ts +38 -0
  210. package/dist/release/stable.js +55 -0
  211. package/dist/release/stable.js.map +1 -0
  212. package/dist/runs/agent-tmpdir.d.ts +41 -0
  213. package/dist/runs/agent-tmpdir.js +183 -0
  214. package/dist/runs/agent-tmpdir.js.map +1 -0
  215. package/dist/runs/auto-name.d.ts +75 -0
  216. package/dist/runs/auto-name.js +179 -0
  217. package/dist/runs/auto-name.js.map +1 -0
  218. package/dist/runs/event-history.d.ts +50 -0
  219. package/dist/runs/event-history.js +613 -0
  220. package/dist/runs/event-history.js.map +1 -0
  221. package/dist/runs/retention.d.ts +60 -0
  222. package/dist/runs/retention.js +86 -0
  223. package/dist/runs/retention.js.map +1 -0
  224. package/dist/runs/review-gate.d.ts +18 -0
  225. package/dist/runs/review-gate.js +21 -0
  226. package/dist/runs/review-gate.js.map +1 -0
  227. package/dist/runs/run-index.d.ts +19 -0
  228. package/dist/runs/run-index.js +43 -0
  229. package/dist/runs/run-index.js.map +1 -0
  230. package/dist/runs/store.d.ts +446 -0
  231. package/dist/runs/store.js +1118 -0
  232. package/dist/runs/store.js.map +1 -0
  233. package/dist/runs/task-markers.d.ts +28 -0
  234. package/dist/runs/task-markers.js +74 -0
  235. package/dist/runs/task-markers.js.map +1 -0
  236. package/dist/runs/task-refs.d.ts +25 -0
  237. package/dist/runs/task-refs.js +74 -0
  238. package/dist/runs/task-refs.js.map +1 -0
  239. package/dist/runs/ui-event-sink.d.ts +81 -0
  240. package/dist/runs/ui-event-sink.js +210 -0
  241. package/dist/runs/ui-event-sink.js.map +1 -0
  242. package/dist/server/app-type.d.ts +21 -0
  243. package/dist/server/app-type.js +2 -0
  244. package/dist/server/app-type.js.map +1 -0
  245. package/dist/server/capabilities.d.ts +70 -0
  246. package/dist/server/capabilities.js +152 -0
  247. package/dist/server/capabilities.js.map +1 -0
  248. package/dist/server/checkout.d.ts +128 -0
  249. package/dist/server/checkout.js +296 -0
  250. package/dist/server/checkout.js.map +1 -0
  251. package/dist/server/forge/github.d.ts +513 -0
  252. package/dist/server/forge/github.js +2364 -0
  253. package/dist/server/forge/github.js.map +1 -0
  254. package/dist/server/forge/index.d.ts +40 -0
  255. package/dist/server/forge/index.js +72 -0
  256. package/dist/server/forge/index.js.map +1 -0
  257. package/dist/server/forge/types.d.ts +242 -0
  258. package/dist/server/forge/types.js +2 -0
  259. package/dist/server/forge/types.js.map +1 -0
  260. package/dist/server/fs-browse.d.ts +105 -0
  261. package/dist/server/fs-browse.js +182 -0
  262. package/dist/server/fs-browse.js.map +1 -0
  263. package/dist/server/git-changes.d.ts +213 -0
  264. package/dist/server/git-changes.js +555 -0
  265. package/dist/server/git-changes.js.map +1 -0
  266. package/dist/server/git.d.ts +28 -0
  267. package/dist/server/git.js +112 -0
  268. package/dist/server/git.js.map +1 -0
  269. package/dist/server/github.d.ts +9 -0
  270. package/dist/server/github.js +8 -0
  271. package/dist/server/github.js.map +1 -0
  272. package/dist/server/launch-key.d.ts +7 -0
  273. package/dist/server/launch-key.js +33 -0
  274. package/dist/server/launch-key.js.map +1 -0
  275. package/dist/server/open-in-app.d.ts +66 -0
  276. package/dist/server/open-in-app.js +211 -0
  277. package/dist/server/open-in-app.js.map +1 -0
  278. package/dist/server/open-in-terminal.d.ts +70 -0
  279. package/dist/server/open-in-terminal.js +179 -0
  280. package/dist/server/open-in-terminal.js.map +1 -0
  281. package/dist/server/pr.d.ts +7 -0
  282. package/dist/server/pr.js +7 -0
  283. package/dist/server/pr.js.map +1 -0
  284. package/dist/server/project-context.d.ts +120 -0
  285. package/dist/server/project-context.js +182 -0
  286. package/dist/server/project-context.js.map +1 -0
  287. package/dist/server/provider-action-gate.d.ts +8 -0
  288. package/dist/server/provider-action-gate.js +56 -0
  289. package/dist/server/provider-action-gate.js.map +1 -0
  290. package/dist/server/provider-auth-runtime.d.ts +21 -0
  291. package/dist/server/provider-auth-runtime.js +66 -0
  292. package/dist/server/provider-auth-runtime.js.map +1 -0
  293. package/dist/server/server.d.ts +13138 -0
  294. package/dist/server/server.js +5263 -0
  295. package/dist/server/server.js.map +1 -0
  296. package/dist/server/static-ui.d.ts +49 -0
  297. package/dist/server/static-ui.js +92 -0
  298. package/dist/server/static-ui.js.map +1 -0
  299. package/dist/server/validators.d.ts +97 -0
  300. package/dist/server/validators.js +86 -0
  301. package/dist/server/validators.js.map +1 -0
  302. package/dist/server/ws.d.ts +85 -0
  303. package/dist/server/ws.js +218 -0
  304. package/dist/server/ws.js.map +1 -0
  305. package/dist/server/wsl.d.ts +25 -0
  306. package/dist/server/wsl.js +92 -0
  307. package/dist/server/wsl.js.map +1 -0
  308. package/dist/server-install/engine.d.ts +50 -0
  309. package/dist/server-install/engine.js +324 -0
  310. package/dist/server-install/engine.js.map +1 -0
  311. package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
  312. package/dist/server-install/platforms/macosx-ngrok.js +345 -0
  313. package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
  314. package/dist/server-install/platforms/ubuntu-vps.d.ts +78 -0
  315. package/dist/server-install/platforms/ubuntu-vps.js +1016 -0
  316. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
  317. package/dist/server-install/state.d.ts +52 -0
  318. package/dist/server-install/state.js +182 -0
  319. package/dist/server-install/state.js.map +1 -0
  320. package/dist/server-install/steps.d.ts +109 -0
  321. package/dist/server-install/steps.js +334 -0
  322. package/dist/server-install/steps.js.map +1 -0
  323. package/dist/server-install/strategies.d.ts +4 -0
  324. package/dist/server-install/strategies.js +20 -0
  325. package/dist/server-install/strategies.js.map +1 -0
  326. package/dist/server-install/types.d.ts +287 -0
  327. package/dist/server-install/types.js +121 -0
  328. package/dist/server-install/types.js.map +1 -0
  329. package/dist/server-install/ui.d.ts +42 -0
  330. package/dist/server-install/ui.js +130 -0
  331. package/dist/server-install/ui.js.map +1 -0
  332. package/dist/skills-banner.d.ts +22 -0
  333. package/dist/skills-banner.js +41 -0
  334. package/dist/skills-banner.js.map +1 -0
  335. package/dist/skills-remote.d.ts +84 -0
  336. package/dist/skills-remote.js +468 -0
  337. package/dist/skills-remote.js.map +1 -0
  338. package/dist/skills-update.d.ts +88 -0
  339. package/dist/skills-update.js +392 -0
  340. package/dist/skills-update.js.map +1 -0
  341. package/dist/skills.d.ts +82 -0
  342. package/dist/skills.js +251 -0
  343. package/dist/skills.js.map +1 -0
  344. package/dist/todos.d.ts +48 -0
  345. package/dist/todos.js +201 -0
  346. package/dist/todos.js.map +1 -0
  347. package/dist/ui-state.d.ts +26 -0
  348. package/dist/ui-state.js +38 -0
  349. package/dist/ui-state.js.map +1 -0
  350. package/dist/update-check.d.ts +12 -0
  351. package/dist/update-check.js +41 -0
  352. package/dist/update-check.js.map +1 -0
  353. package/dist/workflows/load.d.ts +15 -0
  354. package/dist/workflows/load.js +56 -0
  355. package/dist/workflows/load.js.map +1 -0
  356. package/dist/workflows/run.d.ts +758 -0
  357. package/dist/workflows/run.js +3389 -0
  358. package/dist/workflows/run.js.map +1 -0
  359. package/dist/workflows/types.d.ts +153 -0
  360. package/dist/workflows/types.js +212 -0
  361. package/dist/workflows/types.js.map +1 -0
  362. package/dist/workspace/agent-accounts.d.ts +153 -0
  363. package/dist/workspace/agent-accounts.js +304 -0
  364. package/dist/workspace/agent-accounts.js.map +1 -0
  365. package/dist/workspace/agent-profiles.d.ts +78 -0
  366. package/dist/workspace/agent-profiles.js +115 -0
  367. package/dist/workspace/agent-profiles.js.map +1 -0
  368. package/dist/workspace/config.d.ts +169 -0
  369. package/dist/workspace/config.js +379 -0
  370. package/dist/workspace/config.js.map +1 -0
  371. package/dist/workspace/migrations.d.ts +45 -0
  372. package/dist/workspace/migrations.js +147 -0
  373. package/dist/workspace/migrations.js.map +1 -0
  374. package/dist/workspace/projects-cli.d.ts +25 -0
  375. package/dist/workspace/projects-cli.js +172 -0
  376. package/dist/workspace/projects-cli.js.map +1 -0
  377. package/dist/workspace/projects.d.ts +109 -0
  378. package/dist/workspace/projects.js +250 -0
  379. package/dist/workspace/projects.js.map +1 -0
  380. package/dist/workspace/semaphore.d.ts +179 -0
  381. package/dist/workspace/semaphore.js +195 -0
  382. package/dist/workspace/semaphore.js.map +1 -0
  383. package/dist/workspace/ui-state.d.ts +34 -0
  384. package/dist/workspace/ui-state.js +53 -0
  385. package/dist/workspace/ui-state.js.map +1 -0
  386. package/package.json +70 -0
  387. package/scripts/check-pack.mjs +46 -0
  388. package/scripts/inline-contract.mjs +112 -0
  389. package/scripts/install-as-command.mjs +120 -0
  390. package/scripts/mock-claude.mjs +520 -0
  391. package/scripts/mock-pi-rpc.mjs +83 -0
  392. package/scripts/sync-readme.mjs +20 -0
  393. package/scripts/test-process-usage.mjs +47 -0
  394. package/web/dist/assets/alert-dialog-B4Ofsgv7.js +1 -0
  395. package/web/dist/assets/arrow-down-Dy8dup7j.js +1 -0
  396. package/web/dist/assets/arrow-left-BJaucbhn.js +1 -0
  397. package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
  398. package/web/dist/assets/centered-state-duPW0phQ.js +43 -0
  399. package/web/dist/assets/chevron-right-Be4A5TCS.js +1 -0
  400. package/web/dist/assets/chunk-BO2N2NFS-DE6qKn3r.js +131 -0
  401. package/web/dist/assets/collapsible-DdcNBdSk.js +1 -0
  402. package/web/dist/assets/commit-list-CgvN9vec.js +1 -0
  403. package/web/dist/assets/compare-variants-CQA2nd65.js +1 -0
  404. package/web/dist/assets/core-BCsw8oQw.js +12 -0
  405. package/web/dist/assets/css-BsVw1vtW.js +1 -0
  406. package/web/dist/assets/diff-B6Agp5Hl.js +3 -0
  407. package/web/dist/assets/diff-stat-BoXuGkgq.js +1 -0
  408. package/web/dist/assets/diff-view-CD8XI-fj.js +6 -0
  409. package/web/dist/assets/diff-woXpYk--.js +1 -0
  410. package/web/dist/assets/dropdown-menu-B3SYw-k5.js +1 -0
  411. package/web/dist/assets/editable-title-B-hjp5wl.js +1 -0
  412. package/web/dist/assets/ellipsis-vertical-BYwzw0xl.js +1 -0
  413. package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
  414. package/web/dist/assets/file-BRj5eISx.js +1 -0
  415. package/web/dist/assets/folder-z4oT5981.js +1 -0
  416. package/web/dist/assets/git-toolbar-owSEkYld.js +1 -0
  417. package/web/dist/assets/github-Bz2VgRX3.js +1 -0
  418. package/web/dist/assets/go-rLFTqkRN.js +1 -0
  419. package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
  420. package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
  421. package/web/dist/assets/html-BY76lXLH.js +1 -0
  422. package/web/dist/assets/image-preview-h3WFluuy.js +1 -0
  423. package/web/dist/assets/index-BbcQNT92.css +2 -0
  424. package/web/dist/assets/index-EtQQQ6Kj.js +7 -0
  425. package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  426. package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  427. package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
  428. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  429. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  430. package/web/dist/assets/json-qhed-kSA.js +1 -0
  431. package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
  432. package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
  433. package/web/dist/assets/lib-BQXq3kEf.js +1 -0
  434. package/web/dist/assets/lib-BxQXEXDF.js +1 -0
  435. package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
  436. package/web/dist/assets/markdown-DqBKYPiB.js +2 -0
  437. package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
  438. package/web/dist/assets/new-task-form-BdJKRBJX.js +1 -0
  439. package/web/dist/assets/pill-BQfmfpYp.js +1 -0
  440. package/web/dist/assets/project-router-DxgFZY1l.js +1 -0
  441. package/web/dist/assets/prompt-templates-B6m16gq-.js +15 -0
  442. package/web/dist/assets/python-gzcpVVnB.js +1 -0
  443. package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
  444. package/web/dist/assets/refresh-cw-mhSpxf2a.js +1 -0
  445. package/web/dist/assets/repo-git-CUNRxR3_.js +1 -0
  446. package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  447. package/web/dist/assets/run-diff-DsmmDVfx.js +3 -0
  448. package/web/dist/assets/run-header-BkhrseLJ.js +1 -0
  449. package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
  450. package/web/dist/assets/search-x-BqDMB2g8.js +1 -0
  451. package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  452. package/web/dist/assets/skill-empty-hint-BWEvTggc.js +1 -0
  453. package/web/dist/assets/skills-9oR_oHse.js +1 -0
  454. package/web/dist/assets/skills-DwIxnnWO.js +1 -0
  455. package/web/dist/assets/sparkles-DIoTQpyx.js +1 -0
  456. package/web/dist/assets/sql-BsFa4tDR.js +1 -0
  457. package/web/dist/assets/square-terminal-Cc7CuQxc.js +1 -0
  458. package/web/dist/assets/tab-link-D1fQgpmO.js +1 -0
  459. package/web/dist/assets/task-changes-BKeLlDpK.js +1 -0
  460. package/web/dist/assets/task-commits-gcEFZIa5.js +1 -0
  461. package/web/dist/assets/task-files-BGAqoXTW.js +2 -0
  462. package/web/dist/assets/task-thread-DT8aUYTS.js +9 -0
  463. package/web/dist/assets/textarea-BOVbfy5C.js +1 -0
  464. package/web/dist/assets/thread-loading-CypW73KD.js +1 -0
  465. package/web/dist/assets/toml-CcmNWLt0.js +1 -0
  466. package/web/dist/assets/trash-2-CJq6LHWn.js +1 -0
  467. package/web/dist/assets/triangle-alert-CjDFvU27.js +1 -0
  468. package/web/dist/assets/tsx-udAQXfEw.js +1 -0
  469. package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
  470. package/web/dist/assets/upload-CPDZlJeK.js +1 -0
  471. package/web/dist/assets/use-desktop-5JsLmi0a.js +1 -0
  472. package/web/dist/assets/use-submit-shortcut-CHVFevty.js +1 -0
  473. package/web/dist/assets/utils-DiO6zyJd.js +64 -0
  474. package/web/dist/assets/workflows-rQDFzWRt.js +11 -0
  475. package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
  476. package/web/dist/assets/zoomable-image-COWqb11v.js +1 -0
  477. package/web/dist/index.html +80 -0
  478. package/web/dist/open-mercato.svg +11 -0
@@ -0,0 +1,2364 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { z } from 'zod';
4
+ import { REFERENCE_STATUS_MAX } from '../../contract/index.js';
5
+ import { autosaveCommit } from '../../git-worktree.js';
6
+ /**
7
+ * The GitHub forge driver — all `gh`-CLI logic in one place, moved here from
8
+ * `src/server/github.ts` (tab listing) and `src/server/pr.ts` (draft PRs)
9
+ * behind the `ForgeDriver` seam. Those modules remain as thin delegates.
10
+ * `/api/github`'s response shape is this driver's serialization and is
11
+ * protected by BACKWARD_COMPATIBILITY.md — additive changes only.
12
+ */
13
+ const exec = promisify(execFile);
14
+ export const GH_PR_DIFF_FILE_CAP = 300;
15
+ export const GH_PR_PATCH_CAP = 512 * 1024;
16
+ export const GH_PR_DIFF_JSON_CAP = 4 * 1024 * 1024;
17
+ const ghPrFileSchema = z.object({
18
+ filename: z.string().min(1),
19
+ previous_filename: z.string().optional(),
20
+ status: z.enum(['added', 'modified', 'removed', 'renamed', 'copied', 'changed']),
21
+ additions: z.number().int().nonnegative(),
22
+ deletions: z.number().int().nonnegative(),
23
+ patch: z.string().optional(),
24
+ });
25
+ const ghPrHeadSchema = z.object({ headRefOid: z.string().regex(/^[0-9a-f]{40}$/i) });
26
+ export class GithubPrNotFoundError extends Error {
27
+ }
28
+ const prDiffCache = new Map();
29
+ /** Fetch no more than the three GitHub pages represented by the public 300-file response cap. */
30
+ export async function fetchPrFilePages(runPage) {
31
+ const rows = [];
32
+ for (let page = 1; page <= GH_PR_DIFF_FILE_CAP / 100; page++) {
33
+ const next = z.array(z.unknown()).parse(JSON.parse(await runPage(page)));
34
+ rows.push(...next);
35
+ if (next.length < 100)
36
+ break;
37
+ }
38
+ return rows;
39
+ }
40
+ export async function fetchGithubPrDiff(repoRoot, number, refresh = false) {
41
+ if (process.env.CEZ_DRY_RUN === '1')
42
+ return mockGithubPrDiff(number);
43
+ try {
44
+ const head = ghPrHeadSchema.parse(JSON.parse(await gh(repoRoot, ['pr', 'view', String(number), '--json', 'headRefOid']))).headRefOid;
45
+ const key = `${repoRoot}\0${number}\0${head}`;
46
+ const hit = prDiffCache.get(key);
47
+ if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
48
+ return hit.data;
49
+ 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)));
50
+ const limited = rows.slice(0, GH_PR_DIFF_FILE_CAP);
51
+ // A full third page may have a successor. Without fetching a 301st file, conservatively call
52
+ // the response partial rather than claiming completeness we cannot prove.
53
+ let responseTruncated = rows.length >= GH_PR_DIFF_FILE_CAP;
54
+ const reasons = responseTruncated ? [`Only the first ${GH_PR_DIFF_FILE_CAP} files are shown.`] : [];
55
+ const files = limited.map((row) => {
56
+ let patch = row.patch;
57
+ let truncated = false;
58
+ let patchUnavailableReason;
59
+ if (patch !== undefined && Buffer.byteLength(patch, 'utf8') > GH_PR_PATCH_CAP) {
60
+ patch = undefined;
61
+ truncated = true;
62
+ patchUnavailableReason = 'too-large';
63
+ responseTruncated = true;
64
+ }
65
+ else if (patch === undefined) {
66
+ patchUnavailableReason = row.additions === 0 && row.deletions === 0 ? 'binary' : 'not-provided';
67
+ }
68
+ return {
69
+ path: row.filename,
70
+ ...(row.previous_filename ? { previousPath: row.previous_filename } : {}),
71
+ status: row.status,
72
+ additions: row.additions,
73
+ deletions: row.deletions,
74
+ ...(patch !== undefined ? { patch } : {}),
75
+ ...(patchUnavailableReason ? { patchUnavailableReason } : {}),
76
+ ...(truncated ? { truncated: true } : {}),
77
+ };
78
+ });
79
+ let kept = files;
80
+ while (kept.length > 0 &&
81
+ Buffer.byteLength(JSON.stringify({ available: true, number, headSha: head, files: kept }), 'utf8') >
82
+ GH_PR_DIFF_JSON_CAP) {
83
+ kept = kept.slice(0, -1);
84
+ responseTruncated = true;
85
+ }
86
+ if (kept.length < files.length)
87
+ reasons.push('The response size limit omitted some files.');
88
+ if (files.some((file) => file.truncated))
89
+ reasons.push('One or more patches exceeded the per-file limit.');
90
+ const data = {
91
+ available: true,
92
+ number,
93
+ headSha: head,
94
+ files: kept,
95
+ additions: rows.reduce((sum, row) => sum + row.additions, 0),
96
+ deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
97
+ truncated: responseTruncated,
98
+ ...(reasons.length ? { reason: reasons.join(' ') } : {}),
99
+ };
100
+ prDiffCache.set(key, { at: Date.now(), data });
101
+ while (prDiffCache.size > 50)
102
+ prDiffCache.delete(prDiffCache.keys().next().value);
103
+ return data;
104
+ }
105
+ catch (err) {
106
+ const message = err instanceof Error ? err.message : String(err);
107
+ if (/HTTP 404|Could not resolve to a PullRequest|no pull requests found/i.test(message)) {
108
+ throw new GithubPrNotFoundError(`Pull request #${number} was not found`);
109
+ }
110
+ return {
111
+ available: false,
112
+ reason: /ENOENT/.test(message)
113
+ ? 'gh CLI not found — install it and run `gh auth login`'
114
+ : firstLine(message),
115
+ };
116
+ }
117
+ }
118
+ function mockGithubPrDiff(number) {
119
+ return {
120
+ available: true,
121
+ number,
122
+ headSha: '0123456789abcdef0123456789abcdef01234567',
123
+ additions: 15,
124
+ deletions: 4,
125
+ truncated: true,
126
+ reason: 'One or more patches were not provided by GitHub.',
127
+ files: [
128
+ { path: 'src/session.ts', status: 'modified', additions: 8, deletions: 3, patch: '@@ -1,3 +1,4 @@\n-old\n+new\n context' },
129
+ { 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' },
130
+ { path: 'assets/logo.png', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'binary' },
131
+ { path: 'generated/output.txt', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'too-large', truncated: true },
132
+ ],
133
+ };
134
+ }
135
+ // `gh … --json` output — validated at the boundary, extras stripped.
136
+ const ghAuthor = z.object({ login: z.string() }).nullish();
137
+ // `color` is the 6-hex GitHub label color (no `#`), '' when gh omits it.
138
+ const ghLabel = z.object({ name: z.string(), color: z.string().default('') });
139
+ const ghIssueSchema = z.object({
140
+ number: z.number(),
141
+ title: z.string(),
142
+ author: ghAuthor,
143
+ createdAt: z.string(),
144
+ labels: z.array(ghLabel).default([]),
145
+ body: z.string().nullish(),
146
+ url: z.string(),
147
+ });
148
+ // One check run's `gh --json statusCheckRollup` entry — every field optional/nullish because
149
+ // gh's shape varies by check provider (exported so #400's unit tests can build fixtures).
150
+ export const ghCheckRunSchema = z.object({
151
+ state: z.string().nullish(),
152
+ status: z.string().nullish(),
153
+ conclusion: z.string().nullish(),
154
+ });
155
+ const ghStatusCheckRollup = z.array(ghCheckRunSchema).nullish();
156
+ // The list tier no longer requests `statusCheckRollup` (#664) — it is hydrated lazily per
157
+ // on-screen PR row (`fetchGithubChecks`). `checks` is set to `null` on list rows; the schema
158
+ // keeps no rollup field because the list call never asks for it.
159
+ const ghPrSchema = ghIssueSchema.extend({
160
+ isDraft: z.boolean().default(false),
161
+ additions: z.number().default(0),
162
+ deletions: z.number().default(0),
163
+ });
164
+ const ghPrViewSchema = z.object({
165
+ number: z.number(),
166
+ url: z.string(),
167
+ state: z.string().default('OPEN'),
168
+ isDraft: z.boolean().default(false),
169
+ statusCheckRollup: ghStatusCheckRollup,
170
+ });
171
+ const mergeCheckSchema = z.object({
172
+ name: z.string().default('Check'),
173
+ state: z.string().nullish(),
174
+ status: z.string().nullish(),
175
+ conclusion: z.string().nullish(),
176
+ detailsUrl: z.string().nullish(),
177
+ });
178
+ const mergePrSchema = z.object({
179
+ number: z.number(),
180
+ title: z.string(),
181
+ url: z.string(),
182
+ state: z.string(),
183
+ isDraft: z.boolean().default(false),
184
+ headRefName: z.string(),
185
+ baseRefName: z.string(),
186
+ headRefOid: z.string().regex(/^[0-9a-f]{40}$/),
187
+ mergeable: z.string().nullish(),
188
+ mergeStateStatus: z.string().nullish(),
189
+ reviewDecision: z.string().nullish(),
190
+ statusCheckRollup: z.array(mergeCheckSchema).nullish(),
191
+ });
192
+ const repoMergePolicySchema = z.object({
193
+ allow_merge_commit: z.boolean().default(false),
194
+ allow_squash_merge: z.boolean().default(false),
195
+ allow_rebase_merge: z.boolean().default(false),
196
+ merge_commit_title: z.string().nullish(),
197
+ squash_merge_commit_title: z.string().nullish(),
198
+ });
199
+ const ghMergeResultSchema = z.object({
200
+ merged: z.boolean(),
201
+ message: z.string().nullish(),
202
+ sha: z.string().nullish(),
203
+ });
204
+ /** Exported for unit tests (#400) — collapses a zod-validated `statusCheckRollup` array down to
205
+ * the single enum the GitHub tab (list rows + detail badge) renders. */
206
+ export function rollupToChecks(rollup) {
207
+ if (!rollup || rollup.length === 0)
208
+ return null;
209
+ const states = rollup.map((r) => (r.conclusion || r.state || r.status || '').toUpperCase());
210
+ if (states.some((s) => ['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(s)))
211
+ return 'failing';
212
+ if (states.some((s) => ['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', ''].includes(s)))
213
+ return 'pending';
214
+ return 'passing';
215
+ }
216
+ async function gh(repoRoot, args, timeout = 15_000) {
217
+ const { stdout } = await exec('gh', args, {
218
+ cwd: repoRoot,
219
+ timeout,
220
+ maxBuffer: 50 * 1024 * 1024,
221
+ });
222
+ return stdout;
223
+ }
224
+ /** GraphQL's max page size; pagination is capped at 10 pages/kind (1000 rows — the GUI's full
225
+ * background shot). Rows past the window keep `comments: 0`, which the UI reads as "no badge". */
226
+ export const GH_COUNTS_MAX_PAGES = 10;
227
+ const countsQuery = (root) => `
228
+ query ($owner: String!, $name: String!, $endCursor: String) {
229
+ repository(owner: $owner, name: $name) {
230
+ ${root}(first: 100, after: $endCursor, states: OPEN,
231
+ orderBy: {field: CREATED_AT, direction: DESC}) {
232
+ nodes { number comments { totalCount } }
233
+ pageInfo { hasNextPage endCursor }
234
+ }
235
+ }
236
+ }`;
237
+ const ghCountsPageSchema = z.object({
238
+ nodes: z.array(z.object({ number: z.number(), comments: z.object({ totalCount: z.number() }) })),
239
+ pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullish() }),
240
+ });
241
+ /** Validate + flatten one gh GraphQL counts response into a `number → count` map plus the page
242
+ * cursor. Exported for unit tests (the zod boundary + shape). Throws on a malformed envelope. */
243
+ export function parseCountsPage(out, root) {
244
+ const parsed = JSON.parse(out);
245
+ const page = ghCountsPageSchema.parse(parsed?.data?.repository?.[root]);
246
+ const counts = {};
247
+ for (const node of page.nodes)
248
+ counts[node.number] = node.comments.totalCount;
249
+ return { counts, hasNextPage: page.pageInfo.hasNextPage, endCursor: page.pageInfo.endCursor ?? null };
250
+ }
251
+ async function paginateCounts(runGraphql, owner, name, root, maxPages) {
252
+ const counts = {};
253
+ let cursor = null;
254
+ for (let page = 0; page < maxPages; page++) {
255
+ const variables = { owner, name };
256
+ if (cursor)
257
+ variables.endCursor = cursor;
258
+ const res = parseCountsPage(await runGraphql(countsQuery(root), variables), root);
259
+ Object.assign(counts, res.counts);
260
+ if (!res.hasNextPage || !res.endCursor)
261
+ break;
262
+ cursor = res.endCursor;
263
+ }
264
+ return counts;
265
+ }
266
+ /** Comment counts for open issues and PRs as `number → count` maps. Two independent paginated
267
+ * queries (issues and PRs need separate cursors) run in parallel; any failure degrades the whole
268
+ * thing to empty maps so the tab is never held up by counts. Exported for unit tests. */
269
+ export async function fetchCommentCounts(runGraphql, owner, name, maxPages = GH_COUNTS_MAX_PAGES) {
270
+ try {
271
+ const [issues, prs] = await Promise.all([
272
+ paginateCounts(runGraphql, owner, name, 'issues', maxPages),
273
+ paginateCounts(runGraphql, owner, name, 'pullRequests', maxPages),
274
+ ]);
275
+ return { issues, prs };
276
+ }
277
+ catch {
278
+ return { issues: {}, prs: {} };
279
+ }
280
+ }
281
+ /** `owner/name` → `{ owner, name }`, or null when the handle isn't a clean two-part slug. */
282
+ export function parseOwnerName(nameWithOwner) {
283
+ const [owner, name, ...rest] = nameWithOwner.trim().split('/');
284
+ return owner && name && rest.length === 0 ? { owner, name } : null;
285
+ }
286
+ /* Reads degrade to `available: false` with a hint — never an error (plan rule
287
+ 7): no `gh`, no remote, offline all land on the same quiet path. A short
288
+ cache keeps tab switches from hammering the GitHub API; a cached fetch with
289
+ a bigger limit than asked serves fine (it's a superset). Keyed by `repoRoot`
290
+ (multi-project workspace, step 2.6): one project's — possibly private —
291
+ issues/PRs must never be served under another project's scope. Bounded like
292
+ `commentsCache` so an unbounded workspace can't grow it without limit. */
293
+ const listCache = new Map();
294
+ const LIST_CACHE_MAX = 50;
295
+ const CACHE_MS = 60_000;
296
+ export const GH_MAX_LIMIT = 1000;
297
+ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
298
+ if (process.env.CEZ_DRY_RUN === '1')
299
+ return mockGithub();
300
+ const capped = Math.min(Math.max(limit, 1), GH_MAX_LIMIT);
301
+ const hit = listCache.get(repoRoot);
302
+ if (!refresh && hit && Date.now() - hit.at < CACHE_MS && hit.limit >= capped) {
303
+ return hit.data;
304
+ }
305
+ try {
306
+ // No `comments` field — `gh … --json comments` ships full comment bodies.
307
+ // No `statusCheckRollup` either (#664): the CI rollup for every open PR was the
308
+ // dominant cost — it forced the 60 s budget below — and is now hydrated lazily per
309
+ // on-screen PR row via `fetchGithubChecks`. A big list still gets a little more wall
310
+ // clock than the default 30, but nothing like the old rollup walk.
311
+ const timeout = capped > 100 ? 30_000 : 15_000;
312
+ const fields = 'number,title,author,createdAt,labels,body,url';
313
+ // The repo handle first (cheap) so the counts GraphQL query — which needs owner/name —
314
+ // can run parallel to the two expensive list calls below.
315
+ const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], timeout);
316
+ const ownerName = parseOwnerName(repoOut);
317
+ const runGraphql = (query, variables) => {
318
+ const args = ['api', 'graphql', '-f', `query=${query}`];
319
+ for (const [key, value] of Object.entries(variables))
320
+ args.push('-f', `${key}=${value}`);
321
+ return gh(repoRoot, args, timeout);
322
+ };
323
+ // Bound the counts pagination to the rows actually being fetched: a page is 100, so
324
+ // `ceil(capped / 100)` pages (still capped at GH_COUNTS_MAX_PAGES) cover exactly the visible
325
+ // window and no more — the default 30-item load pays ONE counts round-trip, not ten. Rows
326
+ // beyond the window keep `comments: 0`, which the UI reads as "no badge" (same as before).
327
+ const countsMaxPages = Math.min(GH_COUNTS_MAX_PAGES, Math.max(1, Math.ceil(capped / 100)));
328
+ const [issuesOut, prsOut, counts] = await Promise.all([
329
+ gh(repoRoot, ['issue', 'list', '--limit', String(capped), '--json', fields], timeout),
330
+ gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions`], timeout),
331
+ // Real comment counts (#499). Degrades to empty maps on its own — a failure here leaves
332
+ // every count at 0, never fails the tab. Skipped entirely if the handle isn't parseable.
333
+ ownerName
334
+ ? fetchCommentCounts(runGraphql, ownerName.owner, ownerName.name, countsMaxPages)
335
+ : Promise.resolve({ issues: {}, prs: {} }),
336
+ ]);
337
+ // One repo-wide label→color map, filled as we flatten each item's labels.
338
+ const labelColors = {};
339
+ const recordColor = (l) => {
340
+ if (l.color && !labelColors[l.name])
341
+ labelColors[l.name] = l.color;
342
+ };
343
+ const issues = z.array(ghIssueSchema).parse(JSON.parse(issuesOut)).map((i) => {
344
+ i.labels.forEach(recordColor);
345
+ return {
346
+ kind: 'issue',
347
+ number: i.number,
348
+ title: i.title,
349
+ author: i.author?.login ?? '?',
350
+ createdAt: i.createdAt,
351
+ labels: i.labels.map((l) => l.name),
352
+ body: (i.body ?? '').slice(0, 8_000),
353
+ url: i.url,
354
+ comments: counts.issues[i.number] ?? 0,
355
+ };
356
+ });
357
+ const prs = z.array(ghPrSchema).parse(JSON.parse(prsOut)).map((p) => {
358
+ p.labels.forEach(recordColor);
359
+ return {
360
+ kind: 'pr',
361
+ number: p.number,
362
+ title: p.title,
363
+ author: p.author?.login ?? '?',
364
+ createdAt: p.createdAt,
365
+ labels: [...p.labels.map((l) => l.name), ...(p.isDraft ? ['draft'] : [])],
366
+ body: (p.body ?? '').slice(0, 8_000),
367
+ url: p.url,
368
+ comments: counts.prs[p.number] ?? 0,
369
+ isDraft: p.isDraft,
370
+ additions: p.additions,
371
+ deletions: p.deletions,
372
+ // Hydrated lazily by `fetchGithubChecks` for on-screen rows (#664) — the list no
373
+ // longer pays for the CI rollup of every open PR.
374
+ checks: null,
375
+ };
376
+ });
377
+ const data = {
378
+ available: true,
379
+ repo: repoOut.trim() || undefined,
380
+ syncedAt: new Date().toISOString(),
381
+ issues,
382
+ prs,
383
+ labelColors,
384
+ };
385
+ listCache.delete(repoRoot); // re-insert so this key becomes the newest
386
+ listCache.set(repoRoot, { at: Date.now(), limit: capped, data });
387
+ while (listCache.size > LIST_CACHE_MAX) {
388
+ const oldest = listCache.keys().next().value;
389
+ if (oldest === undefined)
390
+ break;
391
+ listCache.delete(oldest);
392
+ }
393
+ return data;
394
+ }
395
+ catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ const reason = /ENOENT/.test(message)
398
+ ? 'gh CLI not found — install it and run `gh auth login`'
399
+ : firstLine(message);
400
+ return { available: false, reason, issues: [], prs: [] };
401
+ }
402
+ }
403
+ function firstLine(s) {
404
+ return s.split('\n').find((l) => l.trim().length > 0)?.trim() ?? 'gh failed';
405
+ }
406
+ /** CEZ_DRY_RUN=1 — a small fixed catalog so the GitHub tab is demoable offline. */
407
+ function mockGithub() {
408
+ const mk = (over) => ({
409
+ author: 'mock',
410
+ createdAt: new Date(Date.now() - over.number * 3_600_000).toISOString(),
411
+ labels: [],
412
+ url: `https://github.com/mock/repo/${over.kind === 'pr' ? 'pull' : 'issues'}/${over.number}`,
413
+ comments: 0,
414
+ ...over,
415
+ });
416
+ return {
417
+ available: true,
418
+ repo: 'mock/repo',
419
+ syncedAt: new Date().toISOString(),
420
+ issues: [
421
+ mk({ kind: 'issue', number: 142, title: 'Login form drops session on refresh', labels: ['bug', 'auth'], comments: 3, body: 'Repro: log in, hit reload — you land back on /login. The session cookie is set correctly, but the client store rehydrates before the cookie check resolves, so the auth guard redirects.' }),
422
+ mk({ kind: 'issue', number: 139, title: 'Add --json flag to cez CLI output', labels: ['enhancement', 'cli'], comments: 1, body: 'For scripting it would help if `cez list` and `cez status` could emit machine-readable JSON instead of the table view.' }),
423
+ mk({ kind: 'issue', number: 135, title: 'Flaky e2e: worktree cleanup race on cancel', labels: ['bug', 'flaky-test'], comments: 6, body: 'Cancelling a run while the agent holds a file lock leaves a dangling worktree. The next run on the same branch then fails with "worktree already exists".' }),
424
+ ],
425
+ prs: [
426
+ mk({ kind: 'pr', number: 128, title: 'Fix flaky auth test in CI', labels: ['tests'], checks: 'passing', additions: 6, deletions: 3, body: 'Loosens the timing assertion in refresh.test.ts to a realistic budget.' }),
427
+ mk({ kind: 'pr', number: 124, title: 'Rate limit /api/runs', labels: ['server', 'draft'], isDraft: true, checks: 'failing', additions: 118, deletions: 7, comments: 4, body: 'Draft: token-bucket middleware on the runs router. Still needs the config surface and README docs before review.' }),
428
+ ],
429
+ labelColors: {
430
+ bug: 'd73a4a',
431
+ auth: '5319e7',
432
+ enhancement: 'a2eeef',
433
+ cli: '0e8a16',
434
+ 'flaky-test': 'fbca04',
435
+ tests: 'c5def5',
436
+ server: '1d76db',
437
+ draft: '6a737d',
438
+ },
439
+ };
440
+ }
441
+ // ---- comment threads (#499 Phase 2) ----------------------------------------
442
+ // A lazy per-thread fetch behind `GET /api/github/comments/:kind/:number`: the
443
+ // conversation comments (issues endpoint — GitHub serves PR conversation
444
+ // comments there too), plus submitted reviews for PRs, normalized into one
445
+ // chronological `ForgeComment[]`. Its own bounded 60 s cache keeps an open
446
+ // detail view from re-fetching on every focus. Degrades to `available: false`
447
+ // exactly like the list fetch — never a 5xx.
448
+ const ghCommentUser = z.object({ login: z.string(), avatar_url: z.string().nullish() }).nullish();
449
+ const ghIssueCommentSchema = z.object({
450
+ id: z.number(),
451
+ user: ghCommentUser,
452
+ created_at: z.string(),
453
+ body: z.string().nullish(),
454
+ html_url: z.string(),
455
+ });
456
+ const ghReviewSchema = z.object({
457
+ id: z.number(),
458
+ user: ghCommentUser,
459
+ body: z.string().nullish(),
460
+ state: z.string(),
461
+ submitted_at: z.string().nullish(),
462
+ html_url: z.string(),
463
+ });
464
+ // ---- timeline events (#525) -------------------------------------------------
465
+ // The thread's non-comment history — commits, label changes, assignments, merges, force-pushes,
466
+ // cross-references. Sourced from `/issues/{n}/timeline`, which returns comments AND events in one
467
+ // chronological stream, so the `commented` rows keep flowing through `normalizeComments` unchanged
468
+ // and `comments[]` stays exactly what BACKWARD_COMPATIBILITY.md §2 promises.
469
+ /** The event kinds rendered in v1 — an allowlist, so a new GitHub event type is dropped rather
470
+ * than rendered and can never crash or clutter the thread. Real timelines carry plenty that
471
+ * github.com itself doesn't surface (`subscribed`, `mentioned`, `review_requested`).
472
+ *
473
+ * `reviewed` is deliberately absent: timeline `reviewed` rows DO carry a body and would work,
474
+ * but `/pulls/{n}/reviews` is already normalized, chipped and empty-body-filtered, so sourcing
475
+ * both would render every review twice. */
476
+ export const TIMELINE_EVENT_KINDS = new Set([
477
+ 'committed',
478
+ 'labeled',
479
+ 'unlabeled',
480
+ 'assigned',
481
+ 'unassigned',
482
+ 'merged',
483
+ 'closed',
484
+ 'reopened',
485
+ 'head_ref_force_pushed',
486
+ 'cross-referenced',
487
+ 'renamed',
488
+ ]);
489
+ /** Events get their OWN cap, independent of `THREAD_ENTRY_CAP`. A combined cap would mean a
490
+ * thread with 150 comments and 100 events returns ~120 comments — silently removing contents
491
+ * from a §2-protected response. */
492
+ export const TIMELINE_EVENT_CAP = 200;
493
+ /** `gh api --paginate` has no page limit, so the timeline fetch hand-rolls a bounded loop. */
494
+ export const TIMELINE_MAX_PAGES = 10;
495
+ /** ONE budget shared by every page. `gh()`'s timeout is per invocation, so ten pages at the 15 s
496
+ * default would put the ceiling at 150 s — an order of magnitude worse than the single
497
+ * `--paginate` spawn this replaces. The loop tracks a deadline and passes what's left. */
498
+ export const TIMELINE_BUDGET_MS = 15_000;
499
+ /** Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
500
+ * boundary; the realistic case is 300 ms left, which spawns `gh` with a 300 ms timeout, throws,
501
+ * and is indistinguishable from a real endpoint failure. */
502
+ export const TIMELINE_MIN_PAGE_MS = 2_000;
503
+ /** `committed` messages are trimmed to their first line, then this. */
504
+ const COMMIT_MESSAGE_CAP = 120;
505
+ /** One timeline row. `event` stays a loose `z.string()` so unknown kinds parse and are dropped by
506
+ * the allowlist rather than throwing the whole page. Extras are stripped by default — notably
507
+ * `author.email`, which is read for nothing and must not reach the wire type. */
508
+ export const ghTimelineEventSchema = z.object({
509
+ event: z.string(),
510
+ id: z.number().nullish(),
511
+ node_id: z.string().nullish(),
512
+ created_at: z.string().nullish(),
513
+ actor: z.object({ login: z.string(), avatar_url: z.string().nullish() }).nullish(),
514
+ url: z.string().nullish(),
515
+ html_url: z.string().nullish(),
516
+ // `committed`
517
+ sha: z.string().nullish(),
518
+ message: z.string().nullish(),
519
+ author: z.object({ name: z.string().nullish(), date: z.string().nullish() }).nullish(),
520
+ // `labeled` / `unlabeled`
521
+ label: z.object({ name: z.string(), color: z.string().nullish() }).nullish(),
522
+ // `assigned` / `unassigned`
523
+ assignee: z.object({ login: z.string() }).nullish(),
524
+ // `renamed`
525
+ rename: z.object({ from: z.string().nullish(), to: z.string().nullish() }).nullish(),
526
+ // `cross-referenced`
527
+ source: z
528
+ .object({
529
+ issue: z
530
+ .object({
531
+ number: z.number().nullish(),
532
+ title: z.string().nullish(),
533
+ html_url: z.string().nullish(),
534
+ pull_request: z.unknown().nullish(),
535
+ })
536
+ .nullish(),
537
+ })
538
+ .nullish(),
539
+ });
540
+ const REVIEW_STATE = {
541
+ APPROVED: 'approved',
542
+ CHANGES_REQUESTED: 'changes_requested',
543
+ COMMENTED: 'commented',
544
+ DISMISSED: 'dismissed',
545
+ };
546
+ /** First 200 thread entries, then `truncated`; each body sliced to 8 000 chars (same cap as
547
+ * item bodies). */
548
+ export const THREAD_ENTRY_CAP = 200;
549
+ const COMMENT_BODY_CAP = 8_000;
550
+ /** `gh api …/issues/{n}/comments` JSON → `ForgeComment[]`. Exported for unit tests. */
551
+ export function normalizeComments(raw) {
552
+ return z.array(ghIssueCommentSchema).parse(raw).map((c) => ({
553
+ id: c.id,
554
+ author: c.user?.login ?? '?',
555
+ avatarUrl: c.user?.avatar_url ?? undefined,
556
+ createdAt: c.created_at,
557
+ body: (c.body ?? '').slice(0, COMMENT_BODY_CAP),
558
+ kind: 'comment',
559
+ url: c.html_url,
560
+ }));
561
+ }
562
+ /** `gh api …/pulls/{n}/reviews` JSON → `ForgeComment[]`. Reviews with an empty body AND state
563
+ * COMMENTED/PENDING carry no signal in a flat thread and are dropped; the rest map to
564
+ * `kind: 'review'` (Q4). Exported for unit tests. */
565
+ export function normalizeReviews(raw) {
566
+ return z
567
+ .array(ghReviewSchema)
568
+ .parse(raw)
569
+ .filter((r) => {
570
+ const state = r.state.toUpperCase();
571
+ const emptyBody = (r.body ?? '').trim().length === 0;
572
+ return !(emptyBody && (state === 'COMMENTED' || state === 'PENDING'));
573
+ })
574
+ .map((r) => ({
575
+ id: r.id,
576
+ author: r.user?.login ?? '?',
577
+ avatarUrl: r.user?.avatar_url ?? undefined,
578
+ createdAt: r.submitted_at ?? '',
579
+ body: (r.body ?? '').slice(0, COMMENT_BODY_CAP),
580
+ kind: 'review',
581
+ reviewState: REVIEW_STATE[r.state.toUpperCase()],
582
+ url: r.html_url,
583
+ }));
584
+ }
585
+ /**
586
+ * `gh api …/issues/{n}/timeline` JSON → `ForgeTimelineEvent[]`, plus whether the cap fired.
587
+ *
588
+ * Returns `truncated` rather than just the array because the caller has no other way to learn it:
589
+ * `events.length === TIMELINE_EVENT_CAP` is ambiguous on a thread with exactly that many.
590
+ *
591
+ * Three details here are load-bearing and were verified against a real timeline, not assumed:
592
+ *
593
+ * 1. **`committed` rows return `created_at: null`** — the real timestamp is at `author.date`.
594
+ * Mapping `created_at` naively yields `createdAt: null` on every commit, which string-sorts to
595
+ * the top and silently reorders the entire thread.
596
+ * 2. **`committed` carries a git author, not a GitHub actor** — a name, no login, no avatar.
597
+ * 3. **The cap keeps the NEWEST events — `slice(-cap)`**, the opposite of the neighbouring
598
+ * `mergeThread`, which head-slices. The timeline arrives oldest-first, so `slice(0, cap)` would
599
+ * retain 200 stale day-one `labeled` rows and discard the merge and the recent commits — the
600
+ * exact rows #525 asks for.
601
+ *
602
+ * Exported for unit tests.
603
+ */
604
+ export function normalizeEvents(raw, cap = TIMELINE_EVENT_CAP) {
605
+ const rows = z.array(ghTimelineEventSchema).parse(raw);
606
+ const mapped = [];
607
+ rows.forEach((row, index) => {
608
+ const kind = row.event;
609
+ if (!TIMELINE_EVENT_KINDS.has(kind))
610
+ return; // unknown//noise → dropped, never rendered
611
+ // Per-kind timestamp resolution — see (1) above.
612
+ const rawAt = kind === 'committed' ? row.author?.date : row.created_at;
613
+ if (!rawAt)
614
+ return; // no resolvable timestamp → drop, rather than merge at an arbitrary spot
615
+ // `author.date` arrives with a numeric offset (`+02:00`); normalize so the string compare the
616
+ // thread sorts by stays correct across zones.
617
+ const parsedAt = new Date(rawAt);
618
+ if (Number.isNaN(parsedAt.getTime()))
619
+ return;
620
+ const createdAt = parsedAt.toISOString();
621
+ // Per-kind actor resolution — see (2) above.
622
+ const actor = (kind === 'committed' ? row.author?.name : row.actor?.login) ?? '?';
623
+ // Identity: `sha` sits ahead of `node_id` deliberately. `committed` rows carry both, and a
624
+ // node_id-first order would key commits by an opaque `C_kwDO…` blob instead of the SHA, which
625
+ // is the natural, debuggable identifier and is already the rollup key. The bare-index fallback
626
+ // reaches only `cross-referenced`, the one kind with no identity at all — as a general scheme
627
+ // it would be wrong, since the id becomes the React key and an index over the post-sort array
628
+ // shifts for every row below an insertion, remounting them on each 60 s refetch.
629
+ const identity = row.id ?? row.sha ?? row.node_id ?? index;
630
+ const event = {
631
+ id: `evt-${identity}`,
632
+ kind,
633
+ actor,
634
+ createdAt,
635
+ };
636
+ // A git author has no avatar, so `committed` deliberately carries none.
637
+ if (kind !== 'committed' && row.actor?.avatar_url)
638
+ event.avatarUrl = row.actor.avatar_url;
639
+ switch (kind) {
640
+ case 'committed': {
641
+ // Enforce the full-40-hex invariant the rollup query depends on rather than assuming it:
642
+ // `oid` rejects anything else, and a malformed value embedded in the batched query would
643
+ // cost the whole chunk its glyphs instead of just this commit.
644
+ if (row.sha && /^[0-9a-f]{40}$/i.test(row.sha))
645
+ event.sha = row.sha;
646
+ if (row.message)
647
+ event.message = (row.message.split('\n')[0] ?? '').slice(0, COMMIT_MESSAGE_CAP);
648
+ if (row.html_url)
649
+ event.url = row.html_url;
650
+ break;
651
+ }
652
+ case 'labeled':
653
+ case 'unlabeled': {
654
+ if (row.label) {
655
+ event.label = { name: row.label.name };
656
+ if (row.label.color)
657
+ event.label.color = row.label.color;
658
+ }
659
+ break;
660
+ }
661
+ case 'assigned':
662
+ case 'unassigned': {
663
+ if (row.assignee?.login)
664
+ event.subject = row.assignee.login;
665
+ break;
666
+ }
667
+ case 'renamed': {
668
+ if (row.rename?.to)
669
+ event.subject = row.rename.to;
670
+ break;
671
+ }
672
+ case 'cross-referenced': {
673
+ const issue = row.source?.issue;
674
+ if (issue?.number != null)
675
+ event.refNumber = issue.number;
676
+ if (issue?.title)
677
+ event.refTitle = issue.title;
678
+ if (issue)
679
+ event.refIsPr = Boolean(issue.pull_request);
680
+ if (issue?.html_url)
681
+ event.url = issue.html_url;
682
+ break;
683
+ }
684
+ default:
685
+ break;
686
+ }
687
+ mapped.push(event);
688
+ });
689
+ const truncated = mapped.length > cap;
690
+ // slice(-cap), NOT slice(0, cap) — see (3) above.
691
+ return { events: truncated ? mapped.slice(-cap) : mapped, truncated };
692
+ }
693
+ /** Merge comment/review lists chronologically (oldest first) and apply the entry cap. Exported
694
+ * for unit tests. */
695
+ export function mergeThread(parts, cap = THREAD_ENTRY_CAP) {
696
+ const all = parts.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
697
+ const truncated = all.length > cap;
698
+ return { comments: truncated ? all.slice(0, cap) : all, truncated };
699
+ }
700
+ // Per-thread cache: keyed `repoRoot␀kind#number` (the root scopes the key — two projects each
701
+ // having a PR #42 must not collide; step 2.6), same 60 s TTL as the list cache but BOUNDED — a
702
+ // long browsing session can't grow it without limit (Map preserves insertion order → oldest
703
+ // first).
704
+ const commentsCache = new Map();
705
+ const COMMENTS_CACHE_MAX = 50;
706
+ function cacheComments(key, data) {
707
+ commentsCache.delete(key); // re-insert so this key becomes the newest
708
+ commentsCache.set(key, { at: Date.now(), data });
709
+ while (commentsCache.size > COMMENTS_CACHE_MAX) {
710
+ const oldest = commentsCache.keys().next().value;
711
+ if (oldest === undefined)
712
+ break;
713
+ commentsCache.delete(oldest);
714
+ }
715
+ }
716
+ /** Test-only: drop the per-thread cache so cases don't leak state into each other. */
717
+ export function __clearCommentsCacheForTests() {
718
+ commentsCache.clear();
719
+ }
720
+ const TIMELINE_PER_PAGE = 100;
721
+ // The repo handle for the per-commit checks query (#525 Phase 2). Memoized per repoRoot — stable
722
+ // in practice, and keyed per root rather than globally for multi-project forward-compatibility.
723
+ // `null` is a cached PERMANENT negative (the slug isn't a clean two-part name, so retrying cannot
724
+ // help). A *thrown* gh failure is transient and deliberately NOT cached: caching it would disable
725
+ // glyphs until process restart on one network blip.
726
+ const repoHandleCache = new Map();
727
+ /** Test-only: drop the memoized repo handles. */
728
+ export function __clearRepoHandleCacheForTests() {
729
+ repoHandleCache.clear();
730
+ }
731
+ /** The `owner/name` for `repoRoot`, memoized. Returns null when the handle isn't a clean two-part
732
+ * slug or `gh` failed — the caller then skips checks entirely and commits render unglyphed. */
733
+ export async function resolveRepoHandle(repoRoot) {
734
+ const memo = repoHandleCache.get(repoRoot);
735
+ if (memo !== undefined)
736
+ return memo;
737
+ let handle;
738
+ try {
739
+ handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
740
+ }
741
+ catch {
742
+ // Transient — do NOT memoize, so the next thread retries.
743
+ return null;
744
+ }
745
+ repoHandleCache.set(repoRoot, handle); // includes the permanent negative
746
+ return handle;
747
+ }
748
+ /**
749
+ * Walk `/issues/{n}/timeline` under ONE shared time budget.
750
+ *
751
+ * `gh api --paginate` is not used here: it pages *"until there are no more pages of results"* and
752
+ * exposes no page-limit flag, so the only way to bound the walk is to hand-roll it. The cap that
753
+ * `paginateCounts` gets comes from being a JS cursor loop, not from a `gh` flag.
754
+ *
755
+ * The budget is a **total**, not a per-page allowance. `gh()` takes its timeout per invocation, so
756
+ * ten sequential spawns at the 15 s default would put the ceiling at 150 s — an order of magnitude
757
+ * worse than the single `--paginate` spawn this replaces. The loop tracks a deadline and passes
758
+ * each page whatever remains.
759
+ *
760
+ * Exported for unit tests; `run` is injected so the loop is testable without shelling out.
761
+ */
762
+ export async function fetchTimelinePages(run, opts = {}) {
763
+ const maxPages = opts.maxPages ?? TIMELINE_MAX_PAGES;
764
+ const budgetMs = opts.budgetMs ?? TIMELINE_BUDGET_MS;
765
+ const minPageMs = opts.minPageMs ?? TIMELINE_MIN_PAGE_MS;
766
+ const now = opts.now ?? Date.now;
767
+ const deadline = now() + budgetMs;
768
+ const rows = [];
769
+ let stoppedShort = false;
770
+ let page = 1;
771
+ for (; page <= maxPages; page++) {
772
+ const remaining = deadline - now();
773
+ // Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
774
+ // boundary; the realistic case is 300 ms left, which spawns gh with a 300 ms timeout, throws,
775
+ // and looks indistinguishable from a real endpoint failure.
776
+ if (remaining < minPageMs) {
777
+ stoppedShort = true;
778
+ break;
779
+ }
780
+ let parsed;
781
+ try {
782
+ parsed = z.array(z.unknown()).parse(JSON.parse(await run(page, remaining)));
783
+ }
784
+ catch (err) {
785
+ // Page 1 rethrows so the caller's inner catch can decide whether substitution helps —
786
+ // nothing was fetched, so there is nothing to lose. A failure on any later page keeps the
787
+ // pages already in hand: discarding nine good pages to re-fetch comments-only is strictly
788
+ // worse than what the loop already holds.
789
+ if (page === 1)
790
+ throw err;
791
+ stoppedShort = true;
792
+ break;
793
+ }
794
+ rows.push(...parsed);
795
+ if (parsed.length < TIMELINE_PER_PAGE)
796
+ break; // short page — the real end of the timeline
797
+ }
798
+ if (page > maxPages)
799
+ stoppedShort = true; // fell out on the page cap
800
+ return { rows, stoppedShort };
801
+ }
802
+ /** SHAs per rollup query. Aliases resolve independently, so a chunk that fails costs only its own
803
+ * glyphs — but an unbounded alias list would eventually blow the query size limit. */
804
+ export const COMMIT_CHECKS_CHUNK = 50;
805
+ /** One aliased `object(oid:)` per SHA. `oid` requires a FULL 40-char SHA in both literal and
806
+ * variable form (`Could not coerce value "babda63" to GitObjectID`), which constrains fixtures
807
+ * rather than production data — the timeline always supplies full SHAs. */
808
+ function commitChecksQuery(shas) {
809
+ const aliases = shas
810
+ .map((sha, i) => ` c${i}: object(oid: "${sha}") { ... on Commit { statusCheckRollup { state } } }`)
811
+ .join('\n');
812
+ return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
813
+ }
814
+ const ghCommitChecksSchema = z.record(z.string(), z.object({ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish() }).nullish());
815
+ /**
816
+ * Rolled-up CI state per commit SHA, as a `sha → checks` map.
817
+ *
818
+ * Batched and aliased so a 40-commit PR costs one subprocess, not forty. Verified against the live
819
+ * API: each alias resolves independently and an unknown SHA comes back `null` rather than erroring
820
+ * the batch, so partial results degrade cleanly.
821
+ *
822
+ * Degrades to an empty map on any failure — exactly as `fetchCommentCounts` does for counts. The
823
+ * caller then leaves `checks` **absent**, which the UI renders as no glyph. Exported for tests;
824
+ * `runGraphql` is injected so this is testable without shelling out.
825
+ */
826
+ export async function fetchCommitChecks(runGraphql, owner, name, shas, chunkSize = COMMIT_CHECKS_CHUNK) {
827
+ const out = {};
828
+ if (shas.length === 0)
829
+ return out;
830
+ for (let i = 0; i < shas.length; i += chunkSize) {
831
+ const chunk = shas.slice(i, i + chunkSize);
832
+ try {
833
+ const raw = JSON.parse(await runGraphql(commitChecksQuery(chunk), { owner, name }));
834
+ const repository = ghCommitChecksSchema.parse(raw?.data?.repository ?? {});
835
+ chunk.forEach((sha, index) => {
836
+ const node = repository[`c${index}`];
837
+ if (!node)
838
+ return; // unknown SHA → alias resolved null; leave `checks` absent
839
+ // Adapt the single rollup state into the array shape `rollupToChecks` expects, so the
840
+ // existing FAILURE/PENDING/SUCCESS vocabulary is reused rather than duplicated.
841
+ out[sha] = node.statusCheckRollup
842
+ ? rollupToChecks([{ state: node.statusCheckRollup.state, status: null, conclusion: null }])
843
+ : null; // no CI configured — distinct from absent
844
+ });
845
+ }
846
+ catch {
847
+ // A failed chunk costs only its own glyphs; the rest still resolve.
848
+ }
849
+ }
850
+ return out;
851
+ }
852
+ /** PR numbers per checks query. Aliases resolve independently (a failed chunk costs only its own
853
+ * glyphs); bounded so an unbounded number list can't blow the query size limit. Also the route's
854
+ * hard cap on how many PRs one request may ask about. */
855
+ export const GH_CHECKS_MAX = 100;
856
+ /** One aliased `pullRequest(number:)` per PR; the rolled-up CI state lives on the head commit. */
857
+ function prChecksQuery(numbers) {
858
+ const aliases = numbers
859
+ .map((n, i) => ` p${i}: pullRequest(number: ${n}) { commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } }`)
860
+ .join('\n');
861
+ return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
862
+ }
863
+ const ghPrChecksSchema = z.record(z.string(), z
864
+ .object({
865
+ commits: z.object({
866
+ nodes: z.array(z.object({
867
+ commit: z.object({
868
+ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
869
+ }),
870
+ })),
871
+ }),
872
+ })
873
+ .nullish());
874
+ /**
875
+ * Rolled-up CI state per PR number, as a `number → glyph` map.
876
+ *
877
+ * Batched and aliased so a 100-PR window costs one subprocess, not a hundred. Each alias resolves
878
+ * independently and an unknown number comes back `null` (alias resolved null → left absent), so
879
+ * partial results degrade cleanly. Degrades to an empty map on any failure — exactly as
880
+ * `fetchCommitChecks` does. Exported for tests; `runGraphql` is injected so this is testable
881
+ * without shelling out.
882
+ */
883
+ export async function fetchPrChecks(runGraphql, owner, name, numbers, chunkSize = GH_CHECKS_MAX) {
884
+ const out = {};
885
+ if (numbers.length === 0)
886
+ return out;
887
+ for (let i = 0; i < numbers.length; i += chunkSize) {
888
+ const chunk = numbers.slice(i, i + chunkSize);
889
+ try {
890
+ const raw = JSON.parse(await runGraphql(prChecksQuery(chunk), { owner, name }));
891
+ const repository = ghPrChecksSchema.parse(raw?.data?.repository ?? {});
892
+ chunk.forEach((number, index) => {
893
+ const node = repository[`p${index}`];
894
+ if (!node)
895
+ return; // unknown PR → alias resolved null; leave the glyph absent
896
+ const rollup = node.commits.nodes[0]?.commit.statusCheckRollup;
897
+ // Adapt the single rollup state into the array shape `rollupToChecks` expects, reusing the
898
+ // FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
899
+ out[number] = rollup
900
+ ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null
901
+ : null; // no CI configured — distinct from absent
902
+ });
903
+ }
904
+ catch {
905
+ // A failed chunk costs only its own glyphs; the rest still resolve.
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+ // Per-PR checks cache: keyed `repoRoot␀number`, same 60 s TTL as the list cache but BOUNDED. Lets
911
+ // a repeated visible-window hydration within a minute serve cached glyphs instead of re-querying.
912
+ const checksCache = new Map();
913
+ const CHECKS_CACHE_MAX = 500;
914
+ /** Test hook — the per-PR checks cache would otherwise leak state across cases in one process. */
915
+ export function __clearChecksCacheForTests() {
916
+ checksCache.clear();
917
+ }
918
+ /**
919
+ * Lazy checks glyphs for the given PR numbers (route-facing). Resolves the repo handle once,
920
+ * serves fresh cache entries, queries only the misses, and degrades to `{ available: false,
921
+ * reason }` when `gh` or the handle is unavailable — never a throw, never a 5xx (plan rule 7).
922
+ * Numbers are de-duplicated, validated, and capped at `GH_CHECKS_MAX`.
923
+ */
924
+ export async function fetchGithubChecks(repoRoot, numbers) {
925
+ if (process.env.CEZ_DRY_RUN === '1')
926
+ return mockGithubChecks(numbers);
927
+ const wanted = [...new Set(numbers)].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_CHECKS_MAX);
928
+ const checks = {};
929
+ const misses = [];
930
+ const now = Date.now();
931
+ for (const n of wanted) {
932
+ const hit = checksCache.get(`${repoRoot}\0${n}`);
933
+ if (hit && now - hit.at < CACHE_MS)
934
+ checks[n] = hit.glyph;
935
+ else
936
+ misses.push(n);
937
+ }
938
+ if (misses.length === 0)
939
+ return { available: true, checks };
940
+ try {
941
+ const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
942
+ const ownerName = parseOwnerName(repoOut);
943
+ if (!ownerName)
944
+ return { available: false, reason: 'repository handle unavailable' };
945
+ const runGraphql = (query, variables) => {
946
+ const args = ['api', 'graphql', '-f', `query=${query}`];
947
+ for (const [key, value] of Object.entries(variables))
948
+ args.push('-f', `${key}=${value}`);
949
+ return gh(repoRoot, args);
950
+ };
951
+ const fetched = await fetchPrChecks(runGraphql, ownerName.owner, ownerName.name, misses);
952
+ for (const n of misses) {
953
+ // Absent (unknown/closed PR) caches as `null` so a nonexistent number isn't re-queried.
954
+ const glyph = n in fetched ? fetched[n] : null;
955
+ checks[n] = glyph;
956
+ checksCache.set(`${repoRoot}\0${n}`, { at: now, glyph });
957
+ }
958
+ while (checksCache.size > CHECKS_CACHE_MAX) {
959
+ const oldest = checksCache.keys().next().value;
960
+ if (oldest === undefined)
961
+ break;
962
+ checksCache.delete(oldest);
963
+ }
964
+ return { available: true, checks };
965
+ }
966
+ catch (err) {
967
+ const message = err instanceof Error ? err.message : String(err);
968
+ return {
969
+ available: false,
970
+ reason: /ENOENT/.test(message)
971
+ ? 'gh CLI not found — install it and run `gh auth login`'
972
+ : firstLine(message),
973
+ };
974
+ }
975
+ }
976
+ /** CEZ_DRY_RUN=1 — glyphs straight from the mock catalog so the offline demo shows checks. */
977
+ function mockGithubChecks(numbers) {
978
+ const byNumber = new Map(mockGithub().prs.map((p) => [p.number, (p.checks ?? null)]));
979
+ const checks = {};
980
+ for (const n of numbers)
981
+ checks[n] = byNumber.get(n) ?? null;
982
+ return { available: true, checks };
983
+ }
984
+ /** Numbers per kind in one ref-status query — the same bound, and for the same reasons, as
985
+ * `GH_CHECKS_MAX`: aliases resolve independently, and the query size stays finite. Taken from the
986
+ * contract rather than restated, because the cockpit caps its batches by the same number and a
987
+ * client that guessed high would have every chip in a batch 400 instead of losing its tail. */
988
+ export const GH_REF_STATUS_MAX = REFERENCE_STATUS_MAX;
989
+ /** A requested reviewer, as either connection spells them. `login` covers `User` and `Bot`, `slug`
990
+ * covers `Team`; `reviewerKey` folds them into one comparable string. */
991
+ const reviewerRefSchema = z
992
+ .object({
993
+ __typename: z.string().nullish(),
994
+ login: z.string().nullish(),
995
+ slug: z.string().nullish(),
996
+ })
997
+ .nullish();
998
+ /** One reviewer as a comparable key, or `null` when GitHub named nobody we can match on — which
999
+ * must never compare equal to another unnamed reviewer, hence `null` rather than a shared "". */
1000
+ function reviewerKey(ref) {
1001
+ const name = ref?.login ?? ref?.slug;
1002
+ return name ? `${ref?.__typename ?? '?'}:${name}` : null;
1003
+ }
1004
+ const ghRefStatusPrSchema = z
1005
+ .object({
1006
+ state: z.string(),
1007
+ isDraft: z.boolean().nullish(),
1008
+ reviewDecision: z.string().nullish(),
1009
+ /** `MERGEABLE` | `CONFLICTING` | `UNKNOWN` — GitHub computes it in the background, so
1010
+ * `UNKNOWN` is normal on a PR that was just pushed and must never read as "clean". */
1011
+ mergeable: z.string().nullish(),
1012
+ commits: z
1013
+ .object({
1014
+ nodes: z.array(z.object({
1015
+ commit: z.object({
1016
+ committedDate: z.string().nullish(),
1017
+ /** `totalCount` says whether the head is a merge; the first parent is the branch's
1018
+ * previous tip, which is the newest commit that is actual WORK when it is. */
1019
+ parents: z
1020
+ .object({
1021
+ totalCount: z.number(),
1022
+ nodes: z.array(z.object({ committedDate: z.string().nullish() }).nullish()).nullish(),
1023
+ })
1024
+ .nullish(),
1025
+ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
1026
+ }),
1027
+ })),
1028
+ })
1029
+ .nullish(),
1030
+ reviews: z.object({ nodes: z.array(z.object({ submittedAt: z.string().nullish() })) }).nullish(),
1031
+ /** Who is on the hook RIGHT NOW. The reviewers are carried, not just the count, because a
1032
+ * request's date has to be matched to a request that still stands — see `reviewRequestedAt`. */
1033
+ reviewRequests: z
1034
+ .object({
1035
+ totalCount: z.number(),
1036
+ nodes: z.array(z.object({ requestedReviewer: reviewerRefSchema }).nullish()).nullish(),
1037
+ })
1038
+ .nullish(),
1039
+ timelineItems: z
1040
+ .object({
1041
+ nodes: z
1042
+ .array(z.object({ createdAt: z.string().nullish(), requestedReviewer: reviewerRefSchema }).nullish())
1043
+ .nullish(),
1044
+ })
1045
+ .nullish(),
1046
+ })
1047
+ .nullish();
1048
+ /**
1049
+ * When was the newest STILL-STANDING review request made?
1050
+ *
1051
+ * The two connections answer different halves and neither answers both: `reviewRequests` says who
1052
+ * is on the hook right now but carries no date, while a `ReviewRequestedEvent` carries the date and
1053
+ * survives the request being withdrawn. Reading the newest event on its own therefore dates a
1054
+ * request that may no longer exist — and a request added after a review and then removed would make
1055
+ * a live rejection read as answered, which is the exact bug the precedence above exists to fix.
1056
+ * Matching the two by reviewer is what keeps the date attached to a request that is really there.
1057
+ *
1058
+ * `null` when nothing matches, which the precedence reads as an undated request and treats
1059
+ * conservatively: the review stands rather than being dismissed on a guess.
1060
+ */
1061
+ function standingReviewRequestedAt(requests, timeline) {
1062
+ const standing = new Set();
1063
+ for (const node of requests?.nodes ?? []) {
1064
+ const key = reviewerKey(node?.requestedReviewer);
1065
+ if (key)
1066
+ standing.add(key);
1067
+ }
1068
+ if (standing.size === 0)
1069
+ return null;
1070
+ let newest = null;
1071
+ for (const node of timeline?.nodes ?? []) {
1072
+ if (!node?.createdAt)
1073
+ continue;
1074
+ const key = reviewerKey(node.requestedReviewer);
1075
+ if (!key || !standing.has(key))
1076
+ continue;
1077
+ if (!newest || isAfter(node.createdAt, newest))
1078
+ newest = node.createdAt;
1079
+ }
1080
+ return newest;
1081
+ }
1082
+ const ghRefStatusIssueSchema = z.object({ state: z.string(), stateReason: z.string().nullish() }).nullish();
1083
+ // The aliases hold two DIFFERENT shapes, so the record stays unvalidated here and each alias is
1084
+ // parsed by its own schema below. A union at this level would be wrong, not merely loose: zod
1085
+ // strips unknown keys, so a PR node matched by the issue arm would come back with `isDraft` and
1086
+ // `commits` silently removed.
1087
+ const ghRefStatusSchema = z.record(z.string(), z.unknown());
1088
+ /**
1089
+ * One alias per NUMBER, asking `issueOrPullRequest` rather than `pullRequest`/`issue`.
1090
+ *
1091
+ * Issues and pull requests share one numbering space in a repository, so a number is exactly one
1092
+ * of the two and asking which is a question the forge can answer — where asking `issue(number:)`
1093
+ * about a pull request is a question with no answer, and GitHub says so with a NOT_FOUND *error*
1094
+ * rather than a null. That mattered far more than it looks: `gh api graphql` exits non-zero the
1095
+ * moment a response carries an `errors` array, so one mis-guessed kind used to fail the whole
1096
+ * batch and report every reference in it as "not found" (see `refStatusGraphql`).
1097
+ *
1098
+ * It also fixes the reference whose kind the cockpit guessed wrong — `taskReferences` infers it
1099
+ * from which field carried the number, and a bare `#774` can land in either — because the answer
1100
+ * carries `__typename` and is filed under what the number REALLY is.
1101
+ *
1102
+ * `committedDate`, the head commit's `parents.totalCount` (`first: 0` — the COUNT is the whole
1103
+ * question, so no parent is fetched) and the last CHANGES_REQUESTED review's `submittedAt` cost
1104
+ * nothing extra, riding the same node, and are what let the precedence tell a review the author has
1105
+ * already responded to from one still about the code on screen. The last `ReviewRequestedEvent` is
1106
+ * asked for the same reason: `reviewRequests` says only THAT someone is on the hook, never since
1107
+ * when, and the difference between a request made before the review and one made after it is the
1108
+ * difference between a reviewer who has not looked yet and an author who has answered.
1109
+ *
1110
+ * `mergeable` rides it too, and is the reason the batch can answer "this one conflicts" without
1111
+ * the per-PR probe `prMergeState` runs for the merge box. It is NOT folded into the status: see
1112
+ * `conflicts` in the contract for why the two stay separate axes.
1113
+ */
1114
+ function refStatusQuery(numbers) {
1115
+ const aliases = numbers
1116
+ .map((n, i) => ` r${i}: issueOrPullRequest(number: ${n}) { __typename ... on PullRequest { state isDraft reviewDecision mergeable commits(last: 1) { nodes { commit { committedDate parents(first: 1) { totalCount nodes { committedDate } } statusCheckRollup { state } } } } reviews(last: 1, states: CHANGES_REQUESTED) { nodes { submittedAt } } reviewRequests(first: 20) { totalCount nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { slug } } } } timelineItems(last: 20, itemTypes: [REVIEW_REQUESTED_EVENT]) { nodes { ... on ReviewRequestedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { slug } } } } } } ... on Issue { state stateReason } }`)
1117
+ .join('\n');
1118
+ return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
1119
+ }
1120
+ /**
1121
+ * The one place a PR's signals collapse into a single word.
1122
+ *
1123
+ * The question the chip answers is "what is this waiting on RIGHT NOW", so the ranking is by
1124
+ * freshness of the signal, not by how heavy a blocker it is:
1125
+ *
1126
+ * Read the ranking as **whose move is it**, which is the question a table is scanned for:
1127
+ *
1128
+ * 1. `merged` / `closed` — nobody's. Terminal, whatever checks or reviews say.
1129
+ * 2. `draft` — the author's, and they have said so themselves.
1130
+ * 3. `checks-pending` — the machine's. CI running means a commit was JUST pushed, the newest
1131
+ * thing that has happened to this PR, so it outranks a requested change unconditionally.
1132
+ * 4. `changes-requested` — the AUTHOR's, and only while that is still true: the review must be
1133
+ * about the code that is there now, with no re-review already asked for.
1134
+ * 5. `checks-failing` — the author's again. A reviewer cannot approve a red PR anyway.
1135
+ * 6. `review-required` — the REVIEWER's: they have been asked, or the author has answered and
1136
+ * the merge is now blocked on someone coming back to look.
1137
+ * 7. `ready` — open, not a draft, nothing failing, nothing running, nobody waited on.
1138
+ *
1139
+ * The subtle one is (4) → (6), and it is not a preference but a data finding. `reviewDecision`
1140
+ * stays `CHANGES_REQUESTED` after the author has responded — GitHub does not clear it until a
1141
+ * reviewer submits again — so on its own it points at the author forever. Two signals say the ball
1142
+ * has moved back:
1143
+ *
1144
+ * - **a review request made AFTER the review**, which is the author clicking re-request.
1145
+ * Authoritative, and observed live alongside a stale `CHANGES_REQUESTED` and an EMPTY
1146
+ * `latestReviews` — the case that has no other tell.
1147
+ * - **a non-merge commit newer than the review**, the fallback for an author who pushed without
1148
+ * clicking anything. Merges are excluded because GitHub's "Update branch" button writes one,
1149
+ * dated now, that answers nothing — the reflexive click on a stale PR must not clear a
1150
+ * rejection.
1151
+ *
1152
+ * The "after" in the first one is load-bearing, and its absence was a reported bug. A request that
1153
+ * PREDATES the review is a reviewer who has not looked yet, and on a PR where several people were
1154
+ * asked and one of them rejected it the others stay listed forever — so a bare
1155
+ * `reviewRequests.totalCount > 0` reads a live "changes requested" as answered and hides it behind
1156
+ * "waiting for review" (observed live: three reviewers asked at 10:28, changes requested
1157
+ * the next day, one reviewer still pending).
1158
+ *
1159
+ * Either way the words change from "you owe edits" to "they owe a look", and so does the colour:
1160
+ * danger is the author's move, info is the reviewer's.
1161
+ *
1162
+ * Both timestamps are optional and their ABSENCE is conservative: with no dates to compare and no
1163
+ * re-request, a review counts as current, and the chip keeps pointing at the author.
1164
+ *
1165
+ * `reviewDecision` is null on a repo with no review policy, which is exactly why `ready` is not
1166
+ * spelled "approved": on such a repo a green PR IS ready, and no approval will ever arrive — but a
1167
+ * requested reviewer still moves it to `review-required`, because someone was explicitly asked.
1168
+ */
1169
+ export function derivePrReferenceStatus(pr) {
1170
+ const state = pr.state.toUpperCase();
1171
+ if (state === 'MERGED')
1172
+ return 'merged';
1173
+ if (state === 'CLOSED')
1174
+ return 'closed';
1175
+ if (pr.isDraft)
1176
+ return 'draft';
1177
+ if (pr.checks === 'pending')
1178
+ return 'checks-pending';
1179
+ const decision = (pr.reviewDecision ?? '').toUpperCase();
1180
+ const changesRequested = decision === 'CHANGES_REQUESTED';
1181
+ // Has the author already answered the review — by asking for another look, or by pushing?
1182
+ // A standing request counts as the ask only if it POSTDATES the review; one made before it is a
1183
+ // reviewer who has not got to the PR yet, and on a PR where someone else rejected it that
1184
+ // request would otherwise mask the rejection indefinitely. With no review date at all it still
1185
+ // counts — that is the empty-`reviews` case above, where it is the only signal there is.
1186
+ const reRequested = pr.reviewRequested === true && (!pr.changesRequestedAt || isAfter(pr.reviewRequestedAt, pr.changesRequestedAt));
1187
+ // A push counts as the answer, judged by the newest commit that is actual WORK. GitHub's "Update
1188
+ // branch" button (and this cockpit's own "Resolve conflicts") writes `Merge branch 'main' into
1189
+ // <branch>` dated NOW, newer than any review while addressing none of it: the click people make
1190
+ // reflexively on a stale PR must not wipe a rejection off the chip. Two parents is what tells
1191
+ // that commit apart from work.
1192
+ //
1193
+ // Which is why a merge is not simply DISQUALIFYING: it is transparent. Reading only the head
1194
+ // would let a merge landing on top of a genuine fix erase that fix's answer and flip the chip
1195
+ // back to red, blaming an author who already responded — the same misattribution this whole
1196
+ // function exists to prevent, just pointed the other way. The merge's FIRST parent is the
1197
+ // branch's previous tip, so it carries the date of the work the merge sat on top of.
1198
+ const workCommittedAt = (pr.headParentCount ?? 1) < 2 ? pr.headCommittedAt : pr.headFirstParentCommittedAt;
1199
+ const pushed = isAfter(workCommittedAt, pr.changesRequestedAt);
1200
+ const answered = reRequested || pushed;
1201
+ if (changesRequested && !answered)
1202
+ return 'changes-requested';
1203
+ if (pr.checks === 'failing')
1204
+ return 'checks-failing';
1205
+ // `APPROVED` is the forge saying the review requirement IS MET, and it outranks a pending
1206
+ // request: a reviewer left on the list after someone else approved is a courtesy ask, not an
1207
+ // unmet gate. (A repo needing two approvals reports `REVIEW_REQUIRED` until it has both, so
1208
+ // `APPROVED` never arrives early.) Without this, an approved, green, mergeable pull request
1209
+ // reads "waiting for review" for as long as anyone stays listed — which is indefinitely.
1210
+ if (decision === 'APPROVED')
1211
+ return 'ready';
1212
+ if (changesRequested || decision === 'REVIEW_REQUIRED' || pr.reviewRequested === true) {
1213
+ return 'review-required';
1214
+ }
1215
+ return 'ready';
1216
+ }
1217
+ export function mergeabilityOf(state, mergeable) {
1218
+ if (state.toUpperCase() !== 'OPEN')
1219
+ return undefined;
1220
+ switch (mergeable?.toUpperCase()) {
1221
+ case 'MERGEABLE':
1222
+ return 'mergeable';
1223
+ case 'CONFLICTING':
1224
+ return 'conflicting';
1225
+ default:
1226
+ // Includes a field GitHub omitted entirely: not being told is not being told.
1227
+ return 'unknown';
1228
+ }
1229
+ }
1230
+ /** Did `later` happen AFTER `earlier` — a commit, or a re-request, landing past the review?
1231
+ * Unparseable or missing dates answer `false`, which is the conservative direction for both
1232
+ * callers: they only ever use a `true` to demote a review, so no answer must keep the review
1233
+ * current rather than silently dismiss one. */
1234
+ function isAfter(later, earlier) {
1235
+ const a = later ? Date.parse(later) : NaN;
1236
+ const b = earlier ? Date.parse(earlier) : NaN;
1237
+ return Number.isFinite(a) && Number.isFinite(b) && a > b;
1238
+ }
1239
+ /**
1240
+ * An issue's two signals as one word. `NOT_PLANNED` is kept apart from `completed` because they
1241
+ * are opposite outcomes — "we did it" vs "we won't" — and a task whose issue was declined must
1242
+ * not read as a task that landed.
1243
+ */
1244
+ export function deriveIssueReferenceStatus(issue) {
1245
+ if (issue.state.toUpperCase() !== 'CLOSED')
1246
+ return 'open';
1247
+ return (issue.stateReason ?? '').toUpperCase() === 'NOT_PLANNED' ? 'not-planned' : 'completed';
1248
+ }
1249
+ /**
1250
+ * Status per NUMBER, resolved to whatever that number actually is.
1251
+ *
1252
+ * Batched and aliased like `fetchPrChecks`: one subprocess per chunk, each alias resolving
1253
+ * independently, and a number the forge does not have simply staying absent from `resolved` (its
1254
+ * chip then renders neutral, as it did before this seam existed). A failed chunk costs only its
1255
+ * own numbers, and says so rather than letting them look absent. Exported for tests; `runGraphql`
1256
+ * is injected so this is testable without shelling out.
1257
+ */
1258
+ export async function fetchRefStatuses(runGraphql, owner, name, numbers, chunkSize = GH_REF_STATUS_MAX) {
1259
+ const out = { resolved: {}, failed: [] };
1260
+ if (numbers.length === 0)
1261
+ return out;
1262
+ for (let i = 0; i < numbers.length; i += chunkSize) {
1263
+ const chunk = numbers.slice(i, i + chunkSize);
1264
+ try {
1265
+ const raw = JSON.parse(await runGraphql(refStatusQuery(chunk), { owner, name }));
1266
+ const repository = ghRefStatusSchema.parse(raw?.data?.repository ?? {});
1267
+ chunk.forEach((number, index) => {
1268
+ const node = repository[`r${index}`];
1269
+ // Absent alias → the number is not in this repository at all. Left out of the map, which
1270
+ // is what the route reports as "not found" and the chip paints as neutral.
1271
+ if (!node)
1272
+ return;
1273
+ if (node.__typename === 'PullRequest') {
1274
+ const pr = ghRefStatusPrSchema.parse(node);
1275
+ if (!pr)
1276
+ return;
1277
+ const head = pr.commits?.nodes[0]?.commit;
1278
+ const rollup = head?.statusCheckRollup;
1279
+ const mergeability = mergeabilityOf(pr.state, pr.mergeable);
1280
+ out.resolved[number] = {
1281
+ kind: 'pr',
1282
+ status: derivePrReferenceStatus({
1283
+ state: pr.state,
1284
+ isDraft: pr.isDraft,
1285
+ reviewDecision: pr.reviewDecision,
1286
+ // Adapt the single rollup state into the array shape `rollupToChecks` expects,
1287
+ // reusing the FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
1288
+ checks: rollup ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null : null,
1289
+ headCommittedAt: head?.committedDate,
1290
+ // Two parents = "Update branch", which is dated now and answers nothing on its own —
1291
+ // so the first parent's date, the work it sat on top of, is carried with it.
1292
+ headParentCount: head?.parents?.totalCount,
1293
+ headFirstParentCommittedAt: head?.parents?.nodes?.[0]?.committedDate,
1294
+ // `reviews(last: 1, states: CHANGES_REQUESTED)` — the timestamp only. WHETHER changes
1295
+ // are requested stays `reviewDecision`'s answer, which is the one that accounts for
1296
+ // dismissed and superseded reviews.
1297
+ changesRequestedAt: pr.reviews?.nodes[0]?.submittedAt,
1298
+ reviewRequested: (pr.reviewRequests?.totalCount ?? 0) > 0,
1299
+ // WHEN that standing request was made. `reviewRequests` carries no date of its own,
1300
+ // and without one an old request looks exactly like a re-request.
1301
+ reviewRequestedAt: standingReviewRequestedAt(pr.reviewRequests, pr.timelineItems),
1302
+ }),
1303
+ // The tri-state, not a boolean: `unknown` has to survive as far as the cache, which
1304
+ // is what decides to ask again in seconds rather than in a minute.
1305
+ ...(mergeability ? { mergeable: mergeability } : {}),
1306
+ };
1307
+ }
1308
+ else if (node.__typename === 'Issue') {
1309
+ const issue = ghRefStatusIssueSchema.parse(node);
1310
+ if (!issue)
1311
+ return;
1312
+ out.resolved[number] = { kind: 'issue', status: deriveIssueReferenceStatus(issue) };
1313
+ }
1314
+ });
1315
+ }
1316
+ catch (err) {
1317
+ // A failed chunk costs only its own numbers; the rest still resolve. They are recorded as
1318
+ // FAILED rather than left absent, so nothing downstream mistakes them for "no such number".
1319
+ out.failed.push(...chunk);
1320
+ out.reason ??= firstLine(err instanceof Error ? err.message : String(err));
1321
+ }
1322
+ }
1323
+ return out;
1324
+ }
1325
+ // Per-reference cache: keyed `repoRoot␀number` — by NUMBER, not by kind, because the kind is now
1326
+ // something the forge answers rather than something the caller asserts. Same 60 s TTL and bounded
1327
+ // shape as the checks cache; `null` is a cached "this repository has no such number", so a
1328
+ // transcript-scraped number from another repo is not re-queried on every table repaint.
1329
+ //
1330
+ // `unknownSince` is when this reference FIRST came back with its mergeability still being
1331
+ // computed, carried across refreshes so the fast recheck below is bounded to that first window
1332
+ // rather than restarting on every answer that is still `unknown`.
1333
+ const refStatusCache = new Map();
1334
+ const REF_STATUS_CACHE_MAX = 500;
1335
+ /** Test-only: drop the per-reference cache so cases don't leak state into each other. */
1336
+ export function __clearRefStatusCacheForTests() {
1337
+ refStatusCache.clear();
1338
+ }
1339
+ /** Test-only: warm the cache the way the lazy route would have, so a reader can be tested
1340
+ * without a forge behind it. */
1341
+ export function __seedRefStatusCacheForTests(repoRoot, entries) {
1342
+ for (const [number, resolved] of entries) {
1343
+ refStatusCache.set(refStatusKey(repoRoot, number), { at: Date.now(), resolved });
1344
+ }
1345
+ }
1346
+ /**
1347
+ * Forget what we knew about one reference, so the next read asks GitHub again.
1348
+ *
1349
+ * Called where cezar itself CHANGES a pull request — it merges one, it opens one — because those
1350
+ * are the only forge changes this process can know about without asking. Everything else has to
1351
+ * be polled (GitHub cannot push to a cockpit with no public endpoint), but waiting out a TTL to
1352
+ * notice our own merge is a self-inflicted staleness: for up to a minute every chip would keep
1353
+ * showing the pre-merge status of a pull request the user watched this server merge.
1354
+ *
1355
+ * Deleting rather than overwriting with a guessed `merged`: the forge is the authority on what a
1356
+ * reference is, and a mutation that reports success is still not the same as having read the
1357
+ * result. The next reader pays one query and gets the truth — after which the answer is `merged`,
1358
+ * `recheckAfterMs` goes null, and the cockpit stops polling that batch entirely. Invalidating here
1359
+ * therefore REDUCES long-run traffic rather than adding to it.
1360
+ */
1361
+ export function forgetRefStatus(repoRoot, number) {
1362
+ refStatusCache.delete(refStatusKey(repoRoot, number));
1363
+ }
1364
+ /**
1365
+ * Everything the cache ALREADY knows about these numbers. Never spawns `gh`, never awaits.
1366
+ *
1367
+ * This is what lets a status ride along with the rows that carry the references, instead of the
1368
+ * cockpit fetching it separately a moment later: the run index reads whatever is warm and ships
1369
+ * it, and a cold entry is simply absent — the lazy `/github/ref-status` route stays the thing that
1370
+ * actually goes and asks.
1371
+ *
1372
+ * Because it cannot cost anything, the caller may pass a SUPERSET of the numbers it will really
1373
+ * display. That matters: deciding which of a run's references a chip shows is the cockpit's rule
1374
+ * (#407, #526), deliberately not duplicated server-side, and a cache read does not need to know —
1375
+ * it can look up every number a run mentions and let the client pick.
1376
+ */
1377
+ export function readCachedRefStatuses(repoRoot, numbers) {
1378
+ const out = { prs: {}, issues: {} };
1379
+ const now = Date.now();
1380
+ for (const number of new Set(numbers)) {
1381
+ const hit = refStatusCache.get(refStatusKey(repoRoot, number));
1382
+ if (!hit || !hit.resolved || now - hit.at >= refStatusTtl(hit.resolved, hit.unknownSince, now))
1383
+ continue;
1384
+ out[hit.resolved.kind === 'pr' ? 'prs' : 'issues'][number] = hit.resolved.status;
1385
+ }
1386
+ return out;
1387
+ }
1388
+ /** The `#N` in a forge URL — `…/pull/774` → 774. Null when the tail is not a number, so a URL
1389
+ * shape we do not recognise invalidates nothing rather than inventing a key. */
1390
+ export function refNumberFromUrl(url) {
1391
+ const last = /\/(\d+)\/?$/.exec(url.trim());
1392
+ const parsed = last ? Number(last[1]) : Number.NaN;
1393
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
1394
+ }
1395
+ /** A closed issue or an abandoned PR can be REOPENED, so this is long rather than forever — but
1396
+ * it is the rare event, and re-asking a hundred settled references every minute to catch it is
1397
+ * the wrong trade. */
1398
+ const REF_STATUS_CLOSED_TTL = 10 * 60_000;
1399
+ /** A merged pull request is merged forever: GitHub has no un-merge. Capped at a day only so a
1400
+ * long-lived server eventually re-reads rather than trusting a value from another era. */
1401
+ const REF_STATUS_MERGED_TTL = 24 * 60 * 60_000;
1402
+ /**
1403
+ * How long a cached answer stays fresh — by how changeable that answer IS.
1404
+ *
1405
+ * One TTL for everything gets both ends wrong: it re-asks about a merged PR (which cannot change)
1406
+ * every minute, and it is the only thing standing between a running CI job and a stale chip. What
1407
+ * a reader wants rechecked is precisely what is still moving.
1408
+ *
1409
+ * A number the repository does not have keeps the short TTL: it is usually a wrong number, but it
1410
+ * is also what a reference to a not-yet-created PR looks like, and re-asking is cheap.
1411
+ */
1412
+ function refStatusTtl(entry, unknownSince, now = Date.now()) {
1413
+ if (!entry)
1414
+ return CACHE_MS;
1415
+ // Mergeability GitHub has not finished computing is not an answer to cache for a minute. It is
1416
+ // the normal reply for the first seconds after a push, and holding it that long is what let a
1417
+ // conflicting pull request read "Ready to merge" until the page was reloaded. Ask again in
1418
+ // seconds instead — and only while it is still plausibly being computed, so a repository that
1419
+ // answers `UNKNOWN` indefinitely settles back to the ordinary cadence rather than spawning `gh`
1420
+ // every few seconds forever.
1421
+ if (entry.mergeable === 'unknown' &&
1422
+ unknownSince !== undefined &&
1423
+ now - unknownSince < MERGEABILITY_UNKNOWN_WINDOW_MS) {
1424
+ return MERGEABILITY_UNKNOWN_TTL_MS;
1425
+ }
1426
+ switch (entry.status) {
1427
+ case 'merged':
1428
+ return REF_STATUS_MERGED_TTL;
1429
+ case 'closed':
1430
+ case 'completed':
1431
+ case 'not-planned':
1432
+ return REF_STATUS_CLOSED_TTL;
1433
+ default:
1434
+ return CACHE_MS;
1435
+ }
1436
+ }
1437
+ /** How long a status can be trusted to stay put — `null` when it can never change again. The
1438
+ * cadence half of `refStatusTtl`, and deliberately the same function: a value the cache would
1439
+ * still be serving is a value there is no point asking for, and a value it would NOT serve —
1440
+ * mergeability still being computed — is one the cockpit should come back for just as soon. */
1441
+ function refStatusRecheckAfter(entry, unknownSince, now = Date.now()) {
1442
+ if (entry?.status === 'merged')
1443
+ return null; // GitHub has no un-merge
1444
+ return refStatusTtl(entry, unknownSince, now);
1445
+ }
1446
+ /** How long the WHOLE answer holds — the soonest any single reference in it could differ. `null`
1447
+ * only when every one of them is immutable, which is what tells the cockpit to stop scheduling.
1448
+ * Taking the per-reference values rather than the entries, because one of them may be on the fast
1449
+ * mergeability cadence and the batch has to travel at the speed of its most impatient member. */
1450
+ function batchRecheckAfter(rechecks) {
1451
+ let soonest = null;
1452
+ for (const after of rechecks) {
1453
+ if (after === null)
1454
+ continue;
1455
+ soonest = soonest === null ? after : Math.min(soonest, after);
1456
+ }
1457
+ return soonest;
1458
+ }
1459
+ /**
1460
+ * How long a still-computing mergeability holds, and for how long that fast cadence applies.
1461
+ *
1462
+ * Five seconds because that is the shape of the thing being waited for: GitHub kicks off the
1463
+ * merge-base computation when asked and usually has it by the next request. Bounded to a minute
1464
+ * because a value that is STILL unknown after that is not a computation in flight any more — it is
1465
+ * a repository that will not answer, and re-asking it every five seconds forever costs a `gh`
1466
+ * subprocess a second for nothing.
1467
+ */
1468
+ const MERGEABILITY_UNKNOWN_TTL_MS = 5_000;
1469
+ const MERGEABILITY_UNKNOWN_WINDOW_MS = 60_000;
1470
+ /** A forge that could not be reached is worth retrying, and worth not hammering: a workspace with
1471
+ * no `gh` installed would otherwise spawn a subprocess a minute, forever, to be told the same
1472
+ * thing. Five minutes is the same order as the cockpit's own reconnect cadence. */
1473
+ const REF_STATUS_RETRY_MS = 5 * 60_000;
1474
+ /**
1475
+ * GraphQL through `gh`, tolerating a PARTIALLY failed response.
1476
+ *
1477
+ * `gh api graphql` exits non-zero whenever the reply carries an `errors` array — even when `data`
1478
+ * is fully populated for every alias that DID resolve. With one alias per reference that is not an
1479
+ * edge case, it is the normal case: a single number that no longer exists (or never did, having
1480
+ * been scraped from a transcript that named another repository) makes GitHub answer
1481
+ * `{data: {...everything else...}, errors: [NOT_FOUND]}` and `gh` exit 1.
1482
+ *
1483
+ * `execFile` rejects on a non-zero exit, so that used to throw away a whole batch's worth of
1484
+ * perfectly good statuses and report every reference in it as "not found" — including, in the bug
1485
+ * that produced this function, an open pull request in the project's own repository.
1486
+ *
1487
+ * So: a non-zero exit whose stdout still carries a usable `data.repository` is a partial success
1488
+ * and is used as-is. Anything else — no stdout, unparseable stdout, or a null `repository`, which
1489
+ * means the repo handle itself did not resolve — rethrows, so a real failure still degrades to
1490
+ * `{available: false}` instead of masquerading as "none of these exist".
1491
+ */
1492
+ function refStatusGraphql(repoRoot) {
1493
+ return async (query, variables) => {
1494
+ const args = ['api', 'graphql', '-f', `query=${query}`];
1495
+ for (const [key, value] of Object.entries(variables))
1496
+ args.push('-f', `${key}=${value}`);
1497
+ try {
1498
+ return await gh(repoRoot, args);
1499
+ }
1500
+ catch (err) {
1501
+ const stdout = err.stdout;
1502
+ if (typeof stdout === 'string' && hasResolvedRepository(stdout))
1503
+ return stdout;
1504
+ throw err;
1505
+ }
1506
+ };
1507
+ }
1508
+ /**
1509
+ * The repo handle, memoized in the same map `resolveRepoHandle` uses — but rethrowing instead of
1510
+ * answering `null`.
1511
+ *
1512
+ * The memo is the point: without it every recheck tick spawned a second subprocess to re-learn an
1513
+ * `owner/name` that changes approximately never, doubling the cost of the one query that actually
1514
+ * carries information. The difference from `resolveRepoHandle` is the failure: this route turns a
1515
+ * `gh` error into the payload's `reason` — "gh CLI not found — install it and run `gh auth login`"
1516
+ * is what the chip's tooltip now shows a user — and a `null` would erase which failure it was.
1517
+ */
1518
+ async function resolveRepoHandleStrict(repoRoot) {
1519
+ const memo = repoHandleCache.get(repoRoot);
1520
+ if (memo !== undefined)
1521
+ return memo;
1522
+ // A throw here is transient and deliberately NOT memoized, exactly as in `resolveRepoHandle`.
1523
+ const handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
1524
+ repoHandleCache.set(repoRoot, handle); // includes the permanent negative
1525
+ return handle;
1526
+ }
1527
+ /** Is this a reply we can still read aliases out of? `repository: null` says the handle did not
1528
+ * resolve, which is a real failure and must not be read as "the numbers are all missing". */
1529
+ function hasResolvedRepository(stdout) {
1530
+ try {
1531
+ const parsed = JSON.parse(stdout);
1532
+ const repository = parsed?.data?.repository;
1533
+ return typeof repository === 'object' && repository !== null;
1534
+ }
1535
+ catch {
1536
+ return false;
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Route-facing reference status. Resolves the repo handle once, serves fresh cache entries,
1541
+ * queries only the misses, and degrades to `{ available: false, reason }` when `gh` or the handle
1542
+ * is unavailable — never a throw, never a 5xx.
1543
+ *
1544
+ * The two request lists are merged into ONE set of numbers: issues and pull requests share a
1545
+ * repository's numbering space, so a number is one thing and asking about it twice would be asking
1546
+ * the same question twice. What comes back is filed by what each number turned out to BE, which is
1547
+ * why a chip whose kind the cockpit guessed wrong still gets the right status.
1548
+ */
1549
+ export async function fetchGithubRefStatus(repoRoot, input) {
1550
+ const asPrs = sanitizeRefNumbers(input.prs);
1551
+ const asIssues = sanitizeRefNumbers(input.issues);
1552
+ if (process.env.CEZ_DRY_RUN === '1')
1553
+ return mockGithubRefStatus(asPrs, asIssues);
1554
+ const wanted = [...new Set([...asPrs, ...asIssues])];
1555
+ const resolved = { prs: {}, issues: {} };
1556
+ // How soon each reference in the answer could differ — what the batch's cadence is the minimum
1557
+ // of. Per reference rather than per batch because mergeability still being computed is worth
1558
+ // coming back for in seconds while everything else holds for a minute.
1559
+ const rechecks = [];
1560
+ // The second axis, and a list rather than a map because it is nearly always empty: only the
1561
+ // pull requests the forge actively called CONFLICTING are named (see `mergeabilityOf`).
1562
+ const conflicts = [];
1563
+ const file = (number, entry, unknownSince) => {
1564
+ rechecks.push(refStatusRecheckAfter(entry, unknownSince));
1565
+ if (!entry)
1566
+ return;
1567
+ resolved[entry.kind === 'pr' ? 'prs' : 'issues'][number] = entry.status;
1568
+ if (entry.mergeable === 'conflicting')
1569
+ conflicts.push(number);
1570
+ };
1571
+ const misses = [];
1572
+ const now = Date.now();
1573
+ for (const n of wanted) {
1574
+ const hit = refStatusCache.get(refStatusKey(repoRoot, n));
1575
+ if (!hit || now - hit.at >= refStatusTtl(hit.resolved, hit.unknownSince, now))
1576
+ misses.push(n);
1577
+ else
1578
+ file(n, hit.resolved, hit.unknownSince);
1579
+ }
1580
+ if (misses.length === 0) {
1581
+ return {
1582
+ available: true,
1583
+ prs: resolved.prs,
1584
+ issues: resolved.issues,
1585
+ conflicts,
1586
+ recheckAfterMs: batchRecheckAfter(rechecks),
1587
+ };
1588
+ }
1589
+ try {
1590
+ const ownerName = await resolveRepoHandleStrict(repoRoot);
1591
+ if (!ownerName) {
1592
+ return { available: false, reason: 'repository handle unavailable', recheckAfterMs: REF_STATUS_RETRY_MS };
1593
+ }
1594
+ const batch = await fetchRefStatuses(refStatusGraphql(repoRoot), ownerName.owner, ownerName.name, misses);
1595
+ const failed = new Set(batch.failed);
1596
+ // Stamped when the answer ARRIVED, not when the request was assembled: `now` above predates
1597
+ // the round trip, and dating an entry by it would age a slow query's results by its own
1598
+ // duration — shortening the TTL of exactly the answers that cost the most to get.
1599
+ const storedAt = Date.now();
1600
+ for (const n of misses) {
1601
+ // A number we could not ask about is NOT cached: caching it would pin "this repository has
1602
+ // no such number" for a minute on the strength of a network blip.
1603
+ if (failed.has(n))
1604
+ continue;
1605
+ const entry = batch.resolved[n] ?? null;
1606
+ // Kept from the previous answer, not restarted: the fast cadence is bounded from when this
1607
+ // reference FIRST came back still-computing, so a forge that never resolves it cannot hold
1608
+ // the batch on a five-second poll indefinitely.
1609
+ const unknownSince = entry?.mergeable === 'unknown'
1610
+ ? (refStatusCache.get(refStatusKey(repoRoot, n))?.unknownSince ?? storedAt)
1611
+ : undefined;
1612
+ file(n, entry, unknownSince);
1613
+ refStatusCache.set(refStatusKey(repoRoot, n), {
1614
+ at: storedAt,
1615
+ resolved: entry,
1616
+ ...(unknownSince === undefined ? {} : { unknownSince }),
1617
+ });
1618
+ }
1619
+ while (refStatusCache.size > REF_STATUS_CACHE_MAX) {
1620
+ const oldest = refStatusCache.keys().next().value;
1621
+ if (oldest === undefined)
1622
+ break;
1623
+ refStatusCache.delete(oldest);
1624
+ }
1625
+ // Anything unasked makes the whole answer `unavailable`, deliberately. The alternative is a
1626
+ // payload where a number we could not reach is indistinguishable from one that does not exist,
1627
+ // and the cockpit would paint "not found on this repository" over a perfectly good PR — the
1628
+ // exact defect this route was reported for. The successes are cached either way, so the next
1629
+ // request costs only the numbers that failed and usually answers in full.
1630
+ if (failed.size > 0) {
1631
+ return {
1632
+ available: false,
1633
+ reason: batch.reason ?? 'GitHub could not be reached',
1634
+ recheckAfterMs: REF_STATUS_RETRY_MS,
1635
+ };
1636
+ }
1637
+ return {
1638
+ available: true,
1639
+ prs: resolved.prs,
1640
+ issues: resolved.issues,
1641
+ conflicts,
1642
+ recheckAfterMs: batchRecheckAfter(rechecks),
1643
+ };
1644
+ }
1645
+ catch (err) {
1646
+ const message = err instanceof Error ? err.message : String(err);
1647
+ return {
1648
+ available: false,
1649
+ reason: /ENOENT/.test(message)
1650
+ ? 'gh CLI not found — install it and run `gh auth login`'
1651
+ : firstLine(message),
1652
+ recheckAfterMs: REF_STATUS_RETRY_MS,
1653
+ };
1654
+ }
1655
+ }
1656
+ /** NUL separator, as everywhere else here: two projects each having a #42 must not collide. */
1657
+ function refStatusKey(repoRoot, number) {
1658
+ return `${repoRoot}\0#${number}`;
1659
+ }
1660
+ function sanitizeRefNumbers(numbers) {
1661
+ return [...new Set(numbers ?? [])].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_REF_STATUS_MAX);
1662
+ }
1663
+ /** CEZ_DRY_RUN=1 — statuses derived from the mock catalog so the offline demo paints real chips. */
1664
+ function mockGithubRefStatus(prs, issues) {
1665
+ const catalog = mockGithub();
1666
+ const byPr = new Map(catalog.prs.map((p) => [p.number, p]));
1667
+ const byIssue = new Set(catalog.issues.map((i) => i.number));
1668
+ const out = { available: true, prs: {}, issues: {}, recheckAfterMs: CACHE_MS };
1669
+ for (const n of prs) {
1670
+ const pr = byPr.get(n);
1671
+ if (pr) {
1672
+ out.prs[n] = derivePrReferenceStatus({
1673
+ state: 'OPEN',
1674
+ isDraft: pr.isDraft,
1675
+ reviewDecision: null,
1676
+ checks: (pr.checks ?? null),
1677
+ });
1678
+ }
1679
+ }
1680
+ for (const n of issues)
1681
+ if (byIssue.has(n))
1682
+ out.issues[n] = 'open';
1683
+ return out;
1684
+ }
1685
+ /**
1686
+ * The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
1687
+ * filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
1688
+ * gh missing/offline → `{ available: false, reason }`, a 404 → a "not found" hint — never a throw.
1689
+ *
1690
+ * Since #525 the thread is sourced from `/issues/{n}/timeline`, which returns comments AND events
1691
+ * in one stream: `commented` rows go through the unchanged `normalizeComments`, the rest through
1692
+ * `normalizeEvents`. `comments[]` therefore keeps its exact pre-#525 shape, contents and cap
1693
+ * (BACKWARD_COMPATIBILITY.md §2) — see the top-up below for the one case that needed defending.
1694
+ */
1695
+ export async function fetchGithubComments(repoRoot, kind, number, refresh = false) {
1696
+ if (process.env.CEZ_DRY_RUN === '1')
1697
+ return mockGithubComments(kind);
1698
+ // NUL separator: cannot appear in a filesystem path, so roots can never alias.
1699
+ const key = `${repoRoot}\0${kind}#${number}`;
1700
+ const hit = commentsCache.get(key);
1701
+ if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
1702
+ return hit.data;
1703
+ // PR conversation comments live on the issues endpoint too — always use it for the body thread.
1704
+ const legacyComments = () => gh(repoRoot, ['api', `repos/{owner}/{repo}/issues/${number}/comments`, '--paginate']);
1705
+ try {
1706
+ let commentRows = [];
1707
+ let events;
1708
+ let eventsTruncated = false;
1709
+ let stoppedShort = false;
1710
+ try {
1711
+ const pages = await fetchTimelinePages((page, timeoutMs) => gh(repoRoot, [
1712
+ 'api',
1713
+ '-H',
1714
+ 'Accept: application/vnd.github+json',
1715
+ `repos/{owner}/{repo}/issues/${number}/timeline?per_page=${TIMELINE_PER_PAGE}&page=${page}`,
1716
+ ], timeoutMs));
1717
+ stoppedShort = pages.stoppedShort;
1718
+ commentRows = pages.rows.filter((r) => r?.event === 'commented');
1719
+ const normalized = normalizeEvents(pages.rows);
1720
+ events = normalized.events;
1721
+ eventsTruncated = normalized.truncated;
1722
+ // Comments top-up. The two ENTRY caps are independent, but the FETCH budget is not: today's
1723
+ // call is an unbounded --paginate over a comments-only endpoint, so every comment is fetched
1724
+ // and mergeThread picks the oldest 200 from the complete set. Under the timeline those same
1725
+ // 200 slots are filled from at most 1000 rows in which events compete with comments — so on
1726
+ // a 1500-row thread with 250 interleaved comments, comments[] would return ~167 where today
1727
+ // it returns 200. Same §2 defect class as a combined entry cap, arriving via the source set.
1728
+ //
1729
+ // The trigger deliberately over-fires: it cannot distinguish "comments were cut off" from
1730
+ // "this thread just has few comments", because the second is only knowable by fetching them.
1731
+ // So on an event-heavy thread with 30 comments it re-fetches 30 complete comments. Accepted
1732
+ // — it fires only past ~1000 timeline rows, behind the 60 s LRU, and the alternative is a §2
1733
+ // regression. In the other direction the threshold is provably sound: with ≥200 commented
1734
+ // rows already in the oldest-first prefix, mergeThread's oldest-200 cut lies inside it.
1735
+ if (stoppedShort && commentRows.length < THREAD_ENTRY_CAP) {
1736
+ // Its OWN nested catch. The inner handler's remedy is the fallback — which is this very
1737
+ // call — and the outer handler returns { available: false, comments: [] }, emptying a
1738
+ // thread that was about to render. So a thrown top-up is swallowed: keep the commented
1739
+ // rows the timeline already returned and carry on. That is at worst the short list the
1740
+ // safeguard existed to avoid, never worse than not having attempted it.
1741
+ try {
1742
+ commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
1743
+ }
1744
+ catch {
1745
+ // keep the timeline's own commented rows
1746
+ }
1747
+ }
1748
+ }
1749
+ catch (timelineErr) {
1750
+ // Scoped INSIDE the existing outer catch on purpose: the outer handler's /404|not found/i
1751
+ // branch would otherwise turn a timeline 404 into an empty thread. ENOENT is the one failure
1752
+ // the fallback cannot rescue — nothing will work, and a second spawn fails identically.
1753
+ const message = timelineErr instanceof Error ? timelineErr.message : String(timelineErr);
1754
+ if (/ENOENT/.test(message))
1755
+ throw timelineErr;
1756
+ // Every other endpoint-level failure substitutes the legacy comments call. It is a
1757
+ // substitution, not a retry — a different endpoint, which typically still answers. (A 403
1758
+ // attaches to the token rather than the endpoint, so it cannot succeed either; not
1759
+ // special-cased, since rate-limit replies are immediate and it costs one fast spawn.)
1760
+ commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
1761
+ events = undefined;
1762
+ }
1763
+ // Per-commit CI (#525 Phase 2). One extra subprocess per opened thread that contains commits,
1764
+ // behind the same 60 s LRU — a per-thread-open cost, not per-render. Every failure path here
1765
+ // leaves `checks` ABSENT rather than null, so commits simply render unglyphed: the fetch
1766
+ // degrades to "no data" and the render decides what absence looks like.
1767
+ const commitShas = (events ?? []).flatMap((e) => (e.kind === 'committed' && e.sha ? [e.sha] : []));
1768
+ if (commitShas.length > 0) {
1769
+ const handle = await resolveRepoHandle(repoRoot);
1770
+ if (handle) {
1771
+ const runGraphql = (query, variables) => {
1772
+ const args = ['api', 'graphql', '-f', `query=${query}`];
1773
+ for (const [k, v] of Object.entries(variables))
1774
+ args.push('-f', `${k}=${v}`);
1775
+ return gh(repoRoot, args);
1776
+ };
1777
+ const checks = await fetchCommitChecks(runGraphql, handle.owner, handle.name, commitShas);
1778
+ for (const event of events ?? []) {
1779
+ if (event.kind !== 'committed' || !event.sha)
1780
+ continue;
1781
+ if (event.sha in checks)
1782
+ event.checks = checks[event.sha];
1783
+ }
1784
+ }
1785
+ }
1786
+ const parts = [normalizeComments(commentRows)];
1787
+ if (kind === 'pr') {
1788
+ const reviewsOut = await gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/reviews`, '--paginate']);
1789
+ parts.push(normalizeReviews(JSON.parse(reviewsOut)));
1790
+ }
1791
+ const { comments, truncated } = mergeThread(parts);
1792
+ const data = {
1793
+ available: true,
1794
+ comments,
1795
+ // OR-folded exactly as before, so the pre-existing >200-comments trigger is preserved
1796
+ // rather than replaced.
1797
+ truncated: truncated || eventsTruncated || stoppedShort || undefined,
1798
+ };
1799
+ if (events)
1800
+ data.events = events;
1801
+ cacheComments(key, data);
1802
+ return data;
1803
+ }
1804
+ catch (err) {
1805
+ const message = err instanceof Error ? err.message : String(err);
1806
+ const reason = /ENOENT/.test(message)
1807
+ ? 'gh CLI not found — install it and run `gh auth login`'
1808
+ : /404|not found/i.test(message)
1809
+ ? 'not found on GitHub — it may be closed or deleted'
1810
+ : firstLine(message);
1811
+ return { available: false, reason, comments: [] };
1812
+ }
1813
+ }
1814
+ /** CEZ_DRY_RUN=1 — a small fixed thread (one image-bearing comment, plus a review for PRs) so
1815
+ * the whole feature is demoable and e2e-testable offline. */
1816
+ function mockGithubComments(kind) {
1817
+ const base = Date.now() - 3_600_000;
1818
+ const at = (offset) => new Date(base + offset).toISOString();
1819
+ const comments = [
1820
+ {
1821
+ id: 1,
1822
+ author: 'ada',
1823
+ avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4',
1824
+ createdAt: at(0),
1825
+ body: 'Thanks for the report — I can reproduce. Which browser were you on?\n\n```\nchrome 126, macOS\n```',
1826
+ kind: 'comment',
1827
+ url: 'https://github.com/mock/repo/issues/1#issuecomment-1',
1828
+ },
1829
+ {
1830
+ id: 2,
1831
+ author: 'lin',
1832
+ createdAt: at(600_000),
1833
+ body: 'Here is the failing screen:\n\n![failure](https://avatars.githubusercontent.com/u/2?v=4)',
1834
+ kind: 'comment',
1835
+ url: 'https://github.com/mock/repo/issues/1#issuecomment-2',
1836
+ },
1837
+ ];
1838
+ if (kind === 'pr') {
1839
+ comments.push({
1840
+ id: 3,
1841
+ author: 'grace',
1842
+ avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
1843
+ createdAt: at(1_200_000),
1844
+ body: 'Looks good overall — please add a regression test before this lands.',
1845
+ kind: 'review',
1846
+ reviewState: 'changes_requested',
1847
+ url: 'https://github.com/mock/repo/pull/1#pullrequestreview-3',
1848
+ });
1849
+ }
1850
+ // Timeline events (#525) so the whole feature is demoable and e2e-testable offline. Deliberately
1851
+ // covers the cases that are easy to get wrong rather than one of each: a multi-commit run by ONE
1852
+ // author (exercises the client-side grouping) with MIXED check states (passing/failing/pending
1853
+ // plus one `null` = no CI configured), a label change, a cross-reference, and — for PRs — a
1854
+ // merge. SHAs are full 40-char because the rollup query's `oid` rejects abbreviated ones, and a
1855
+ // fixture that cheated there would not survive being pasted into a real query.
1856
+ const sha = (seed) => seed.repeat(40).slice(0, 40);
1857
+ const events = [
1858
+ {
1859
+ id: 'evt-100',
1860
+ kind: 'labeled',
1861
+ actor: 'ada',
1862
+ avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4',
1863
+ createdAt: at(300_000),
1864
+ label: { name: 'bug', color: 'd73a4a' },
1865
+ },
1866
+ {
1867
+ id: `evt-${sha('a')}`,
1868
+ kind: 'committed',
1869
+ actor: 'Lin Zhao',
1870
+ createdAt: at(900_000),
1871
+ sha: sha('a'),
1872
+ message: 'fix(session): keep the refresh token on reload',
1873
+ checks: 'passing',
1874
+ },
1875
+ {
1876
+ id: `evt-${sha('b')}`,
1877
+ kind: 'committed',
1878
+ actor: 'Lin Zhao',
1879
+ createdAt: at(960_000),
1880
+ sha: sha('b'),
1881
+ message: 'test(session): cover the reload path',
1882
+ checks: 'failing',
1883
+ },
1884
+ {
1885
+ id: `evt-${sha('c')}`,
1886
+ kind: 'committed',
1887
+ actor: 'Lin Zhao',
1888
+ createdAt: at(1_020_000),
1889
+ sha: sha('c'),
1890
+ message: 'chore: appease the linter',
1891
+ checks: 'pending',
1892
+ },
1893
+ {
1894
+ id: `evt-${sha('d')}`,
1895
+ kind: 'committed',
1896
+ actor: 'Lin Zhao',
1897
+ createdAt: at(1_080_000),
1898
+ sha: sha('d'),
1899
+ message: 'docs: note the new behavior',
1900
+ checks: null, // no CI configured — renders no glyph, distinct from absent
1901
+ },
1902
+ {
1903
+ id: 'evt-101',
1904
+ kind: 'cross-referenced',
1905
+ actor: 'grace',
1906
+ avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
1907
+ createdAt: at(1_500_000),
1908
+ refNumber: 42,
1909
+ refTitle: 'Session handling rewrite',
1910
+ refIsPr: true,
1911
+ url: 'https://github.com/mock/repo/pull/42',
1912
+ },
1913
+ ];
1914
+ if (kind === 'pr') {
1915
+ events.push({
1916
+ id: 'evt-102',
1917
+ kind: 'merged',
1918
+ actor: 'grace',
1919
+ avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
1920
+ createdAt: at(1_800_000),
1921
+ });
1922
+ }
1923
+ return { available: true, comments, events };
1924
+ }
1925
+ // ---- draft-PR creation (review gate, spec 009) ------------------------------
1926
+ // Final autosave-commit → `git push -u origin cez/<id8>` → `gh pr create
1927
+ // --draft`, all executed in the task worktree (gh picks the repo up from the
1928
+ // worktree's remote). Every failure maps to a one-line human error — the GUI
1929
+ // shows it as a toast plus the manual `git merge <branch>` fallback. Never throws.
1930
+ const PR_URL_RE = /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/;
1931
+ const PUSH_TIMEOUT_MS = 60_000;
1932
+ const PROGRESS_LINES_MAX = 10;
1933
+ export async function createDraftPr(input) {
1934
+ const { run } = input;
1935
+ const worktree = run.worktreePath;
1936
+ const branch = run.branch;
1937
+ if (!worktree || !branch) {
1938
+ return { ok: false, error: 'this task has no worktree/branch to publish' };
1939
+ }
1940
+ // Final autosave: the branch must hold everything before it leaves the box.
1941
+ // This is the LAST flush — unlike the turn-end and run-finalize ones there is
1942
+ // no later autosave to pick the work up, so a refusal (conflicted tree) or a
1943
+ // failed commit has to stop the publish instead of silently opening a PR from
1944
+ // a branch that is missing the run's final state.
1945
+ const saved = await autosaveCommit(worktree, 'pre-PR');
1946
+ if (saved === 'refused') {
1947
+ return {
1948
+ ok: false,
1949
+ error: 'worktree has unresolved merge conflicts — resolve them, then publish again',
1950
+ };
1951
+ }
1952
+ if (saved === 'failed') {
1953
+ return { ok: false, error: 'could not commit the final changes — check git status in the worktree' };
1954
+ }
1955
+ // DRY-RUN (CEZ_DRY_RUN=1): no push, no gh — simulate success with a fake PR
1956
+ // URL so the whole review → PR flow is testable without GitHub.
1957
+ if (process.env.CEZ_DRY_RUN === '1') {
1958
+ return { ok: true, url: 'https://github.com/open-mercato/demo/pull/777', dryRun: true };
1959
+ }
1960
+ const remote = await execTool(['remote', 'get-url', 'origin'], worktree, 'git');
1961
+ if (!remote.ok || !remote.stdout.trim()) {
1962
+ return { ok: false, error: 'no git remote — add one (git remote add origin <url>) or merge the branch locally' };
1963
+ }
1964
+ const push = await execTool(['push', '-u', 'origin', branch], worktree, 'git', PUSH_TIMEOUT_MS);
1965
+ if (!push.ok) {
1966
+ return { ok: false, error: `git push failed — ${tail(push.stderr) || 'unknown error'}` };
1967
+ }
1968
+ const body = buildPrBody(input.handoffText, run.task);
1969
+ // Target the branch the worktree forked from (config `baseBranch`) — without
1970
+ // --base, gh aims at the repo default (main) even when work started on
1971
+ // develop. `origin/x` normalizes to `x`; a raw sha (detached-HEAD fork
1972
+ // point) can't be a PR base, so gh falls back to the default branch.
1973
+ const prBase = run.baseBranch?.replace(/^origin\//, '');
1974
+ const baseArgs = prBase && !/^[0-9a-f]{7,40}$/i.test(prBase) ? ['--base', prBase] : [];
1975
+ const pr = await execTool(['pr', 'create', '--draft', '--head', branch, ...baseArgs, '--title', run.title, '--body', body], worktree, 'gh', PUSH_TIMEOUT_MS);
1976
+ if (!pr.ok) {
1977
+ if (pr.notFound) {
1978
+ return { ok: false, error: 'gh not found — install the GitHub CLI and run `gh auth login`, or merge the branch locally' };
1979
+ }
1980
+ const hint = /auth|log ?in|credential/i.test(pr.stderr) ? ' (try `gh auth login`)' : '';
1981
+ return { ok: false, error: `gh pr create failed — ${tail(pr.stderr) || 'unknown error'}${hint}` };
1982
+ }
1983
+ // gh prints the PR URL on stdout; some versions echo it to stderr instead.
1984
+ const match = PR_URL_RE.exec(`${pr.stdout}\n${pr.stderr}`);
1985
+ if (!match) {
1986
+ return { ok: false, error: 'gh pr create returned no PR URL — check `gh pr list` manually' };
1987
+ }
1988
+ return { ok: true, url: match[0], dryRun: false };
1989
+ }
1990
+ /**
1991
+ * PR body from the handoff journal: the "## Goal" section (task text as
1992
+ * fallback) + the first ~10 lines of "## Progress log" (newest first) +
1993
+ * the cezar footer.
1994
+ */
1995
+ export function buildPrBody(handoffText, task) {
1996
+ const goal = section(handoffText, '## Goal') || task.trim();
1997
+ const progress = section(handoffText, '## Progress log')
1998
+ .split('\n')
1999
+ .filter((l) => l.trim())
2000
+ .slice(0, PROGRESS_LINES_MAX)
2001
+ .join('\n');
2002
+ const parts = ['## Goal', '', goal];
2003
+ if (progress)
2004
+ parts.push('', '## Progress log', '', progress);
2005
+ parts.push('', '---', '', '🤖 made with cezar');
2006
+ return parts.join('\n');
2007
+ }
2008
+ /** Text of one `## Header` section, up to the next `## ` header. */
2009
+ function section(text, header) {
2010
+ const start = text.indexOf(`${header}\n`);
2011
+ if (start < 0)
2012
+ return '';
2013
+ const rest = text.slice(start + header.length + 1);
2014
+ const next = rest.indexOf('\n## ');
2015
+ return (next >= 0 ? rest.slice(0, next) : rest).trim();
2016
+ }
2017
+ /** Last 3 stderr lines, pipe-joined — enough context, toast-sized. */
2018
+ function tail(stderr) {
2019
+ return stderr.trim().split('\n').slice(-3).join(' | ').slice(0, 300);
2020
+ }
2021
+ function execTool(args, cwd, bin, timeoutMs = 30_000) {
2022
+ return new Promise((resolve) => {
2023
+ execFile(bin, args, { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => resolve({
2024
+ ok: !err,
2025
+ stdout: stdout ?? '',
2026
+ stderr: stderr ?? '',
2027
+ notFound: err?.code === 'ENOENT',
2028
+ }));
2029
+ });
2030
+ }
2031
+ // ---- the driver -------------------------------------------------------------
2032
+ /** Cached availability probe so `GET /api/health` never pays a full listing. */
2033
+ let detectCache = null;
2034
+ async function detectGithub(repoRoot) {
2035
+ if (process.env.CEZ_DRY_RUN === '1')
2036
+ return { available: true };
2037
+ if (detectCache && detectCache.repoRoot === repoRoot && Date.now() - detectCache.at < CACHE_MS) {
2038
+ return detectCache.result;
2039
+ }
2040
+ let result;
2041
+ try {
2042
+ await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner'], 5_000);
2043
+ result = { available: true };
2044
+ }
2045
+ catch (err) {
2046
+ const message = err instanceof Error ? err.message : String(err);
2047
+ result = {
2048
+ available: false,
2049
+ reason: /ENOENT/.test(message)
2050
+ ? 'gh CLI not found — install it and run `gh auth login`'
2051
+ : firstLine(message),
2052
+ };
2053
+ }
2054
+ detectCache = { at: Date.now(), repoRoot, result };
2055
+ return result;
2056
+ }
2057
+ /**
2058
+ * Non-blocking availability for `GET /api/health` (#major-health-latency): serves the last-known
2059
+ * probe immediately (stale-while-revalidate) and only returns `null` on a cold start, before the
2060
+ * first probe has ever warmed the cache. It NEVER shells out to `gh` on the request that reads it,
2061
+ * so health stays under the bookmarklet's 800 ms port budget (a `gh repo view` round-trip is
2062
+ * ~500–650 ms on its own). `null` is contract-safe — the whole `forge` field is additive, so
2063
+ * "unknown until warm" is a valid answer.
2064
+ *
2065
+ * Serving the stale value while revalidating is what keeps the GitHub nav item from flickering:
2066
+ * without it, every time the 60 s cache expired this returned `null` for one 5 s health poll,
2067
+ * dropping `forge.available` and blinking the sidebar item out until the background probe warmed.
2068
+ */
2069
+ export function detectGithubCached(repoRoot) {
2070
+ if (process.env.CEZ_DRY_RUN === '1')
2071
+ return { available: true };
2072
+ const cached = detectCache && detectCache.repoRoot === repoRoot ? detectCache.result : null;
2073
+ const fresh = detectCache && detectCache.repoRoot === repoRoot && Date.now() - detectCache.at < CACHE_MS;
2074
+ if (!fresh) {
2075
+ void detectGithub(repoRoot).catch(() => { }); // revalidate off the request path
2076
+ }
2077
+ return cached; // last-known value while revalidating; null only until the first probe warms
2078
+ }
2079
+ const mergeStateCache = new Map();
2080
+ const mergeInflight = new Set();
2081
+ const MERGE_CACHE_MS = 15_000;
2082
+ function mergeCheckState(check) {
2083
+ const value = (check.conclusion || check.state || check.status || '').toUpperCase();
2084
+ if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(value))
2085
+ return 'passing';
2086
+ if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'CANCELLED'].includes(value))
2087
+ return 'failing';
2088
+ if (['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', 'WAITING', 'REQUESTED'].includes(value))
2089
+ return 'pending';
2090
+ return 'unknown';
2091
+ }
2092
+ export function normalizeMergeState(raw, policyRaw, requirements = {
2093
+ readable: false,
2094
+ requiredChecks: [],
2095
+ }) {
2096
+ const pr = mergePrSchema.parse(raw);
2097
+ const policy = repoMergePolicySchema.parse(policyRaw);
2098
+ const state = pr.state.toUpperCase() === 'MERGED' ? 'merged' : pr.state.toUpperCase() === 'CLOSED' ? 'closed' : 'open';
2099
+ const mergeable = pr.mergeable?.toUpperCase() === 'MERGEABLE'
2100
+ ? 'mergeable'
2101
+ : pr.mergeable?.toUpperCase() === 'CONFLICTING'
2102
+ ? 'conflicting'
2103
+ : 'unknown';
2104
+ const reviewDecision = pr.reviewDecision?.toUpperCase() === 'APPROVED'
2105
+ ? 'approved'
2106
+ : pr.reviewDecision?.toUpperCase() === 'CHANGES_REQUESTED'
2107
+ ? 'changes-requested'
2108
+ : pr.reviewDecision?.toUpperCase() === 'REVIEW_REQUIRED'
2109
+ ? 'review-required'
2110
+ : 'unknown';
2111
+ const checks = (pr.statusCheckRollup ?? []).map((check) => ({
2112
+ name: check.name,
2113
+ state: mergeCheckState(check),
2114
+ required: requirements.readable ? requirements.requiredChecks.includes(check.name) : null,
2115
+ ...(check.detailsUrl?.startsWith('https://') || check.detailsUrl?.startsWith('http://')
2116
+ ? { url: check.detailsUrl }
2117
+ : {}),
2118
+ }));
2119
+ const methods = [];
2120
+ if (policy.allow_squash_merge)
2121
+ methods.push('squash');
2122
+ if (policy.allow_merge_commit)
2123
+ methods.push('merge');
2124
+ if (policy.allow_rebase_merge)
2125
+ methods.push('rebase');
2126
+ const defaultMethod = policy.squash_merge_commit_title && methods.includes('squash')
2127
+ ? 'squash'
2128
+ : policy.merge_commit_title && methods.includes('merge')
2129
+ ? 'merge'
2130
+ : methods[0] ?? null;
2131
+ const blockers = [];
2132
+ let eligibility = 'ready';
2133
+ if (state !== 'open') {
2134
+ eligibility = 'terminal';
2135
+ blockers.push({ code: 'terminal', message: state === 'merged' ? 'This pull request is merged.' : 'This pull request is closed.' });
2136
+ }
2137
+ else if (pr.isDraft) {
2138
+ eligibility = 'blocked';
2139
+ blockers.push({ code: 'draft', message: 'Mark the pull request ready for review before merging.' });
2140
+ }
2141
+ else if (mergeable === 'conflicting') {
2142
+ eligibility = 'blocked';
2143
+ blockers.push({ code: 'conflicts', message: 'Conflicts must be resolved before merging.' });
2144
+ }
2145
+ else if (checks.some((check) => check.state === 'failing')) {
2146
+ eligibility = 'blocked';
2147
+ blockers.push({ code: 'checks-failing', message: 'One or more checks are failing.' });
2148
+ }
2149
+ else if (reviewDecision === 'changes-requested' || reviewDecision === 'review-required') {
2150
+ eligibility = 'blocked';
2151
+ blockers.push({ code: 'reviews', message: reviewDecision === 'changes-requested' ? 'Changes were requested.' : 'A required review is missing.' });
2152
+ }
2153
+ else if (reviewDecision === 'unknown' || !requirements.readable) {
2154
+ eligibility = 'unknown';
2155
+ blockers.push({
2156
+ code: 'rules-unknown',
2157
+ message: 'GitHub could not confirm review and branch-protection requirements.',
2158
+ });
2159
+ }
2160
+ else if (checks.some((check) => check.state === 'pending') || pr.mergeStateStatus?.toUpperCase() === 'UNSTABLE') {
2161
+ eligibility = 'pending';
2162
+ blockers.push({ code: 'pending', message: 'Checks or GitHub mergeability are still pending.' });
2163
+ }
2164
+ else if (mergeable !== 'mergeable' ||
2165
+ !['CLEAN', 'HAS_HOOKS'].includes(pr.mergeStateStatus?.toUpperCase() ?? '') ||
2166
+ methods.length === 0) {
2167
+ eligibility = 'unknown';
2168
+ blockers.push({ code: 'unknown', message: 'GitHub could not confirm every merge requirement.' });
2169
+ }
2170
+ const canMerge = eligibility === 'ready';
2171
+ const canOverride = !canMerge &&
2172
+ state === 'open' &&
2173
+ !pr.isDraft &&
2174
+ mergeable !== 'conflicting' &&
2175
+ methods.length > 0;
2176
+ return {
2177
+ number: pr.number,
2178
+ title: pr.title,
2179
+ url: pr.url,
2180
+ state,
2181
+ isDraft: pr.isDraft,
2182
+ headRef: pr.headRefName,
2183
+ baseRef: pr.baseRefName,
2184
+ headSha: pr.headRefOid,
2185
+ mergeable,
2186
+ reviewDecision,
2187
+ checks,
2188
+ methods,
2189
+ defaultMethod,
2190
+ eligibility,
2191
+ blockers,
2192
+ canMerge,
2193
+ canOverride,
2194
+ };
2195
+ }
2196
+ async function fetchPrMergeState(repoRoot, repoRef, number, refresh = false) {
2197
+ if (process.env.CEZ_DRY_RUN === '1') {
2198
+ return {
2199
+ available: true,
2200
+ mergeState: normalizeMergeState({
2201
+ number,
2202
+ title: 'Dry-run pull request',
2203
+ url: `https://github.com/mock/repo/pull/${number}`,
2204
+ state: 'OPEN',
2205
+ isDraft: false,
2206
+ headRefName: 'feat/dry-run',
2207
+ baseRefName: 'main',
2208
+ headRefOid: '0123456789abcdef0123456789abcdef01234567',
2209
+ mergeable: 'MERGEABLE',
2210
+ mergeStateStatus: 'CLEAN',
2211
+ reviewDecision: 'APPROVED',
2212
+ statusCheckRollup: [{ name: 'test', conclusion: 'SUCCESS', detailsUrl: 'https://github.com/mock/repo/actions' }],
2213
+ }, { allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: true }, { readable: true, requiredChecks: ['test'] }),
2214
+ };
2215
+ }
2216
+ if (!repoRef)
2217
+ return { available: false, reason: 'GitHub remote could not be resolved' };
2218
+ const key = `${repoRoot}:${number}`;
2219
+ const hit = mergeStateCache.get(key);
2220
+ if (!refresh && hit && Date.now() - hit.at < MERGE_CACHE_MS)
2221
+ return hit.value;
2222
+ try {
2223
+ const prOut = await gh(repoRoot, [
2224
+ 'pr', 'view', String(number), '--json',
2225
+ 'number,title,url,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup',
2226
+ ]);
2227
+ const parsedPr = mergePrSchema.parse(JSON.parse(prOut));
2228
+ const [policyOut, requiredChecks] = await Promise.all([
2229
+ gh(repoRoot, ['api', `repos/${repoRef.owner}/${repoRef.repo}`]),
2230
+ gh(repoRoot, [
2231
+ 'api',
2232
+ `repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(parsedPr.baseRefName)}/protection/required_status_checks`,
2233
+ '--jq',
2234
+ '[.contexts[]?, .checks[]?.context] | unique',
2235
+ ])
2236
+ .then((output) => ({ readable: true, requiredChecks: z.array(z.string()).parse(JSON.parse(output)) }))
2237
+ .catch(() => ({ readable: false, requiredChecks: [] })),
2238
+ ]);
2239
+ const value = {
2240
+ available: true,
2241
+ mergeState: normalizeMergeState(parsedPr, JSON.parse(policyOut), requiredChecks),
2242
+ };
2243
+ mergeStateCache.set(key, { at: Date.now(), value });
2244
+ return value;
2245
+ }
2246
+ catch (error) {
2247
+ return { available: false, reason: firstLine(error instanceof Error ? error.message : String(error)) };
2248
+ }
2249
+ }
2250
+ export function evictGithubProjectCaches(repoRoot) {
2251
+ listCache.delete(repoRoot);
2252
+ mergeStateCache.forEach((_value, key) => {
2253
+ if (key.startsWith(`${repoRoot}:`))
2254
+ mergeStateCache.delete(key);
2255
+ });
2256
+ commentsCache.forEach((_value, key) => {
2257
+ if (key.startsWith(`${repoRoot}:`))
2258
+ commentsCache.delete(key);
2259
+ });
2260
+ }
2261
+ async function mergePullRequest(repoRoot, repoRef, number, input) {
2262
+ const key = `${repoRoot}:${number}`;
2263
+ if (mergeInflight.has(key))
2264
+ return { merged: false, status: 409, error: 'A merge is already in progress.', code: 'concurrent' };
2265
+ mergeInflight.add(key);
2266
+ try {
2267
+ const fresh = await fetchPrMergeState(repoRoot, repoRef, number, true);
2268
+ if (!fresh.available)
2269
+ return { merged: false, status: 502, error: fresh.reason };
2270
+ const current = fresh.mergeState;
2271
+ if (current.headSha !== input.expectedHeadSha) {
2272
+ return { merged: false, status: 409, error: 'The pull request head changed. Review the new commits before merging.', code: 'stale-head', current };
2273
+ }
2274
+ if (!current.methods.includes(input.method)) {
2275
+ return { merged: false, status: 409, error: 'That merge method is no longer enabled.', code: 'disabled-method', current };
2276
+ }
2277
+ if (!mergePreflightAllowed(current, input.overrideRules)) {
2278
+ return { merged: false, status: 409, error: current.blockers[0]?.message ?? 'The pull request is not eligible to merge.', code: current.eligibility, current };
2279
+ }
2280
+ if (process.env.CEZ_DRY_RUN === '1') {
2281
+ evictGithubProjectCaches(repoRoot);
2282
+ return { merged: true, number, url: current.url, method: input.method, mergeCommitSha: 'abcdef0123456789abcdef0123456789abcdef01' };
2283
+ }
2284
+ if (!repoRef)
2285
+ return { merged: false, status: 404, error: 'GitHub repository not found.' };
2286
+ const out = await gh(repoRoot, [
2287
+ 'api', '--method', 'PUT', `repos/${repoRef.owner}/${repoRef.repo}/pulls/${number}/merge`,
2288
+ '-f', `merge_method=${input.method}`, '-f', `sha=${input.expectedHeadSha}`,
2289
+ ]);
2290
+ const result = ghMergeResultSchema.parse(JSON.parse(out));
2291
+ if (!result.merged)
2292
+ return { merged: false, status: 409, error: result.message ?? 'GitHub refused the merge.', code: 'github-blocked', current };
2293
+ evictGithubProjectCaches(repoRoot);
2294
+ return { merged: true, number, url: current.url, method: input.method, ...(result.sha ? { mergeCommitSha: result.sha } : {}) };
2295
+ }
2296
+ catch (error) {
2297
+ const message = firstLine(error instanceof Error ? error.message : String(error));
2298
+ const status = /403|permission|forbidden/i.test(message) ? 403 : /404|not found/i.test(message) ? 404 : 502;
2299
+ return { merged: false, status, error: status === 403 ? 'GitHub permission denied.' : status === 404 ? 'Pull request or repository not found.' : 'GitHub could not complete the merge.' };
2300
+ }
2301
+ finally {
2302
+ mergeInflight.delete(key);
2303
+ }
2304
+ }
2305
+ export function mergePreflightAllowed(current, overrideRules = false) {
2306
+ return current.canMerge || (overrideRules && current.canOverride);
2307
+ }
2308
+ const GH_PR_STATES = {
2309
+ MERGED: 'merged',
2310
+ CLOSED: 'closed',
2311
+ };
2312
+ export function createGithubDriver(repoRoot, repoRef) {
2313
+ return {
2314
+ kind: 'github',
2315
+ detect: () => detectGithub(repoRoot),
2316
+ detectCached: () => detectGithubCached(repoRoot),
2317
+ listIssues: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).issues,
2318
+ listPRs: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).prs,
2319
+ prDiff: (number, opts) => fetchGithubPrDiff(repoRoot, number, opts?.refresh),
2320
+ createPR: (input) => createDraftPr(input),
2321
+ // Null covers everything from "no PR yet" to "gh missing" — the callers
2322
+ // (Create PR → View PR flip) treat all of it as "nothing to link".
2323
+ prStatus: async (branch) => {
2324
+ if (process.env.CEZ_DRY_RUN === '1')
2325
+ return null;
2326
+ try {
2327
+ const out = await gh(repoRoot, ['pr', 'view', branch, '--json', 'number,url,state,isDraft,statusCheckRollup']);
2328
+ const pr = ghPrViewSchema.parse(JSON.parse(out));
2329
+ return {
2330
+ number: pr.number,
2331
+ url: pr.url,
2332
+ state: GH_PR_STATES[pr.state.toUpperCase()] ?? 'open',
2333
+ isDraft: pr.isDraft,
2334
+ checks: rollupToChecks(pr.statusCheckRollup) ?? null,
2335
+ };
2336
+ }
2337
+ catch {
2338
+ return null;
2339
+ }
2340
+ },
2341
+ prMergeState: (number, opts) => fetchPrMergeState(repoRoot, repoRef, number, opts?.refresh),
2342
+ mergePR: (number, input) => mergePullRequest(repoRoot, repoRef, number, input),
2343
+ viewUrl: (kind, ref) => {
2344
+ if (!repoRef)
2345
+ return null;
2346
+ const base = `https://github.com/${repoRef.owner}/${repoRef.repo}`;
2347
+ // Branch names may contain '/' — encode per segment, keep the slashes.
2348
+ const path = String(ref).split('/').map(encodeURIComponent).join('/');
2349
+ switch (kind) {
2350
+ case 'repo':
2351
+ return base;
2352
+ case 'issue':
2353
+ return `${base}/issues/${path}`;
2354
+ case 'pr':
2355
+ return `${base}/pull/${path}`;
2356
+ case 'branch':
2357
+ return `${base}/tree/${path}`;
2358
+ case 'commit':
2359
+ return `${base}/commit/${path}`;
2360
+ }
2361
+ },
2362
+ };
2363
+ }
2364
+ //# sourceMappingURL=github.js.map