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
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Reading a GitHub API failure as the gateway passes it on: an Octokit error in-process, or the
3
+ * wire's `GitHubRequestError`, both carrying `status` and the response headers that classify it.
4
+ */
5
+ /** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
6
+ export function getHttpStatus(err) {
7
+ if (err instanceof Error && 'status' in err) {
8
+ const status = err.status;
9
+ if (typeof status === 'number')
10
+ return status;
11
+ }
12
+ return null;
13
+ }
14
+ /** Narrow an unknown value to a response-headers record, if present (Octokit lowercases header names). */
15
+ function getResponseHeaders(err) {
16
+ if (typeof err !== 'object' || err === null || !('response' in err))
17
+ return null;
18
+ const response = err.response;
19
+ if (typeof response !== 'object' || response === null || !('headers' in response))
20
+ return null;
21
+ const headers = response.headers;
22
+ if (typeof headers !== 'object' || headers === null)
23
+ return null;
24
+ return headers;
25
+ }
26
+ /**
27
+ * Whether a 403 is a GitHub rate-limit response rather than a plain permission
28
+ * denial. GitHub signals rate limiting on 403s three ways: the primary limit
29
+ * zeroes `x-ratelimit-remaining`, secondary limits often carry `retry-after`,
30
+ * and both produce a message containing "rate limit".
31
+ */
32
+ export function isRateLimitSignal403(err) {
33
+ const headers = getResponseHeaders(err);
34
+ if (headers) {
35
+ if (headers['x-ratelimit-remaining'] === '0')
36
+ return true;
37
+ if (typeof headers['retry-after'] === 'string' && headers['retry-after'].length > 0)
38
+ return true;
39
+ }
40
+ if (err instanceof Error && /rate limit/i.test(err.message))
41
+ return true;
42
+ return false;
43
+ }
44
+ /** A 403 that is GitHub denying the credential the operation, not a rate limit. */
45
+ export function isPermissionDenial(err) {
46
+ return getHttpStatus(err) === 403 && !isRateLimitSignal403(err);
47
+ }
@@ -219,9 +219,9 @@ class GatewayRequestHandler {
219
219
  return {};
220
220
  },
221
221
  getPullRequest: async ({ number }, signal) => {
222
- const { merged, merged_at, state, node_id, body, mergeable_state, baseRef } = await gateway.getPullRequest(number, signal);
222
+ const { merged, merged_at, state, node_id, body, mergeable_state, baseRef, headSha } = await gateway.getPullRequest(number, signal);
223
223
  // A null body is the gateway withholding it (`GitHubPullRequest.body`), and crosses as one.
224
- return { merged, merged_at, state, node_id, body, mergeable_state, baseRef };
224
+ return { merged, merged_at, state, node_id, body, mergeable_state, baseRef, headSha };
225
225
  },
