@bevel-software/platform-core-backend 0.21.0 → 0.22.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 (177) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +32 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +13 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +51 -5
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  9. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  10. package/dist/modules/access/access-requests.contract.js +20 -0
  11. package/dist/modules/access/access-requests.contract.js.map +1 -0
  12. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  13. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  14. package/dist/modules/access/access-requests.routes.js +237 -0
  15. package/dist/modules/access/access-requests.routes.js.map +1 -0
  16. package/dist/modules/access/access-requests.service.d.ts +123 -0
  17. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  18. package/dist/modules/access/access-requests.service.js +337 -0
  19. package/dist/modules/access/access-requests.service.js.map +1 -0
  20. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +24 -0
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/auth/auth.service.d.ts +26 -1
  25. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  26. package/dist/modules/auth/auth.service.js +11 -2
  27. package/dist/modules/auth/auth.service.js.map +1 -1
  28. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  29. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  30. package/dist/modules/github-app/github-app.client.js +222 -0
  31. package/dist/modules/github-app/github-app.client.js.map +1 -0
  32. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  33. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  34. package/dist/modules/github-app/github-app.connection.js +185 -0
  35. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  36. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  37. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  38. package/dist/modules/github-app/github-app.routes.js +323 -0
  39. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  40. package/dist/modules/github-app/index.d.ts +4 -0
  41. package/dist/modules/github-app/index.d.ts.map +1 -0
  42. package/dist/modules/github-app/index.js +4 -0
  43. package/dist/modules/github-app/index.js.map +1 -0
  44. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  45. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/remote-url.js +35 -0
  47. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  48. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  49. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  50. package/dist/modules/plugins/join-proposals.js +76 -22
  51. package/dist/modules/plugins/join-proposals.js.map +1 -1
  52. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  53. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  54. package/dist/modules/plugins/join-requests.service.js +173 -29
  55. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  56. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  57. package/dist/modules/plugins/plugins.routes.js +3 -2
  58. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  59. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  60. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  61. package/dist/modules/settings/deployment-settings.service.js +93 -4
  62. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  63. package/dist/modules/settings/managed-repository.d.ts +43 -0
  64. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  65. package/dist/modules/settings/managed-repository.js +60 -0
  66. package/dist/modules/settings/managed-repository.js.map +1 -0
  67. package/dist/modules/settings/repository-source.d.ts +128 -0
  68. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  69. package/dist/modules/settings/repository-source.js +150 -0
  70. package/dist/modules/settings/repository-source.js.map +1 -0
  71. package/dist/modules/settings/setup.routes.d.ts +27 -4
  72. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  73. package/dist/modules/settings/setup.routes.js +187 -13
  74. package/dist/modules/settings/setup.routes.js.map +1 -1
  75. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  76. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  77. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  78. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  79. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  80. package/dist/modules/tool-helpers/tool-context.js +15 -0
  81. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
  84. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  85. package/dist/modules/workflow/git/git.service.d.ts +3 -1
  86. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  87. package/dist/modules/workflow/git/git.service.js +17 -4
  88. package/dist/modules/workflow/git/git.service.js.map +1 -1
  89. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  91. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  92. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  93. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  94. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  95. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  97. package/dist/modules/workflow/workflow.routes.js +3 -1
  98. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  99. package/dist/modules/workflow/workflow.service.d.ts +34 -7
  100. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  101. package/dist/modules/workflow/workflow.service.js +148 -48
  102. package/dist/modules/workflow/workflow.service.js.map +1 -1
  103. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
  104. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  105. package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
  106. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  107. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.service.js +17 -1
  109. package/dist/modules/workspace/workspace.service.js.map +1 -1
  110. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.tools.js +28 -2
  112. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  113. package/dist/shared/domain-errors.d.ts +39 -0
  114. package/dist/shared/domain-errors.d.ts.map +1 -1
  115. package/dist/shared/domain-errors.js +55 -0
  116. package/dist/shared/domain-errors.js.map +1 -1
  117. package/dist/shared/git.contract.d.ts +10 -0
  118. package/dist/shared/git.contract.d.ts.map +1 -1
  119. package/dist/shared/git.contract.js.map +1 -1
  120. package/package.json +3 -3
  121. package/src/core/create-core-server.ts +37 -1
  122. package/src/core/create-core-services.ts +65 -8
  123. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  124. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  125. package/src/modules/access/access-requests.contract.ts +93 -0
  126. package/src/modules/access/access-requests.routes.ts +286 -0
  127. package/src/modules/access/access-requests.service.ts +420 -0
  128. package/src/modules/access-model/access-grammar.ts +22 -0
  129. package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
  130. package/src/modules/auth/auth.service.ts +28 -1
  131. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  132. package/src/modules/github-app/github-app.client.ts +268 -0
  133. package/src/modules/github-app/github-app.connection.ts +204 -0
  134. package/src/modules/github-app/github-app.routes.ts +359 -0
  135. package/src/modules/github-app/index.ts +19 -0
  136. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  137. package/src/modules/kb-fs/remote-url.ts +35 -0
  138. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  139. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  140. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  141. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  142. package/src/modules/plugins/join-proposals.ts +87 -19
  143. package/src/modules/plugins/join-requests.service.ts +199 -39
  144. package/src/modules/plugins/plugins.routes.ts +3 -2
  145. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  146. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
  147. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
  148. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
  149. package/src/modules/settings/deployment-settings.service.ts +109 -3
  150. package/src/modules/settings/managed-repository.ts +68 -0
  151. package/src/modules/settings/repository-source.ts +197 -0
  152. package/src/modules/settings/setup.routes.ts +214 -10
  153. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  154. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  155. package/src/modules/tool-helpers/tool-context.ts +15 -0
  156. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  157. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  158. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  159. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
  160. package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
  161. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  162. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  163. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  164. package/src/modules/workflow/git/git.service.ts +25 -4
  165. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  166. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  167. package/src/modules/workflow/workflow.routes.ts +3 -1
  168. package/src/modules/workflow/workflow.service.ts +170 -52
  169. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  170. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
  171. package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
  172. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
  173. package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
  174. package/src/modules/workspace/workspace.service.ts +17 -1
  175. package/src/modules/workspace/workspace.tools.ts +35 -0
  176. package/src/shared/domain-errors.ts +58 -0
  177. package/src/shared/git.contract.ts +10 -0
