@open-mercato/cezar 0.9.2 → 0.10.0-pr892.1298

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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -38
  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 +8 -0
  7. package/dist/agent-config/catalog.js +18 -0
  8. package/dist/agent-config/catalog.js.map +1 -1
  9. package/dist/agent-config/model-settings/claude.d.ts +2 -0
  10. package/dist/agent-config/model-settings/claude.js +11 -0
  11. package/dist/agent-config/model-settings/claude.js.map +1 -0
  12. package/dist/agent-config/model-settings/codex.d.ts +2 -0
  13. package/dist/agent-config/model-settings/codex.js +16 -0
  14. package/dist/agent-config/model-settings/codex.js.map +1 -0
  15. package/dist/agent-config/model-settings/opencode.d.ts +2 -0
  16. package/dist/agent-config/model-settings/opencode.js +8 -0
  17. package/dist/agent-config/model-settings/opencode.js.map +1 -0
  18. package/dist/agent-config/model-settings/pi.d.ts +12 -0
  19. package/dist/agent-config/model-settings/pi.js +18 -0
  20. package/dist/agent-config/model-settings/pi.js.map +1 -0
  21. package/dist/agent-config/model-settings/shared.d.ts +9 -0
  22. package/dist/agent-config/model-settings/shared.js +93 -0
  23. package/dist/agent-config/model-settings/shared.js.map +1 -0
  24. package/dist/agent-config/model-settings/types.d.ts +9 -0
  25. package/dist/agent-config/model-settings/types.js +2 -0
  26. package/dist/agent-config/model-settings/types.js.map +1 -0
  27. package/dist/agent-config/models.d.ts +9 -0
  28. package/dist/agent-config/models.js +32 -0
  29. package/dist/agent-config/models.js.map +1 -0
  30. package/dist/agent-config/service.js +4 -4
  31. package/dist/agent-config/service.js.map +1 -1
  32. package/dist/automations/types.d.ts +4 -0
  33. package/dist/automations/types.js +2 -1
  34. package/dist/automations/types.js.map +1 -1
  35. package/dist/config.d.ts +10 -1
  36. package/dist/config.js +48 -5
  37. package/dist/config.js.map +1 -1
  38. package/dist/contract/agent-config.d.ts +2 -0
  39. package/dist/contract/agent-profiles.d.ts +347 -0
  40. package/dist/contract/automations.d.ts +24 -0
  41. package/dist/contract/events.d.ts +59 -0
  42. package/dist/contract/github.d.ts +88 -0
  43. package/dist/contract/health.d.ts +7 -1
  44. package/dist/contract/index.d.ts +1 -0
  45. package/dist/contract/index.js +1005 -555
  46. package/dist/contract/projects.d.ts +33 -5
  47. package/dist/contract/runs.d.ts +247 -0
  48. package/dist/contract/skills.d.ts +7 -0
  49. package/dist/contract/workflows.d.ts +6 -0
  50. package/dist/contract/workspace.d.ts +137 -2
  51. package/dist/core/agent-env.js +41 -16
  52. package/dist/core/agent-env.js.map +1 -1
  53. package/dist/core/agent-model-policy.d.ts +10 -0
  54. package/dist/core/agent-model-policy.js +36 -0
  55. package/dist/core/agent-model-policy.js.map +1 -0
  56. package/dist/core/agent-profiles.d.ts +62 -0
  57. package/dist/core/agent-profiles.js +90 -0
  58. package/dist/core/agent-profiles.js.map +1 -0
  59. package/dist/core/agent-runner.d.ts +37 -6
  60. package/dist/core/agent-runner.js +35 -3
  61. package/dist/core/agent-runner.js.map +1 -1
  62. package/dist/core/backend-detect.d.ts +5 -5
  63. package/dist/core/backend-detect.js +44 -7
  64. package/dist/core/backend-detect.js.map +1 -1
  65. package/dist/core/claude-cli-runner.js +10 -5
  66. package/dist/core/claude-cli-runner.js.map +1 -1
  67. package/dist/core/codex-app-server-runner.js +13 -3
  68. package/dist/core/codex-app-server-runner.js.map +1 -1
  69. package/dist/core/codex-app-server-transport.js +7 -2
  70. package/dist/core/codex-app-server-transport.js.map +1 -1
  71. package/dist/core/model-identity.d.ts +23 -11
  72. package/dist/core/model-identity.js +41 -24
  73. package/dist/core/model-identity.js.map +1 -1
  74. package/dist/core/model-presets.d.ts +8 -2
  75. package/dist/core/model-presets.js +51 -8
  76. package/dist/core/model-presets.js.map +1 -1
  77. package/dist/core/opencode-model-catalog.d.ts +32 -0
  78. package/dist/core/opencode-model-catalog.js +153 -0
  79. package/dist/core/opencode-model-catalog.js.map +1 -0
  80. package/dist/core/opencode-server-runner.d.ts +2 -0
  81. package/dist/core/opencode-server-runner.js +42 -11
  82. package/dist/core/opencode-server-runner.js.map +1 -1
  83. package/dist/core/pi-runner.d.ts +24 -0
  84. package/dist/core/pi-runner.js +349 -0
  85. package/dist/core/pi-runner.js.map +1 -0
  86. package/dist/core/pi-ui-mapper.d.ts +34 -0
  87. package/dist/core/pi-ui-mapper.js +270 -0
  88. package/dist/core/pi-ui-mapper.js.map +1 -0
  89. package/dist/core/provider-auth.d.ts +95 -3
  90. package/dist/core/provider-auth.js +234 -15
  91. package/dist/core/provider-auth.js.map +1 -1
  92. package/dist/core/provider-availability.d.ts +1 -1
  93. package/dist/core/provider-availability.js +2 -1
  94. package/dist/core/provider-availability.js.map +1 -1
  95. package/dist/core/runner-factory.js +3 -0
  96. package/dist/core/runner-factory.js.map +1 -1
  97. package/dist/core/shell-env.d.ts +28 -0
  98. package/dist/core/shell-env.js +56 -0
  99. package/dist/core/shell-env.js.map +1 -0
  100. package/dist/core/ui-events.d.ts +1 -1
  101. package/dist/core/usage-limit.d.ts +42 -0
  102. package/dist/core/usage-limit.js +209 -0
  103. package/dist/core/usage-limit.js.map +1 -0
  104. package/dist/git-diff-base.d.ts +51 -26
  105. package/dist/git-diff-base.js +163 -37
  106. package/dist/git-diff-base.js.map +1 -1
  107. package/dist/git-worktree.d.ts +15 -6
  108. package/dist/git-worktree.js +10 -3
  109. package/dist/git-worktree.js.map +1 -1
  110. package/dist/index.js +3 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/paths.d.ts +53 -5
  113. package/dist/paths.js +63 -9
  114. package/dist/paths.js.map +1 -1
  115. package/dist/planner.js +6 -0
  116. package/dist/planner.js.map +1 -1
  117. package/dist/release/snapshot.d.ts +13 -2
  118. package/dist/release/snapshot.js +25 -1
  119. package/dist/release/snapshot.js.map +1 -1
  120. package/dist/runs/agent-tmpdir.d.ts +41 -0
  121. package/dist/runs/agent-tmpdir.js +183 -0
  122. package/dist/runs/agent-tmpdir.js.map +1 -0
  123. package/dist/runs/auto-name.js +6 -1
  124. package/dist/runs/auto-name.js.map +1 -1
  125. package/dist/runs/event-history.d.ts +50 -0
  126. package/dist/runs/event-history.js +613 -0
  127. package/dist/runs/event-history.js.map +1 -0
  128. package/dist/runs/run-index.d.ts +19 -0
  129. package/dist/runs/run-index.js +43 -0
  130. package/dist/runs/run-index.js.map +1 -0
  131. package/dist/runs/store.d.ts +78 -19
  132. package/dist/runs/store.js +175 -43
  133. package/dist/runs/store.js.map +1 -1
  134. package/dist/server/capabilities.d.ts +15 -1
  135. package/dist/server/capabilities.js +16 -1
  136. package/dist/server/capabilities.js.map +1 -1
  137. package/dist/server/forge/github.d.ts +169 -0
  138. package/dist/server/forge/github.js +511 -0
  139. package/dist/server/forge/github.js.map +1 -1
  140. package/dist/server/forge/index.d.ts +9 -0
  141. package/dist/server/forge/index.js +14 -0
  142. package/dist/server/forge/index.js.map +1 -1
  143. package/dist/server/git-changes.d.ts +7 -5
  144. package/dist/server/git-changes.js +15 -10
  145. package/dist/server/git-changes.js.map +1 -1
  146. package/dist/server/github.d.ts +2 -2
  147. package/dist/server/github.js +1 -1
  148. package/dist/server/github.js.map +1 -1
  149. package/dist/server/open-in-app.d.ts +2 -1
  150. package/dist/server/open-in-app.js +3 -1
  151. package/dist/server/open-in-app.js.map +1 -1
  152. package/dist/server/open-in-terminal.d.ts +46 -1
  153. package/dist/server/open-in-terminal.js +72 -13
  154. package/dist/server/open-in-terminal.js.map +1 -1
  155. package/dist/server/provider-action-gate.js +3 -1
  156. package/dist/server/provider-action-gate.js.map +1 -1
  157. package/dist/server/server.d.ts +2206 -434
  158. package/dist/server/server.js +1219 -136
  159. package/dist/server/server.js.map +1 -1
  160. package/dist/server-install/engine.js +5 -18
  161. package/dist/server-install/engine.js.map +1 -1
  162. package/dist/server-install/steps.js +6 -2
  163. package/dist/server-install/steps.js.map +1 -1
  164. package/dist/skills.js +4 -0
  165. package/dist/skills.js.map +1 -1
  166. package/dist/workflows/run.d.ts +229 -9
  167. package/dist/workflows/run.js +877 -147
  168. package/dist/workflows/run.js.map +1 -1
  169. package/dist/workflows/types.d.ts +3 -0
  170. package/dist/workflows/types.js +11 -2
  171. package/dist/workflows/types.js.map +1 -1
  172. package/dist/workspace/agent-accounts.d.ts +153 -0
  173. package/dist/workspace/agent-accounts.js +304 -0
  174. package/dist/workspace/agent-accounts.js.map +1 -0
  175. package/dist/workspace/agent-profiles.d.ts +78 -0
  176. package/dist/workspace/agent-profiles.js +115 -0
  177. package/dist/workspace/agent-profiles.js.map +1 -0
  178. package/dist/workspace/config.d.ts +27 -1
  179. package/dist/workspace/config.js +83 -2
  180. package/dist/workspace/config.js.map +1 -1
  181. package/dist/workspace/projects-cli.js +54 -4
  182. package/dist/workspace/projects-cli.js.map +1 -1
  183. package/dist/workspace/projects.d.ts +19 -1
  184. package/dist/workspace/projects.js +34 -1
  185. package/dist/workspace/projects.js.map +1 -1
  186. package/dist/workspace/semaphore.d.ts +46 -1
  187. package/dist/workspace/semaphore.js +41 -4
  188. package/dist/workspace/semaphore.js.map +1 -1
  189. package/dist/workspace/ui-state.d.ts +3 -2
  190. package/dist/workspace/ui-state.js +3 -2
  191. package/dist/workspace/ui-state.js.map +1 -1
  192. package/package.json +3 -3
  193. package/scripts/mock-claude.mjs +20 -0
  194. package/scripts/mock-pi-rpc.mjs +83 -0
  195. package/web/dist/assets/{alert-dialog-ghffK6g9.js → alert-dialog-Byp42_IG.js} +1 -1
  196. package/web/dist/assets/arrow-down-CGDbDYKD.js +1 -0
  197. package/web/dist/assets/arrow-left-ESCjr1Hq.js +1 -0
  198. package/web/dist/assets/centered-state-LfAW-igm.js +43 -0
  199. package/web/dist/assets/chevron-right-Du-P1IUd.js +1 -0
  200. package/web/dist/assets/{collapsible-B0JVzd0B.js → collapsible-CBiiqDJM.js} +1 -1
  201. package/web/dist/assets/{commit-list-wm6DPHNs.js → commit-list-6PPve7PE.js} +1 -1
  202. package/web/dist/assets/{compare-variants-DbdP4Y5T.js → compare-variants-BDnp6mXJ.js} +1 -1
  203. package/web/dist/assets/{diff-Bizc96qe.js → diff-_jK0OWpF.js} +2 -2
  204. package/web/dist/assets/{diff-stat-Dyfg3ltF.js → diff-stat-CIrTu4Ym.js} +1 -1
  205. package/web/dist/assets/{diff-view-CG8ek6tC.js → diff-view-COA_5IyY.js} +1 -1
  206. package/web/dist/assets/dropdown-menu-BofYpFFw.js +1 -0
  207. package/web/dist/assets/editable-title-_J0zFDl-.js +1 -0
  208. package/web/dist/assets/{ellipsis-vertical-DpGTRqC2.js → ellipsis-vertical-BeMR-l62.js} +1 -1
  209. package/web/dist/assets/{file-DlUGpUlb.js → file-DY996Pee.js} +1 -1
  210. package/web/dist/assets/{folder-BAY6Vddy.js → folder-NO3Ec-TY.js} +1 -1
  211. package/web/dist/assets/git-toolbar-BqJeUWcD.js +1 -0
  212. package/web/dist/assets/github-C96HPV9E.js +1 -0
  213. package/web/dist/assets/{image-preview-BWGk5fPX.js → image-preview-D9cCDfKQ.js} +1 -1
  214. package/web/dist/assets/index-Bx-grqa9.css +2 -0
  215. package/web/dist/assets/index-DoVvGDfW.js +7 -0
  216. package/web/dist/assets/{markdown-BGBOGCA_.js → markdown-DDzk11zj.js} +1 -1
  217. package/web/dist/assets/new-task-form-BNLLgC6P.js +1 -0
  218. package/web/dist/assets/pill-CVM1EmV4.js +1 -0
  219. package/web/dist/assets/{project-router-Cx8NPoqn.js → project-router-LRCtP48W.js} +1 -1
  220. package/web/dist/assets/prompt-templates-DFOvYgtW.js +15 -0
  221. package/web/dist/assets/{refresh-cw-BoGdAykg.js → refresh-cw-D1ys6QY2.js} +1 -1
  222. package/web/dist/assets/repo-git-CzUnsgWY.js +1 -0
  223. package/web/dist/assets/{run-diff-EcOkf3ti.js → run-diff-BlnqwfSS.js} +2 -2
  224. package/web/dist/assets/run-header-Bn7cN0Z1.js +1 -0
  225. package/web/dist/assets/{search-x-X3EpVhgs.js → search-x-D6FSpxId.js} +1 -1
  226. package/web/dist/assets/{skill-empty-hint-CZLALx6l.js → skill-empty-hint-iwpCcqgI.js} +1 -1
  227. package/web/dist/assets/skills-CezbOaol.js +1 -0
  228. package/web/dist/assets/{sparkles-Dt4q7pbG.js → sparkles-MrQrgCI9.js} +1 -1
  229. package/web/dist/assets/{square-terminal-hwBL8XsC.js → square-terminal-TTH64lO7.js} +1 -1
  230. package/web/dist/assets/tab-link-FYksz037.js +1 -0
  231. package/web/dist/assets/task-changes-ClWlnzBL.js +1 -0
  232. package/web/dist/assets/{task-commits-BTTA5k5T.js → task-commits-Crbrl86b.js} +1 -1
  233. package/web/dist/assets/{task-files-Da8_4C9g.js → task-files-uSCWyD0-.js} +2 -2
  234. package/web/dist/assets/task-thread-DPmamBA1.js +9 -0
  235. package/web/dist/assets/{textarea-CUdokhkv.js → textarea-B9Ahj4E4.js} +1 -1
  236. package/web/dist/assets/thread-loading-BH_ypQoy.js +1 -0
  237. package/web/dist/assets/{trash-2-DhVIdWv8.js → trash-2-D8S3V3fu.js} +1 -1
  238. package/web/dist/assets/{triangle-alert-XYT0DGCI.js → triangle-alert-bB7p9n5E.js} +1 -1
  239. package/web/dist/assets/{upload-BKjN59-1.js → upload-Bd5oRLPV.js} +1 -1
  240. package/web/dist/assets/{use-desktop-Bf5LS_P4.js → use-desktop-BNvbRBeZ.js} +1 -1
  241. package/web/dist/assets/{use-submit-shortcut-CzVyoAMd.js → use-submit-shortcut-B-xhu4Bf.js} +1 -1
  242. package/web/dist/assets/utils-BhpbVMwN.js +64 -0
  243. package/web/dist/assets/{workflows-o2QU_pvU.js → workflows-RmfbM9Vy.js} +3 -3
  244. package/web/dist/assets/{zoomable-image-7WyUiaVD.js → zoomable-image-BCW6ZBV0.js} +1 -1
  245. package/web/dist/index.html +24 -23
  246. package/web/dist/assets/arrow-down-Cz8qb71e.js +0 -1
  247. package/web/dist/assets/arrow-left-wcYKQXI_.js +0 -1
  248. package/web/dist/assets/centered-state-ZtnFHA-n.js +0 -43
  249. package/web/dist/assets/chevron-right-G4caF2QV.js +0 -1
  250. package/web/dist/assets/circle-check--KvBxXWp.js +0 -1
  251. package/web/dist/assets/circle-x-C3EFZ3xf.js +0 -1
  252. package/web/dist/assets/dropdown-menu-C1Dlf35P.js +0 -1
  253. package/web/dist/assets/editable-title-5rC_QRBZ.js +0 -1
  254. package/web/dist/assets/git-pull-request-agOG0iGM.js +0 -1
  255. package/web/dist/assets/git-toolbar-DGiNMmtY.js +0 -1
  256. package/web/dist/assets/github-Vp7lxYGQ.js +0 -1
  257. package/web/dist/assets/index-DSxyJpuK.css +0 -2
  258. package/web/dist/assets/index-DiawI6RX.js +0 -6
  259. package/web/dist/assets/new-task-form-BpAw_Dks.js +0 -1
  260. package/web/dist/assets/pill-Dvs-P0xy.js +0 -1
  261. package/web/dist/assets/prompt-templates-CSBYBR1I.js +0 -15
  262. package/web/dist/assets/repo-git-PmX-BNMo.js +0 -1
  263. package/web/dist/assets/run-header-BGiv6_qX.js +0 -1
  264. package/web/dist/assets/skills-CC91ouGH.js +0 -1
  265. package/web/dist/assets/tab-link-DIlR5T07.js +0 -1
  266. package/web/dist/assets/task-changes-XkHDxzQA.js +0 -1
  267. package/web/dist/assets/task-thread-DoNXUsHr.js +0 -9
  268. package/web/dist/assets/thread-loading-B8q8ukXJ.js +0 -1
  269. package/web/dist/assets/utils-DsLuRXwV.js +0 -64
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { z } from 'zod';
4
+ import { REFERENCE_STATUS_MAX } from '../../contract/index.js';
4
5
  import { autosaveCommit } from '../../git-worktree.js';
