@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,407 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ import { PgDialect } from 'drizzle-orm/pg-core';
4
+ import type { SQL } from 'drizzle-orm';
5
+ import { ReviewWorkflowService } from '../review-workflow.service.js';
6
+ import { changeRequests, prMergeLog } from '../../../database/schema.js';
7
+ import type { Database } from '../../../database/connection.js';
8
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
9
+ import type { GitService } from '../../git/git.service.js';
10
+ import type { AppliedMergeResult, AuthUser, FileApprovalState } from '@bevel-software/platform-shared';
11
+ import type { IAccessControl } from '../../../access/access-control.interface.js';
12
+
13
+ /**
14
+ * What `mergePr` records as a change request's `merged_sha`, which is the only
15
+ * thing an applied request's files can later be read from.
16
+ *
17
+ * Two shas come back from the merge and they are not interchangeable: `sha` is
18
+ * the state the target is left at, `mergeCommit` is the commit this request
19
+ * owns. Recording the first publishes another request's files under this
20
+ * request's number (cubic P1 on #347); dropping the second when a retry finds
21
+ * nothing left to merge makes an already-merged request permanently fileless
22
+ * (cubic P2 on #347). The row must take `mergeCommit`, exactly.
23
+ */
24
+
25
+ const USER: AuthUser = { id: 'u-alice', email: 'alice@bevel.software', name: 'Alice' };
26
+ const BASE = 'current-company-state';
27
+ const HEAD_SHA = 'head-sha-1';
28
+
29
+ const CR_ROW = {
30
+ id: 'cr-1',
31
+ number: 12,
32
+ sourceBranch: 'alice/add',
33
+ targetBranch: BASE,
34
+ title: 'Add a note',
35
+ body: '',
36
+ authorEmail: 'alice@bevel.software',
37
+ authorName: 'Alice',
38
+ state: 'open',
39
+ };
40
+
41
+ /**
42
+ * One approved file, which is all the gate needs to let the merge through: an
43
+ * empty approval set is itself a hard block ("no file changes to approve"), so
44
+ * the "nothing to merge" cases below are about git finding the change already on
45
+ * the target, never about a request with no files.
46
+ */
47
+ const APPROVED: FileApprovalState = {
48
+ path: 'Knowledge/Ops/note.md',
49
+ eligibleApprovers: { roles: ['Ops'], users: [] },
50
+ approvedBy: [
51
+ {
52
+ name: 'Olga',
53
+ email: 'olga@bevel.software',
54
+ approvedAt: '2026-10-02T10:00:00Z',
55
+ isStale: false,
56
+ isSelfApproval: false,
57
+ },
58
+ ],
59
+ eligibilityResolved: true,
60
+ isApproved: true,
61
+ inMergeGate: true,
62
+ viewerCanApprove: false,
63
+ };
64
+
65
+ /**
66
+ * Every write the merge performs, in order, with the table it targeted and
67
+ * whether it was an insert or an update — the ORDER is part of what one of the
68
+ * tests below pins.
69
+ */
70
+ type Captured = { kind: 'insert' | 'update'; table: unknown; values: Record<string, unknown> };
71
+
72
+ /**
73
+ * What the row says, and whether the CAS wins, are PARAMETERS, not constants.
74
+ * `mergePr` and `finalizeAlreadyApplied` both re-validate the lifecycle against
75
+ * the row rather than against the caller's `state` argument, and a stub that
76
+ * always answers `open` and always wins leaves those paths unreachable — a
77
+ * regression that dropped one would still pass (cubic P3 on #347).
78
+ *
79
+ * `rows` is consumed in order and its last entry repeats, so a test can say
80
+ * what the row looked like when the attempt started and what it says when the
81
+ * lost CAS is explained.
82
+ */
83
+ function makeDb(
84
+ captured: Captured[],
85
+ {
86
+ rows = [CR_ROW],
87
+ casWon = true,
88
+ begunLog = true,
89
+ }: { rows?: Record<string, unknown>[]; casWon?: boolean; begunLog?: boolean } = {},
90
+ ): Database {
91
+ let crReads = 0;
92
+ const thenable = (data: unknown) => {
93
+ const p = Promise.resolve(data) as Promise<unknown> & { limit: () => Promise<unknown> };
94
+ p.limit = () => Promise.resolve(data);
95
+ return p;
96
+ };
97
+ /**
98
+ * What the CAS compares against: the row's state at the moment of the
99
+ * update. `casWon` says the row is still open then; a lost CAS means it is
100
+ * what the LAST row says (the terminal state the lost CAS is explained by).
101
+ */
102
+ const stateAtCas = casWon ? 'open' : String(rows[rows.length - 1]?.state ?? 'merged');
103
+ return {
104
+ select: () => ({
105
+ from: (table: unknown) => ({
106
+ where: () => {
107
+ // The merge log begun by the attempt that pushed and crashed —
108
+ // `succeeded: false`, no error — is what makes a retry look for its
109
+ // commit at all.
110
+ if (table === prMergeLog) return thenable(begunLog ? [{ id: 'merge-log-0' }] : []);
111
+ if (table !== changeRequests) return thenable([]);
112
+ const row = rows[Math.min(crReads, rows.length - 1)];
113
+ crReads += 1;
114
+ return thenable([row]);
115
+ },
116
+ }),
117
+ }),
118
+ insert: (table: unknown) => ({
119
+ values: (values: Record<string, unknown>) => {
120
+ captured.push({ kind: 'insert', table, values });
121
+ return { returning: async () => [{ id: 'merge-log-1' }] };
122
+ },
123
+ }),
124
+ update: (table: unknown) => ({
125
+ set: (values: Record<string, unknown>) => {
126
+ captured.push({ kind: 'update', table, values });
127
+ return {
128
+ where: (predicate: SQL) => {
129
+ const t = thenable(undefined) as unknown as Promise<unknown> & {
130
+ returning: () => Promise<unknown>;
131
+ };
132
+ // The CAS is answered FROM THE WHERE CLAUSE, not from a flag: the
133
+ // update of the change request row lands only if the row is in
134
+ // the state the predicate asks for. A predicate that no longer
135
+ // guards on `state = 'open'` would therefore land on a terminal
136
+ // row here — which is the race this test file exists to pin, so
137
+ // dropping the guard fails the lost-CAS tests below instead of
138
+ // passing them by a stub that answered from `casWon` alone.
139
+ const { sql, params } = new PgDialect().sqlToQuery(predicate);
140
+ const guardsOpen = /"state"/.test(sql) && params.includes('open');
141
+ const lands = table !== changeRequests || !guardsOpen || stateAtCas === 'open';
142
+ t.returning = () => Promise.resolve(lands ? [{ id: CR_ROW.id }] : []);
143
+ return t;
144
+ },
145
+ };
146
+ },
147
+ }),
148
+ } as unknown as Database;
149
+ }
150
+
151
+ function makeService(mergeResult: AppliedMergeResult) {
152
+ const captured: Captured[] = [];
153
+ // Typed by its signature rather than its body, so the recorded call keeps the
154
+ // arguments the assertion below reads out of it.
155
+ const mergeChangeRequest = vi.fn<(...args: unknown[]) => Promise<AppliedMergeResult>>(
156
+ async () => mergeResult,
157
+ );
158
+ const workspace = {
159
+ ensureRemotesFetched: vi.fn(async () => undefined),
160
+ getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
161
+ } as unknown as WorkspaceService;
162
+ const svc = new ReviewWorkflowService(
163
+ makeDb(captured),
164
+ {} as unknown as IAccessControl,
165
+ workspace,
166
+ { mergeChangeRequest } as unknown as GitService,
167
+ );
168
+ const merge = () =>
169
+ svc.mergePr(CR_ROW.number, USER, HEAD_SHA, [APPROVED], 'open', CR_ROW.title, BASE, 'ws-1');
170
+ /** The values written to the change request row (not the merge log). */
171
+ const crUpdate = () => captured.find((c) => c.kind === 'update' && c.table === changeRequests)?.values;
172
+ return { merge, crUpdate, mergeChangeRequest };
173
+ }
174
+
175
+ describe('mergePr — which commit the row records', () => {
176
+ it('records the merge commit this request owns, not the state the target is left at', async () => {
177
+ const { merge, crUpdate } = makeService({
178
+ kind: 'merged',
179
+ sha: 'target-tip-which-is-another-requests-merge',
180
+ mergeCommit: 'this-requests-own-merge',
181
+ });
182
+
183
+ const result = await merge();
184
+
185
+ expect(crUpdate()).toMatchObject({ state: 'merged', mergedSha: 'this-requests-own-merge' });
186
+ // The caller still hears the state the target is at, which is what "merged"
187
+ // means for the request even when it is not a commit of its own.
188
+ expect(result.sha).toBe('target-tip-which-is-another-requests-merge');
189
+ });
190
+
191
+ it('records nothing when the request owns no merge commit', async () => {
192
+ // The genuinely empty request: the target already contained its change, and
193
+ // no attempt of its own ever wrote a commit. It has no file list to lose, and
194
+ // the target tip it is merged at belongs to somebody else.
195
+ const { merge, crUpdate } = makeService({
196
+ kind: 'merged',
197
+ sha: 'target-tip-which-is-another-requests-merge',
198
+ mergeCommit: null,
199
+ });
200
+
201
+ await merge();
202
+
203
+ expect(crUpdate()).toMatchObject({ state: 'merged', mergedSha: null });
204
+ });
205
+
206
+ it('names the request to the merge, so a retry can recognise its own commit', async () => {
207
+ // Without the number, a merge that finds nothing to merge cannot tell an
208
+ // empty request from one whose merge commit a previous attempt pushed before
209
+ // the row update failed — and would record null for both.
210
+ const { merge, mergeChangeRequest } = makeService({
211
+ kind: 'merged',
212
+ sha: 'tip',
213
+ mergeCommit: 'own',
214
+ });
215
+
216
+ await merge();
217
+
218
+ expect(mergeChangeRequest).toHaveBeenCalledTimes(1);
219
+ const opts = mergeChangeRequest.mock.calls[0][5] as { appliedChangeNumber?: number };
220
+ expect(opts.appliedChangeNumber).toBe(CR_ROW.number);
221
+ });
222
+ });
223
+
224
+ /**
225
+ * The retry the row-recording fix is FOR, reached the way production reaches it.
226
+ *
227
+ * When an attempt pushes the merge commit and then fails to write the row, the
228
+ * request stays open with its change already on the target — so it differs from
229
+ * the target by nothing, its file list is empty, and the approval gate refuses
230
+ * the next attempt ("no file changes to approve") before any merge runs. The
231
+ * recovery inside `mergeChangeRequest` is therefore never reached from the app,
232
+ * and the request stays fileless and un-appliable for good. The gate has to ask
233
+ * the ownership question itself.
234
+ */
235
+ function makeStuckService(
236
+ own: string | null,
237
+ // The two states a merge is decided against, kept APART because the service
238
+ // treats them apart: `gateState` is the caller's (what drove the gate it
239
+ // clicked), `rowState` is the authoritative row's. A stale caller passing
240
+ // 'open' for a row that is already terminal is exactly what the row-level
241
+ // guard is for.
242
+ {
243
+ gateState = 'open',
244
+ rowState = 'open',
245
+ casWon = true,
246
+ // What the row says when the lost CAS is explained — a cancel closes a
247
+ // request just as a merge does, and the audit line must not guess.
248
+ stateAfterCas = 'merged',
249
+ // Whether an earlier attempt left a begun, never-completed merge-log row —
250
+ // the only evidence that there may be a pushed commit to recover.
251
+ begunLog = true,
252
+ }: {
253
+ gateState?: string;
254
+ rowState?: string;
255
+ casWon?: boolean;
256
+ stateAfterCas?: string;
257
+ begunLog?: boolean;
258
+ } = {},
259
+ ) {
260
+ const rows = [{ ...CR_ROW, state: rowState }, { ...CR_ROW, state: stateAfterCas }];
261
+ const captured: Captured[] = [];
262
+ const mergeChangeRequest = vi.fn<(...args: unknown[]) => Promise<AppliedMergeResult>>(async () => ({
263
+ kind: 'merged',
264
+ sha: 'should-not-be-reached',
265
+ mergeCommit: null,
266
+ }));
267
+ const appliedMergeCommitOnTarget = vi.fn(async () => own);
268
+ const workspace = {
269
+ getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
270
+ } as unknown as WorkspaceService;
271
+ const svc = new ReviewWorkflowService(
272
+ makeDb(captured, { rows, casWon, begunLog }),
273
+ {} as unknown as IAccessControl,
274
+ workspace,
275
+ { mergeChangeRequest, appliedMergeCommitOnTarget } as unknown as GitService,
276
+ );
277
+ // NO approvals: an already-applied request has no file to approve, which is
278
+ // exactly what makes it indistinguishable from an empty one at the gate.
279
+ const merge = () =>
280
+ svc.mergePr(CR_ROW.number, USER, HEAD_SHA, [], gateState as 'open', CR_ROW.title, BASE, 'ws-1');
281
+ const crUpdate = () => captured.find((c) => c.kind === 'update' && c.table === changeRequests)?.values;
282
+ return { merge, crUpdate, captured, mergeChangeRequest, appliedMergeCommitOnTarget, workspace };
283
+ }
284
+
285
+ describe('mergePr — a request whose merge commit was pushed but never recorded', () => {
286
+ it('asks git only when the database shows an attempt that began and never completed', async () => {
287
+ // A genuinely empty request — nothing on the target, no attempt behind it
288
+ // — is refused as it always was, and the refusal touches no clone and no
289
+ // remote: the probe that fetches the target runs only behind the one
290
+ // piece of evidence a crashed attempt leaves, its begun merge-log row.
291
+ const { merge, appliedMergeCommitOnTarget, workspace } = makeStuckService('a-commit', { begunLog: false });
292
+ await expect(merge()).rejects.toThrow(/no file changes to approve/i);
293
+ expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
294
+ expect((workspace as unknown as { getOrCreateForBranch: ReturnType<typeof vi.fn> }).getOrCreateForBranch).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it('hands the stored title to the probe, so a merge commit in the old message format is recognised', async () => {
298
+ const { merge, appliedMergeCommitOnTarget } = makeStuckService('a-commit');
299
+ await merge();
300
+ expect(appliedMergeCommitOnTarget).toHaveBeenCalledWith('ws-base', BASE, CR_ROW.number, CR_ROW.title);
301
+ });
302
+
303
+ it('records that commit instead of refusing, and merges nothing', async () => {
304
+ const { merge, crUpdate, mergeChangeRequest, appliedMergeCommitOnTarget } =
305
+ makeStuckService('the-commit-the-first-attempt-pushed');
306
+
307
+ const result = await merge();
308
+
309
+ expect(crUpdate()).toMatchObject({
310
+ state: 'merged',
311
+ mergedSha: 'the-commit-the-first-attempt-pushed',
312
+ });
313
+ expect(result.sha).toBe('the-commit-the-first-attempt-pushed');
314
+ // Asked of the TARGET branch's own workspace, for this request's number.
315
+ expect(appliedMergeCommitOnTarget).toHaveBeenCalledWith('ws-base', BASE, CR_ROW.number, CR_ROW.title);
316
+ // Nothing was merged, committed or pushed: the merge already happened.
317
+ expect(mergeChangeRequest).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it('still refuses a request that owns no merge commit', async () => {
321
+ // The genuinely empty request. The hard block is the right answer for it,
322
+ // and the probe is what tells the two apart.
323
+ const { merge, crUpdate, mergeChangeRequest } = makeStuckService(null);
324
+
325
+ await expect(merge()).rejects.toThrow('no file changes to approve');
326
+
327
+ expect(crUpdate()).toBeUndefined();
328
+ expect(mergeChangeRequest).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it('does not go looking when the hard block is the request\'s state', async () => {
332
+ // A closed or merged request earns its own refusal, and must keep it: the
333
+ // recovery is only ever about the empty-file-set block.
334
+ const { merge, appliedMergeCommitOnTarget, crUpdate } = makeStuckService('a-commit', { gateState: 'closed', rowState: 'closed' });
335
+
336
+ await expect(merge()).rejects.toThrow('This pull request is closed.');
337
+
338
+ expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
339
+ expect(crUpdate()).toBeUndefined();
340
+ });
341
+
342
+ it('refuses when the AUTHORITATIVE row is already terminal, whatever the caller passed', async () => {
343
+ // The caller's gate inputs were resolved before a concurrent apply landed,
344
+ // so it still says `open` while the row says `merged`. The recovery runs
345
+ // ahead of `mergePr`'s own lifecycle re-check, so its own row-state guard is
346
+ // the only thing between a stale click and a second finalization.
347
+ const { merge, crUpdate, appliedMergeCommitOnTarget } = makeStuckService('a-commit', {
348
+ gateState: 'open',
349
+ rowState: 'merged',
350
+ });
351
+
352
+ await expect(merge()).rejects.toThrow('no file changes to approve');
353
+
354
+ expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
355
+ expect(crUpdate()).toBeUndefined();
356
+ });
357
+
358
+ it('logs the attempt before it finalizes the row, and completes the log after', async () => {
359
+ // Order matters: a log write that failed after the CAS would leave a request
360
+ // recorded as merged with no attempt behind it, and the caller erroring on a
361
+ // request that IS merged. The attempt goes in first, carrying the commit it
362
+ // is about, and is completed once the row is won.
363
+ const { merge, captured } = makeStuckService('the-commit-the-first-attempt-pushed');
364
+
365
+ await merge();
366
+
367
+ const inserted = captured.find((c) => c.kind === 'insert');
368
+ expect(inserted?.values).toMatchObject({ succeeded: false, mergeMethod: 'merge' });
369
+ expect(String(inserted?.values.error)).toContain('the-commit-the-first-attempt-pushed');
370
+ // The log's completion comes AFTER the change request row is finalized.
371
+ const order = captured.map((c) => (c.kind === 'insert' ? 'log-insert' : c.table === changeRequests ? 'cr' : 'log-update'));
372
+ expect(order).toEqual(['log-insert', 'cr', 'log-update']);
373
+ expect(captured[2].values).toMatchObject({ succeeded: true });
374
+ });
375
+ it('names the state the row actually ended in when the CAS is lost to a CANCEL', async () => {
376
+ // A cancel closes a request exactly as a merge does, and both lose this CAS.
377
+ // Filing a cancellation as "a concurrent merge won" sends whoever reads
378
+ // pr_merge_log afterwards to the wrong cause (cubic P2 on #347).
379
+ const { merge, captured } = makeStuckService('a-commit', {
380
+ casWon: false,
381
+ stateAfterCas: 'closed',
382
+ });
383
+
384
+ await expect(merge()).rejects.toThrow('This change request is closed.');
385
+
386
+ const logUpdate = captured.filter((c) => c.kind === 'update' && c.table !== changeRequests).pop();
387
+ expect(logUpdate?.values).toMatchObject({ succeeded: false });
388
+ expect(String(logUpdate?.values.error)).toBe(
389
+ 'Change request was no longer open (closed); this attempt did not finalize it.',
390
+ );
391
+ expect(String(logUpdate?.values.error)).not.toContain('concurrent');
392
+ });
393
+
394
+ it('says merged, and refuses as merged, when the CAS is lost to another merge', async () => {
395
+ const { merge, captured } = makeStuckService('a-commit', {
396
+ casWon: false,
397
+ stateAfterCas: 'merged',
398
+ });
399
+
400
+ await expect(merge()).rejects.toThrow('This change request has already been merged.');
401
+
402
+ const logUpdate = captured.filter((c) => c.kind === 'update' && c.table !== changeRequests).pop();
403
+ expect(String(logUpdate?.values.error)).toBe(
404
+ 'Change request was no longer open (merged); this attempt did not finalize it.',
405
+ );
406
+ });
407
+ });
@@ -1,4 +1,4 @@
1
- import { and, asc, eq } from 'drizzle-orm';
1
+ import { and, asc, eq, isNull } from 'drizzle-orm';
2
2
  import { logger } from '../../../shared/logging.js';
3
3
 
4
4
  const log = logger('review-workflow');
@@ -18,6 +18,7 @@ import { changeRequests, prComments, prFileApprovals, prMergeLog, users } from '
18
18
  import { AccessUnreadableError } from '../../access-model/access-errors.js';
19
19
  import type { IAccessControl } from '../../access/access-control.interface.js';
20
20
  import type { GitService } from '../git/git.service.js';
21
+ import { mergeCommitSubject } from '../git/merge-commit.js';
21
22
  import { redactSecret } from '../../../shared/redact-secret.js';
22
23
  import {
23
24
  ChangeRequestConflictsError,
@@ -44,6 +45,14 @@ import {
44
45
  // trail — squashing would collapse them into one. A merge commit preserves every
45
46
  // commit on the branch.
46
47
  const MERGE_METHOD = 'merge' as const;
48
+
49
+ /**
50
+ * The hard block an empty file set earns — named, because two places have to
51
+ * agree on it: the gate that raises it, and `finalizeAlreadyApplied`, which
52
+ * recognises it as the one refusal that can mean "already applied" rather than
53
+ * "nothing to apply".
54
+ */
55
+ const NOTHING_TO_APPROVE = 'This pull request has no file changes to approve.';
47
56
  const EVERYONE_CANONICAL = 'everyone';
48
57
 
49
58
  /**
@@ -239,7 +248,7 @@ function evaluateGateParts(input: MergeGateInput): { hardReasons: string[]; warn
239
248
  // mergeable either — a PR that touches no files shouldn't be opened in
240
249
  // the first place, let alone merged.
241
250
  if (input.approvals.length === 0 && input.state === 'open') {
242
- reasons.push('This pull request has no file changes to approve.');
251
+ reasons.push(NOTHING_TO_APPROVE);
243
252
  }
244
253
 
245
254
  // Ownership enforcement binds every file with an eligible approver,
@@ -671,7 +680,17 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
671
680
  // opted into bypass; with bypass, the bypassed warnings get inlined in the
672
681
  // merge commit body so git history captures the decision.
673
682
  const gate = evaluateGateParts({ prNumber, state, approvals });
674
- if (gate.hardReasons.length > 0) throw new MergeBlockedError(gate.hardReasons);
683
+ if (gate.hardReasons.length > 0) {
684
+ // One of those reasons can mean the opposite of what it says. "No file
685
+ // changes to approve" is also what an ALREADY APPLIED request looks like:
686
+ // its change is on the target, so it differs from it by nothing. That is
687
+ // the state a request is left in when an attempt pushed its merge commit
688
+ // and then failed to record the row — and refusing the retry here is what
689
+ // kept the recovery inside the merge from ever running (cubic P2 on #347).
690
+ const recovered = await this.finalizeAlreadyApplied(prNumber, user, headSha, gate.hardReasons);
691
+ if (recovered) return recovered;
692
+ throw new MergeBlockedError(gate.hardReasons);
693
+ }
675
694
  if (gate.warnings.length > 0 && !opts.bypass) {
676
695
  throw new MergeBlockedError(gate.warnings);
677
696
  }
@@ -730,7 +749,9 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
730
749
  // Attribution lives on the merge commit itself (authored as the human
731
750
  // triggerer). When bypass is used, the bypassed warnings are appended so the
732
751
  // decision survives in git history — no separate audit table needed.
733
- const subject = `${prTitle} (#${prNumber})`;
752
+ // One place builds this, one place reads it back — `merge-commit.ts` says
753
+ // why an applied request's reader has to verify the subject at all.
754
+ const subject = mergeCommitSubject(prTitle, prNumber);
734
755
  const bypassFooter =
735
756
  opts.bypass && gate.warnings.length > 0
736
757
  ? `\n\nApproval requirements bypassed:\n${gate.warnings.map((w) => `- ${w}`).join('\n')}`
@@ -750,6 +771,10 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
750
771
  cr.targetBranch,
751
772
  { subject, body },
752
773
  user,
774
+ // Named so a merge that finds nothing to merge can tell an empty request
775
+ // apart from one a previous attempt already merged and failed to record
776
+ // (see `ownMergeCommitOn`).
777
+ { appliedChangeNumber: prNumber, appliedChangeTitle: cr.title },
753
778
  );
754
779
  } catch (err) {
755
780
  const redacted = redactTokens(err instanceof Error ? err.message : String(err), this.git.credentials?.token());
@@ -780,16 +805,35 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
780
805
  const completedAt = new Date();
781
806
  const [updatedCr] = await this.db
782
807
  .update(changeRequests)
783
- .set({ state: 'merged', mergedSha: mergeResult.sha, closedAt: completedAt, updatedAt: completedAt })
808
+ .set({
809
+ state: 'merged',
810
+ // Only the commit this request OWNS, never `sha`. With nothing to merge
811
+ // `sha` is the target tip — usually another request's merge commit — and
812
+ // recording it here would let this request be read back with that other
813
+ // request's files under its number (cubic P1 on #347).
814
+ //
815
+ // `mergeCommit` is also what makes a retry after a failed finalization
816
+ // idempotent: the merge commit a previous attempt pushed is found and
817
+ // recorded instead of being dropped as "nothing was merged" (cubic P2 on
818
+ // #347). It is null only when the request has no merge commit at all,
819
+ // and such a request has no file list to lose.
820
+ mergedSha: mergeResult.mergeCommit,
821
+ closedAt: completedAt,
822
+ updatedAt: completedAt,
823
+ })
784
824
  .where(and(eq(changeRequests.id, cr.id), eq(changeRequests.state, 'open')))
785
825
  .returning({ id: changeRequests.id });
786
826
  if (!updatedCr) {
787
- // A concurrent merge won the CAS between our lifecycle re-check and here.
788
- // Our own git merge was a harmless idempotent no-op, but this attempt did
789
- // NOT finalize the CR. Finalize this log row with an explanatory error and
790
- // a completedAt so it doesn't linger as a phantom `succeeded=false,
791
- // error=null` entry that a "failed merges" audit query would misread.
792
- const raceError = 'Change request was merged by a concurrent request; this attempt did not finalize it.';
827
+ // Something else moved the row out of `open` between our lifecycle
828
+ // re-check and here. Our own git merge was a harmless idempotent no-op,
829
+ // but this attempt did NOT finalize the CR. Finalize this log row with an
830
+ // explanatory error and a completedAt so it doesn't linger as a phantom
831
+ // `succeeded=false, error=null` entry that a "failed merges" audit query
832
+ // would misread — and say only what the lost CAS proves, which is that the
833
+ // row was no longer open: a cancel closes it too, and naming a concurrent
834
+ // MERGE would file that cancellation under the wrong cause (cubic P2 on
835
+ // #347, raised against the copy of this line in `finalizeAlreadyApplied`).
836
+ const raceError = 'Change request was no longer open; this attempt did not finalize it.';
793
837
  await this.db
794
838
  .update(prMergeLog)
795
839
  .set({ succeeded: false, completedAt, error: raceError })
@@ -808,6 +852,140 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
808
852
  };
809
853
  }
810
854
 
855
+ /**
856
+ * Record a change request that is ALREADY applied — its merge commit is on the
857
+ * target — and that the gate was about to refuse for having nothing to merge.
858
+ * Null when the request owns no such commit, which is every other reason an
859
+ * approval set can be empty.
860
+ *
861
+ * The case this exists for: an attempt pushed the merge commit and then failed
862
+ * to write the row (a transient database fault in between). The request stays
863
+ * `open` while its change is on the target, so the diff that produces its file
864
+ * list — and with it every approval the gate reads — is empty. The retry is
865
+ * then refused BEFORE the merge runs, which is where the recovery inside
866
+ * `mergeChangeRequest` sits, so the request can never be finalized: it is
867
+ * fileless, author-only, and un-appliable for good, with the commit holding
868
+ * its files sitting on the target unreferenced (cubic P2 on #347).
869
+ *
870
+ * Nothing is merged, committed or pushed here. The only write is the row that
871
+ * should have been written by the attempt that pushed, and the merge-log entry
872
+ * that says this caller finalized it.
873
+ *
874
+ * Narrow on purpose:
875
+ * • exactly ONE hard reason, and it is the empty-file-set one. A request that
876
+ * is already `merged` or `closed` has its own reason and must keep it.
877
+ * • the row must still be `open`, read here and re-checked by the CAS below.
878
+ * • the commit must be the request's OWN, by the same subject predicate the
879
+ * reader verifies with (`mergeCommitSubjectNames`), so this records only
880
+ * what `appliedChangeShas` will accept. No branch name a user can choose
881
+ * can produce that subject — `assertValidBranchName` forbids both the space
882
+ * and the `#` the agent `merge_branch` subject would need.
883
+ *
884
+ * Who may trigger it needs no extra check: the app resolves the request's
885
+ * detail for the caller first and refuses when it answers nothing, and a
886
+ * request with no readable file list is visible to its author alone. So the
887
+ * author is the one who can finalize their own stuck request.
888
+ */
889
+ private async finalizeAlreadyApplied(
890
+ prNumber: number,
891
+ user: AuthUser,
892
+ headSha: string,
893
+ hardReasons: string[],
894
+ ): Promise<MergePrResult | null> {
895
+ if (hardReasons.length !== 1 || hardReasons[0] !== NOTHING_TO_APPROVE) return null;
896
+
897
+ const [cr] = await this.db
898
+ .select()
899
+ .from(changeRequests)
900
+ .where(eq(changeRequests.number, prNumber))
901
+ .limit(1);
902
+ if (!cr || cr.state !== 'open' || !cr.targetBranch) return null;
903
+
904
+ // The cheap question first, of the database alone: did an attempt on this
905
+ // request push and then crash before recording? Such an attempt leaves its
906
+ // log row begun and never completed — `succeeded: false` with no error,
907
+ // the shape no finished attempt has. Without one there is nothing to
908
+ // recover, and a genuinely empty request is refused here as it always
909
+ // was, without a clone being created or a remote fetched on its behalf.
910
+ const [begun] = await this.db
911
+ .select({ id: prMergeLog.id })
912
+ .from(prMergeLog)
913
+ .where(and(eq(prMergeLog.prNumber, prNumber), eq(prMergeLog.succeeded, false), isNull(prMergeLog.error)))
914
+ .limit(1);
915
+ if (!begun) return null;
916
+
917
+ const baseWorkspace = await this.workspaceService.getOrCreateForBranch(cr.targetBranch);
918
+ const own = await this.git.appliedMergeCommitOnTarget(baseWorkspace.id, cr.targetBranch, prNumber, cr.title);
919
+ if (!own) return null;
920
+
921
+ // The attempt is logged BEFORE the row is finalized, in the same order the
922
+ // merge path logs its own: a log write that fails must not leave a request
923
+ // recorded as merged with no attempt behind it, and failing here fails
924
+ // before anything is committed, so the retry is clean (cubic P2 on #347).
925
+ const triggeredByEmail = canonicalEmail(user.email);
926
+ const [logRow] = await this.db
927
+ .insert(prMergeLog)
928
+ .values({
929
+ prNumber,
930
+ triggeredByEmail,
931
+ triggeredByEmailBidx: triggeredByEmail,
932
+ triggeredByName: user.name,
933
+ headShaAtMerge: headSha,
934
+ mergeMethod: MERGE_METHOD,
935
+ succeeded: false,
936
+ // Not an error — the one thing a reader of this row has to know is that
937
+ // no merge was performed under it, because it had already been
938
+ // performed. Written at the start so it survives a crash in between.
939
+ error: `Finalizing an earlier attempt's merge commit ${own}; nothing is merged by this one.`,
940
+ })
941
+ .returning({ id: prMergeLog.id });
942
+
943
+ // Same CAS as the merge path: a concurrent finalization wins and we say so
944
+ // rather than overwriting its terminal state.
945
+ const completedAt = new Date();
946
+ const [updated] = await this.db
947
+ .update(changeRequests)
948
+ .set({ state: 'merged', mergedSha: own, closedAt: completedAt, updatedAt: completedAt })
949
+ .where(and(eq(changeRequests.id, cr.id), eq(changeRequests.state, 'open')))
950
+ .returning({ id: changeRequests.id });
951
+ if (!updated) {
952
+ // The CAS loses to ANY terminal transition, not just a merge: `cancelPr`
953
+ // closes a request the same way. So the row is re-read and the reason it
954
+ // actually carries is what goes into the audit line and into the refusal
955
+ // — a cancellation filed as "a concurrent merge won" sends whoever reads
956
+ // pr_merge_log after the fact to the wrong cause (cubic P2 on #347).
957
+ const [current] = await this.db
958
+ .select({ state: changeRequests.state })
959
+ .from(changeRequests)
960
+ .where(eq(changeRequests.id, cr.id))
961
+ .limit(1);
962
+ const raceError =
963
+ `Change request was no longer open (${current?.state ?? 'row missing'}); this attempt did not finalize it.`;
964
+ await this.db
965
+ .update(prMergeLog)
966
+ .set({ succeeded: false, completedAt, error: raceError })
967
+ .where(eq(prMergeLog.id, logRow.id));
968
+ throw new MergeBlockedError([
969
+ current?.state === 'merged'
970
+ ? 'This change request has already been merged.'
971
+ : 'This change request is closed.',
972
+ ]);
973
+ }
974
+ await this.db
975
+ .update(prMergeLog)
976
+ .set({
977
+ succeeded: true,
978
+ completedAt,
979
+ error: `Finalized an earlier attempt's merge commit ${own}; nothing was merged by this one.`,
980
+ })
981
+ .where(eq(prMergeLog.id, logRow.id));
982
+ log.warn(
983
+ `change request #${prNumber} was already merged as ${own} but its row was never updated; recorded it now.`,
984
+ );
985
+
986
+ return { prNumber, sha: own, mergedAt: completedAt.toISOString() };
987
+ }
988
+
811
989
  async cancelPr(
812
990
  prNumber: number,
813
991
  user: AuthUser,