canopycms 0.12.1 → 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 (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -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 {};
@@ -12,10 +12,67 @@ import { getErrorMessage } from '../utils/error.js';
12
12
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
13
13
  import { getBranchProtection, isAdmin } from '../authorization/index.js';
14
14
  import { sanitizeDisplayName } from '../submission-attribution.js';
15
- /** 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();
16
18
  const requestChangesBodySchema = z
17
- .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
+ })
18
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
+ };
19
76
  const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
20
77
  /**
21
78
  * The 409 for a branch that left review before this request arrived, because the reviewer's page
@@ -43,38 +100,62 @@ const approveConflict = (status) => ({
43
100
  * The verdict `user` gives. The reviewer guard admits only authenticated users, and the record is
44
101
  * always written, so a new verdict replaces whatever the last one left.
45
102
  */
46
- const reviewBy = (user, state) => ({
103
+ const reviewBy = (user, state, commit) => ({
47
104
  state,
48
105
  by: {
49
106
  userId: user.userId,
50
107
  name: user.type === 'authenticated' ? sanitizeDisplayName(user.name) : undefined,
51
108
  },
52
109
  at: new Date().toISOString(),
110
+ commit,
53
111
  });
54
112
  const requestChangesHandler = async (gc, ctx, req, _params, body) => {
55
113
  const { branchContext } = gc;
56
114
  if (!canRequestChangesFrom(branchContext.branch.status)) {
57
115
  return requestChangesConflict(branchContext.branch.status);
58
116
  }
59
- await syncConvertToDraft(ctx, branchContext);
117
+ const headSha = body?.headSha;
60
118
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
61
119
  // The status the guard read can be stale by now, so the save checks the status on disk, in the
62
- // same step that writes. A branch that left review meanwhile is refused, and the draft
63
- // conversion above is left as it is: withdraw converts the pull request to a draft too, and a
64
- // 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.
65
122
  let found;
66
- const updated = await meta.saveIf({
67
- branch: {
68
- name: branchContext.branch.name,
69
- status: 'editing',
70
- review: reviewBy(req.user, 'changes-requested'),
71
- },
72
- }, (existing) => {
73
- found = existing?.branch.status;
74
- return canRequestChangesFrom(found);
75
- });
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
+ }
76
155
  if (!updated)
77
156
  return requestChangesConflict(found);
157
+ // Best-effort: syncConvertToDraft only logs a failure.
158
+ await syncConvertToDraft(ctx, { ...branchContext, branch: updated.branch });
78
159
  // The note is written after the status change so a failed write never blocks the branch from
79
160
  // returning to editing; the caller is warned instead.
80
161
  const note = body?.note;
@@ -106,8 +187,9 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
106
187
  },
107
188
  };
108
189
  };