5
6
  /**
6
7
  * The GitHub forge driver — all `gh`-CLI logic in one place, moved here from
@@ -980,6 +981,516 @@ function mockGithubChecks(numbers) {
980
981
  checks[n] = byNumber.get(n) ?? null;
981
982
  return { available: true, checks };
982
983
  }
984
+ /** Numbers per kind in one ref-status query — the same bound, and for the same reasons, as
985
+ * `GH_CHECKS_MAX`: aliases resolve independently, and the query size stays finite. Taken from the
986
+ * contract rather than restated, because the cockpit caps its batches by the same number and a
987
+ * client that guessed high would have every chip in a batch 400 instead of losing its tail. */
988
+ export const GH_REF_STATUS_MAX = REFERENCE_STATUS_MAX;
989
+ const ghRefStatusPrSchema = z
990
+ .object({
991
+ state: z.string(),
992
+ isDraft: z.boolean().nullish(),
993
+ reviewDecision: z.string().nullish(),
994
+ commits: z
995
+ .object({
996
+ nodes: z.array(z.object({
997
+ commit: z.object({
998
+ committedDate: z.string().nullish(),
999
+ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
1000
+ }),
1001
+ })),
1002
+ })
1003
+ .nullish(),
1004
+ reviews: z.object({ nodes: z.array(z.object({ submittedAt: z.string().nullish() })) }).nullish(),
1005
+ reviewRequests: z.object({ totalCount: z.number() }).nullish(),
1006
+ })
1007
+ .nullish();
1008
+ const ghRefStatusIssueSchema = z.object({ state: z.string(), stateReason: z.string().nullish() }).nullish();
1009
+ // The aliases hold two DIFFERENT shapes, so the record stays unvalidated here and each alias is
1010
+ // parsed by its own schema below. A union at this level would be wrong, not merely loose: zod
1011
+ // strips unknown keys, so a PR node matched by the issue arm would come back with `isDraft` and
1012
+ // `commits` silently removed.
1013
+ const ghRefStatusSchema = z.record(z.string(), z.unknown());
1014
+ /**
1015
+ * One alias per NUMBER, asking `issueOrPullRequest` rather than `pullRequest`/`issue`.
1016
+ *
1017
+ * Issues and pull requests share one numbering space in a repository, so a number is exactly one
1018
+ * of the two and asking which is a question the forge can answer — where asking `issue(number:)`
1019
+ * about a pull request is a question with no answer, and GitHub says so with a NOT_FOUND *error*
1020
+ * rather than a null. That mattered far more than it looks: `gh api graphql` exits non-zero the
1021
+ * moment a response carries an `errors` array, so one mis-guessed kind used to fail the whole
1022
+ * batch and report every reference in it as "not found" (see `refStatusGraphql`).
1023
+ *
1024
+ * It also fixes the reference whose kind the cockpit guessed wrong — `taskReferences` infers it
1025
+ * from which field carried the number, and a bare `#774` can land in either — because the answer
1026
+ * carries `__typename` and is filed under what the number REALLY is.
1027
+ *
1028
+ * `committedDate` and the last CHANGES_REQUESTED review's `submittedAt` cost nothing extra, riding
1029
+ * the same node, and are what let the precedence tell a review the author has already responded to
1030
+ * from one still about the code on screen.
1031
+ */
1032
+ function refStatusQuery(numbers) {
1033
+ const aliases = numbers
1034
+ .map((n, i) => ` r${i}: issueOrPullRequest(number: ${n}) { __typename ... on PullRequest { state isDraft reviewDecision commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } } reviews(last: 1, states: CHANGES_REQUESTED) { nodes { submittedAt } } reviewRequests(first: 1) { totalCount } } ... on Issue { state stateReason } }`)
1035
+ .join('\n');
1036
+ return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
1037
+ }
1038
+ /**
1039
+ * The one place a PR's signals collapse into a single word.
1040
+ *
1041
+ * The question the chip answers is "what is this waiting on RIGHT NOW", so the ranking is by
1042
+ * freshness of the signal, not by how heavy a blocker it is:
1043
+ *
1044
+ * Read the ranking as **whose move is it**, which is the question a table is scanned for:
1045
+ *
1046
+ * 1. `merged` / `closed` — nobody's. Terminal, whatever checks or reviews say.
1047
+ * 2. `draft` — the author's, and they have said so themselves.
1048
+ * 3. `checks-pending` — the machine's. CI running means a commit was JUST pushed, the newest
1049
+ * thing that has happened to this PR, so it outranks a requested change unconditionally.
1050
+ * 4. `changes-requested` — the AUTHOR's, and only while that is still true: the review must be
1051
+ * about the code that is there now, with no re-review already asked for.
1052
+ * 5. `checks-failing` — the author's again. A reviewer cannot approve a red PR anyway.
1053
+ * 6. `review-required` — the REVIEWER's: they have been asked, or the author has answered and
1054
+ * the merge is now blocked on someone coming back to look.
1055
+ * 7. `ready` — open, not a draft, nothing failing, nothing running, nobody waited on.
1056
+ *
1057
+ * The subtle one is (4) → (6), and it is not a preference but a data finding. `reviewDecision`
1058
+ * stays `CHANGES_REQUESTED` after the author has responded — GitHub does not clear it until a
1059
+ * reviewer submits again — so on its own it points at the author forever. Two signals say the ball
1060
+ * has moved back:
1061
+ *
1062
+ * - **a pending review request** (`reviewRequests.totalCount`), which is the author clicking
1063
+ * re-request. Authoritative, and observed live alongside a stale `CHANGES_REQUESTED` and an
1064
+ * EMPTY `latestReviews` — the case that has no other tell.
1065
+ * - **a commit newer than the review**, the fallback for an author who pushed without clicking
1066
+ * anything.
1067
+ *
1068
+ * Either way the words change from "you owe edits" to "they owe a look", and so does the colour:
1069
+ * danger is the author's move, info is the reviewer's.
1070
+ *
1071
+ * Both timestamps are optional and their ABSENCE is conservative: with no dates to compare and no
1072
+ * re-request, a review counts as current, and the chip keeps pointing at the author.
1073
+ *
1074
+ * `reviewDecision` is null on a repo with no review policy, which is exactly why `ready` is not
1075
+ * spelled "approved": on such a repo a green PR IS ready, and no approval will ever arrive — but a
1076
+ * requested reviewer still moves it to `review-required`, because someone was explicitly asked.
1077
+ */
1078
+ export function derivePrReferenceStatus(pr) {
1079
+ const state = pr.state.toUpperCase();
1080
+ if (state === 'MERGED')
1081
+ return 'merged';
1082
+ if (state === 'CLOSED')
1083
+ return 'closed';
1084
+ if (pr.isDraft)
1085
+ return 'draft';
1086
+ if (pr.checks === 'pending')
1087
+ return 'checks-pending';
1088
+ const decision = (pr.reviewDecision ?? '').toUpperCase();
1089
+ const changesRequested = decision === 'CHANGES_REQUESTED';
1090
+ // Has the author already answered the review — by asking for another look, or by pushing?
1091
+ const answered = pr.reviewRequested === true || pushedSince(pr.headCommittedAt, pr.changesRequestedAt);
1092
+ if (changesRequested && !answered)
1093
+ return 'changes-requested';
1094
+ if (pr.checks === 'failing')
1095
+ return 'checks-failing';
1096
+ // `APPROVED` is the forge saying the review requirement IS MET, and it outranks a pending
1097
+ // request: a reviewer left on the list after someone else approved is a courtesy ask, not an
1098
+ // unmet gate. (A repo needing two approvals reports `REVIEW_REQUIRED` until it has both, so
1099
+ // `APPROVED` never arrives early.) Without this, an approved, green, mergeable pull request
1100
+ // reads "waiting for review" for as long as anyone stays listed — which is indefinitely.
1101
+ if (decision === 'APPROVED')
1102
+ return 'ready';
1103
+ if (changesRequested || decision === 'REVIEW_REQUIRED' || pr.reviewRequested === true) {
1104
+ return 'review-required';
1105
+ }
1106
+ return 'ready';
1107
+ }
1108
+ /** Did a commit land AFTER the review? Unparseable or missing dates answer `false` — the
1109
+ * conservative direction, since it keeps a review current rather than silently demoting one. */
1110
+ function pushedSince(headCommittedAt, reviewedAt) {
1111
+ if (!headCommittedAt || !reviewedAt)
1112
+ return false;
1113
+ const head = Date.parse(headCommittedAt);
1114
+ const reviewed = Date.parse(reviewedAt);
1115
+ return Number.isFinite(head) && Number.isFinite(reviewed) && head > reviewed;
1116
+ }
1117
+ /**
1118
+ * An issue's two signals as one word. `NOT_PLANNED` is kept apart from `completed` because they
1119
+ * are opposite outcomes — "we did it" vs "we won't" — and a task whose issue was declined must
1120
+ * not read as a task that landed.
1121
+ */
1122
+ export function deriveIssueReferenceStatus(issue) {
1123
+ if (issue.state.toUpperCase() !== 'CLOSED')
1124
+ return 'open';
1125
+ return (issue.stateReason ?? '').toUpperCase() === 'NOT_PLANNED' ? 'not-planned' : 'completed';
1126
+ }
1127
+ /**
1128
+ * Status per NUMBER, resolved to whatever that number actually is.
1129
+ *
1130
+ * Batched and aliased like `fetchPrChecks`: one subprocess per chunk, each alias resolving
1131
+ * independently, and a number the forge does not have simply staying absent from `resolved` (its
1132
+ * chip then renders neutral, as it did before this seam existed). A failed chunk costs only its
1133
+ * own numbers, and says so rather than letting them look absent. Exported for tests; `runGraphql`
1134
+ * is injected so this is testable without shelling out.
1135
+ */
1136
+ export async function fetchRefStatuses(runGraphql, owner, name, numbers, chunkSize = GH_REF_STATUS_MAX) {
1137
+ const out = { resolved: {}, failed: [] };
1138
+ if (numbers.length === 0)
1139
+ return out;
1140
+ for (let i = 0; i < numbers.length; i += chunkSize) {
1141
+ const chunk = numbers.slice(i, i + chunkSize);
1142
+ try {
1143
+ const raw = JSON.parse(await runGraphql(refStatusQuery(chunk), { owner, name }));
1144
+ const repository = ghRefStatusSchema.parse(raw?.data?.repository ?? {});
1145
+ chunk.forEach((number, index) => {
1146
+ const node = repository[`r${index}`];
1147
+ // Absent alias → the number is not in this repository at all. Left out of the map, which
1148
+ // is what the route reports as "not found" and the chip paints as neutral.
1149
+ if (!node)
1150
+ return;
1151
+ if (node.__typename === 'PullRequest') {
1152
+ const pr = ghRefStatusPrSchema.parse(node);
1153
+ if (!pr)
1154
+ return;
1155
+ const head = pr.commits?.nodes[0]?.commit;
1156
+ const rollup = head?.statusCheckRollup;
1157
+ out.resolved[number] = {
1158
+ kind: 'pr',
1159
+ status: derivePrReferenceStatus({
1160
+ state: pr.state,
1161
+ isDraft: pr.isDraft,
1162
+ reviewDecision: pr.reviewDecision,
1163
+ // Adapt the single rollup state into the array shape `rollupToChecks` expects,
1164
+ // reusing the FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
1165
+ checks: rollup ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null : null,
1166
+ headCommittedAt: head?.committedDate,
1167
+ // `reviews(last: 1, states: CHANGES_REQUESTED)` — the timestamp only. WHETHER changes
1168
+ // are requested stays `reviewDecision`'s answer, which is the one that accounts for
1169
+ // dismissed and superseded reviews.
1170
+ changesRequestedAt: pr.reviews?.nodes[0]?.submittedAt,
1171
+ reviewRequested: (pr.reviewRequests?.totalCount ?? 0) > 0,
1172
+ }),
1173
+ };
1174
+ }
1175
+ else if (node.__typename === 'Issue') {
1176
+ const issue = ghRefStatusIssueSchema.parse(node);
1177
+ if (!issue)
1178
+ return;
1179
+ out.resolved[number] = { kind: 'issue', status: deriveIssueReferenceStatus(issue) };
1180
+ }
1181
+ });
1182
+ }
1183
+ catch (err) {
1184
+ // A failed chunk costs only its own numbers; the rest still resolve. They are recorded as
1185
+ // FAILED rather than left absent, so nothing downstream mistakes them for "no such number".
1186
+ out.failed.push(...chunk);
1187
+ out.reason ??= firstLine(err instanceof Error ? err.message : String(err));
1188
+ }
1189
+ }
1190
+ return out;
1191
+ }
1192
+ // Per-reference cache: keyed `repoRoot␀number` — by NUMBER, not by kind, because the kind is now
1193
+ // something the forge answers rather than something the caller asserts. Same 60 s TTL and bounded
1194
+ // shape as the checks cache; `null` is a cached "this repository has no such number", so a
1195
+ // transcript-scraped number from another repo is not re-queried on every table repaint.
1196
+ const refStatusCache = new Map();
1197
+ const REF_STATUS_CACHE_MAX = 500;
1198
+ /** Test-only: drop the per-reference cache so cases don't leak state into each other. */
1199
+ export function __clearRefStatusCacheForTests() {
1200
+ refStatusCache.clear();
1201
+ }
1202
+ /** Test-only: warm the cache the way the lazy route would have, so a reader can be tested
1203
+ * without a forge behind it. */
1204
+ export function __seedRefStatusCacheForTests(repoRoot, entries) {
1205
+ for (const [number, resolved] of entries) {
1206
+ refStatusCache.set(refStatusKey(repoRoot, number), { at: Date.now(), resolved });
1207
+ }
1208
+ }
1209
+ /**
1210
+ * Forget what we knew about one reference, so the next read asks GitHub again.
1211
+ *
1212
+ * Called where cezar itself CHANGES a pull request — it merges one, it opens one — because those
1213
+ * are the only forge changes this process can know about without asking. Everything else has to
1214
+ * be polled (GitHub cannot push to a cockpit with no public endpoint), but waiting out a TTL to
1215
+ * notice our own merge is a self-inflicted staleness: for up to a minute every chip would keep
1216
+ * showing the pre-merge status of a pull request the user watched this server merge.
1217
+ *
1218
+ * Deleting rather than overwriting with a guessed `merged`: the forge is the authority on what a
1219
+ * reference is, and a mutation that reports success is still not the same as having read the
1220
+ * result. The next reader pays one query and gets the truth — after which the answer is `merged`,
1221
+ * `recheckAfterMs` goes null, and the cockpit stops polling that batch entirely. Invalidating here
1222
+ * therefore REDUCES long-run traffic rather than adding to it.
1223
+ */
1224
+ export function forgetRefStatus(repoRoot, number) {
1225
+ refStatusCache.delete(refStatusKey(repoRoot, number));
1226
+ }
1227
+ /**
1228
+ * Everything the cache ALREADY knows about these numbers. Never spawns `gh`, never awaits.
1229
+ *
1230
+ * This is what lets a status ride along with the rows that carry the references, instead of the
1231
+ * cockpit fetching it separately a moment later: the run index reads whatever is warm and ships
1232
+ * it, and a cold entry is simply absent — the lazy `/github/ref-status` route stays the thing that
1233
+ * actually goes and asks.
1234
+ *
1235
+ * Because it cannot cost anything, the caller may pass a SUPERSET of the numbers it will really
1236
+ * display. That matters: deciding which of a run's references a chip shows is the cockpit's rule
1237
+ * (#407, #526), deliberately not duplicated server-side, and a cache read does not need to know —
1238
+ * it can look up every number a run mentions and let the client pick.
1239
+ */
1240
+ export function readCachedRefStatuses(repoRoot, numbers) {
1241
+ const out = { prs: {}, issues: {} };
1242
+ const now = Date.now();
1243
+ for (const number of new Set(numbers)) {
1244
+ const hit = refStatusCache.get(refStatusKey(repoRoot, number));
1245
+ if (!hit || !hit.resolved || now - hit.at >= refStatusTtl(hit.resolved))
1246
+ continue;
1247
+ out[hit.resolved.kind === 'pr' ? 'prs' : 'issues'][number] = hit.resolved.status;
1248
+ }
1249
+ return out;
1250
+ }
1251
+ /** The `#N` in a forge URL — `…/pull/774` → 774. Null when the tail is not a number, so a URL
1252
+ * shape we do not recognise invalidates nothing rather than inventing a key. */
1253
+ export function refNumberFromUrl(url) {
1254
+ const last = /\/(\d+)\/?$/.exec(url.trim());
1255
+ const parsed = last ? Number(last[1]) : Number.NaN;
1256
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
1257
+ }
1258
+ /** A closed issue or an abandoned PR can be REOPENED, so this is long rather than forever — but
1259
+ * it is the rare event, and re-asking a hundred settled references every minute to catch it is
1260
+ * the wrong trade. */
1261
+ const REF_STATUS_CLOSED_TTL = 10 * 60_000;
1262
+ /** A merged pull request is merged forever: GitHub has no un-merge. Capped at a day only so a
1263
+ * long-lived server eventually re-reads rather than trusting a value from another era. */
1264
+ const REF_STATUS_MERGED_TTL = 24 * 60 * 60_000;
1265
+ /**
1266
+ * How long a cached answer stays fresh — by how changeable that answer IS.
1267
+ *
1268
+ * One TTL for everything gets both ends wrong: it re-asks about a merged PR (which cannot change)
1269
+ * every minute, and it is the only thing standing between a running CI job and a stale chip. What
1270
+ * a reader wants rechecked is precisely what is still moving.
1271
+ *
1272
+ * A number the repository does not have keeps the short TTL: it is usually a wrong number, but it
1273
+ * is also what a reference to a not-yet-created PR looks like, and re-asking is cheap.
1274
+ */
1275
+ function refStatusTtl(entry) {
1276
+ if (!entry)
1277
+ return CACHE_MS;
1278
+ switch (entry.status) {
1279
+ case 'merged':
1280
+ return REF_STATUS_MERGED_TTL;
1281
+ case 'closed':
1282
+ case 'completed':
1283
+ case 'not-planned':
1284
+ return REF_STATUS_CLOSED_TTL;
1285
+ default:
1286
+ return CACHE_MS;
1287
+ }
1288
+ }
1289
+ /** How long a status can be trusted to stay put — `null` when it can never change again. The
1290
+ * cadence half of `refStatusTtl`, and deliberately the same table: a value the cache would still
1291
+ * be serving is a value there is no point asking for. */
1292
+ function refStatusRecheckAfter(entry) {
1293
+ if (entry?.status === 'merged')
1294
+ return null; // GitHub has no un-merge
1295
+ return refStatusTtl(entry);
1296
+ }
1297
+ /** How long the WHOLE answer holds — the soonest any single reference in it could differ. `null`
1298
+ * only when every one of them is immutable, which is what tells the cockpit to stop scheduling. */
1299
+ function batchRecheckAfter(entries) {
1300
+ let soonest = null;
1301
+ for (const entry of entries) {
1302
+ const after = refStatusRecheckAfter(entry);
1303
+ if (after === null)
1304
+ continue;
1305
+ soonest = soonest === null ? after : Math.min(soonest, after);
1306
+ }
1307
+ return soonest;
1308
+ }
1309
+ /** A forge that could not be reached is worth retrying, and worth not hammering: a workspace with
1310
+ * no `gh` installed would otherwise spawn a subprocess a minute, forever, to be told the same
1311
+ * thing. Five minutes is the same order as the cockpit's own reconnect cadence. */
1312
+ const REF_STATUS_RETRY_MS = 5 * 60_000;
1313
+ /**
1314
+ * GraphQL through `gh`, tolerating a PARTIALLY failed response.
1315
+ *
1316
+ * `gh api graphql` exits non-zero whenever the reply carries an `errors` array — even when `data`
1317
+ * is fully populated for every alias that DID resolve. With one alias per reference that is not an
1318
+ * edge case, it is the normal case: a single number that no longer exists (or never did, having
1319
+ * been scraped from a transcript that named another repository) makes GitHub answer
1320
+ * `{data: {...everything else...}, errors: [NOT_FOUND]}` and `gh` exit 1.
1321
+ *
1322
+ * `execFile` rejects on a non-zero exit, so that used to throw away a whole batch's worth of
1323
+ * perfectly good statuses and report every reference in it as "not found" — including, in the bug
1324
+ * that produced this function, an open pull request in the project's own repository.
1325
+ *
1326
+ * So: a non-zero exit whose stdout still carries a usable `data.repository` is a partial success
1327
+ * and is used as-is. Anything else — no stdout, unparseable stdout, or a null `repository`, which
1328
+ * means the repo handle itself did not resolve — rethrows, so a real failure still degrades to
1329
+ * `{available: false}` instead of masquerading as "none of these exist".
1330
+ */
1331
+ function refStatusGraphql(repoRoot) {
1332
+ return async (query, variables) => {
1333
+ const args = ['api', 'graphql', '-f', `query=${query}`];
1334
+ for (const [key, value] of Object.entries(variables))
1335
+ args.push('-f', `${key}=${value}`);
1336
+ try {
1337
+ return await gh(repoRoot, args);
1338
+ }
1339
+ catch (err) {
1340
+ const stdout = err.stdout;
1341
+ if (typeof stdout === 'string' && hasResolvedRepository(stdout))
1342
+ return stdout;
1343
+ throw err;
1344
+ }
1345
+ };
1346
+ }
1347
+ /**
1348
+ * The repo handle, memoized in the same map `resolveRepoHandle` uses — but rethrowing instead of
1349
+ * answering `null`.
1350
+ *
1351
+ * The memo is the point: without it every recheck tick spawned a second subprocess to re-learn an
1352
+ * `owner/name` that changes approximately never, doubling the cost of the one query that actually
1353
+ * carries information. The difference from `resolveRepoHandle` is the failure: this route turns a
1354
+ * `gh` error into the payload's `reason` — "gh CLI not found — install it and run `gh auth login`"
1355
+ * is what the chip's tooltip now shows a user — and a `null` would erase which failure it was.
1356
+ */
1357
+ async function resolveRepoHandleStrict(repoRoot) {
1358
+ const memo = repoHandleCache.get(repoRoot);
1359
+ if (memo !== undefined)
1360
+ return memo;
1361
+ // A throw here is transient and deliberately NOT memoized, exactly as in `resolveRepoHandle`.
1362
+ const handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
1363
+ repoHandleCache.set(repoRoot, handle); // includes the permanent negative
1364
+ return handle;
1365
+ }
1366
+ /** Is this a reply we can still read aliases out of? `repository: null` says the handle did not
1367
+ * resolve, which is a real failure and must not be read as "the numbers are all missing". */
1368
+ function hasResolvedRepository(stdout) {
1369
+ try {
1370
+ const parsed = JSON.parse(stdout);
1371
+ const repository = parsed?.data?.repository;
1372
+ return typeof repository === 'object' && repository !== null;
1373
+ }
1374
+ catch {
1375
+ return false;
1376
+ }
1377
+ }
1378
+ /**
1379
+ * Route-facing reference status. Resolves the repo handle once, serves fresh cache entries,
1380
+ * queries only the misses, and degrades to `{ available: false, reason }` when `gh` or the handle
1381
+ * is unavailable — never a throw, never a 5xx.
1382
+ *
1383
+ * The two request lists are merged into ONE set of numbers: issues and pull requests share a
1384
+ * repository's numbering space, so a number is one thing and asking about it twice would be asking
1385
+ * the same question twice. What comes back is filed by what each number turned out to BE, which is
1386
+ * why a chip whose kind the cockpit guessed wrong still gets the right status.
1387
+ */
1388
+ export async function fetchGithubRefStatus(repoRoot, input) {
1389
+ const asPrs = sanitizeRefNumbers(input.prs);
1390
+ const asIssues = sanitizeRefNumbers(input.issues);
1391
+ if (process.env.CEZ_DRY_RUN === '1')
1392
+ return mockGithubRefStatus(asPrs, asIssues);
1393
+ const wanted = [...new Set([...asPrs, ...asIssues])];
1394
+ const resolved = { prs: {}, issues: {} };
1395
+ // Every reference in the answer, resolved or not — what the recheck cadence is computed from.
1396
+ const entries = [];
1397
+ const file = (number, entry) => {
1398
+ entries.push(entry);
1399
+ if (entry)
1400
+ resolved[entry.kind === 'pr' ? 'prs' : 'issues'][number] = entry.status;
1401
+ };
1402
+ const misses = [];
1403
+ const now = Date.now();
1404
+ for (const n of wanted) {
1405
+ const hit = refStatusCache.get(refStatusKey(repoRoot, n));
1406
+ if (!hit || now - hit.at >= refStatusTtl(hit.resolved))
1407
+ misses.push(n);
1408
+ else
1409
+ file(n, hit.resolved);
1410
+ }
1411
+ if (misses.length === 0) {
1412
+ return { available: true, prs: resolved.prs, issues: resolved.issues, recheckAfterMs: batchRecheckAfter(entries) };
1413
+ }
1414
+ try {
1415
+ const ownerName = await resolveRepoHandleStrict(repoRoot);
1416
+ if (!ownerName) {
1417
+ return { available: false, reason: 'repository handle unavailable', recheckAfterMs: REF_STATUS_RETRY_MS };
1418
+ }
1419
+ const batch = await fetchRefStatuses(refStatusGraphql(repoRoot), ownerName.owner, ownerName.name, misses);
1420
+ const failed = new Set(batch.failed);
1421
+ // Stamped when the answer ARRIVED, not when the request was assembled: `now` above predates
1422
+ // the round trip, and dating an entry by it would age a slow query's results by its own
1423
+ // duration — shortening the TTL of exactly the answers that cost the most to get.
1424
+ const storedAt = Date.now();
1425
+ for (const n of misses) {
1426
+ // A number we could not ask about is NOT cached: caching it would pin "this repository has
1427
+ // no such number" for a minute on the strength of a network blip.
1428
+ if (failed.has(n))
1429
+ continue;
1430
+ const entry = batch.resolved[n] ?? null;
1431
+ file(n, entry);
1432
+ refStatusCache.set(refStatusKey(repoRoot, n), { at: storedAt, resolved: entry });
1433
+ }
1434
+ while (refStatusCache.size > REF_STATUS_CACHE_MAX) {
1435
+ const oldest = refStatusCache.keys().next().value;
1436
+ if (oldest === undefined)
1437
+ break;
1438
+ refStatusCache.delete(oldest);
1439
+ }
1440
+ // Anything unasked makes the whole answer `unavailable`, deliberately. The alternative is a
1441
+ // payload where a number we could not reach is indistinguishable from one that does not exist,
1442
+ // and the cockpit would paint "not found on this repository" over a perfectly good PR — the
1443
+ // exact defect this route was reported for. The successes are cached either way, so the next
1444
+ // request costs only the numbers that failed and usually answers in full.
1445
+ if (failed.size > 0) {
1446
+ return {
1447
+ available: false,
1448
+ reason: batch.reason ?? 'GitHub could not be reached',
1449
+ recheckAfterMs: REF_STATUS_RETRY_MS,
1450
+ };
1451
+ }
1452
+ return { available: true, prs: resolved.prs, issues: resolved.issues, recheckAfterMs: batchRecheckAfter(entries) };
1453
+ }
1454
+ catch (err) {
1455
+ const message = err instanceof Error ? err.message : String(err);
1456
+ return {
1457
+ available: false,
1458
+ reason: /ENOENT/.test(message)
1459
+ ? 'gh CLI not found — install it and run `gh auth login`'
1460
+ : firstLine(message),
1461
+ recheckAfterMs: REF_STATUS_RETRY_MS,
1462
+ };
1463
+ }
1464
+ }
1465
+ /** NUL separator, as everywhere else here: two projects each having a #42 must not collide. */
1466
+ function refStatusKey(repoRoot, number) {
1467
+ return `${repoRoot}\0#${number}`;
1468
+ }
1469
+ function sanitizeRefNumbers(numbers) {
1470
+ return [...new Set(numbers ?? [])].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_REF_STATUS_MAX);
1471
+ }
1472
+ /** CEZ_DRY_RUN=1 — statuses derived from the mock catalog so the offline demo paints real chips. */
1473
+ function mockGithubRefStatus(prs, issues) {
1474
+ const catalog = mockGithub();
1475
+ const byPr = new Map(catalog.prs.map((p) => [p.number, p]));
1476
+ const byIssue = new Set(catalog.issues.map((i) => i.number));
1477
+ const out = { available: true, prs: {}, issues: {}, recheckAfterMs: CACHE_MS };
1478
+ for (const n of prs) {
1479
+ const pr = byPr.get(n);
1480
+ if (pr) {
1481
+ out.prs[n] = derivePrReferenceStatus({
1482
+ state: 'OPEN',
1483
+ isDraft: pr.isDraft,
1484
+ reviewDecision: null,
1485
+ checks: (pr.checks ?? null),
1486
+ });
1487
+ }
1488
+ }
1489
+ for (const n of issues)
1490
+ if (byIssue.has(n))
1491
+ out.issues[n] = 'open';
1492
+ return out;
1493
+ }
983
1494
  /**
984
1495
  * The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
985
1496
  * filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades: