@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
@@ -366,6 +366,60 @@ export class PullRequestService implements IPullRequestService {
366
366
  return this.summaryOf(row, workspaceId);
367
367
  }
368
368
 
369
+ /**
370
+ * Did the target change, since the fork point, any file this request also
371
+ * changes? The question behind `needsUpdate`.
372
+ *
373
+ * The intersection is taken over BOTH names of every rename on both sides,
374
+ * because a wrong "no" is the expensive answer: it would open a request on
375
+ * a diff read against text that has since moved, with Approve live. So
376
+ * `pathsChangedBetween` runs without rename detection (a rename on the
377
+ * TARGET reports the path it left as well as the one it arrived at), and
378
+ * the request's own side contributes each file's `previousPath` alongside
379
+ * its `path`.
380
+ *
381
+ * Answers true without asking git in the two cases where nothing may be
382
+ * assumed: no fork point (the branches share no history, so there is no
383
+ * range to diff) and a failed diff (an infra error must not read as "this
384
+ * request is fine"). Answers false without asking git when the request is
385
+ * not behind at all, or changes no files — the common case, and the one
386
+ * that must stay free.
387
+ */
388
+ private async targetTouchesRequestFiles(
389
+ workspaceId: string,
390
+ forkPoint: { mergeBaseSha: string | null; behind: boolean },
391
+ baseSha: string,
392
+ files: PullRequestFile[],
393
+ ): Promise<boolean> {
394
+ if (!forkPoint.behind) return false;
395
+ if (files.length === 0) return false;
396
+ // Diverged with no common ancestor: the target's whole history is
397
+ // "changed since the fork point", so anything this request touches is
398
+ // touched. Nothing to intersect against, and nothing to skip for.
399
+ if (!forkPoint.mergeBaseSha) return true;
400
+ const requestPaths = new Set<string>();
401
+ for (const f of files) {
402
+ requestPaths.add(f.path);
403
+ if (f.previousPath) requestPaths.add(f.previousPath);
404
+ }
405
+ try {
406
+ const onTarget = await this.gitService.pathsChangedBetween(
407
+ workspaceId,
408
+ forkPoint.mergeBaseSha,
409
+ // The target tip the WHOLE detail is pinned to, so this range ends
410
+ // exactly where `behind` and the file list were computed.
411
+ baseSha,
412
+ );
413
+ return onTarget.some((path) => requestPaths.has(path));
414
+ } catch (err) {
415
+ log.warn(
416
+ 'could not compare the target against a change request\'s files; treating it as needing an update',
417
+ { err },
418
+ );
419
+ return true;
420
+ }
421
+ }
422
+
369
423
  async getPrDetail(
370
424
  prNumber: number,
371
425
  opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean } = {},
@@ -392,6 +446,8 @@ export class PullRequestService implements IPullRequestService {
392
446
  mergeBaseSha: null,
393
447
  behind: false,
394
448
  };