@@ -5,6 +5,7 @@ import { toolDef, withBranchInput } from '../../tool-helpers/tool-def.js';
5
5
  import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
6
6
  import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
7
7
  import type { KbContext } from '../../../shared/kb-context.js';
8
+ import { assertBranchProvided } from '../../../shared/domain-errors.js';
8
9
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
10
  import { assertInsideRepo, normalizePathArgs } from '../../kb-fs/repo-path.js';
10
11
  import { RETIRED_TOOL_MESSAGES } from '@bevel-software/platform-mcp-core';
@@ -144,16 +145,30 @@ export function registerWorkflowTools(
144
145
  write: boolean;
145
146
  /** Register on the internal catalog only (e.g. agent-control tools that make no sense to a remote caller). */
146
147
  internalOnly?: boolean;
147
- /** Skip the auto-injected `branch` input — for a tool that already declares its own `branch` (switch_branch). */
148
+ /**
149
+ * Skip the auto-injected `branch` input — for a tool that declares its own
150
+ * `branch` with its own meaning (`create_branch`'s fork base,
151
+ * `switch_branch`'s target draft) or none at all (`merge_branch`,
152
+ * `open_change_request`, the repo-global reads). It says NOTHING about
153
+ * whether the tool requires a branch: that is read off the declared schema
154
+ * below, because reading it off this flag is what let `create_branch` opt
155
+ * out of the check while still demanding the input.
156
+ */
148
157
  skipBranch?: boolean;
149
158
  handler: ToolHandler;
150
159
  }): void => {
151
160
  const path = `/api/agent/tools/${spec.name}`;
161
+ const inputs = spec.skipBranch ? spec.inputs : withBranchInput(spec.inputs);
162
+ // The tool's OWN declaration is the only honest answer to "must this call
163
+ // name a branch": the injected input and a hand-declared one (the fork
164
+ // base, the draft to switch to) are equally required, and a tool that adds
165
+ // or drops the input later moves this with it.
166
+ const requiresBranch = ((inputs as { required?: string[] }).required ?? []).includes('branch');
152
167
  const def = toolDef({
153
168
  name: spec.name,
154
169
  description: spec.description,
155
170
  path,
156
- inputs: spec.skipBranch ? spec.inputs : withBranchInput(spec.inputs),
171
+ inputs,
157
172
  outputs: spec.outputs,
158
173
  tags: spec.write ? ['workflow', 'write'] : ['workflow'],
159
174
  });
@@ -171,7 +186,27 @@ export function registerWorkflowTools(
171
186
  // normaliser: the root-anchored `/<kbDirName>/…` form names the same
172
187
  // workspace path, and a path with no prefix is placed under
173
188
  // `<kbDirName>/` rather than refused.
174
- toolHandler((args, ctx) => spec.handler(normalizePathArgs(args, kbDirName), ctx), { write: spec.write }),
189
+ toolHandler(
190
+ (args, ctx) => {
191
+ // These tools hand `branch` STRAIGHT to `workspaceIdForBranch` —
192
+ // they address a workspace by id rather than going through
193
+ // `getFilesystem`, so the choke point that guards the file tools
194
+ // never sees them. Without this, a branch-less `commit_change`
195
+ // commits to a workspace id literally named "undefined", and a
196
+ // branch-less `create_branch` forks from one: it clones a branch of
197
+ // that name, fails, and answers `There is no branch named undefined.`
198
+ //
199
+ // Keyed on whether the SCHEMA requires `branch`, not on whether the
200
+ // input was injected. `create_branch` and `switch_branch` declare
201
+ // their own required `branch`; a check keyed on the injection skipped
202
+ // exactly those two, which is how the reported bug survived on
203
+ // `create_branch` — the one tool whose `branch` is a branch the
204
+ // caller must already have.
205
+ if (requiresBranch) assertBranchProvided(args.branch);
206
+ return spec.handler(normalizePathArgs(args, kbDirName), ctx);
207
+ },
208
+ { write: spec.write },
209
+ ),
175
210
  );
176
211
  };
