@bevel-software/platform-core-backend 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,712 @@
1
+ /**
2
+ * The mapping from Hexis's change-request types to the shapes the five agent
3
+ * read tools answer in.
4
+ *
5
+ * The tools are named and shaped after GitHub's pull-request API — one tool per
6
+ * GitHub endpoint, the same filters, the same paging — but they SPEAK HEXIS
7
+ * (decision by Razvan, 2026-10-02, on the review of PR #347). An agent opens a
8
+ * change request with `open_change_request` and reads it back with these five;
9
+ * answering the first in Hexis's words and the second in GitHub's would make
10
+ * one request two vocabularies. So every field these answers share with
11
+ * `open_change_request`'s summary takes that summary's name — `url`, `number`,
12
+ * `title`, `state`, `sourceBranch`, `targetBranch`, `approvals`,
13
+ * `mergeBlockedReasons`, `files[].path`, `files[].change` — and the rest follow
14
+ * the same style (camelCase, Hexis's own words for Hexis's own facts).
15
+ *
16
+ * What that changed, against GitHub, and why each is the better answer here:
17
+ *
18
+ * - **State is Hexis's own.** GitHub has two states and a `merged` flag;
19
+ * Hexis has three — `open`, `merged`, `closed` — and reports them. A merged
20
+ * request is `state: "merged"`, not `closed` with `merged: true`: the flag
21
+ * exists on GitHub only because its model has nowhere else to put the fact,
22
+ * and an agent reading `state` should not have to read a second field to
23
+ * learn whether the change landed.
24
+ * - **One name per path.** GitHub names a pull-request file's path `filename`
25
+ * and a review comment's `path`; mirroring that inconsistency was the point
26
+ * while these tools wore GitHub's names, and is now just a trap. Every path
27
+ * in every answer is `path`, and a moved file's old name is `previousPath`,
28
+ * as `open_change_request` names them.
29
+ * - **A file's kind of change** is `change` — `added` / `changed` / `deleted`
30
+ * / `moved`, from the very function `open_change_request` uses
31
+ * ({@link changeKindOf}), not git's seven-way `status`. One request, one
32
+ * spelling of what happened to a file.
33
+ * - **Approvals are per FILE**, not per review submission, because that is
34
+ * what Hexis records. A reviewer's approvals at one staleness are gathered
35
+ * into one entry naming the files, which is as close to a GitHub review as
36
+ * Hexis's model honestly gets.
37
+ * - **`stale`, not `DISMISSED`.** Hexis's word for an approval a later push
38
+ * invalidated is `isStale`; a review entry reports `stale: true` rather
39
+ * than borrowing GitHub's review state.
40
+ * - **A reply names its parent `parentId`**, which is the argument
41
+ * `post_change_request_comment` takes to create one. Reading a comment and
42
+ * replying to it use the same word for the same thing.
43
+ *
44
+ * The tools' INPUTS are untouched and stay GitHub's (`state`, `head`, `base`,
45
+ * `author`, `per_page`, `page`, `include`): the ticket pins them by name, and
46
+ * they are the one part of the surface where matching GitHub's spelling helps —
47
+ * an agent that knows GitHub's list filters can use these without reading the
48
+ * schema. Only the ANSWERS are Hexis's.
49
+ *
50
+ * Pure, and deliberately so: every judgement these tools make about what a
51
+ * caller may see is either an access lookup (which belongs to the service) or
52
+ * one of the functions below (which belongs to a test that can read it). The
53
+ * tools file does the IO and calls in here for every decision.
54
+ */
55
+
56
+ import type {
57
+ ChangeRequest,
58
+ ChangeRequestComment,
59
+ ChangeRequestDetail,
60
+ ChangeRequestState,
61
+ ChangedFile,
62
+ FileApproval,
63
+ } from '@bevel-software/platform-shared';
64
+ import { canonicalEmail, hashEmail } from '../../../shared/email-identity.js';
65
+ import { changeKindOf, type ChangeKind } from './change-request-summary.js';
66
+
67
+ /** A person on a change request, a review or a comment. */
68
+ export interface CrUser {
69
+ /** Hexis's author login — `user-<first 12 of the email hash>`, no raw email. */
70
+ login: string;
71
+ /** Display name, when Hexis knows one. */
72
+ name?: string;
73
+ /**
74
+ * The person's address, for a comment author and an approver — the two
75
+ * places the app's own change-request surfaces already show it, and what an
76
+ * agent needs to address a reply to a reviewer. Never set on a change
77
+ * request's `author`, which carries a hash-derived login only.
78
+ */
79
+ email?: string;
80
+ }
81
+
82
+ /**
83
+ * A change request as a LIST row.
84
+ *
85
+ * `url` first, for the reason `open_change_request` puts it first: it is the
86
+ * one field an agent must be able to hand a person, so it survives a truncation
87
+ * of anything after it.
88
+ */
89
+ export interface CrSummary {
90
+ url: string;
91
+ /** Present only when `url` is relative — how to get absolute links. */
92
+ urlNote?: string;
93
+ number: number;
94
+ title: string;
95
+ /** Hexis's own three states. A merged request says so; there is no flag. */
96
+ state: ChangeRequestState;
97
+ author: CrUser;
98
+ sourceBranch: string;
99
+ targetBranch: string;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ /** How many of this request's files the CALLER may read. */
103
+ changedFiles: number;
104
+ /** How many it may not. Never named. */
105
+ withheldFiles: number;
106
+ /**
107
+ * The most recent apply attempt that did not land, while the request is
108
+ * open — as the app shows it. Its `reason` can name files, so it is the
109
+ * author's words only for a caller who may read EVERY file of a request
110
+ * that has some; otherwise the reason is the withheld line the app shows.
111
+ */
112
+ lastApplyFailure?: { reason: string; conflicts: boolean; at: string };
113
+ }
114
+
115
+ /** What the app says in place of an apply-failure reason the caller may not read. */
116
+ export const APPLY_FAILURE_REASON_WITHHELD = 'The reason names files you do not have access to read.';
117
+
118
+ /** A change request read by number: the row, its description, and the gate. */
119
+ export interface CrDetail extends CrSummary {
120
+ body: string;
121
+ /** The source tip the answer describes. Absent when no ref could be resolved. */
122
+ headSha?: string;
123
+ /** The target tip it is read against. Absent when no ref could be resolved. */
124
+ baseSha?: string;
125
+ /**
126
+ * Whether Hexis's merge gate would let this be applied — which, unlike
127
+ * GitHub's `mergeable`, also waits on the per-file approvals. Kept beside the
128
+ * blockers because a filtered `mergeBlockedReasons` can be empty while the
129
+ * gate is still shut: see {@link visibleBlockers}.
130
+ */
131
+ mergeable: boolean;
132
+ /**
133
+ * Why it cannot be applied yet, in the gate's own words — the detail's list,
134
+ * minus any line naming a file the caller may not read.
135
+ */
136
+ mergeBlockedReasons: string[];
137
+ /** How many blockers were withheld because they name a withheld file. */
138
+ withheldMergeBlockedReasons: number;
139
+ /** What this caller, specifically, may do with the request. */
140
+ viewer: CrViewer;
141
+ }
142
+
143
+ /** What the caller may do — Hexis's own facts, under Hexis's own names. */
144
+ export interface CrViewer {
145
+ /** Whether the caller may approve at least one file they are shown. */
146
+ mayApprove: boolean;
147
+ /** Whether the caller's Apply would be accepted right now. */
148
+ mayMerge: boolean;
149
+ /** Whether the caller opened this request (their agent counts as them). */
150
+ isAuthor: boolean;
151
+ }
152
+
153
+ export interface CrFile {
154
+ path: string;
155
+ /** Where a `moved` file came from. */
156
+ previousPath?: string;
157
+ /** `added` | `changed` | `deleted` | `moved` — `open_change_request`'s four. */
158
+ change: ChangeKind;
159
+ additions: number;
160
+ deletions: number;
161
+ /** Unified diff. Only on `include: ["patches"]`, and absent for binaries. */
162
+ patch?: string;
163
+ isBinary: boolean;
164
+ /** Who must approve this file for the request to be applied. */
165
+ requiredApprovers: { roles: string[]; users: CrUser[] };
166
+ /**
167
+ * Present when the approver set could not be resolved — then an empty
168
+ * `requiredApprovers` means "not known", NOT "nobody has to approve". Same
169
+ * name and same fail-closed reading as `open_change_request`'s.
170
+ */
171
+ approversUnknown?: true;
172
+ /** Who has approved this file, and when. */
173
+ approvedBy: CrFileApproval[];
174
+ /** Whether a current approval by an eligible approver stands. */
175
+ approved: boolean;
176
+ /** Whether the merge gate waits on this file at all. */
177
+ inMergeGate: boolean;
178
+ /** Whether the caller may approve this file. */
179
+ viewerMayApprove: boolean;
180
+ }
181
+
182
+ export interface CrFileApproval {
183
+ user: CrUser;
184
+ approvedAt: string;
185
+ /** True when the approval was given against a head that has since moved. */
186
+ stale: boolean;
187
+ /** True when the approver is the request's own author. */
188
+ selfApproval: boolean;
189
+ }
190
+
191
+ export interface CrReview {
192
+ /** Stable within the request: the reviewer, and whether their approvals stand. */
193
+ id: string;
194
+ reviewer: CrUser;
195
+ /** True once a later push invalidated the approvals gathered here. */
196
+ stale: boolean;
197
+ /** The most recent of the approvals gathered into this entry. */
198
+ submittedAt: string;
199
+ /** The files this reviewer approved — the readable ones. */
200
+ files: string[];
201
+ /** How many further files of this review the caller may not read. */
202
+ withheldFiles: number;
203
+ }
204
+
205
+ export interface CrComment {
206
+ id: string;
207
+ author: CrUser;
208
+ body: string;
209
+ /** Set on a file-level or inline comment. */
210
+ path?: string;
211
+ /** Set on an inline comment. */
212
+ line?: number;
213
+ /**
214
+ * The comment this one replies to — the same `parentId`
215
+ * `post_change_request_comment` takes to post the reply.
216
+ */
217
+ parentId?: string;
218
+ /** The head the comment was anchored to. */
219
+ headSha: string;
220
+ createdAt: string;
221
+ /** Present once edited. */
222
+ updatedAt?: string;
223
+ }
224
+
225
+ /** One page of a list, and whether there is another. */
226
+ export interface CrPage<T> {
227
+ items: T[];
228
+ totalCount: number;
229
+ page: number;
230
+ perPage: number;
231
+ hasNextPage: boolean;
232
+ }
233
+
234
+ export const PER_PAGE_DEFAULT = 30;
235
+ export const PER_PAGE_MAX = 100;
236
+
237
+ /**
238
+ * GitHub's `per_page` (default 30, at most 100) and `page` (from 1), read off
239
+ * whatever the caller sent. A value out of range is clamped rather than
240
+ * refused, because a tool call that asks for 500 wants as many as it can have.
241
+ */
242
+ export function pagingOf(args: { per_page?: unknown; page?: unknown }): {
243
+ perPage: number;
244
+ page: number;
245
+ } {
246
+ const rawPer = typeof args.per_page === 'number' ? Math.trunc(args.per_page) : PER_PAGE_DEFAULT;
247
+ const rawPage = typeof args.page === 'number' ? Math.trunc(args.page) : 1;
248
+ return {
249
+ perPage: Math.min(PER_PAGE_MAX, Math.max(1, Number.isFinite(rawPer) ? rawPer : PER_PAGE_DEFAULT)),
250
+ page: Math.max(1, Number.isFinite(rawPage) ? rawPage : 1),
251
+ };
252
+ }
253
+
254
+ /**
255
+ * One page out of an already-filtered list. Paging runs AFTER the access
256
+ * filter, always: a page sliced before it would say by its own short length
257
+ * how many entries were withheld, which is the count's job to say and nobody
258
+ * else's.
259
+ */
260
+ export function pageOf<T>(all: T[], perPage: number, page: number): CrPage<T> {
261
+ const start = (page - 1) * perPage;
262
+ return {
263
+ items: all.slice(start, start + perPage),
264
+ totalCount: all.length,
265
+ page,
266
+ perPage,
267
+ hasNextPage: start + perPage < all.length,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * The Hexis states the `state` FILTER selects. The filter keeps GitHub's three
273
+ * words because the ticket names them, and `closed` there means "not open" —
274
+ * which over Hexis's states is `closed` (declined) and `merged` alike. The
275
+ * ANSWER still reports which of the two a request is.
276
+ */
277
+ export function statesFor(filter: 'open' | 'closed' | 'all'): ChangeRequestState[] {
278
+ if (filter === 'open') return ['open'];
279
+ if (filter === 'closed') return ['closed', 'merged'];
280
+ return ['open', 'closed', 'merged'];
281
+ }
282
+
283
+ /** The request's author: a hash-derived login, never an email. */
284
+ export function crAuthor(cr: Pick<ChangeRequest, 'author' | 'appAuthor'>): CrUser {
285
+ const name = cr.appAuthor?.name ?? cr.author.name;
286
+ return { login: cr.author.login, ...(name ? { name } : {}) };
287
+ }
288
+
289
+ /**
290
+ * Whether `needle` names this request's author — an email (compared as the
291
+ * author hash, since the request stores no raw address) or a login. Mirrors
292
+ * `listPrsAuthoredBy`, so the tool's `author` filter and the app's "mine" list
293
+ * agree on who wrote what.
294
+ */
295
+ export function matchesAuthor(
296
+ cr: Pick<ChangeRequest, 'author' | 'authorId'>,
297
+ needle: string,
298
+ ): boolean {
299
+ const wanted = canonicalEmail(needle);
300
+ if (!wanted) return false;
301
+ if (wanted.includes('@')) return !!(cr.authorId && cr.authorId === hashEmail(wanted));
302
+ return cr.author.login.toLowerCase() === wanted;
303
+ }
304
+
305
+ /** Whether `viewerEmail` opened this request. */
306
+ export function isAuthor(
307
+ cr: Pick<ChangeRequest, 'authorId'>,
308
+ viewerEmail: string | undefined,
309
+ ): boolean {
310
+ return !!(viewerEmail && cr.authorId && cr.authorId === hashEmail(viewerEmail));
311
+ }
312
+
313
+ /**
314
+ * Whether the caller may SEE a change request at all.
315
+ *
316
+ * Readable files, or authorship. Nothing else: a request every file of which
317
+ * is closed to the caller tells them, by existing, that somebody proposed a
318
+ * change to something they may not look at, and requirement 7 answers that
319
+ * with a 404 rather than an empty shell.
320
+ *
321
+ * A request with NO files resolved is not visible to a non-author either, and
322
+ * that is deliberate rather than an oversight: an empty file set is what both a
323
+ * request that genuinely changes nothing and a request whose diff could not be
324
+ * computed look like, and the two are indistinguishable from here. So an empty
325
+ * set proves no read access — the same rule `scopeApplyFailures` applies to the
326
+ * same question on the same lists.
327
+ *
328
+ * Since the 2026-10-02 decision this is a far smaller class than it was: a
329
+ * MERGED request's files are recovered from its merge commit, so being applied
330
+ * no longer hides a request from everyone but its author. What is left is a
331
+ * DECLINED request (no merge commit was ever written, and its branch is gone)
332
+ * and a clone that cannot resolve the commit at all.
333
+ */
334
+ export function maySeeChangeRequest(input: {
335
+ readableFiles: number;
336
+ isAuthor: boolean;
337
+ }): boolean {
338
+ return input.isAuthor || input.readableFiles > 0;
339
+ }
340
+
341
+ /** A list row. `readable` / `withheld` come from the access filter. */
342
+ export function toCrSummary(
343
+ cr: ChangeRequest,
344
+ counts: { readable: number; withheld: number },
345
+ ): CrSummary {
346
+ return {
347
+ url: cr.url,
348
+ ...(cr.urlNote ? { urlNote: cr.urlNote } : {}),
349
+ number: cr.number,
350
+ title: cr.title,
351
+ state: cr.state,
352
+ author: crAuthor(cr),
353
+ sourceBranch: cr.branch,
354
+ targetBranch: cr.base,
355
+ createdAt: cr.createdAt,
356
+ updatedAt: cr.updatedAt ?? cr.createdAt,
357
+ changedFiles: counts.readable,
358
+ withheldFiles: counts.withheld,
359
+ // The same predicate the app's own list applies (`scopeApplyFailures`): a
360
+ // non-empty file set, every file readable. An empty set proves nothing,
361
+ // so it never grants the reason.
362
+ ...(cr.lastApplyFailure
363
+ ? {
364
+ lastApplyFailure: {
365
+ reason:
366
+ counts.readable > 0 && counts.withheld === 0
367
+ ? cr.lastApplyFailure.reason
368
+ : APPLY_FAILURE_REASON_WITHHELD,
369
+ conflicts: cr.lastApplyFailure.conflicts,
370
+ at: cr.lastApplyFailure.at,
371
+ },
372
+ }
373
+ : {}),
374
+ };
375
+ }
376
+
377
+ /** A detail: the row, the author's description, and the gate as this caller sees it. */
378
+ export function toCrDetail(
379
+ detail: ChangeRequestDetail,
380
+ counts: { readable: number; withheld: number },
381
+ blockers: { visible: string[]; withheld: number },
382
+ viewer: CrViewer,
383
+ ): CrDetail {
384
+ return {
385
+ ...toCrSummary(detail, counts),
386
+ // Empty rather than present-and-empty: a merged request read from its merge
387
+ // commit knows both commits, a declined one knows neither, and `''` would
388
+ // read as a sha nobody can look up.
389
+ ...(detail.headSha ? { headSha: detail.headSha } : {}),
390
+ ...(detail.baseSha ? { baseSha: detail.baseSha } : {}),
391
+ body: authorsDescription(detail.body),
392
+ mergeable: detail.mergeableInBevel,
393
+ mergeBlockedReasons: blockers.visible,
394
+ withheldMergeBlockedReasons: blockers.withheld,
395
+ viewer,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * The part of a change-request body its AUTHOR wrote.
401
+ *
402
+ * A Hexis body is part human and part machine: `openChangeRequest` appends an
403
+ * `## Affected owners` block — one line per changed path, naming that path and
404
+ * its eligible approvers — to whatever the author typed. Returning the body
405
+ * verbatim NAMED every file of the request, including the ones this caller may
406
+ * not read: Local Testing caught a GTM-team reader being handed
407
+ * `- \`KnowledgeBase/Engineering/…\` — Admin` out of a request whose file list
408
+ * had correctly withheld that very path.
409
+ *
410
+ * So the generated block goes, and NOTHING ELSE does. The first cut at this
411
+ * copied the app's dialog, which drops everything from the first `##` heading
412
+ * on; Razvan's review (2026-10-02) asked for the narrower cut, because an
413
+ * author who writes their description in sections loses it from that heading
414
+ * onwards and is never told. The generated block is appended LAST and begins
415
+ * with a line that is exactly `## Affected owners`, so cutting at the LAST such
416
+ * line removes the machinery and keeps every heading a person wrote. (An author
417
+ * who writes that exact heading themselves loses their text from there — the
418
+ * safe direction, and the only way this can err.)
419
+ *
420
+ * Hexis's OWN hidden markers go too — the `<!--hexis:…-->` / `<!--bevel:…-->`
421
+ * comments older bodies carry identity in — and no other comment: an HTML
422
+ * comment the author wrote is the author's prose, like the rest.
423
+ *
424
+ * Who must approve each file is NOT lost by this — it is what
425
+ * `list_change_request_files` answers under `requiredApprovers`, per file and
426
+ * access-filtered, which is where a caller should read it from anyway.
427
+ *
428
+ * Two things this deliberately does not do. It does not vary by caller: one
429
+ * body for everyone is a body nobody has to reason about. And it does not touch
430
+ * the author's own prose, which may mention any path they chose to write about —
431
+ * that is a person's sentence, shown to every viewer in the app, not Hexis
432
+ * naming a file it was asked to withhold.
433
+ */
434
+ export function authorsDescription(body: string): string {
435
+ const lines = body.split('\n');
436
+ const generated = lines.lastIndexOf('## Affected owners');
437
+ return (generated === -1 ? lines : lines.slice(0, generated))
438
+ .join('\n')
439
+ .replace(/<!--\s*(?:hexis|bevel)[:/][\s\S]*?-->/g, '')
440
+ .trim();
441
+ }
442
+
443
+ /**
444
+ * What this caller may do with the request.
445
+ *
446
+ * `mayMerge` reproduces what `mergePr` would accept, from the detail's own
447
+ * published fields: no hard block, and either nothing missing or an admin who
448
+ * may bypass what is. Deriving it here rather than asking a second time keeps
449
+ * the answer and the enforcement reading the same gate.
450
+ *
451
+ * `mayApprove` answers over `shownApprovals` — the approvals of the files this
452
+ * caller is SHOWN — and not over the request's whole approval set, which is why
453
+ * the set is passed in rather than read off `detail`. The two differ:
454
+ * `viewerCanApprove` is a WRITE grant at `origin/<base>`, and a write grant can
455
+ * hold for a file whose read verdict withholds it. Answering true off such a
456
+ * file would both contradict the contract ("at least one file they are shown")
457
+ * and tell the caller something about a file they were refused.
458
+ */
459
+ export function toCrViewer(
460
+ detail: Pick<
461
+ ChangeRequestDetail,
462
+ 'state' | 'mergeBlockedReasons' | 'mergeWarnings' | 'viewerCanBypassMerge'
463
+ >,
464
+ viewerIsAuthor: boolean,
465
+ shownApprovals: Pick<FileApproval, 'viewerCanApprove'>[],
466
+ ): CrViewer {
467
+ const warnings = new Set(detail.mergeWarnings);
468
+ const hard = detail.mergeBlockedReasons.filter((r) => !warnings.has(r));
469
+ return {
470
+ // Nothing on a merged or declined request is there to approve, whatever
471
+ // write grant the caller holds on its files.
472
+ mayApprove: detail.state === 'open' && shownApprovals.some((a) => a.viewerCanApprove),
473
+ mayMerge:
474
+ detail.state === 'open' &&
475
+ hard.length === 0 &&
476
+ (detail.mergeWarnings.length === 0 || detail.viewerCanBypassMerge),
477
+ isAuthor: viewerIsAuthor,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * The merge blockers the caller may read.
483
+ *
484
+ * A gate warning quotes the path it waits on (`Waiting on approval for
485
+ * X from …`), so a blocker about a withheld file would name that file. Each
486
+ * blocker mentioning any withheld path is dropped and counted. Matching on the
487
+ * text rather than rebuilding the gate's sentences is deliberate: the gate owns
488
+ * its wording, and the only way this can err is by withholding a line that
489
+ * contains a withheld path as a substring — which is the safe direction.
490
+ */
491
+ export function visibleBlockers(
492
+ reasons: string[],
493
+ withheldPaths: string[],
494
+ ): { visible: string[]; withheld: number } {
495
+ if (withheldPaths.length === 0) return { visible: [...reasons], withheld: 0 };
496
+ const visible = reasons.filter((r) => !withheldPaths.some((p) => r.includes(p)));
497
+ return { visible, withheld: reasons.length - visible.length };
498
+ }
499
+
500
+ /**
501
+ * Whether the caller may read a changed file AT ALL.
502
+ *
503
+ * Both of its names must be readable. A moved file carries its old path in
504
+ * `previousPath`, and the diff of a move shows the content that was at that
505
+ * old path — so a file moved OUT of a folder the caller may not read is not
506
+ * readable here either, however open its new home is, and `previousPath` can
507
+ * never name a path they were refused. Fail-closed in the one direction that
508
+ * matters: a file with nowhere to hide is listed, a file with a hidden side is
509
+ * withheld and counted.
510
+ */
511
+ export function fileIsReadable(
512
+ file: Pick<ChangedFile, 'path' | 'previousPath'>,
513
+ mayRead: (path: string) => boolean,
514
+ ): boolean {
515
+ if (!mayRead(file.path)) return false;
516
+ return file.previousPath === undefined || mayRead(file.previousPath);
517
+ }
518
+
519
+ /** Every path a changed file names — both sides of a move. */
520
+ export function pathsOf(file: Pick<ChangedFile, 'path' | 'previousPath'>): string[] {
521
+ return file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
522
+ }
523
+
524
+ /** A changed file plus its approval state. */
525
+ export function toCrFile(
526
+ file: ChangedFile,
527
+ approval: FileApproval | undefined,
528
+ /** `open`: whether the request still is — an approval can be given on an open request only. */
529
+ opts: { patches: boolean; open: boolean },
530
+ ): CrFile {
531
+ return {
532
+ path: file.path,
533
+ ...(file.previousPath ? { previousPath: file.previousPath } : {}),
534
+ change: changeKindOf(file.status),
535
+ additions: file.additions,
536
+ deletions: file.deletions,
537
+ // No `sha`. GitHub's file entry carries the blob sha at the head, and an
538
+ // earlier draft of this answer did too — but nothing in Hexis populates it:
539
+ // `GitService.prFilesForRange`, the only builder of `PullRequestFile` and
540
+ // the one that serves an open request and an applied one alike, writes
541
+ // `sha: ''` for every file. Answering a field whose every value is the empty
542
+ // string is worse than not answering it, because an agent that pins content
543
+ // or a diff on it gets no error (cubic P3 on #347). If a blob sha is wanted
544
+ // here, the git layer has to produce it first — `git diff --raw` carries
545
+ // both sides' blob shas — and the app's own change-request detail gets it in
546
+ // the same change.
547
+ ...(opts.patches && file.patch !== undefined ? { patch: file.patch } : {}),
548
+ isBinary: file.isBinary,
549
+ requiredApprovers: {
550
+ roles: approval?.eligibleApprovers.roles ?? [],
551
+ users: (approval?.eligibleApprovers.users ?? []).map((u) => ({
552
+ login: loginFor(u.email),
553
+ name: u.name,
554
+ email: u.email,
555
+ })),
556
+ },
557
+ // No approval entry at all means the lookup never ran (no workspace, an
558
+ // unwired enricher), which is exactly the "unknown" the flag reports — and
559
+ // it is reported the way `open_change_request` reports it: a present
560
+ // `approversUnknown`, absent when the set IS known, so the fail-closed
561
+ // reading is the one a caller gets by default.
562
+ ...(approval?.eligibilityResolved === true ? {} : { approversUnknown: true as const }),
563
+ approvedBy: (approval?.approvedBy ?? []).map(toCrFileApproval),
564
+ approved: approval?.isApproved === true,
565
+ inMergeGate: approval?.inMergeGate === true,
566
+ viewerMayApprove: opts.open && approval?.viewerCanApprove === true,
567
+ };
568
+ }
569
+
570
+ function toCrFileApproval(entry: FileApproval['approvedBy'][number]): CrFileApproval {
571
+ return {
572
+ user: { login: loginFor(entry.email), name: entry.name, email: entry.email },
573
+ approvedAt: entry.approvedAt,
574
+ stale: entry.isStale,
575
+ selfApproval: entry.isSelfApproval,
576
+ };
577
+ }
578
+
579
+ /** The same hash-derived login the change-request summaries carry. */
580
+ export function loginFor(email: string): string {
581
+ return `user-${hashEmail(email).slice(0, 12)}`;
582
+ }
583
+
584
+ /**
585
+ * The reviews of a change request, gathered out of its per-file approvals.
586
+ *
587
+ * Hexis records an approval per FILE; a review list wants one entry per review.
588
+ * One entry per (reviewer, staleness) is the closest honest join: it names the
589
+ * reviewer once and the files they approved, which is what the ticket's
590
+ * scenario asks to read back. A reviewer holding both a current and a stale
591
+ * approval appears twice — one entry for the files that still stand and one
592
+ * with `stale: true` for the ones the latest push invalidated.
593
+ *
594
+ * `mayShow` decides, per file, what this caller may be told. A review keeps the
595
+ * files they may read and counts the rest in its own `withheldFiles`; a review
596
+ * EVERY file of which is withheld is dropped and counted in `withheldReviews`,
597
+ * because naming the reviewer would itself say that somebody approved a change
598
+ * to something the caller may not look at. `submittedAt` is taken from the
599
+ * readable approvals alone, for the same reason.
600
+ *
601
+ * Hexis records nothing about who DECLINED a request — a rejection closes it
602
+ * and stores no reviewer — so no review entry can report one. A reviewer's
603
+ * objection lives in a comment, which `list_change_request_comments` returns.
604
+ */
605
+ export function toCrReviews(
606
+ approvals: FileApproval[],
607
+ mayShow: (path: string) => boolean = () => true,
608
+ ): { reviews: CrReview[]; withheldReviews: number } {
609
+ const byReviewer = new Map<string, CrReview>();
610
+ for (const approval of approvals) {
611
+ const readable = mayShow(approval.path);
612
+ for (const entry of approval.approvedBy) {
613
+ const login = loginFor(entry.email);
614
+ const id = `${login}:${entry.isStale ? 'stale' : 'current'}`;
615
+ let review = byReviewer.get(id);
616
+ if (!review) {
617
+ review = {
618
+ id,
619
+ reviewer: { login, name: entry.name, email: entry.email },
620
+ stale: entry.isStale,
621
+ submittedAt: '',
622
+ files: [],
623
+ withheldFiles: 0,
624
+ };
625
+ byReviewer.set(id, review);
626
+ }
627
+ if (!readable) {
628
+ review.withheldFiles += 1;
629
+ continue;
630
+ }
631
+ review.files.push(approval.path);
632
+ if (entry.approvedAt > review.submittedAt) review.submittedAt = entry.approvedAt;
633
+ }
634
+ }
635
+ const all = [...byReviewer.values()];
636
+ const reviews = all.filter((r) => r.files.length > 0);
637
+ return {
638
+ reviews: reviews.sort(
639
+ (a, b) => a.submittedAt.localeCompare(b.submittedAt) || a.id.localeCompare(b.id),
640
+ ),
641
+ withheldReviews: all.length - reviews.length,
642
+ };
643
+ }
644
+
645
+ /** A comment. Covers the file-anchored and the general ones — Hexis has one table. */
646
+ export function toCrComment(comment: ChangeRequestComment): CrComment {
647
+ return {
648
+ id: comment.id,
649
+ author: {
650
+ login: loginFor(comment.author.email),
651
+ name: comment.author.name,
652
+ email: comment.author.email,
653
+ },
654
+ body: comment.body,
655
+ ...(comment.path ? { path: comment.path } : {}),
656
+ ...(comment.line !== undefined ? { line: comment.line } : {}),
657
+ ...(comment.parentId ? { parentId: comment.parentId } : {}),
658
+ headSha: comment.headSha,
659
+ createdAt: comment.createdAt,
660
+ ...(comment.updatedAt ? { updatedAt: comment.updatedAt } : {}),
661
+ };
662
+ }
663
+
664
+ /**
665
+ * The comments the caller may read, replies included in the answer only when
666
+ * the comment they reply to is.
667
+ *
668
+ * A comment with no path is about the request as a whole, and a comment with
669
+ * one is as readable as that path is NAMEABLE here (`mayShow`, so a comment
670
+ * anchored to the new name of a file withheld for its old one goes with the
671
+ * file rather than announcing it). A REPLY is neither, quite: it carries no
672
+ * path of its own when it was posted without one, so judged on itself it is a
673
+ * general comment — and `post_change_request_comment` takes `parentId` without
674
+ * requiring `path`, so a reply to a comment on a withheld file was returned
675
+ * with its body and a `parentId` naming a comment the caller cannot see.
676
+ * Razvan's review (2026-10-02) asked for the rule the ticket states: a reply is
677
+ * shown only when its parent is.
678
+ *
679
+ * So a comment is visible when its OWN path may be shown (if it has one) AND
680
+ * its parent is visible, all the way up the chain. Both halves are needed:
681
+ * inheriting only would show a reply naming a withheld file under a readable
682
+ * parent, and checking only its own path would show it under a withheld one.
683
+ *
684
+ * Fail-closed at the edges. A `parentId` naming a comment that is not in this
685
+ * request's list (deleted, or from somewhere this answer cannot see) is not
686
+ * provably pathless, so the reply goes. A cycle — which the schema does not
687
+ * permit and a corrupted row could still hold — is unresolvable, so it goes
688
+ * too. Both are counted in the withheld total, never named.
689
+ */
690
+ export function visibleComments<T extends { id: string; path?: string; parentId?: string }>(
691
+ comments: T[],
692
+ mayShow: (path: string) => boolean,
693
+ ): { visible: T[]; withheld: number } {
694
+ const byId = new Map(comments.map((c) => [c.id, c]));
695
+ /** Memoised per id, so a long thread costs one walk and not one per reply. */
696
+ const verdicts = new Map<string, boolean>();
697
+ const mayShowComment = (comment: T, seen: Set<string>): boolean => {
698
+ const cached = verdicts.get(comment.id);
699
+ if (cached !== undefined) return cached;
700
+ if (seen.has(comment.id)) return false;
701
+ seen.add(comment.id);
702
+ let verdict = !comment.path || mayShow(comment.path);
703
+ if (verdict && comment.parentId) {
704
+ const parent = byId.get(comment.parentId);
705
+ verdict = parent !== undefined && mayShowComment(parent, seen);
706
+ }
707
+ verdicts.set(comment.id, verdict);
708
+ return verdict;
709
+ };
710
+ const visible = comments.filter((c) => mayShowComment(c, new Set()));
711
+ return { visible, withheld: comments.length - visible.length };
712
+ }