449
+ /** Did the target change, since the fork point, a file this request changes? */
450
+ let targetChangedShared = false;
395
451
  if (workspaceId) {
396
452
  const shas = await this.gitService.resolvePrShas(
397
453
  workspaceId,
@@ -417,6 +473,17 @@ export class PullRequestService implements IPullRequestService {
417
473
  // Pinned to the same two commits as the file list, so "needs updating"
418
474
  // and the diff it qualifies can never describe different heads.
419
475
  forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
476
+ // Only a target that moved in a file THIS request also changes makes the
477
+ // proposal's diff describe text that has moved. One extra
478
+ // `diff --name-only` (and only when the branches have diverged at all)
479
+ // buys the difference between opening instantly and paying for a merge,
480
+ // a push and a second detail read.
481
+ targetChangedShared = await this.targetTouchesRequestFiles(
482
+ workspaceId,
483
+ forkPoint,
484
+ baseSha,
485
+ files,
486
+ );
420
487
  }
421
488
 
422
489
  // Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
@@ -511,6 +578,22 @@ export class PullRequestService implements IPullRequestService {
511
578
  approvals,
512
579
  });
513
580
 
581
+ // Hashed here, not on the client: the detail carries an author HASH and no
582
+ // author email, so the comparison can only happen where the viewer's email
583
+ // is already known.
584
+ const viewerIsAuthor = !!(
585
+ opts.viewerEmail &&
586
+ summary.authorId &&
587
+ summary.authorId === hashEmail(opts.viewerEmail)
588
+ );
589
+
590
+ const viewerCanDelete = computeViewerCanDelete({
591
+ state: summary.state,
592
+ authorId: summary.authorId,
593
+ viewerEmail: opts.viewerEmail,
594
+ viewerCanBypassMerge,
595
+ });
596
+
514
597
  const detail: PullRequestDetail = {
515
598
  ...summary,
516
599
  body: row.body,
@@ -526,7 +609,12 @@ export class PullRequestService implements IPullRequestService {
526
609
  viewerCanCancel,
527
610
  mergeBaseSha: forkPoint.mergeBaseSha,
528
611
  behind: summary.state === 'open' && forkPoint.behind,
612
+ // `behind` AND the divergence actually reaches this request's files.
613
+ // Both halves, in this order, so a closed request is never either.
614
+ needsUpdate: summary.state === 'open' && forkPoint.behind && targetChangedShared,
529
615
  viewerCanUpdate,
616
+ viewerIsAuthor,
617
+ viewerCanDelete,
530
618
  };
531
619
 
532
620
  // A patch-less detail is an internal read; it must not be served to the
@@ -631,7 +719,38 @@ export function computeViewerCanUpdate(input: {
631
719
  return viewerIsAuthor || input.viewerCanBypassMerge || viewerMayApply;
632
720
  }
633
721
 
722
+ /**
723
+ * Pure predicate for the `viewerCanDelete` hint — who may delete a request
724
+ * outright, closing it AND retiring its branch. The author (their own
725
+ * proposal and their own branch) or an admin (`viewerCanBypassMerge` is the
726
+ * proxy — the same `canWriteAtRef('roles.yaml')` predicate the DELETE route
727
+ * enforces). Mirrors `deleteChangeRequest` exactly, which is why it differs
728
+ * from its two siblings in both directions:
729
+ *
730
+ * - It does NOT grant the owner of every changed file, as
731
+ * `computeViewerCanCancel` does. Declining someone else's request leaves
732
+ * their branch to rework; deleting it destroys their text.
733
+ * - It bars only `merged`, not everything that is not `open`. A request
734
+ * withdrawn in another tab is `closed` and still has a leftover branch the
735
+ * server will retire, so the button must survive it. Applied history is
736
+ * nobody's to delete.
737
+ *
738
+ * Fail-closed: no viewer email → false, whatever the grants say.
739
+ */
740
+ export function computeViewerCanDelete(input: {
741
+ state: PullRequestState;
742
+ authorId: string | undefined;
743
+ viewerEmail: string | undefined;
744
+ viewerCanBypassMerge: boolean;
745
+ }): boolean {
746
+ if (input.state === 'merged') return false;
747
+ if (!input.viewerEmail) return false;
748
+ const viewerIsAuthor = !!(input.authorId && input.authorId === hashEmail(input.viewerEmail));
749
+ return viewerIsAuthor || input.viewerCanBypassMerge;
750
+ }
751
+
634
752
  export const __testing = {
635
753
  computeViewerCanCancel,
636
754
  computeViewerCanUpdate,
755
+ computeViewerCanDelete,
637
756
  };
@@ -790,7 +790,9 @@ export function createWorkflowRoutes(
790
790
 
791
791
  /**
792
792
  * Delete a change request outright — close it and retire its source
793
- * branch. Admin-only; the moderation verb for a shared deployment.
793
+ * branch. The request's author or an admin: their own proposal to throw
794
+ * away, and the moderation verb for a shared deployment. The changed files'
795
+ * owners are NOT included — they keep reject, which leaves the branch.
794
796
  */
795
797
  router.delete('/workflow/change-requests/:number', async (req, res) => {
796
798
  const num = parsePrNumber(req.params.number);
@@ -43,6 +43,7 @@ import type {
43
43
  ChangeRequestApplyFailureKind,
44
44
  ChangeRequestComment,
45
45
  ChangeRequestDetail,
46
+ ChangeRequestUpdateResult,
46
47
  ChangeRequestState,
47
48
  ChangedFile,
48
49
  FileApproval,
@@ -62,7 +63,7 @@ import {
62
63
  isPlatformRestoreShape,
63
64
  } from '@bevel-software/platform-shared';
64
65
  import type { KbContext } from '../../shared/kb-context.js';
65
- import { and, eq, inArray, isNotNull, isNull, lt, or } from 'drizzle-orm';
66
+ import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm';
66
67
  import type { Database } from '../database/connection.js';
67
68
  import { changeRequests } from '../database/schema.js';
68
69
  import type { GitService } from './git/git.service.js';
@@ -576,8 +577,11 @@ export class WorkflowService implements IWorkflowService {
576
577
  * and nothing else. The pull itself reports the change, since it is the
577
578
  * only code that can observe it under the workspace mutex.
578
579
  */
579
- private async pullWorkspace(workspaceId: string): Promise<boolean> {
580
- const { treeChanged } = await this.git.pull(workspaceId);
580
+ private async pullWorkspace(
581
+ workspaceId: string,
582
+ opts: { preserveMerges?: boolean } = {},
583
+ ): Promise<boolean> {
584
+ const { treeChanged } = await this.git.pull(workspaceId, opts);
581
585
  const defaultBranch = this.kb.defaultBranch;
582
586
  if (treeChanged && branchForWorkspaceId(workspaceId) === defaultBranch) {
583
587
  this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: defaultBranch });
@@ -1729,6 +1733,38 @@ export class WorkflowService implements IWorkflowService {
1729
1733
  return this.prs.getPr(number);
1730
1734
  }
1731
1735
 
1736
+ /**
1737
+ * The newest non-open change request from `sourceBranch` by `authorEmail`.
1738
+ *
1739
+ * Read straight off `change_requests` rather than through the PR listing,
1740
+ * which serves OPEN requests only: the whole point here is the closed ones.
1741
+ * `author_email` is stored lowercased at insert, so the needle is too.
1742
+ * Ordered by `closed_at` with `created_at` as the tiebreak, because a row
1743
+ * closed before the column existed (or by a path that forgot to stamp it)
1744
+ * still has a creation time and must not sort as the oldest.
1745
+ */
1746
+ async latestClosedChangeRequest(
1747
+ authorEmail: string,
1748
+ sourceBranch: string,
1749
+ ): Promise<{ number: number; state: ChangeRequestState } | null> {
1750
+ const email = authorEmail.trim().toLowerCase();
1751
+ if (!email || !sourceBranch) return null;
1752
+ const [row] = await this.db
1753
+ .select({ number: changeRequests.number, state: changeRequests.state })
1754
+ .from(changeRequests)
1755
+ .where(
1756
+ and(
1757
+ eq(changeRequests.authorEmail, email),
1758
+ eq(changeRequests.sourceBranch, sourceBranch),
1759
+ ne(changeRequests.state, 'open'),
1760
+ ),
1761
+ )
1762
+ .orderBy(desc(changeRequests.closedAt), desc(changeRequests.createdAt))
1763
+ .limit(1);
1764
+ if (!row) return null;
1765
+ return { number: row.number, state: row.state as ChangeRequestState };
1766
+ }
1767
+
1732
1768
  getChangeRequestDetail(
1733
1769
  number: number,
1734
1770
  opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
@@ -1953,13 +1989,29 @@ export class WorkflowService implements IWorkflowService {
1953
1989
  * the enforcement read one predicate. A conflicting merge is aborted
1954
1990
  * inside `mergeFromOrigin`: nothing is committed or pushed, and the branch
1955
1991
  * is exactly what it was.
1992
+ *
1993
+ * ONE detail read after the merge. The read before it exists only to
1994
+ * decide authority and to name the two branches, so it asks for no patches
1995
+ * — that is one git subprocess per changed file it does not spend. The
1996
+ * approvals carry-forward runs BEFORE the final read rather than after it,
1997
+ * against a head resolved with `resolvePrShas` (two rev-parses) instead of
1998
+ * a whole second detail, so the detail that goes back already describes the
1999
+ * approvals it carried. What used to be three detail reads is now one.
1956
2000
  */
1957
2001
  async updateFromTarget(
1958
2002
  workspaceId: string,
1959
2003
  user: AuthUser,
1960
2004
  number: number,
1961
- ): Promise<ChangeRequestDetail> {
1962
- const detail = await this.prs.getPrDetail(number, { fresh: true, workspaceId, viewerEmail: user.email });
2005
+ ): Promise<ChangeRequestUpdateResult> {
2006
+ // No patches: all this read decides is authority, state and the two
2007
+ // branch names. Asking for patch text here would diff every changed file
2008
+ // in a request that is about to be merged and read in full anyway.
2009
+ const detail = await this.prs.getPrDetail(number, {
2010
+ fresh: true,
2011
+ workspaceId,
2012
+ viewerEmail: user.email,
2013
+ patches: false,
2014
+ });
1963
2015
  if (!detail) {
1964
2016
  throw new WorkflowValidationError(
1965
2017
  `Change request #${number} not found.`,
@@ -1987,8 +2039,15 @@ export class WorkflowService implements IWorkflowService {
1987
2039
  // merge commit that then cannot push, stranding it in the workspace. A
1988
2040
  // rebase conflict hands the stranded saves to recovery, as every other
1989
2041
  // pull does, before the refusal reaches the caller.
2042
+ //
2043
+ // `preserveMerges`: this is the one pull whose unpushed commit may be a
2044
+ // MERGE — the update that ran before this one and could not push. A plain
2045
+ // rebase replays it as cherry-picks of the target's commits, which drops
2046
+ // the target as a parent, so the branch never contains the target's head
2047
+ // however many times the update runs. Replayed with `--rebase-merges` it
2048
+ // stays a merge and the next push ends the loop.
1990
2049
  try {
1991
- await this.pullWorkspace(workspaceId);
2050
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
1992
2051
  } catch (err) {
1993
2052
  if (err instanceof PullRebaseConflictError) {
1994
2053
  await this.queuePullConflictRecovery(workspaceId, err, user);
@@ -2026,24 +2085,53 @@ export class WorkflowService implements IWorkflowService {
2026
2085
  if (outcome.kind === 'conflicts') {
2027
2086
  throw new ChangeRequestConflictsError(detail.branch, detail.base, outcome.paths);
2028
2087
  }
2029
- // If a new merge commit landed, push so the CR picks it up. When
2030
- // already up to date there's nothing to share — short-circuit the push.
2031
- if (!outcome.alreadyUpToDate) {
2088
+ // Push whenever the CLONE holds a commit origin has not seen — not when
2089
+ // THIS merge authored one. The two differ in exactly the case that keeps
2090
+ // a request behind for good: an earlier update merged, its push failed,
2091
+ // and the merge is sitting in the workspace. That merge already contains
2092
+ // the target, so this call's `mergeFromOrigin` reports
2093
+ // `alreadyUpToDate` and the old gate skipped the push — leaving the
2094
+ // request behind on the remote, and the next open with the same work to
2095
+ // do. Asking git what is unpushed retries it instead.
2096
+ //
2097
+ // A failed probe falls back to the old gate rather than to "push
2098
+ // anyway": `trackedPush` on a clone with nothing to push is a wasted
2099
+ // round trip on every single update.
2100
+ const hasUnpushed = await this.git
2101
+ .hasUnpushedCommits(workspaceId)
2102
+ .catch((err: unknown) => {
2103
+ log.warn(
2104
+ `could not tell whether change request #${number} has unpushed commits; falling back to whether this merge authored one`,
2105
+ { err },
2106
+ );
2107
+ return !outcome.alreadyUpToDate;
2108
+ });
2109
+ if (hasUnpushed) {
2032
2110
  await this.trackedPush(workspaceId, user);
2033
- // The source head moved: the refusal described a revision that is gone.
2034
- await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
2035
2111
  }
2036
- this.prs.invalidateDetailCache(number);
2037
- const refreshed = await this.prs.getPrDetail(number, {
2038
- fresh: true,
2039
- workspaceId,
2040
- viewerEmail: user.email,
2041
- });
2042
- if (!refreshed) {
2043
- throw new WorkflowValidationError(
2044
- `Refreshed change request #${number} but could not re-fetch its detail.`,
2112
+ // Where the branch ended up, as PUBLISHED — two rev-parses, not a second
2113
+ // detail read. Everything below needs only the sha, and the detail that
2114
+ // goes back to the caller is read once, at the end, after the approvals
2115
+ // it should describe have been carried.
2116
+ let headAfterMerge = headBeforeMerge;
2117
+ try {
2118
+ const at = await this.git.resolvePrShas(workspaceId, detail.base, detail.branch);
2119
+ headAfterMerge = at.headSha;
2120
+ } catch (err) {
2121
+ log.warn(
2122
+ `could not resolve the published head of change request #${number} after updating it from target`,
2123
+ { err },
2045
2124
  );
2046
2125
  }
2126
+ const headMoved = Boolean(headBeforeMerge) && headAfterMerge !== headBeforeMerge;
2127
+ // The source head moved: the refusal described a revision that is gone.
2128
+ // `hasUnpushed` counts as well as the sha comparison, and deliberately —
2129
+ // a push that just landed moved the published head by definition, and
2130
+ // reading the sha to confirm it is a call that can fail. A refusal left
2131
+ // standing over a revision nobody can apply any more is the worse error.
2132
+ if (headMoved || hasUnpushed) {
2133
+ await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
2134
+ }
2047
2135
  // The merge is a commit the APPROVERS did not make. Re-pin their
2048
2136
  // approvals onto the new head for every file whose bytes it left alone —
2049
2137
  // git's own two-dot diff between the two heads is the verdict on which
@@ -2051,45 +2139,61 @@ export class WorkflowService implements IWorkflowService {
2051
2139
  // than the bookkeeping, so a failure here is logged and the fresh detail
2052
2140
  // still goes back.
2053
2141
  //
2054
- // `alreadyUpToDate` is the real gate: no merge commit, no head to compare,
2055
- // nothing moved under anyone. The other two conditions are belt-and-braces
2056
- // over a sha that is not a sha — a resolve that failed onto a detail with
2057
- // no head, or two heads that somehow read equal — both of which
2058
- // `pathsChangedBetween` and `carryApprovalsForward` would refuse anyway.
2059
- // A merge that authored a commit cannot leave the head where it was.
2060
- if (!outcome.alreadyUpToDate && headBeforeMerge && refreshed.headSha !== headBeforeMerge) {
2142
+ // The head moving is the real gate: nothing moved, nothing moved under
2143
+ // anyone. It also covers the sha that is not a sha — a resolve that
2144
+ // failed onto a detail with no head — which `pathsChangedBetween` and
2145
+ // `carryApprovalsForward` would refuse anyway.
2146
+ //
2147
+ // This runs BEFORE the detail read, not after it. The rows it writes are
2148
+ // what the caller's `approvals` must describe, so doing it first means
2149
+ // the one read at the end is already correct — where carrying afterwards
2150
+ // needed a THIRD read to correct a detail assembled before the rows
2151
+ // existed.
2152
+ //
2153
+ // The same list answers the caller's `updatedPaths`: "which files did
2154
+ // this merge change" is one question, and the two answers must not be
2155
+ // allowed to differ — a file whose approval was carried (so: untouched)
2156
+ // that the dialog then re-read would be the dialog discarding content the
2157
+ // server just called unchanged.
2158
+ let updatedPaths: string[] = [];
2159
+ if (headMoved) {
2061
2160
  try {
2062
- const changedPaths = await this.git.pathsChangedBetween(
2161
+ updatedPaths = await this.git.pathsChangedBetween(
2063
2162
  workspaceId,
2064
2163
  headBeforeMerge,
2065
- refreshed.headSha,
2164
+ headAfterMerge,
2066
2165
  );
2067
- const carried = await this.reviewWorkflow.carryApprovalsForward(
2166
+ await this.reviewWorkflow.carryApprovalsForward(
2068
2167
  number,
2069
2168
  headBeforeMerge,
2070
- refreshed.headSha,
2071
- changedPaths,
2169
+ headAfterMerge,
2170
+ updatedPaths,
2072
2171
  );
2073
- // The detail above was assembled before those rows existed, so its
2074
- // `approvals` (and the merge gate derived from them) still describe
2075
- // the pre-merge head. Only re-read when something actually carried.
2076
- if (carried > 0) {
2077
- this.prs.invalidateDetailCache(number);
2078
- const withApprovals = await this.prs.getPrDetail(number, {
2079
- fresh: true,
2080
- workspaceId,
2081
- viewerEmail: user.email,
2082
- });
2083
- if (withApprovals) return withApprovals;
2084
- }
2085
2172
  } catch (err) {
2086
2173
  log.warn(
2087
2174
  `could not carry approvals forward on change request #${number} after updating from target`,
2088
2175
  { err },
2089
2176
  );
2177
+ // The diff is what failed, so which files moved is unknown. Say so as
2178
+ // "every file this request had", which is every file a caller can
2179
+ // already be showing: slow beats a dialog that goes on presenting
2180
+ // pre-merge text as current. Anything the merge ADDED is not on the
2181
+ // list and does not need to be — no caller has read it yet.
2182
+ updatedPaths = (detail.files ?? []).map((f) => f.path);
2090
2183
  }
2091
2184
  }
2092
- return refreshed;
2185
+ this.prs.invalidateDetailCache(number);
2186
+ const refreshed = await this.prs.getPrDetail(number, {
2187
+ fresh: true,
2188
+ workspaceId,
2189
+ viewerEmail: user.email,
2190
+ });
2191
+ if (!refreshed) {
2192
+ throw new WorkflowValidationError(
2193
+ `Refreshed change request #${number} but could not re-fetch its detail.`,
2194
+ );
2195
+ }
2196
+ return { ...refreshed, updatedPaths };
2093
2197
  }
2094
2198
 
2095
2199
  listComments(number: number): Promise<ChangeRequestComment[]> {
@@ -2651,11 +2755,16 @@ export class WorkflowService implements IWorkflowService {
2651
2755
  /**
2652
2756
  * DELETE a change request outright: close it (whatever its diff says) and
2653
2757
  * retire its source branch — the request, its proposal, and the branch that
2654
- * carried it are gone in one verb. Admin-only, resolved the same way every
2655
- * other admin check here is (write on `roles.yaml` at `origin/<base>`,
2656
- * never a local ref): this is the moderation verb for a shared deployment,
2657
- * stronger than reject (which the author or the files' owners can do, and
2658
- * which leaves the branch for a second round).
2758
+ * carried it are gone in one verb. The request's AUTHOR or an admin, and
2759
+ * nobody else: it is the author's own proposal and their own branch to throw
2760
+ * away, and it is the moderation verb for a shared deployment. Admin rights
2761
+ * resolve the same way every other admin check here does (write on
2762
+ * `roles.yaml` at `origin/<base>`, never a local ref); authorship is the
2763
+ * STORED `authorId` hash, never anything the caller sends.
2764
+ *
2765
+ * Stronger than reject, which the changed files' owners may also do and which
2766
+ * leaves the branch for a second round. They keep reject and do not get this:
2767
+ * an owner must not be able to destroy someone else's text and branch.
2659
2768
  */
2660
2769
  async deleteChangeRequest(number: number, user: AuthUser): Promise<void> {
2661
2770
  const summary = await this.prs.getPr(number);
@@ -2714,8 +2823,17 @@ export class WorkflowService implements IWorkflowService {
2714
2823
  409,
2715
2824
  );
2716
2825
  }
2717
- if (isAdmin !== true) {
2718
- throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
2826
+ // Authorship is decided from the STORED author hash, never from anything
2827
+ // the caller sends. Note what is deliberately NOT short-circuited for an
2828
+ // author: the strict fetch and the `null` check above still run. An
2829
+ // author's grant needs no `roles.yaml` read, so skipping them would look
2830
+ // free — but it would let a delete close the request while the branch
2831
+ // removal silently failed against an unreachable origin, and "the shared
2832
+ // repository was down, so nothing changed" is the one refusal that has to
2833
+ // stay honest.
2834
+ const callerIsAuthor = !!(summary.authorId && summary.authorId === hashEmail(user.email));
2835
+ if (!callerIsAuthor && isAdmin !== true) {
2836
+ throw new WorkflowDomainError("Only the request's author or an admin can delete it.", 403);
2719
2837
  }
2720
2838
 
2721
2839
  // Close first (idempotent: an already-closed request just skips to the
@@ -77,6 +77,49 @@ describe('WorkspaceService — a branch origin does not have', () => {
77
77
  });
78
78
  });
79
79
 
80
+ /**
81
+ * The second defect on this ticket. A bootstrap that fails to clone used to
82
+ * leave its workspace directory behind, and a directory named after a branch
83
+ * is one of the ways the platform remembers having KNOWN a branch. So the
84
+ * first attempt at a typo answered 404 and then quietly created the evidence
85
+ * that turned every later attempt into 410 — the reader was told a branch
86
+ * they had invented "no longer exists on the remote".
87
+ */
88
+ it('leaves nothing on disk when the clone fails, so a second attempt is still a 404', async () => {
89
+ const svc = service();
90
+
91
+ await expect(svc.getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
92
+ name: 'BranchNotFoundError',
93
+ status: 404,
94
+ });
95
+ // The failed bootstrap rolled back completely — not the clone directory,
96
+ // not the workspace shell around it.
97
+ expect(await fs.readdir(workspacesRoot)).toEqual([]);
98
+
99
+ // The same name again, on the same instance that has now already failed on
100
+ // it once. Nothing about the first failure may change this answer.
101
+ await expect(svc.getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
102
+ name: 'BranchNotFoundError',
103
+ status: 404,
104
+ message: 'There is no branch named nobody/never-made-this.',
105
+ payload: { kind: 'branch-not-found', branch: 'nobody/never-made-this' },
106
+ });
107
+ expect(await fs.readdir(workspacesRoot)).toEqual([]);
108
+ });
109
+
110
+ it('does not count a bare directory with no clone in it as a branch it knew', async () => {
111
+ // A shell left by an older process (one that crashed mid-bootstrap, or that
112
+ // predates the rollback above). The "have I heard of this branch" probe asks
113
+ // for a COMPLETED clone, so a directory with no `.git` under it is absence:
114
+ // the honest 404 survives whatever is lying around the workspaces root.
115
+ await fs.mkdir(path.join(workspacesRoot, workspaceIdForBranch('nobody/never-made-this')), { recursive: true });
116
+
117
+ await expect(service().getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
118
+ name: 'BranchNotFoundError',
119
+ status: 404,
120
+ });
121
+ });
122
+
80
123
  it('a name a listing showed us, and origin no longer has, stays a 410', async () => {
81
124
  const svc = service();
82
125
  // What the branch selector saw while the draft still existed.