177
212
 
@@ -330,6 +365,17 @@ export function registerWorkflowTools(
330
365
  handler: async (args, ctx: ToolContext) => {
331
366
  const name = args.name as string;
332
367
  const base = args.branch as string;
368
+ // `name` is declared required too, and the same-name refusal below quotes
369
+ // it. With nothing enforcing it, a call that named neither input compared
370
+ // undefined to undefined, matched, and answered "not 'undefined', the
371
+ // draft being created" — an absent value presented as a draft name, the
372
+ // very sentence this ticket exists to stop. Refused by name instead,
373
+ // without echoing what was not sent.
374
+ if (typeof name !== 'string' || name.length === 0) {
375
+ throw new ToolError('`name` is required: pass the name of the draft to create.', 400, {
376
+ kind: 'name-required',
377
+ });
378
+ }
333
379
  // The git op runs in the base's workspace, whose clone has the base
334
380
  // checked out — the new draft forks from its HEAD. Resolving a workspace
335
381
  // lazily clones its branch from origin, so a `branch` naming the
@@ -353,3 +353,124 @@ describe('GitService.pull', () => {
353
353
  await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
354
354
  });
355
355
  });
356
+
357
+ /**
358
+ * `preserveMerges` — why an unpushed MERGE must survive the pull's rebase.
359
+ *
360
+ * The change-request Update merges the target into the proposal branch and
361
+ * pushes. When that push fails, the merge sits in the clone, and the NEXT
362
+ * update pulls first. A plain `git rebase` replays `--no-merges
363
+ * upstream..HEAD`, which turns that merge commit into cherry-picks of the
364
+ * commits it merged: the target stops being a parent, so the branch never
365
+ * contains the target's head however many times the update runs, and the
366
+ * request stays behind for good.
367
+ */
368
+ describe('GitService.pull — an unpushed merge commit', () => {
369
+ let root: string;
370
+ const workspaceId = 'alice%2Fdeal';
371
+
372
+ beforeEach(async () => {
373
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-pull-merges-'));
374
+ });
375
+
376
+ afterEach(async () => {
377
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
378
+ });
379
+
380
+ /**
381
+ * A clone of `alice/deal` holding a merge of `target-company-state` that
382
+ * origin has not seen, with origin's own `alice/deal` one commit further on
383
+ * so the pull has something to rebase ONTO.
384
+ */
385
+ async function seedUnpushedMerge(): Promise<{ repo: string; targetTip: string }> {
386
+ const upstream = path.join(root, 'upstream.git');
387
+ await runGit(root, ['init', '--bare', '-b', 'target-company-state', upstream]);
388
+
389
+ const seed = path.join(root, '.seed');
390
+ await fs.mkdir(seed);
391
+ await runGit(seed, ['init', '-b', 'target-company-state']);
392
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
393
+ await fs.writeFile(path.join(seed, 'shared.txt'), 'one\n');
394
+ await runGit(seed, ['add', '.']);
395
+ await runGit(seed, ['commit', '-m', 'init']);
396
+ await runGit(seed, ['push', 'origin', 'target-company-state']);
397
+ // The proposal branch, forked here.
398
+ await runGit(seed, ['checkout', '-b', 'alice/deal']);
399
+ await fs.writeFile(path.join(seed, 'proposal.txt'), 'alice proposes\n');
400
+ await runGit(seed, ['add', '.']);
401
+ await runGit(seed, ['commit', '-m', 'propose']);
402
+ await runGit(seed, ['push', 'origin', 'alice/deal']);
403
+ // The target moves on, in a file the proposal does not touch.
404
+ await runGit(seed, ['checkout', 'target-company-state']);
405
+ await fs.writeFile(path.join(seed, 'elsewhere.txt'), 'someone else\n');
406
+ await runGit(seed, ['add', '.']);
407
+ await runGit(seed, ['commit', '-m', 'target moves']);
408
+ await runGit(seed, ['push', 'origin', 'target-company-state']);
409
+ const targetTip = await gitOut(seed, ['rev-parse', 'HEAD']);
410
+
411
+ const workspaceDir = path.join(root, workspaceId);
412
+ const repo = path.join(workspaceDir, 'knowledge-base');
413
+ await fs.mkdir(workspaceDir, { recursive: true });
414
+ await runGit(root, ['clone', '-b', 'alice/deal', upstream, repo]);
415
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
416
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
417
+ await runGit(repo, ['config', 'core.autocrlf', 'false']);
418
+
419
+ // The Update that merged and could not push: a real merge commit in the
420
+ // clone, with the target's tip as its second parent.
421
+ await runGit(repo, ['fetch', 'origin']);
422
+ await runGit(repo, [
423
+ 'merge', '--no-ff', '-m', 'Refresh alice/deal', 'origin/target-company-state',
424
+ ]);
425
+ expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('1');
426
+
427
+ // Origin's alice/deal moves on too (the author saved from another client),
428
+ // so the pull has a real rebase to do rather than nothing.
429
+ await runGit(seed, ['checkout', 'alice/deal']);
430
+ await fs.writeFile(path.join(seed, 'proposal.txt'), 'alice proposes\nand adds\n');
431
+ await runGit(seed, ['add', '.']);
432
+ await runGit(seed, ['commit', '-m', 'another save']);
433
+ await runGit(seed, ['push', 'origin', 'alice/deal']);
434
+
435
+ return { repo, targetTip };
436
+ }
437
+
438
+ function svcFor(): GitService {
439
+ return new GitService(
440
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
441
+ stubWorkflowHooks(),
442
+ testKbContext(),
443
+ );
444
+ }
445
+
446
+ it('is flattened by a plain pull — the branch stops containing the target', async () => {
447
+ // The bug, stated as the behaviour that causes it. Kept as a test so the
448
+ // option below is never "simplified" away as redundant.
449
+ const { repo, targetTip } = await seedUnpushedMerge();
450
+ await svcFor().pull(workspaceId);
451
+
452
+ expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('0');
453
+ const contains = await gitOut(repo, [
454
+ 'merge-base', '--is-ancestor', targetTip, 'HEAD',
455
+ ]).then(() => true, () => false);
456
+ expect(contains).toBe(false);
457
+ });
458
+
459
+ it('survives `preserveMerges`, and the branch still contains the target', async () => {
460
+ const { repo, targetTip } = await seedUnpushedMerge();
461
+ await svcFor().pull(workspaceId, { preserveMerges: true });
462
+
463
+ // The merge is still a merge...
464
+ expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('1');
465
+ // ...so the target's head is an ancestor of the branch, which is exactly
466
+ // what "this request is no longer behind" means.
467
+ const contains = await gitOut(repo, [
468
+ 'merge-base', '--is-ancestor', targetTip, 'HEAD',
469
+ ]).then(() => true, () => false);
470
+ expect(contains).toBe(true);
471
+ // And the save origin had that the clone did not is replayed on top.
472
+ expect(await fs.readFile(path.join(repo, 'proposal.txt'), 'utf8')).toBe(
473
+ 'alice proposes\nand adds\n',
474
+ );
475
+ });
476
+ });
@@ -49,7 +49,13 @@ describe('PullRequestService.getPrDetail — git work per read', () => {
49
49
  const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
50
50
  const changedFilesForPr = vi.fn(async () => []);
51
51
  const forkPointForPr = vi.fn(async () => ({ mergeBaseSha: FORK, behind: true }));
52
- const git = { resolvePrShas, changedFilesForPr, forkPointForPr } as unknown as GitService;
52
+ const pathsChangedBetween = vi.fn(async () => []);
53
+ const git = {
54
+ resolvePrShas,
55
+ changedFilesForPr,
56
+ forkPointForPr,
57
+ pathsChangedBetween,
58
+ } as unknown as GitService;
53
59
  const workspace = {
54
60
  findAnyWorkspaceId: async () => 'ws-main',
55
61
  ensureRemotesFetched: async () => undefined,
@@ -63,7 +69,14 @@ describe('PullRequestService.getPrDetail — git work per read', () => {
63
69
  getApprovalStates,
64
70
  evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
65
71
  });
66
- return { svc, resolvePrShas, changedFilesForPr, forkPointForPr, getApprovalStates };
72
+ return {
73
+ svc,
74
+ resolvePrShas,
75
+ changedFilesForPr,
76
+ forkPointForPr,
77
+ pathsChangedBetween,
78
+ getApprovalStates,
79
+ };
67
80
  }
68
81
 
69
82
  it('pins the file list to the SHAs it just resolved, and keeps patches for a client read', async () => {
@@ -179,3 +192,133 @@ describe('PullRequestService — change request link', () => {
179
192
  expect(detail?.urlNote).toBe('Set PUBLIC_FRONTEND_URL to get absolute links.');
180
193
  });
181
194
  });
195
+
196
+ /**
197
+ * `needsUpdate` — the answer the dialog acts on, as distinct from `behind`.
198
+ *
199
+ * `behind` is the honest git fact that the two branches have diverged. On a
200
+ * knowledge base where every shared save commits to the default branch it is
201
+ * true again minutes after every update, and acting on it made opening a
202
+ * request pay for a merge on almost every open — for changes to files the
203
+ * request does not contain. `needsUpdate` adds the half that matters: the
204
+ * target changed a file THIS request also changes.
205
+ */
206
+ describe('PullRequestService.getPrDetail — needsUpdate', () => {
207
+ const FILE = 'Sales/Deal.md';
208
+ const OTHER = 'Ops/Runbook.md';
209
+
210
+ function harness(opts: {
211
+ behind?: boolean;
212
+ mergeBaseSha?: string | null;
213
+ files?: { path: string; previousPath?: string }[];
214
+ changedOnTarget?: string[];
215
+ changedOnTargetError?: Error;
216
+ state?: string;
217
+ } = {}) {
218
+ const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
219
+ const changedFilesForPr = vi.fn(async () => (opts.files ?? [{ path: FILE }]) as never);
220
+ const forkPointForPr = vi.fn(async () => ({
221
+ mergeBaseSha: opts.mergeBaseSha === undefined ? FORK : opts.mergeBaseSha,
222
+ behind: opts.behind ?? true,
223
+ }));
224
+ const pathsChangedBetween = opts.changedOnTargetError
225
+ ? vi.fn().mockRejectedValue(opts.changedOnTargetError)
226
+ : vi.fn(async () => opts.changedOnTarget ?? []);
227
+ const git = {
228
+ resolvePrShas,
229
+ changedFilesForPr,
230
+ forkPointForPr,
231
+ pathsChangedBetween,
232
+ } as unknown as GitService;
233
+ const workspace = {
234
+ findAnyWorkspaceId: async () => 'ws-main',
235
+ ensureRemotesFetched: async () => undefined,
236
+ } as unknown as WorkspaceService;
237
+ const access = { canWriteAtRef: async () => false } as unknown as IAccessControl;
238
+ const db = {
239
+ select: () => ({
240
+ from: () => ({
241
+ where: () => ({ limit: async () => [{ ...ROW, state: opts.state ?? 'open' }] }),
242
+ }),
243
+ }),
244
+ } as unknown as Database;
245
+ const svc = new PullRequestService(db, workspace, access, git);
246
+ svc.setDetailEnricher({
247
+ listComments: async () => [],
248
+ getApprovalStates: async () => [],
249
+ evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
250
+ });
251
+ return { svc, pathsChangedBetween };
252
+ }
253
+
254
+ it('is false when the target moved in files this request does not change', async () => {
255
+ // The case that made the message appear on essentially every open: a
256
+ // shared save somewhere else in the knowledge base.
257
+ const h = harness({ files: [{ path: FILE }], changedOnTarget: [OTHER] });
258
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
259
+ expect(detail?.behind).toBe(true);
260
+ expect(detail?.needsUpdate).toBe(false);
261
+ });
262
+
263
+ it('is true when the target changed a file this request also changes', async () => {
264
+ const h = harness({ files: [{ path: FILE }], changedOnTarget: [OTHER, FILE] });
265
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
266
+ expect(detail?.needsUpdate).toBe(true);
267
+ // Intersected over the fork point → the target's tip: the same two
268
+ // commits the rest of the detail is pinned to.
269
+ expect(h.pathsChangedBetween).toHaveBeenCalledWith('ws-main', FORK, BASE);
270
+ });
271
+
272
+ it('asks git nothing at all when the request is not behind', async () => {
273
+ // The common case, and the one that has to stay free.
274
+ const h = harness({ behind: false });
275
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
276
+ expect(detail?.needsUpdate).toBe(false);
277
+ expect(h.pathsChangedBetween).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it('asks git nothing when the request changes no files', async () => {
281
+ const h = harness({ files: [], changedOnTarget: [FILE] });
282
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
283
+ expect(detail?.needsUpdate).toBe(false);
284
+ expect(h.pathsChangedBetween).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('counts a file the target RENAMED, under either of its names', async () => {
288
+ // `pathsChangedBetween` runs without rename detection, so the target's
289
+ // rename reports the path it left as well as the one it arrived at — and
290
+ // a request still editing the old name is affected.
291
+ const h = harness({
292
+ files: [{ path: FILE }],
293
+ changedOnTarget: [FILE, 'Sales/Deal-2026.md'],
294
+ });
295
+ expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
296
+ });
297
+
298
+ it('counts a file THIS request renamed, under the name it came from', async () => {
299
+ const h = harness({
300
+ files: [{ path: 'Sales/Deal-final.md', previousPath: FILE }],
301
+ changedOnTarget: [FILE],
302
+ });
303
+ expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
304
+ });
305
+
306
+ it('is true when the branches share no history — there is nothing to intersect', async () => {
307
+ const h = harness({ mergeBaseSha: null });
308
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
309
+ expect(detail?.needsUpdate).toBe(true);
310
+ expect(h.pathsChangedBetween).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('is true when the comparison itself fails — an infra error is not "this is fine"', async () => {
314
+ const h = harness({ changedOnTargetError: new Error('git exploded') });
315
+ expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
316
+ });
317
+
318
+ it('is false for a request that is not open, whatever git says', async () => {
319
+ const h = harness({ state: 'merged', changedOnTarget: [FILE] });
320
+ const detail = await h.svc.getPrDetail(7, { fresh: true });
321
+ expect(detail?.needsUpdate).toBe(false);
322
+ expect(detail?.behind).toBe(false);
323
+ });
324
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeViewerCanDelete } from '../pull-request.service.js';
3
+ import { hashEmail } from '../../../../shared/email-identity.js';
4
+
5
+ const EMAIL = 'juan@bevel.software';
6
+ const AUTHOR_HASH = hashEmail(EMAIL);
7
+ const OTHER_AUTHOR_HASH = hashEmail('someone-else@bevel.software');
8
+
9
+ /**
10
+ * The truth table for who may delete a request outright — close it AND retire
11
+ * its branch. It must mirror `deleteChangeRequest`'s own check, which is why
12
+ * the interesting rows here are the ones where it DIFFERS from its siblings:
13
+ * an owner of every changed file is absent (they decline, they do not
14
+ * destroy), and a closed request still says yes (its leftover branch is still
15
+ * the server's to retire).
16
+ */
17
+ describe('computeViewerCanDelete', () => {
18
+ it('lets the author delete their own open request', () => {
19
+ expect(
20
+ computeViewerCanDelete({
21
+ state: 'open',
22
+ authorId: AUTHOR_HASH,
23
+ viewerEmail: EMAIL,
24
+ viewerCanBypassMerge: false,
25
+ }),
26
+ ).toBe(true);
27
+ });
28
+
29
+ it('lets an admin delete someone else’s open request', () => {
30
+ expect(
31
+ computeViewerCanDelete({
32
+ state: 'open',
33
+ authorId: OTHER_AUTHOR_HASH,
34
+ viewerEmail: EMAIL,
35
+ viewerCanBypassMerge: true,
36
+ }),
37
+ ).toBe(true);
38
+ });
39
+
40
+ it('says yes once for a viewer who is both author and admin', () => {
41
+ expect(
42
+ computeViewerCanDelete({
43
+ state: 'open',
44
+ authorId: AUTHOR_HASH,
45
+ viewerEmail: EMAIL,
46
+ viewerCanBypassMerge: true,
47
+ }),
48
+ ).toBe(true);
49
+ });
50
+
51
+ it('refuses a signed-in user who is neither author nor admin', () => {
52
+ expect(
53
+ computeViewerCanDelete({
54
+ state: 'open',
55
+ authorId: OTHER_AUTHOR_HASH,
56
+ viewerEmail: EMAIL,
57
+ viewerCanBypassMerge: false,
58
+ }),
59
+ ).toBe(false);
60
+ });
61
+
62
+ it('fails closed with no viewer, even on one’s own-looking request', () => {
63
+ expect(
64
+ computeViewerCanDelete({
65
+ state: 'open',
66
+ authorId: AUTHOR_HASH,
67
+ viewerEmail: undefined,
68
+ viewerCanBypassMerge: false,
69
+ }),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it('fails closed for an anonymous admin-flagged read', () => {
74
+ expect(
75
+ computeViewerCanDelete({
76
+ state: 'open',
77
+ authorId: OTHER_AUTHOR_HASH,
78
+ viewerEmail: undefined,
79
+ viewerCanBypassMerge: true,
80
+ }),
81
+ ).toBe(false);
82
+ });
83
+
84
+ it('refuses an APPLIED request to its author — applied history is nobody’s to delete', () => {
85
+ expect(
86
+ computeViewerCanDelete({
87
+ state: 'merged',
88
+ authorId: AUTHOR_HASH,
89
+ viewerEmail: EMAIL,
90
+ viewerCanBypassMerge: false,
91
+ }),
92
+ ).toBe(false);
93
+ });
94
+
95
+ it('refuses an APPLIED request to an admin too', () => {
96
+ expect(
97
+ computeViewerCanDelete({
98
+ state: 'merged',
99
+ authorId: OTHER_AUTHOR_HASH,
100
+ viewerEmail: EMAIL,
101
+ viewerCanBypassMerge: true,
102
+ }),
103
+ ).toBe(false);
104
+ });
105
+
106
+ it('still says yes on a CLOSED request: the leftover branch is the delete’s job', () => {
107
+ // Withdrawn in another tab. The server's delete still succeeds there and
108
+ // removes the branch the withdraw left behind, so the hint must not go
109
+ // quiet the moment the row flips — unlike `viewerCanCancel`, which is
110
+ // open-only because there is nothing left to cancel.
111
+ expect(
112
+ computeViewerCanDelete({
113
+ state: 'closed',
114
+ authorId: AUTHOR_HASH,
115
+ viewerEmail: EMAIL,
116
+ viewerCanBypassMerge: false,
117
+ }),
118
+ ).toBe(true);
119
+ expect(
120
+ computeViewerCanDelete({
121
+ state: 'closed',
122
+ authorId: OTHER_AUTHOR_HASH,
123
+ viewerEmail: EMAIL,
124
+ viewerCanBypassMerge: true,
125
+ }),
126
+ ).toBe(true);
127
+ });
128
+
129
+ it('refuses a request with no stored author to a non-admin (opened outside this backend)', () => {
130
+ expect(
131
+ computeViewerCanDelete({
132
+ state: 'open',
133
+ authorId: undefined,
134
+ viewerEmail: EMAIL,
135
+ viewerCanBypassMerge: false,
136
+ }),
137
+ ).toBe(false);
138
+ });
139
+
140
+ it('leaves normalization to hashEmail, exactly as its siblings do', () => {
141
+ expect(
142
+ computeViewerCanDelete({
143
+ state: 'open',
144
+ authorId: AUTHOR_HASH,
145
+ viewerEmail: ' Juan@Bevel.Software ',
146
+ viewerCanBypassMerge: false,
147
+ }),
148
+ ).toBe(true);
149
+ });
150
+
151
+ it('does NOT grant the owner of every changed file — it takes no such input', () => {
152
+ // Decision 4. The owner of every changed file is exactly the person
153
+ // `computeViewerCanCancel` says yes to and this predicate must not: they
154
+ // may decline the request, which leaves the author's branch to rework.
155
+ // There is no input here to express that grant, and the row a
156
+ // file-owning non-admin produces reads false.
157
+ expect(
158
+ computeViewerCanDelete({
159
+ state: 'open',
160
+ authorId: OTHER_AUTHOR_HASH,
161
+ viewerEmail: EMAIL,
162
+ viewerCanBypassMerge: false,
163
+ }),
164
+ ).toBe(false);
165
+ });
166
+ });
@@ -1760,7 +1760,12 @@ export class GitService implements IGitService {
1760
1760
  * conflict into the typed error the workflow layer queues recovery for.
1761
1761
  * Shared by `pull` and `syncFromRemote` so the two cannot drift.
1762
1762
  */
1763
- private async rebaseOntoRemote(cwd: string, branch: string, remoteRef: string): Promise<void> {
1763
+ private async rebaseOntoRemote(
1764
+ cwd: string,
1765
+ branch: string,
1766
+ remoteRef: string,
1767
+ opts: { preserveMerges?: boolean } = {},
1768
+ ): Promise<void> {
1764
1769
  // An UNBORN HEAD (a clone of an upstream that was empty when cloned) has
1765
1770
  // no commit to replay and `git rebase` refuses to start from it, so the
1766
1771
  // branch is moved to the remote commit instead. That is only safe while
@@ -1798,7 +1803,20 @@ export class GitService implements IGitService {
1798
1803
  // the local commits onto origin, then reapplies them, so the retry
1799
1804
  // push can fast-forward. Untracked files don't block rebase and are
1800
1805
  // left untouched.
1801
- await this.git(cwd, ['rebase', '--autostash', remoteRef]);
1806
+ // `--rebase-merges` is what keeps an unpushed MERGE a merge. Plain
1807
+ // rebase replays `--no-merges upstream..HEAD`, which turns a merge
1808
+ // commit the clone holds but origin has not seen into cherry-picks of
1809
+ // the commits it merged — the merged branch stops being a parent, so
1810
+ // "does the published head contain the target's head" answers no
1811
+ // however many times the update runs. Only the change-request update
1812
+ // asks for it; every other pull replays plain saves, where the two
1813
+ // spellings produce the same history.
1814
+ await this.git(cwd, [
1815
+ 'rebase',
1816
+ '--autostash',
1817
+ ...(opts.preserveMerges ? ['--rebase-merges'] : []),
1818
+ remoteRef,
1819
+ ]);
1802
1820
  } catch (err) {
1803
1821
  // Capture the contested paths BEFORE the abort wipes the rebase
1804
1822
  // state — `--diff-filter=U` lists exactly the files whose replay
@@ -1922,7 +1940,10 @@ export class GitService implements IGitService {
1922
1940
  });
1923
1941
  }
1924
1942
 
1925
- async pull(workspaceId: string): Promise<{ treeChanged: boolean }> {
1943
+ async pull(
1944
+ workspaceId: string,
1945
+ opts: { preserveMerges?: boolean } = {},
1946
+ ): Promise<{ treeChanged: boolean }> {
1926
1947
  return this.mutex.run(workspaceId, async () => {
1927
1948
  const cwd = await this.repoDir(workspaceId);
1928
1949
  const branch = await this.checkedOutBranch(cwd);
@@ -1935,7 +1956,7 @@ export class GitService implements IGitService {
1935
1956
  // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
1936
1957
  // `refreshRemoteBranchRef` for why both halves are load-bearing.
1937
1958
  const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1938
- await this.rebaseOntoRemote(cwd, branch, remoteRef);
1959
+ await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
1939
1960
  this.accessControl?.invalidate(workspaceId);
1940
1961
  const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1941
1962
  return { treeChanged: treeAfter !== treeBefore };
@@ -250,6 +250,12 @@ export class NodeGitRunner implements IGitRunner {
250
250
  const timers: NodeJS.Timeout[] = [];
251
251
  let timedOut = false;
252
252
 
253
+ // A credential that expires is renewed before it is read. Renewing is
254
+ // the credential's own business and its failure is not this call's:
255
+ // plenty of git never leaves the disk, and a call that does is told by
256
+ // the host what the token in hand is worth.
257
+ await this.credentials.prepare?.().catch(() => undefined);
258
+
253
259
  try {
254
260
  const { promise: pending, child } = spawnGit(args, {
255
261
  cwd,