canopycms 0.12.0 → 0.13.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/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. package/package.json +1 -1
@@ -91,10 +91,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
91
91
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
92
92
  limit: z.ZodOptional<z.ZodNumber>;
93
93
  }, "strip", z.ZodTypeAny, {
94
- status: "failed" | "pending" | "corrupt" | "processing" | "completed";
94
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
95
95
  limit?: number | undefined;
96
96
  }, {
97
- status: "failed" | "pending" | "corrupt" | "processing" | "completed";
97
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
98
98
  limit?: number | undefined;
99
99
  }>;
100
100
  export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
@@ -168,10 +168,10 @@ export declare const ADMIN_ROUTES: {
168
168
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
169
169
  limit: z.ZodOptional<z.ZodNumber>;
170
170
  }, "strip", z.ZodTypeAny, {
171
- status: "failed" | "pending" | "corrupt" | "processing" | "completed";
171
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
172
172
  limit?: number | undefined;
173
173
  }, {
174
- status: "failed" | "pending" | "corrupt" | "processing" | "completed";
174
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
175
175
  limit?: number | undefined;
176
176
  }>, undefined, AdminTasksResponse>;
177
177
  readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -1,9 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  import { type BranchListItemResponse } from './branch.js';
3
- export interface RequestChangesBody {
3
+ /** What a verdict carries besides the branch: the commit the reviewer's page showed. */
4
+ interface ReviewedHead {
5
+ /** The listing's `headSha` (api/branch.ts) the page was showing. */
6
+ headSha?: string;
7
+ }
8
+ export interface RequestChangesBody extends ReviewedHead {
4
9
  /** Saved as a branch comment; trimmed before its length is checked, and ignored when empty. */
5
10
  note?: string;
6
11
  }
12
+ export type ApproveBody = ReviewedHead;
13
+ /** @internal Exported for tests. */
14
+ export declare const BRANCH_MOVED_ERROR = "This branch changed since you opened it. Review the latest changes.";
7
15
  export declare const requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
8
16
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
9
17
  }, "strip", z.ZodTypeAny, {
@@ -16,9 +24,12 @@ export declare const requestChanges: import("./route-builder.js").RouteDefinitio
16
24
  };
17
25
  }>, z.ZodOptional<z.ZodObject<{
18
26
  note: z.ZodOptional<z.ZodString>;
27
+ headSha: z.ZodOptional<z.ZodString>;
19
28
  }, "strip", z.ZodTypeAny, {
29
+ headSha?: string | undefined;
20
30
  note?: string | undefined;
21
31
  }, {
32
+ headSha?: string | undefined;
22
33
  note?: string | undefined;
23
34
  }>>, BranchListItemResponse>;
24
35
  export declare const approveBranch: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -31,4 +42,11 @@ export declare const approveBranch: import("./route-builder.js").RouteDefinition
31
42
  branch: string & {
32
43
  readonly __brand: "BranchName";
33
44
  };
34
- }>, undefined, BranchListItemResponse>;
45
+ }>, z.ZodOptional<z.ZodObject<{
46
+ headSha: z.ZodOptional<z.ZodString>;
47
+ }, "strip", z.ZodTypeAny, {
48
+ headSha?: string | undefined;
49
+ }, {
50
+ headSha?: string | undefined;
51
+ }>>, BranchListItemResponse>;
52
+ export {};
@@ -6,14 +6,73 @@ import { defineEndpoint } from './route-builder.js';
6
6
  import { syncConvertToDraft } from './github-sync.js';
7
7
  import { syncPrComments } from './pr-comment-sync.js';
8
8
  import { CommentStore } from '../comment-store.js';
9
+ import { assertNoOpenConflicts, OpenMergeConflictsError } from '../merge-conflicts.js';
10
+ import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
9
11
  import { getErrorMessage } from '../utils/error.js';
10
12
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
13
  import { getBranchProtection, isAdmin } from '../authorization/index.js';
12
14
  import { sanitizeDisplayName } from '../submission-attribution.js';