109
- const approveBranchHandler = async (gc, ctx, req, _params) => {
190
+ const approveBranchHandler = async (gc, ctx, req, _params, body) => {
110
191
  const { branchContext } = gc;
192
+ const headSha = body?.headSha;
111
193
  // Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
112
194
  // an author's approval review, so nothing is sent to GitHub.
113
195
  if (branchContext.branch.createdBy === req.user.userId) {
@@ -132,22 +214,23 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
132
214
  };
133
215
  }
134
216
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
135
- // As for request changes, the status on disk decides, in the step that writes. So does an
136
- // unfinished submit: what's approved may not be what the pull request holds. So do conflicts: a
137
- // merge of GitHub's commits can record some on a branch in review, under the content-write lock
138
- // held here, so none lands between the check and the write.
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.
139
221
  let found;
140
222
  let unfinished = false;
141
223
  let notQueued = false;
142
224
  let updated;
143
225
  try {
144
226
  updated = await withContentWriteLock(branchContext.branchRoot, async () => {
227
+ const commit = await assertReviewedHead(ctx, branchContext, headSha);
145
228
  await assertNoOpenConflicts(branchContext.branchRoot);
146
229
  return meta.saveIf({
147
230
  branch: {
148
231
  name: branchContext.branch.name,
149
232
  status: 'approved',
150
- review: reviewBy(req.user, 'approved'),
233
+ review: reviewBy(req.user, 'approved', commit),
151
234
  },
152
235
  }, (existing) => {
153
236
  found = existing?.branch.status;
@@ -158,6 +241,10 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
158
241
  });
159
242
  }
160
243
  catch (err) {
244
+ if (err instanceof BranchMovedError)
245
+ return branchMoved;
246
+ if (err instanceof HeadUnreadableError)
247
+ return headUnreadable;
161
248
  if (err instanceof OpenMergeConflictsError) {
162
249
  return {
163
250
  ok: false,
@@ -231,6 +318,8 @@ export const approveBranch = defineEndpoint({
231
318
  method: 'POST',
232
319
  path: '/:branch/approve',
233
320
  params: branchParamSchema,
321
+ body: approveBodySchema,
322
+ bodyType: 'ApproveBody',
234
323
  responseType: 'BranchListItemResponse',
235
324
  response: {},
236
325
  defaultMockData: {
@@ -3,7 +3,7 @@ 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
7
  export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
8
8
  /**
9
9
  * A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
@@ -98,9 +98,12 @@ export declare const WORKFLOW_ROUTES: {
98
98
  };
99
99
  }>, z.ZodOptional<z.ZodObject<{
100
100
  note: z.ZodOptional<z.ZodString>;
101
+ headSha: z.ZodOptional<z.ZodString>;
101
102
  }, "strip", z.ZodTypeAny, {
103
+ headSha?: string | undefined;
102
104
  note?: string | undefined;
103
105
  }, {
106
+ headSha?: string | undefined;
104
107
  note?: string | undefined;
105
108
  }>>, BranchListItemResponse>;
106
109
  readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -113,7 +116,13 @@ export declare const WORKFLOW_ROUTES: {
113
116
  branch: string & {
114
117
  readonly __brand: "BranchName";
115
118
  };
116
- }>, 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>;
117
126
  readonly markMerged: import("./route-builder.js").RouteDefinition<z.ZodObject<{
118
127
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
119
128
  }, "strip", z.ZodTypeAny, {
@@ -208,8 +208,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
208
208
  status: 'submitted',
209
209
  submittedAt,
210
210
  ...pushed,
211
- // Only a request-derived URL: config.editorUrl is read live by every later re-render.
212
- ...(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 } : {}),
213
214
  syncFailureReason: prResult.syncFailureReason,
214
215
  pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
215
216
  pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
@@ -3,7 +3,7 @@ import { toBranchListItem } from './branch.js';
3
3
  import { defineEndpoint } from './route-builder.js';
4
4
  import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
5
5
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
6
- import { clientOperatingStrategy } from '../operating-mode/index.js';
6
+ import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
7
7
  import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
8
8
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
9
9
  import { BASE_UPDATE_GIVE_UP_MS, BASE_UPDATE_REQUEST_INTERVAL_MS, SYNC_BRANCH_ACTION, isBaseUpdatePending, } from '../base-update.js';
@@ -17,6 +17,9 @@ import { baseBranchOf } from '../utils/base-branch.js';
17
17
  *
18
18
  * The request is recorded on branch.json, which is what the worker answers, so a lost task or a
19
19
  * worker restart costs only the wait for its next regular cycle. The task only wakes it early.
20
+ * Where the mode runs no worker loop (operating-mode `baseUpdateRunsInRequest`), a new request runs
21
+ * the worker's merge itself and is answered before the response; one it joins is answered by the
22
+ * run that made it.
20
23
  * A request joins the last one, answering with its time: while that is unanswered, under
21
24
  * BASE_UPDATE_GIVE_UP_MS old and either under BASE_UPDATE_REQUEST_INTERVAL_MS old or still queued;
22
25
  * or once it was answered under BASE_UPDATE_REQUEST_INTERVAL_MS ago without failing. Anything
@@ -26,6 +29,7 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
26
29
  const { branchContext } = gc;
27
30
  const { config } = ctx.services;
28
31
  const { branch } = branchContext;
32
+ const runsInRequest = operatingStrategy(config.mode).baseUpdateRunsInRequest();
29
33
  if (!clientOperatingStrategy(config.mode).syncsBranchesWithBase()) {
30
34
  return {
31
35
  ok: false,
@@ -86,7 +90,8 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
86
90
  };
87
91
  }
88
92
  const taskDir = getTaskQueueDir(config);
89
- const queued = (await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
93
+ const queued = !runsInRequest &&
94
+ (await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
90
95
  const now = Date.now();
91
96
  let requestedAt = new Date(now).toISOString();
92
97
  let joined = false;
@@ -105,9 +110,11 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
105
110
  const answer = existing.branch.lastBaseUpdate;
106
111
  const pending = isBaseUpdatePending(existing.branch);
107
112
  // An unanswered request is joined until the editor stops waiting on it. An answer under the
108
- // interval old stands for a new click too, except a failure, whose Retry must ask again.
113
+ // interval old stands for a new click too, except a failure, whose Retry must ask again, and
114
+ // except where the request runs the merge itself, which costs no worker cycle.
109
115
  const joinsPending = pending && age < BASE_UPDATE_GIVE_UP_MS && (queued || age < BASE_UPDATE_REQUEST_INTERVAL_MS);
110
- const answeredRecently = !pending &&
116
+ const answeredRecently = !runsInRequest &&
117
+ !pending &&
111
118
  answer !== undefined &&
112
119
  answer.outcome !== 'failed' &&
113
120
  now - Date.parse(answer.at) < BASE_UPDATE_REQUEST_INTERVAL_MS;
@@ -127,12 +134,56 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
127
134
  : "This branch can't be edited, so it can't get the latest changes.",
128
135
  };
129
136
  }
130
- const current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
137
+ let current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
131
138
  if (current?.branch.name !== branch.name) {
132
139
  return { ok: false, status: 404, error: 'This branch no longer exists.' };
133
140
  }
141
+ // A joined request is answered by the run that made it.
142
+ if (runsInRequest && !joined) {
143
+ // Answered here where the run couldn't, as nothing else would: an open request holds the form
144
+ // read-only.
145
+ const answerFailed = () => meta
146
+ .update((existing) => existing?.branch.name === branch.name &&
147
+ existing.branch.baseUpdateRequestedAt === requestedAt &&
148
+ isBaseUpdatePending(existing.branch)
149
+ ? { branch: { lastBaseUpdate: { at: requestedAt, outcome: 'failed' } } }
150
+ : null, { keepUpdatedAt: true })
151
+ .catch(() => undefined);
152
+ let run;
153
+ try {
154
+ run = await ctx.services.runBaseUpdateNow(branchContext);
155
+ }
156
+ catch (err) {
157
+ console.error(`CanopyCMS: Could not bring the latest changes into ${branch.name}:`, redactCredentials(getErrorMessage(err)));
158
+ await answerFailed();
159
+ return {
160
+ ok: false,
161
+ status: 500,
162
+ error: "The latest changes couldn't be added. Your work is safe.",
163
+ };
164
+ }
165
+ if (run === 'other-remote') {
166
+ await answerFailed();
167
+ return {
168
+ ok: false,
169
+ status: 409,
170
+ error: "This branch uses a git remote other than the local one, so its latest changes can't be brought in here.",
171
+ };
172
+ }
173
+ if (run === 'busy') {
174
+ return {
175
+ ok: false,
176
+ status: 409,
177
+ error: "This branch is busy with a save or another update, so the latest changes weren't added. Try again in a moment.",
178
+ };
179
+ }
180
+ current = await BranchMetadataFileManager.loadOnly(branchContext.branchRoot);
181
+ if (current?.branch.name !== branch.name) {
182
+ return { ok: false, status: 404, error: 'This branch no longer exists.' };
183
+ }
184
+ }
134
185
  // A wake-up already queued serves the new request too.
135
- if (!joined && !queued) {
186
+ if (!runsInRequest && !joined && !queued) {
136
187
  try {
137
188
  await enqueueTask(taskDir, { action: SYNC_BRANCH_ACTION, payload: { branch: branch.name } });
138
189
  }
@@ -60,6 +60,11 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOve
60
60
  users: number;
61
61
  groups: number;
62
62
  };
63
+ /**
64
+ * The commit a branch in review held when it was listed, which Approve and Request changes send
65
+ * back to say what the reviewer saw (api/branch-review.ts). Only on a listing.
66
+ */
67
+ headSha?: string;
63
68
  }
64
69
  /**
65
70
  * Response type for branch creation and the workflow transitions. Carries the
@@ -11,7 +11,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
11
11
  import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
12
12
  import { getErrorMessage } from '../utils/error.js';
13
13
  import { filePathExists } from '../utils/fs.js';
14
- import { isNetworkRemoteUrl } from '../utils/git.js';
14
+ import { isNetworkRemoteUrl, readHeadCommitFromFiles } from '../utils/git.js';
15
15
  import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
16
16
  import { GitManager } from '../git-manager.js';
17
17
  import { syncDeleteRemoteBranch } from './github-sync.js';
@@ -521,17 +521,42 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
521
521
  console.error('CanopyCMS: Failed to look up branch owners:', getErrorMessage(error));
522
522
  }
523
523
  }
524
+ const heads = await Promise.all(branches.map((context) => reviewedHead(ctx, context)));
524
525
  return {
525
526
  ok: true,
526
527
  status: 200,
527
528
  data: {
528
- branches: branches.map((context) => toBranchListItem(ctx.services.config, context.branch, req.user)),
529
+ branches: branches.map((context, i) => {
530
+ const item = toBranchListItem(ctx.services.config, context.branch, req.user);
531
+ const headSha = heads[i];
532
+ return headSha ? { ...item, headSha } : item;
533
+ }),
529
534
  defaultBranch,
530
535
  users,
531
536
  serverTime: Date.now(),
532
537
  },
533
538
  };
534
539
  };
540
+ /**
541
+ * A branch in review's head commit, from the clone's files, so a listing of many spawns no git;
542
+ * git reads only a layout the files can't answer. Undefined when unreadable: the editor then has
543
+ * nothing to send, and the review is refused until a listing reads it.
544
+ */
545
+ const reviewedHead = async (ctx, { branch, branchRoot }) => {
546
+ if (branch.status !== 'submitted' && branch.status !== 'approved')
547
+ return undefined;
548
+ try {
549
+ const fromFiles = await readHeadCommitFromFiles(branchRoot);
550
+ if (fromFiles)
551
+ return fromFiles;
552
+ const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
553
+ return await git.headSha();
554
+ }
555
+ catch (err) {
556
+ console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
557
+ return undefined;
558
+ }
559
+ };
535
560
  /** A person's delete, as deletion by age never does, can take commits only the branch still has. */
536
561
  const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
537
562
  (branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
@@ -12,7 +12,7 @@ import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse,
12
12
  import type { BranchChangesResponse } from './branch-changes.js';
13
13
  import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
14
14
  import type { EntryConflictResponse, MergeConflictsResponse, ReleaseConflictsHoldResponse, ResolveEntryConflictBody, ResolveEntryConflictResponse } from './merge-conflicts.js';
15
- import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
15
+ import type { ApproveBody, BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
16
16
  import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
17
17
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
18
18
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -114,7 +114,7 @@ export declare class CanopyApiClient {
114
114
  /** POST /:branch/request-changes */
115
115
  requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
116
116
  /** POST /:branch/approve */
117
- approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
117
+ approve: (params: Record<string, string>, body: ApproveBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
118
118
  /** POST /:branch/mark-merged */
119
119
  markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
120
120
  /** POST /:branch/archive */
@@ -87,8 +87,8 @@ export class CanopyApiClient {
87
87
  return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
88
88
  },
89
89
  /** POST /:branch/approve */
90
- approve: (params, options) => {
91
- return this.request('POST', this.buildPath('/:branch/approve', params), undefined, 'standard', options);
90
+ approve: (params, body, options) => {
91
+ return this.request('POST', this.buildPath('/:branch/approve', params), body, 'standard', options);
92
92
  },
93
93
  /** POST /:branch/mark-merged */
94
94
  markMerged: (params, options) => {
@@ -493,7 +493,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
493
493
  if (!id || resolving.length === 0)
494
494
  return;
495
495
  try {
496
- resolvedConflicts = await clearFieldConflicts(branchContext, id, resolving);
496
+ resolvedConflicts = await clearFieldConflicts(ctx.services.config, branchContext, id, resolving);
497
497
  }
498
498
  catch (err) {
499
499
  canopyLogWarn(`CanopyCMS: saved ${logicalPathSegments.join('/')} but couldn't mark its conflicts resolved:`, getErrorMessage(err));
@@ -7,6 +7,7 @@
7
7
  import { z } from 'zod';
8
8
  import type { BranchContext } from '../types.js';
9
9
  import type { ApiResponse } from './types.js';
10
+ import type { CanopyConfig } from '../config/index.js';
10
11
  import type { DataPathSegment } from '../editor/entry-diff.js';
11
12
  import type { FieldConflictKind, SidePaths } from '../merge/entry-merge.js';
12
13
  import { type MergeConflictKind, type MergeSides, type ResolvedFieldConflict } from '../merge-conflicts.js';
@@ -105,7 +106,7 @@ export declare const resolvedConflictsSchema: z.ZodArray<z.ZodObject<{
105
106
  * call once the entry is written, inside its locks: the record's own locks come after them in
106
107
  * the one lock order (docs/concurrency.md).
107
108
  */
108
- export declare function clearFieldConflicts(branchContext: BranchContext, contentId: string, resolved: readonly ResolvedFieldConflict[]): Promise<ResolvedFieldConflict[]>;
109
+ export declare function clearFieldConflicts(config: Pick<CanopyConfig, 'mode'>, branchContext: BranchContext, contentId: string, resolved: readonly ResolvedFieldConflict[]): Promise<ResolvedFieldConflict[]>;
109
110
  declare const resolveBodySchema: z.ZodObject<{
110
111
  /** The content id the record names the entry by. */
111
112
  id: z.ZodType<ContentId, z.ZodTypeDef, ContentId>;
@@ -118,18 +119,22 @@ declare const resolveBodySchema: z.ZodObject<{
118
119
  * field by field with the latest version (`unreadable`).
119
120
  */
120
121
  choice: z.ZodEnum<["keep", "delete", "restore", "use-latest"]>;
122
+ /** The card promised the entry stays deleted: a `keep` is refused if it is in the tree now. */
123
+ entryGone: z.ZodOptional<z.ZodLiteral<true>>;
121
124
  }, "strip", z.ZodTypeAny, {
122
125
  id: string & {
123
126
  readonly __brand: "ContentId";
124
127
  };
125
128
  mergeSha: string;
126
129
  choice: "delete" | "restore" | "keep" | "use-latest";
130
+ entryGone?: true | undefined;
127
131
  }, {
128
132
  id: string & {
129
133
  readonly __brand: "ContentId";
130
134
  };
131
135
  mergeSha: string;
132
136
  choice: "delete" | "restore" | "keep" | "use-latest";
137
+ entryGone?: true | undefined;
133
138
  }>;
134
139
  export type ResolveEntryConflictBody = z.infer<typeof resolveBodySchema>;
135
140
  export type ReleaseConflictsHoldResponse = ApiResponse<{
@@ -209,18 +214,22 @@ export declare const MERGE_CONFLICT_ROUTES: {
209
214
  * field by field with the latest version (`unreadable`).
210
215
  */
211
216
  choice: z.ZodEnum<["keep", "delete", "restore", "use-latest"]>;
217
+ /** The card promised the entry stays deleted: a `keep` is refused if it is in the tree now. */
218
+ entryGone: z.ZodOptional<z.ZodLiteral<true>>;
212
219
  }, "strip", z.ZodTypeAny, {
213
220
  id: string & {
214
221
  readonly __brand: "ContentId";
215
222
  };
216
223
  mergeSha: string;
217
224
  choice: "delete" | "restore" | "keep" | "use-latest";
225
+ entryGone?: true | undefined;
218
226
  }, {
219
227
  id: string & {
220
228
  readonly __brand: "ContentId";
221
229
  };
222
230
  mergeSha: string;
223
231
  choice: "delete" | "restore" | "keep" | "use-latest";
232
+ entryGone?: true | undefined;
224
233
  }>, ResolveEntryConflictResponse>;
225
234
  };
226
235
  export {};