@qodeca/xezar 0.10.1

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