13
- /** Optional on the wire: a bodiless request means no note. */
15
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
16
+ /** Optional on the wire, so a request without one gets {@link BRANCH_MOVED_ERROR}, not a 400. */
17
+ const headShaSchema = z.string().regex(FULL_SHA).optional();
14
18
  const requestChangesBodySchema = z
15
- .object({ note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional() })
19
+ .object({
20
+ note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional(),
21
+ headSha: headShaSchema,
22
+ })
16
23
  .optional();
24
+ const approveBodySchema = z.object({ headSha: headShaSchema }).optional();
25
+ /** @internal Exported for tests. */
26
+ export const BRANCH_MOVED_ERROR = 'This branch changed since you opened it. Review the latest changes.';
27
+ const branchMoved = {
28
+ ok: false,
29
+ status: 409,
30
+ code: 'BRANCH_MOVED',
31
+ error: BRANCH_MOVED_ERROR,
32
+ };
33
+ class BranchMovedError extends Error {
34
+ constructor() {
35
+ super(...arguments);
36
+ this.name = 'BranchMovedError';
37
+ }
38
+ }
39
+ class HeadUnreadableError extends Error {
40
+ constructor() {
41
+ super(...arguments);
42
+ this.name = 'HeadUnreadableError';
43
+ }
44
+ }
45
+ const headUnreadable = {
46
+ ok: false,
47
+ status: 503,
48
+ error: "Couldn't read this branch right now. Try again in a moment.",
49
+ };
50
+ /**
51
+ * Throws unless the clone's HEAD is `sent`, the commit the reviewer saw, and returns it. A request
52
+ * naming none, from a page loaded before the editor sent one, is refused: what it showed is
53
+ * unknown. Called under the content-write lock: a submit and the worker move HEAD only while
54
+ * holding it (docs/concurrency.md, "Who takes it"), so the head checked is the one the verdict is
55
+ * written against.
56
+ */
57
+ const assertReviewedHead = async (ctx, { branch, branchRoot }, sent) => {
58
+ if (!sent)
59
+ throw new BranchMovedError();
60
+ let head;
61
+ try {
62
+ const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
63
+ head = await git.headSha();
64
+ // A git that exits quietly answers nothing, which is no head at all.
65
+ if (!FULL_SHA.test(head))
66
+ throw new Error(`no commit in its answer (${head.length} chars)`);
67
+ }
68
+ catch (err) {
69
+ console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
70
+ throw new HeadUnreadableError();
71
+ }
72
+ if (head !== sent)
73
+ throw new BranchMovedError();
74
+ return sent;
75
+ };
17
76
  const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
18
77
  /**
19
78
  * The 409 for a branch that left review before this request arrived, because the reviewer's page
@@ -41,38 +100,62 @@ const approveConflict = (status) => ({
41
100
  * The verdict `user` gives. The reviewer guard admits only authenticated users, and the record is
42
101
  * always written, so a new verdict replaces whatever the last one left.
43
102
  */
44
- const reviewBy = (user, state) => ({
103
+ const reviewBy = (user, state, commit) => ({
45
104
  state,
46
105
  by: {
47
106
  userId: user.userId,
48
107
  name: user.type === 'authenticated' ? sanitizeDisplayName(user.name) : undefined,
49
108
  },
50
109
  at: new Date().toISOString(),
110
+ commit,
51
111
  });
52
112
  const requestChangesHandler = async (gc, ctx, req, _params, body) => {
53
113
  const { branchContext } = gc;
54
114
  if (!canRequestChangesFrom(branchContext.branch.status)) {
55
115
  return requestChangesConflict(branchContext.branch.status);
56
116
  }
57
- await syncConvertToDraft(ctx, branchContext);
117
+ const headSha = body?.headSha;
58
118
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
59
119
  // The status the guard read can be stale by now, so the save checks the status on disk, in the
60
- // same step that writes. A branch that left review meanwhile is refused, and the draft
61
- // conversion above is left as it is: withdraw converts the pull request to a draft too, and a
62
- // merged one cannot be converted.
120
+ // same step that writes, and the head under the lock with it. A branch that left review or moved
121
+ // meanwhile is refused before the pull request is touched.
63
122
  let found;
64
- const updated = await meta.saveIf({
65
- branch: {
66
- name: branchContext.branch.name,
67
- status: 'editing',
68
- review: reviewBy(req.user, 'changes-requested'),
69
- },
70
- }, (existing) => {
71
- found = existing?.branch.status;
72
- return canRequestChangesFrom(found);
73
- });
123
+ let updated;
124
+ try {
125
+ updated = await withContentWriteLock(branchContext.branchRoot, async () => {
126
+ const commit = await assertReviewedHead(ctx, branchContext, headSha);
127
+ return meta.saveIf({
128
+ branch: {
129
+ name: branchContext.branch.name,
130
+ status: 'editing',
131
+ review: reviewBy(req.user, 'changes-requested', commit),
132
+ },
133
+ }, (existing) => {
134
+ found = existing?.branch.status;
135
+ return canRequestChangesFrom(found);
136
+ });
137
+ });
138
+ }
139
+ catch (err) {
140
+ if (err instanceof BranchMovedError)
141
+ return branchMoved;
142
+ if (err instanceof HeadUnreadableError)
143
+ return headUnreadable;
144
+ if (err instanceof ContentWriteLockBusyError) {
145
+ return {
146
+ ok: false,
147
+ status: 409,
148
+ error: err.outcome === 'not-run'
149
+ ? "This branch is being synced, so changes weren't requested. Try again in a moment."
150
+ : 'This branch was being synced while changes were requested, so the request may not have been recorded. Refresh to check.',
151
+ };
152
+ }
153
+ throw err;
154
+ }
74
155
  if (!updated)
75
156
  return requestChangesConflict(found);
157
+ // Best-effort: syncConvertToDraft only logs a failure.
158
+ await syncConvertToDraft(ctx, { ...branchContext, branch: updated.branch });
76
159
  // The note is written after the status change so a failed write never blocks the branch from
77
160
  // returning to editing; the caller is warned instead.
78
161
  const note = body?.note;
@@ -104,8 +187,9 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
104
187
  },
105
188
  };
106
189
  };