226
226
  createOrUpdatePullRequest: async (input, signal) => {
227
227
  const { number, url, created } = await gateway.createOrUpdatePullRequest(input, signal);
@@ -231,6 +231,10 @@ class GatewayRequestHandler {
231
231
  await gateway.convertPullRequestToDraft(number, signal);
232
232
  return {};
233
233
  },
234
+ setConflictsStatus: async (status, signal) => {
235
+ await gateway.setConflictsStatus(status, signal);
236
+ return {};
237
+ },
234
238
  deleteBranch: async ({ branch }, signal) => {
235
239
  await gateway.deleteBranch(branch, signal);
236
240
  return {};
@@ -409,7 +413,7 @@ class GatewayRequestHandler {
409
413
  }
410
414
  async startFetch(req, res) {
411
415
  await this.prepared;
412
- const { have } = parseRequest(fetchRequest, await readJsonBody(req), 'fetch');
416
+ const { have, fresh } = parseRequest(fetchRequest, await readJsonBody(req), 'fetch');
413
417
  this.evictJobs();
414
418
  const running = [...this.jobs.values()].filter((job) => job.settledAt === null).length;
415
419
  if (running >= MAX_RUNNING_FETCH_JOBS) {
@@ -434,13 +438,19 @@ class GatewayRequestHandler {
434
438
  progress: 0,
435
439
  // No signal: the job outlives the request that started it (see the module doc).
436
440
  settled: this.gateway
437
- .fetch({ have }, undefined, () => {
441
+ .fetch({ have, fresh }, undefined, () => {
438
442
  job.progress++;
439
443
  })
440
- .then(({ refs, bundleId }) => {
444
+ .then(({ refs, bundleId, startedAt }) => {
441
445
  job.answer = {
442
446
  ok: true,
443
- value: { state: 'done', progress: job.progress, refs: [...refs], bundleId },
447
+ value: {
448
+ state: 'done',
449
+ progress: job.progress,
450
+ refs: [...refs],
451
+ bundleId,
452
+ startedAt,
453
+ },
444
454
  };
445
455
  }, (err) => {
446
456
  job.answer = { ok: false, error: encodeError(err) };
@@ -703,11 +713,12 @@ class GitHubGatewayClient {
703
713
  */
704
714
  async fetch(request, signal, onProgress) {
705
715
  const have = [...new Set(request.have)];
716
+ const json = { have, fresh: request.fresh === true };
706
717
  for (let attempt = 1;; attempt++) {
707
718
  const started = await this.send({
708
719
  method: 'POST',
709
720
  path: FETCH_ROUTE,
710
- json: { have },
721
+ json,
711
722
  signal,
712
723
  });
713
724
  const { job } = await readAnswer(started, fetchStarted, signal);
@@ -754,8 +765,9 @@ class GitHubGatewayClient {
754
765
  progress = poll.progress;
755
766
  movedAt = performance.now();
756
767
  }
757
- if (poll.state === 'done')
758
- return { refs: new Map(poll.refs), bundleId: poll.bundleId };
768
+ if (poll.state === 'done') {
769
+ return { refs: new Map(poll.refs), bundleId: poll.bundleId, startedAt: poll.startedAt };
770
+ }
759
771
  if (performance.now() - movedAt >= this.fetchInactivityMs) {
760
772
  throw new GitHubTransientError('github-fetch-failed', `The GitHub gateway's fetch showed no progress for ${this.fetchInactivityMs}ms; it runs on, ` +
761
773
  'and the next fetch joins it');
@@ -816,6 +828,9 @@ class GitHubGatewayClient {
816
828
  async convertPullRequestToDraft(number, signal) {
817
829
  await this.call('convertPullRequestToDraft', { number }, signal);
818
830
  }
831
+ async setConflictsStatus(status, signal) {
832
+ await this.call('setConflictsStatus', status, signal);
833
+ }
819
834
  async deleteBranch(branch, signal) {
820
835
  await this.call('deleteBranch', { branch }, signal);
821
836
  }
@@ -9,10 +9,13 @@ export declare const MAX_JSON_REQUEST_BYTES: number;
9
9
  export declare const MAX_JSON_ANSWER_BYTES: number;
10
10
  export declare const fetchRequest: z.ZodObject<{
11
11
  have: z.ZodArray<z.ZodString, "many">;
12
+ fresh: z.ZodOptional<z.ZodBoolean>;
12
13
  }, "strict", z.ZodTypeAny, {
13
14
  have: string[];
15
+ fresh?: boolean | undefined;
14
16
  }, {
15
17
  have: string[];
18
+ fresh?: boolean | undefined;
16
19
  }>;
17
20
  export declare const pushRequest: z.ZodObject<{
18
21
  branch: z.ZodString;
@@ -56,13 +59,13 @@ export declare const requests: {
56
59
  }, "strict", z.ZodTypeAny, {
57
60
  title: string;
58
61
  base: string;
59
- head: string;
60
62
  body: string;
63
+ head: string;
61
64
  }, {
62
65
  title: string;
63
66
  base: string;
64
- head: string;
65
67
  body: string;
68
+ head: string;
66
69
  }>;
67
70
  readonly updatePullRequest: z.ZodObject<{
68
71
  number: z.ZodNumber;
@@ -97,15 +100,15 @@ export declare const requests: {
97
100
  }, "strict", z.ZodTypeAny, {
98
101
  title: string;
99
102
  base: string;
100
- head: string;
101
103
  body: string;
104
+ head: string;
102
105
  markReadyIfDraft?: boolean | undefined;
103
106
  mergeSectionIntoBody?: boolean | undefined;
104
107
  }, {
105
108
  title: string;
106
109
  base: string;
107
- head: string;
108
110
  body: string;
111
+ head: string;
109
112
  markReadyIfDraft?: boolean | undefined;
110
113
  mergeSectionIntoBody?: boolean | undefined;
111
114
  }>;
@@ -116,6 +119,25 @@ export declare const requests: {
116
119
  }, {
117
120
  number: number;
118
121
  }>;
122
+ readonly setConflictsStatus: z.ZodObject<{
123
+ pullRequest: z.ZodNumber;
124
+ sha: z.ZodString;
125
+ state: z.ZodEnum<["success", "failure"]>;
126
+ description: z.ZodString;
127
+ targetUrl: z.ZodOptional<z.ZodString>;
128
+ }, "strict", z.ZodTypeAny, {
129
+ description: string;
130
+ state: "success" | "failure";
131
+ sha: string;
132
+ pullRequest: number;
133
+ targetUrl?: string | undefined;
134
+ }, {
135
+ description: string;
136
+ state: "success" | "failure";
137
+ sha: string;
138
+ pullRequest: number;
139
+ targetUrl?: string | undefined;
140
+ }>;
119
141
  readonly deleteBranch: z.ZodObject<{
120
142
  branch: z.ZodString;
121
143
  }, "strict", z.ZodTypeAny, {
@@ -143,15 +165,15 @@ export declare const answers: {
143
165
  }, "strict", z.ZodTypeAny, {
144
166
  settingsBranch: string | null;
145
167
  namespace: string;
146
- legacy: string[];
147
168
  configured: string[];
148
169
  githubDefault: string | null;
170
+ legacy: string[];
149
171
  }, {
150
172
  settingsBranch: string | null;
151
173
  namespace: string;
152
- legacy: string[];
153
174
  configured: string[];
154
175
  githubDefault: string | null;
176
+ legacy: string[];
155
177
  }>;
156
178
  }, "strict", z.ZodTypeAny, {
157
179
  ready: boolean;
@@ -161,9 +183,9 @@ export declare const answers: {
161
183
  branchPolicy: {
162
184
  settingsBranch: string | null;
163
185
  namespace: string;
164
- legacy: string[];
165
186
  configured: string[];
166
187
  githubDefault: string | null;
188
+ legacy: string[];
167
189
  };
168
190
  }, {
169
191
  ready: boolean;
@@ -173,9 +195,9 @@ export declare const answers: {
173
195
  branchPolicy: {
174
196
  settingsBranch: string | null;
175
197
  namespace: string;
176
- legacy: string[];
177
198
  configured: string[];
178
199
  githubDefault: string | null;
200
+ legacy: string[];
179
201
  };
180
202
  }>;
181
203
  readonly defaultBranch: z.ZodObject<{
@@ -211,12 +233,14 @@ export declare const answers: {
211
233
  body: z.ZodNullable<z.ZodString>;
212
234
  mergeable_state: z.ZodOptional<z.ZodString>;
213
235
  baseRef: z.ZodOptional<z.ZodString>;
236
+ headSha: z.ZodOptional<z.ZodString>;
214
237
  }, "strict", z.ZodTypeAny, {
215
238
  merged: boolean;
216
239
  state: "open" | "closed";
217
240
  body: string | null;
218
241
  merged_at: string | null;
219
242
  node_id: string;
243
+ headSha?: string | undefined;
220
244
  mergeable_state?: string | undefined;
221
245
  baseRef?: string | undefined;
222
246
  }, {
@@ -225,6 +249,7 @@ export declare const answers: {
225
249
  body: string | null;
226
250
  merged_at: string | null;
227
251
  node_id: string;
252
+ headSha?: string | undefined;
228
253
  mergeable_state?: string | undefined;
229
254
  baseRef?: string | undefined;
230
255
  }>;
@@ -243,6 +268,7 @@ export declare const answers: {
243
268
  url: string;
244
269
  }>;
245
270
  readonly convertPullRequestToDraft: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
271
+ readonly setConflictsStatus: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
246
272
  readonly deleteBranch: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
247
273
  readonly authSnapshot: z.ZodObject<{
248
274
  users: z.ZodArray<z.ZodObject<{
@@ -312,21 +338,21 @@ export declare const answers: {
312
338
  kind: z.ZodEnum<["auto-scaling", "spot"]>;
313
339
  reason: z.ZodString;
314
340
  }, "strict", z.ZodTypeAny, {
315
- kind: "auto-scaling" | "spot";
316
341
  reason: string;
317
- }, {
318
342
  kind: "auto-scaling" | "spot";
343
+ }, {
319
344
  reason: string;
345
+ kind: "auto-scaling" | "spot";
320
346
  }>>;
321
347
  }, "strict", z.ZodTypeAny, {
322
348
  notice: {
323
- kind: "auto-scaling" | "spot";
324
349
  reason: string;
350
+ kind: "auto-scaling" | "spot";
325
351
  } | null;
326
352
  }, {
327
353
  notice: {
328
- kind: "auto-scaling" | "spot";
329
354
  reason: string;
355
+ kind: "auto-scaling" | "spot";
330
356
  } | null;
331
357
  }>;
332
358
  readonly completeLifecycle: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
@@ -360,15 +386,18 @@ export declare const fetchPoll: z.ZodDiscriminatedUnion<"state", [z.ZodObject<{
360
386
  /** Every GitHub branch and its tip, as pairs: a `Map` has no JSON form of its own. */
361
387
  refs: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodString], null>, "many">;
362
388
  bundleId: z.ZodNullable<z.ZodString>;
389
+ startedAt: z.ZodNumber;
363
390
  }, "strict", z.ZodTypeAny, {
364
391
  state: "done";
365
392
  refs: [string, string][];
366
393
  progress: number;
394
+ startedAt: number;
367
395
  bundleId: string | null;
368
396
  }, {
369
397
  state: "done";
370
398
  refs: [string, string][];
371
399
  progress: number;
400
+ startedAt: number;
372
401
  bundleId: string | null;
373
402
  }>]>;
374
403
  export declare const pushAnswer: z.ZodUnion<[z.ZodObject<{
@@ -391,15 +420,15 @@ export declare const pushAnswer: z.ZodUnion<[z.ZodObject<{
391
420
  pastStaleLease: z.ZodBoolean;
392
421
  }, "strict", z.ZodTypeAny, {
393
422
  kind: "pushed";
423
+ moved: true;
394
424
  from: string | null;
395
425
  to: string;
396
- moved: true;
397
426
  pastStaleLease: boolean;
398
427
  }, {
399
428
  kind: "pushed";
429
+ moved: true;
400
430
  from: string | null;
401
431
  to: string;
402
- moved: true;
403
432
  pastStaleLease: boolean;
404
433
  }>, z.ZodObject<{
405
434
  kind: z.ZodLiteral<"need-objects">;
@@ -423,24 +452,24 @@ export declare function readAnswerBody(json: unknown): {
423
452
  declare const errorEnvelope: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
424
453
  kind: z.ZodLiteral<"refusal">;
425
454
  push: z.ZodBoolean;
426
- operation: z.ZodEnum<["push", "delete", "pull-request"]>;
455
+ operation: z.ZodEnum<["push", "delete", "pull-request", "commit-status"]>;
427
456
  branch: z.ZodString;
428
- reason: z.ZodEnum<["invalid", "protected", "settings", "other-settings", "outside-namespace", "not-a-commit", "too-long"]>;
457
+ reason: z.ZodEnum<["invalid", "protected", "settings", "other-settings", "outside-namespace", "not-a-commit", "too-long", "not-head"]>;
429
458
  message: z.ZodString;
430
459
  }, "strict", z.ZodTypeAny, {
431
460
  branch: string;
432
461
  push: boolean;
433
462
  message: string;
463
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long" | "not-head";
434
464
  kind: "refusal";
435
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
436
- operation: "push" | "delete" | "pull-request";
465
+ operation: "push" | "delete" | "pull-request" | "commit-status";
437
466
  }, {
438
467
  branch: string;
439
468
  push: boolean;
440
469
  message: string;
470
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long" | "not-head";
441
471
  kind: "refusal";
442
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
443
- operation: "push" | "delete" | "pull-request";
472
+ operation: "push" | "delete" | "pull-request" | "commit-status";
444
473
  }>, z.ZodObject<{
445
474
  kind: z.ZodLiteral<"push">;
446
475
  pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease", "seen-commit-gone"]>;
@@ -37,7 +37,9 @@ const objectId = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, 'not a full
37
37
  const name = z.string().max(4096);
38
38
  const prNumber = z.number().int().safe();
39
39
  const message = z.string().max(MAX_MESSAGE_LENGTH);
40
- export const fetchRequest = z.object({ have: z.array(objectId).max(MAX_FETCH_HAVE) }).strict();
40
+ export const fetchRequest = z
41
+ .object({ have: z.array(objectId).max(MAX_FETCH_HAVE), fresh: z.boolean().optional() })
42
+ .strict();
41
43
  export const pushRequest = z
42
44
  .object({
43
45
  branch: name,
@@ -75,6 +77,15 @@ export const requests = {
75
77
  })
76
78
  .strict(),
77
79
  convertPullRequestToDraft: z.object({ number: prNumber }).strict(),
80
+ setConflictsStatus: z
81
+ .object({
82
+ pullRequest: prNumber,
83
+ sha: objectId,
84
+ state: z.enum(['success', 'failure']),
85
+ description: z.string().max(4096),
86
+ targetUrl: z.string().max(4096).optional(),
87
+ })
88
+ .strict(),
78
89
  deleteBranch: z.object({ branch: name }).strict(),
79
90
  authSnapshot: z.object({}).strict(),
80
91
  termination: z.object({}).strict(),
@@ -114,10 +125,12 @@ export const answers = {
114
125
  body: pullRequestText.body.nullable(),
115
126
  mergeable_state: z.string().max(64).optional(),
116
127
  baseRef: z.string().max(256).optional(),
128
+ headSha: objectId.optional(),
117
129
  })
118
130
  .strict(),
119
131
  createOrUpdatePullRequest: pullRequestRef.extend({ created: z.boolean() }).strict(),
120
132
  convertPullRequestToDraft: empty,
133
+ setConflictsStatus: empty,
121
134
  deleteBranch: empty,
122
135
  authSnapshot: z
123
136
  .object({
@@ -165,6 +178,7 @@ export const JSON_ROUTES = {
165
178
  getPullRequest: `${WIRE_PREFIX}/pull-request/get`,
166
179
  createOrUpdatePullRequest: `${WIRE_PREFIX}/pull-request/create-or-update`,
167
180
  convertPullRequestToDraft: `${WIRE_PREFIX}/pull-request/convert-to-draft`,
181
+ setConflictsStatus: `${WIRE_PREFIX}/pull-request/conflicts-status`,
168
182
  deleteBranch: `${WIRE_PREFIX}/branch/delete`,
169
183
  authSnapshot: `${WIRE_PREFIX}/auth-snapshot`,
170
184
  termination: `${WIRE_PREFIX}/termination`,
@@ -187,6 +201,7 @@ export const fetchPoll = z.discriminatedUnion('state', [
187
201
  /** Every GitHub branch and its tip, as pairs: a `Map` has no JSON form of its own. */
188
202
  refs: z.array(z.tuple([name, objectId])).max(1_000_000),
189
203
  bundleId: jobId.nullable(),
204
+ startedAt: z.number().int().nonnegative(),
190
205
  })
191
206
  .strict(),
192
207
  ]);
@@ -236,7 +251,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
236
251
  .object({
237
252
  kind: z.literal('refusal'),
238
253
  push: z.boolean(),
239
- operation: z.enum(['push', 'delete', 'pull-request']),
254
+ operation: z.enum(['push', 'delete', 'pull-request', 'commit-status']),
240
255
  branch: name,
241
256
  reason: z.enum([
242
257
  'invalid',
@@ -246,6 +261,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
246
261
  'outside-namespace',
247
262
  'not-a-commit',
248
263
  'too-long',
264
+ 'not-head',
249
265
  ]),
250
266
  message,
251
267
  })
@@ -457,7 +473,7 @@ export function decodeError(value) {
457
473
  const { push, operation, branch, reason } = envelope;
458
474
  if (!push)
459
475
  return new GatewayRefusalError(operation, branch, reason, envelope.message);
460
- if (operation !== 'push' || reason === 'too-long') {
476
+ if (operation !== 'push' || reason === 'too-long' || reason === 'not-head') {
461
477
  return new GitHubGatewayProtocolError(`The GitHub gateway answered with a push refusal for ${operation}, reason ${reason}`);
462
478
  }
463
479
  const err = new RefusedPushError(branch, reason);
@@ -15,6 +15,11 @@ export interface GitHubFetchResult {
15
15
  * request's `have` and their history; null when that leaves nothing to send.
16
16
  */
17
17
  bundleId: string | null;
18
+ /**
19
+ * When the GitHub fetch `refs` come from began, by the gateway's clock, which is the worker's:
20
+ * they share the instance. A fetch can be joined or reused, so this can predate the request.
21
+ */
22
+ startedAt: number;
18
23
  }
19
24
  /** The most `have` IDs one fetch takes. */
20
25
  export declare const MAX_FETCH_HAVE = 10000;
@@ -81,7 +86,19 @@ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['re
81
86
  mergeable_state?: string;
82
87
  /** The branch the pull request merges into on GitHub, which a person can change. */
83
88
  baseRef?: string;
89
+ /** The pull request's head commit, read with `mergeable_state`. */
90
+ headSha?: string;
84
91
  };
92
+ /** A "CanopyCMS conflicts" status for the head of pull request `pullRequest`. */
93
+ export interface GitHubConflictsStatus {
94
+ pullRequest: number;
95
+ /** The pull request's head commit; the gateway refuses any other. */
96
+ sha: string;
97
+ state: 'success' | 'failure';
98
+ description: string;
99
+ /** The editor, opened on the branch. */
100
+ targetUrl?: string;
101
+ }
85
102
  export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
86
103
  /**
87
104
  * Everything the worker does with GitHub, and the only place the credential and Octokit are used.
@@ -114,10 +131,12 @@ export interface GitHubGateway {
114
131
  * Fetch every GitHub branch, and answer with all of them and a bundle of the commits `have`
115
132
  * lacks. `have` is the caller's own tips, at most {@link MAX_FETCH_HAVE} object IDs; any the
116
133
  * gateway does not hold as a commit is ignored. A new fetch replaces a bundle not yet opened.
117
- * `onProgress` is told of each sign of life from the gateway's git while the caller waits.
134
+ * `fresh` takes no GitHub fetch begun before the request arrived. `onProgress` is told of each
135
+ * sign of life from the gateway's git while the caller waits.
118
136
  */
119
137
  fetch(request: {
120
138
  have: readonly string[];
139
+ fresh?: boolean;
121
140
  }, signal?: AbortSignal, onProgress?: () => void): Promise<GitHubFetchResult>;
122
141
  /**
123
142
  * The bundle a fetch answered with, readable once. Throws {@link GitHubTransientError}
@@ -157,6 +176,13 @@ export interface GitHubGateway {
157
176
  created: boolean;
158
177
  }>;
159
178
  convertPullRequestToDraft(number: number, signal?: AbortSignal): Promise<void>;
179
+ /**
180
+ * Set the {@link CONFLICTS_STATUS_CONTEXT} status on a pull request's head. Refused
181
+ * (`GatewayRefusalError`) with reason `not-head` unless `sha` is the head GitHub holds now, as
182
+ * `updatePullRequest` refuses a pull request it would not update, and for a description or link
183
+ * `assertCommitStatusText` refuses (worker/branch-policy.ts).
184
+ */
185
+ setConflictsStatus(status: GitHubConflictsStatus, signal?: AbortSignal): Promise<void>;
160
186
  deleteBranch(branch: string, signal?: AbortSignal): Promise<void>;
161
187
  /**
162
188
  * The auth provider's users, groups and memberships, which the worker writes to the auth cache:
@@ -4,8 +4,9 @@ import { createCanopyOctokit, createOrUpdatePullRequest, } from '../github-servi
4
4
  import { getErrorMessage } from '../utils/error.js';
5
5
  import { isStaleLeaseRejection, workflowPushRefusalFile } from '../utils/git.js';
6
6
  import { isTransientAuthFailure } from './github-auth.js';
7
+ import { isPermissionDenial } from './github-errors.js';
7
8
  import { GitHubMirror, GitHubTransientError, RefusedPushError, assertPlainBranchName, isObjectId, } from './github-mirror.js';
8
- import { BranchPolicy, GatewayRefusalError, assertPullRequestText, } from './branch-policy.js';
9
+ import { BranchPolicy, GatewayRefusalError, assertCommitStatusText, assertPullRequestText, } from './branch-policy.js';
9
10
  import { gatewayLog, gatewayLogError, gatewayLogWarn, redactEgress, redactError, redactValue, registerLiveSecret, } from './gateway-egress.js';
10
11
  import { GatewayInstance, untilAborted } from './gateway-instance.js';
11
12
  /** The most `have` IDs one fetch takes. */
@@ -47,6 +48,11 @@ export function describeMove(update) {
47
48
  const to = update.to.slice(0, 7);
48
49
  return update.from === null ? `new branch at ${to}` : `${update.from.slice(0, 7)}..${to}`;
49
50
  }
51
+ /**
52
+ * The context of the commit status the worker sets on a pull request's head. Fixed by the gateway,
53
+ * so a worker can set no other; adopters name it when they make it a required check.
54
+ */
55
+ const CONFLICTS_STATUS_CONTEXT = 'CanopyCMS conflicts';
50
56
  /** The in-process {@link GitHubGateway}. */
51
57
  export function createLocalGitHubGateway(options) {
52
58
  return new LocalGitHubGateway(options);
@@ -197,11 +203,14 @@ class LocalGitHubGateway {
197
203
  * own, which repairs a corrupt mirror first ({@link GitHubMirror.heal}), and its failure re-reads
198
204
  * the credential when it reached GitHub, whoever started it.
199
205
  */
200
- async joinGitHubFetch(resolve, signal) {
206
+ async joinGitHubFetch(resolve, signal,
207
+ /** Join or reuse only a fetch that began at or after this time. */
208
+ notBefore) {
201
209
  const current = this.githubFetch;
202
210
  const window = this.options.fetchCoalesceMs ?? FETCH_COALESCE_MS;
203
211
  const reusable = current !== null &&
204
212
  !current.failed &&
213
+ (notBefore === undefined || current.startedAt >= notBefore) &&
205
214
  (current.completedAt === null ||
206
215
  (Date.now() - current.completedAt < window && this.isCurrent(current.snapshot)));
207
216
  const job = reusable ? current : this.startGitHubFetch(resolve);
@@ -223,7 +232,7 @@ class LocalGitHubGateway {
223
232
  async afterGitHubFetch(join, fn) {
224
233
  for (let attempt = 1;; attempt++) {
225
234
  const snapshot = await join();
226
- const result = await this.mirror.exclusive(async (session) => this.isCurrent(snapshot) ? { value: await fn(session) } : null);
235
+ const result = await this.mirror.exclusive(async (session) => this.isCurrent(snapshot) ? { value: await fn(session, snapshot) } : null);
227
236
  if (result !== null)
228
237
  return result.value;
229
238
  if (attempt === 2) {
@@ -234,6 +243,7 @@ class LocalGitHubGateway {
234
243
  /** Start a GitHub fetch, taking its place at once, before the credential resolves. */
235
244
  startGitHubFetch(resolve) {
236
245
  let reachedGitHub = false;
246
+ const startedAt = Date.now();
237
247
  const run = async () => {
238
248
  const credential = await resolve();
239
249
  const own = {
@@ -248,12 +258,18 @@ class LocalGitHubGateway {
248
258
  const pushes = this.pushes;
249
259
  await this.mirror.heal(mirror, own);
250
260
  await this.mirror.fetchFromGitHub(mirror, own);
251
- return { generation: this.mirror.generation, pushes };
261
+ return { generation: this.mirror.generation, pushes, startedAt };
252
262
  });
253
263
  await this.mirror.afterFetch();
254
264
  return snapshot;
255
265
  };
256
- const job = { failed: false, completedAt: null, snapshot: null, done: run() };
266
+ const job = {
267
+ failed: false,
268
+ completedAt: null,
269
+ snapshot: null,
270
+ startedAt,
271
+ done: run(),
272
+ };
257
273
  job.done.then((snapshot) => {
258
274
  job.completedAt = Date.now();
259
275
  job.snapshot = snapshot;
@@ -472,19 +488,20 @@ class LocalGitHubGateway {
472
488
  }
473
489
  }
474
490
  fetchOnce(request, signal) {
491
+ const notBefore = request.fresh ? Date.now() : undefined;
475
492
  const have = [...new Set(request.have)];
476
493
  if (have.length > MAX_FETCH_HAVE) {
477
494
  throw new Error(`A fetch takes at most ${MAX_FETCH_HAVE} have IDs, not ${have.length}`);
478
495
  }
479
496
  for (const id of have)
480
497
  assertObjectId(id);
481
- return this.credentialed(signal, (reach) => this.afterGitHubFetch(() => this.joinGitHubFetch(() => this.credential(reach), signal), async (mirror) => {
498
+ return this.credentialed(signal, (reach) => this.afterGitHubFetch(() => this.joinGitHubFetch(() => this.credential(reach), signal, notBefore), async (mirror, snapshot) => {
482
499
  this.mirror.assertUsable();
483
500
  const refs = await mirror.heads();
484
501
  const file = await this.mirror.outgoingBundlePath();
485
502
  const created = await mirror.createBundle(have, file, signal);
486
503
  const bundleId = await this.mirror.setPendingBundle(created ? file : null);
487
- return { refs, bundleId };
504
+ return { refs, bundleId, startedAt: snapshot.startedAt };
488
505
  }));
489
506
  }
490
507
  openBundle(bundleId) {
@@ -688,6 +705,7 @@ class LocalGitHubGateway {
688
705
  body: ours ? (data.body ?? '') : null,
689
706
  mergeable_state: data.mergeable_state,
690
707
  baseRef: data.base.ref,
708
+ headSha: data.head.sha,
691
709
  };
692
710
  });
693
711
  }
@@ -726,6 +744,36 @@ class LocalGitHubGateway {
726
744
  await this.api(reach, () => this.octokit.graphql(`mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: pr.node_id, request: { signal } }));
727
745
  });
728
746
  }
747
+ setConflictsStatus(status, signal) {
748
+ return this.credentialed(signal, async (reach) => {
749
+ const { pullRequest, sha, state, description, targetUrl } = status;
750
+ const refusing = `Refusing to set a status on pull request #${pullRequest}`;
751
+ const pr = await this.assertOwnPullRequest(reach, pullRequest, refusing, signal);
752
+ assertCommitStatusText(refusing, pr.head.ref, { description, targetUrl });
753
+ if (pr.head.sha !== sha) {
754
+ throw new GatewayRefusalError('commit-status', pr.head.ref, 'not-head', `${refusing}: ${sha.slice(0, 7)} is not its head, ${pr.head.sha.slice(0, 7)}`);
755
+ }
756
+ try {
757
+ await this.api(reach, () => this.octokit.repos.createCommitStatus({
758
+ ...this.repo,
759
+ sha,
760
+ state,
761
+ context: CONFLICTS_STATUS_CONTEXT,
762
+ description,
763
+ ...(targetUrl !== undefined && { target_url: targetUrl }),
764
+ request: { signal },
765
+ }));
766
+ }
767
+ catch (err) {
768
+ // A credential without the Commit statuses permission, which the worker meets every cycle
769
+ // until it is granted: the `pulls.get` above just proved the credential works, so a
770
+ // re-read would only spend the refresh floor.
771
+ if (isPermissionDenial(err))
772
+ reach.failed = false;
773
+ throw err;
774
+ }
775
+ });
776
+ }
729
777
  deleteBranch(branch, signal) {
730
778
  return this.credentialed(signal, async (reach) => {
731
779
  await assertPlainBranchName(branch, await this.mirror.checkGit(), 'delete');
@@ -12,11 +12,13 @@ export interface TransferContext {
12
12
  /**
13
13
  * Fetch every GitHub branch into `remote.git`'s tracking namespace (`GITHUB_TRACKING_REF_PREFIX`),
14
14
  * which then holds exactly GitHub's branches. `have` is every tip `remote.git` holds, so the
15
- * bundle carries only what it lacks. Returns GitHub's branches, and what an operator must fix in
16
- * remote.git for the namespace to keep up, if anything (see {@link moveTrackingRefs}).
15
+ * bundle carries only what it lacks. `fresh` takes no GitHub fetch the gateway began before this
16
+ * call. Returns GitHub's branches, when the GitHub fetch they come from began, and what an operator
17
+ * must fix in remote.git for the namespace to keep up, if anything (see {@link moveTrackingRefs}).
17
18
  */
18
- export declare function fetchGitHubIntoRemoteGit(ctx: TransferContext, signal?: AbortSignal): Promise<{
19
+ export declare function fetchGitHubIntoRemoteGit(ctx: TransferContext, signal?: AbortSignal, fresh?: boolean): Promise<{
19
20
  refs: ReadonlyMap<string, string>;
21
+ fetchedAt: number;
20
22
  needsOperator: string | null;
21
23
  }>;
22
24
  /**