107
- const approveBranchHandler = async (gc, ctx, req, _params) => {
190
+ const approveBranchHandler = async (gc, ctx, req, _params, body) => {
108
191
  const { branchContext } = gc;
192
+ const headSha = body?.headSha;
109
193
  // Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
110
194
  // an author's approval review, so nothing is sent to GitHub.
111
195
  if (branchContext.branch.createdBy === req.user.userId) {
@@ -130,23 +214,58 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
130
214
  };
131
215
  }
132
216
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
133
- // As for request changes, the status on disk decides, in the step that writes. So does an
134
- // unfinished submit: what's approved may not be what the pull request holds.
217
+ // As for request changes, the status on disk and the head decide, in the step that writes. So
218
+ // does an unfinished submit: what's approved may not be what the pull request holds. So do
219
+ // conflicts: a merge of GitHub's commits can record some on a branch in review, under the
220
+ // content-write lock held here, so none lands between the check and the write.
135
221
  let found;
136
222
  let unfinished = false;
137
223
  let notQueued = false;
138
- const updated = await meta.saveIf({
139
- branch: {
140
- name: branchContext.branch.name,
141
- status: 'approved',
142
- review: reviewBy(req.user, 'approved'),
143
- },
144
- }, (existing) => {
145
- found = existing?.branch.status;
146
- unfinished = existing?.branch.submitUnfinished === true;
147
- notQueued = existing?.branch.submitNotQueued === true;
148
- return found === 'submitted' && !unfinished;
149
- });
224
+ let updated;
225
+ try {
226
+ updated = await withContentWriteLock(branchContext.branchRoot, async () => {
227
+ const commit = await assertReviewedHead(ctx, branchContext, headSha);
228
+ await assertNoOpenConflicts(branchContext.branchRoot);
229
+ return meta.saveIf({
230
+ branch: {
231
+ name: branchContext.branch.name,
232
+ status: 'approved',
233
+ review: reviewBy(req.user, 'approved', commit),
234
+ },
235
+ }, (existing) => {
236
+ found = existing?.branch.status;
237
+ unfinished = existing?.branch.submitUnfinished === true;
238
+ notQueued = existing?.branch.submitNotQueued === true;
239
+ return found === 'submitted' && !unfinished;
240
+ });
241
+ });
242
+ }
243
+ catch (err) {
244
+ if (err instanceof BranchMovedError)
245
+ return branchMoved;
246
+ if (err instanceof HeadUnreadableError)
247
+ return headUnreadable;
248
+ if (err instanceof OpenMergeConflictsError) {
249
+ return {
250
+ ok: false,
251
+ status: 409,
252
+ code: 'OPEN_CONFLICTS',
253
+ error: err.count === null
254
+ ? err.message
255
+ : `${err.message} Use "Request changes" so its editor can choose what to keep.`,
256
+ };
257
+ }
258
+ if (err instanceof ContentWriteLockBusyError) {
259
+ return {
260
+ ok: false,
261
+ status: 409,
262
+ error: err.outcome === 'not-run'
263
+ ? 'This branch is being synced, or a save is in flight, so it was not approved. Try again in a moment.'
264
+ : 'This branch was being synced while it was approved, so the approval may not have been recorded. Refresh to check.',
265
+ };
266
+ }
267
+ throw err;
268
+ }
150
269
  if (unfinished && found === 'submitted') {
151
270
  return {
152
271
  ok: false,
@@ -199,6 +318,8 @@ export const approveBranch = defineEndpoint({
199
318
  method: 'POST',
200
319
  path: '/:branch/approve',
201
320
  params: branchParamSchema,
321
+ body: approveBodySchema,
322
+ bodyType: 'ApproveBody',
202
323
  responseType: 'BranchListItemResponse',
203
324
  response: {},
204
325
  defaultMockData: {
@@ -3,7 +3,8 @@ import type { ApiResponse } from './types.js';
3
3
  import { type BranchListItem, type BranchListItemResponse } from './branch.js';
4
4
  import type { StampedEntry } from '../stamp-on-publish.js';
5
5
  export type { BranchMergeResponse } from './branch-merge.js';
6
- export type { RequestChangesBody } from './branch-review.js';
6
+ export type { ApproveBody, RequestChangesBody } from './branch-review.js';
7
+ export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
7
8
  /**
8
9
  * A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
9
10
  * there were any, so the editor can carry its open forms and drafts onto their new versions.
@@ -74,6 +75,17 @@ export declare const WORKFLOW_ROUTES: {
74
75
  readonly __brand: "BranchName";
75
76
  };
76
77
  }>, undefined, BranchListItemResponse>;
78
+ readonly updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
79
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
80
+ }, "strip", z.ZodTypeAny, {
81
+ branch: string & {
82
+ readonly __brand: "BranchName";
83
+ };
84
+ }, {
85
+ branch: string & {
86
+ readonly __brand: "BranchName";
87
+ };
88
+ }>, undefined, import("./branch-update-from-base.js").UpdateFromBaseResponse>;
77
89
  readonly requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
78
90
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
79
91
  }, "strip", z.ZodTypeAny, {
@@ -86,9 +98,12 @@ export declare const WORKFLOW_ROUTES: {
86
98
  };
87
99
  }>, z.ZodOptional<z.ZodObject<{
88
100
  note: z.ZodOptional<z.ZodString>;
101
+ headSha: z.ZodOptional<z.ZodString>;
89
102
  }, "strip", z.ZodTypeAny, {
103
+ headSha?: string | undefined;
90
104
  note?: string | undefined;
91
105
  }, {
106
+ headSha?: string | undefined;
92
107
  note?: string | undefined;
93
108
  }>>, BranchListItemResponse>;
94
109
  readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -101,7 +116,13 @@ export declare const WORKFLOW_ROUTES: {
101
116
  branch: string & {
102
117
  readonly __brand: "BranchName";
103
118
  };
104
- }>, undefined, BranchListItemResponse>;
119
+ }>, z.ZodOptional<z.ZodObject<{
120
+ headSha: z.ZodOptional<z.ZodString>;
121
+ }, "strip", z.ZodTypeAny, {
122
+ headSha?: string | undefined;
123
+ }, {
124
+ headSha?: string | undefined;
125
+ }>>, BranchListItemResponse>;
105
126
  readonly markMerged: import("./route-builder.js").RouteDefinition<z.ZodObject<{
106
127
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
107
128
  }, "strip", z.ZodTypeAny, {
@@ -3,6 +3,7 @@ import { branchParamSchema } from './validators.js';
3
3
  import { toBranchListItem } from './branch.js';
4
4
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
5
5
  import { withdrawBranch } from './branch-withdraw.js';
6
+ import { updateFromBase } from './branch-update-from-base.js';
6
7
  import { requestChanges, approveBranch } from './branch-review.js';
7
8
  import { markAsMerged } from './branch-merge.js';
8
9
  import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
@@ -14,6 +15,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
14
15
  import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
15
16
  import { submissionEditorFromUser } from '../submission-attribution.js';
16
17
  import { NothingToSubmitError } from '../services.js';
18
+ import { OpenMergeConflictsError } from '../merge-conflicts.js';
17
19
  import { baseBranchOf } from '../utils/base-branch.js';
18
20
  import { shortNameOf } from '../branch-param.js';
19
21
  import { resolveEditorUrl } from '../editor-url.js';
@@ -118,6 +120,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
118
120
  if (err instanceof NothingToSubmitError) {
119
121
  return { ok: false, status: 400, error: err.message };
120
122
  }
123
+ if (err instanceof OpenMergeConflictsError) {
124
+ return {
125
+ ok: false,
126
+ status: 409,
127
+ code: 'OPEN_CONFLICTS',
128
+ error: err.count === null
129
+ ? err.message
130
+ : `${err.message} Choose what to keep in "Show changes", then submit again.`,
131
+ };
132
+ }
121
133
  const shortName = shortNameOf(ctx.services.config, branchContext.branch.name);
122
134
  // Retriable either way: a resubmit commits nothing new and pushes only
123
135
  // what has not reached the remote.
@@ -196,8 +208,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
196
208
  status: 'submitted',
197
209
  submittedAt,
198
210
  ...pushed,
199
- // Only a request-derived URL: config.editorUrl is read live by every later re-render.
200
- ...(editorUrl && !ctx.services.config.editorUrl ? { editorUrl } : {}),
211
+ // config.editorUrl, when set, still wins in every later re-render here; the worker, which
212
+ // has no config, links the conflicts status with this.
213
+ ...(editorUrl ? { editorUrl } : {}),
201
214
  syncFailureReason: prResult.syncFailureReason,
202
215
  pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
203
216
  pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
@@ -275,6 +288,7 @@ export const WORKFLOW_ROUTES = {
275
288
  getStatus: getBranchStatus,
276
289
  submit: submitBranchForMerge,
277
290
  withdraw: withdrawBranch,
291
+ updateFromBase,
278
292
  requestChanges: requestChanges,
279
293
  approve: approveBranch,
280
294
  markMerged: markAsMerged,
@@ -0,0 +1,23 @@
1
+ import type { z } from 'zod';
2
+ import type { ApiResponse } from './types.js';
3
+ import { type BranchListItem } from './branch.js';
4
+ /**
5
+ * A request for the latest changes: when it was made, which the editor waits on until the
6
+ * branch's `lastBaseUpdate.at` reaches it, and the branch as it stands with the request recorded.
7
+ */
8
+ export type UpdateFromBaseResponse = ApiResponse<{
9
+ requestedAt: string;
10
+ branch: BranchListItem;
11
+ }>;
12
+ /** Get the latest changes: bring the base branch into this one at the worker's next chance. */
13
+ export declare const updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
14
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ branch: string & {
17
+ readonly __brand: "BranchName";
18
+ };
19
+ }, {
20
+ branch: string & {
21
+ readonly __brand: "BranchName";
22
+ };
23
+ }>, undefined, UpdateFromBaseResponse>;