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
@@ -1,17 +1,20 @@
1
1
  import { BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
2
+ import { CommentStore } from '../comment-store.js';
2
3
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
3
4
  import { GITHUB_TRACKING_REF_PREFIX } from '../git-manager.js';
5
+ import { MergeConflictStore, MergeConflictsFileMalformedError, conflictFilesOf, withFoundConflicts, withRemappedItemKeys, withoutMergesGone, } from '../merge-conflicts.js';
4
6
  import { SYNC_PR_COMMENTS_ACTION } from '../pr-comments.js';
5
7
  import { enqueueTask } from '../task-queue/cms-task-queue.js';
6
8
  import { getErrorMessage } from '../utils/error.js';
7
9
  import { escapeForLog } from '../utils/escape-text.js';
8
10
  import { isCanopyInternalPath } from '../utils/git.js';
9
11
  import { publishesBranch } from './branch-policy.js';
10
- import { buildMergeCommit, commitsApart, conflictFilesToContentIds, isCleanMergeOf, publishPlainToLocalRemote, recordConflicts, } from './branch-merge.js';
12
+ import { buildRekey, commitMerge, commitsApart, isCleanMergeOf, planMerge, publishPlainToLocalRemote, } from './branch-merge.js';
11
13
  import { MAX_REPORTED_PATHS, RETIRED_SCHEMA_CACHE_PATH, TRACKED_CANOPY_STATE_FIX, } from './canopy-state.js';
12
14
  import { enqueueGitHubPush, readPublishedSha, recordSeenOnGitHub } from './history-rewrite.js';
13
15
  import { workerLog, workerLogWarn } from './log.js';
14
- import { assertNoIncomingSubmodules, fetchFromRemoteGit } from './shared-repo-git.js';
16
+ import { decidePublish } from './publish-decision.js';
17
+ import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, fetchFromRemoteGit, } from './shared-repo-git.js';
15
18
  export const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
16
19
  const MAX_LISTED_PATH_LENGTH = 200;
17
20
  /** Why a push was refused while a rewrite stands (task-runner.ts); editors read it as a failed sync. */
@@ -33,9 +36,41 @@ export function rewriteCleared(branch) {
33
36
  : { rewrittenOnGitHub: undefined, syncStatus: 'synced', syncFailureReason: undefined };
34
37
  }
35
38
  /** A branch this deployment put on GitHub. Any other GitHub branch of that name is not its own. */
36
- function isPublished({ branch }) {
39
+ export function isPublished({ branch }) {
37
40
  return branch.pushedToGitHubAt !== undefined || branch.pullRequestNumber !== undefined;
38
41
  }
42
+ /**
43
+ * Fast-forward the clone to the merge this worker was landing, when `remote.git` holds it on top of
44
+ * the clone, as a crash or a lost lock between publishing it and moving the clone leaves it,
45
+ * whether or not the branch is on GitHub. Resolves why it stopped, or null when there was nothing
46
+ * to catch up with or it caught up.
47
+ */
48
+ export async function catchUpWithPublished(ctx, args) {
49
+ const { git, branchPath, branchRef } = args;
50
+ const published = await readPublishedSha(ctx, branchRef);
51
+ if (published === null)
52
+ return null;
53
+ // Only the merge this worker stamped as landing: `remote.git` can also hold a same-named branch,
54
+ // or a reviewer's commit, that it never wrote, which other steps judge.
55
+ const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
56
+ if (landing !== published)
57
+ return null;
58
+ const head = (await git.revparse(['HEAD'])).trim();
59
+ if (published === head)
60
+ return null;
61
+ if (!(await holdsCommit(git, published))) {
62
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
63
+ }
64
+ const parents = (await git.raw(['rev-list', '--parents', '-n', '1', '--end-of-options', published]))
65
+ .trim()
66
+ .split(' ');
67
+ // A merge built on the clone's head, or a re-key built on GitHub's commits past it.
68
+ const builtOnHead = parents.length === 3 && parents[1] === head;
69
+ const rekeyPastHead = parents.length === 2 && (await contains(git, published, head));
70
+ if (!builtOnHead && !rekeyPastHead)
71
+ return null;
72
+ return fastForward(ctx, args, head, published);
73
+ }
39
74
  /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
40
75
  export async function bringInGitHubCommits(ctx, args) {
41
76
  const { git, branchRef, metaFile, dirty, gatewayPolicy } = args;
@@ -93,6 +128,12 @@ export async function bringInGitHubCommits(ctx, args) {
93
128
  // below builds on remote.git's head whenever it can.
94
129
  if (published !== null && published !== head) {
95
130
  if ((await commitsApart(git, head, published)).onlyA === 0) {
131
+ // GitHub's commits, which reconcile moved `remote.git` to, come in re-keyed where they need it.
132
+ if (gitHubTip === published) {
133
+ const rekeyed = await rekeyTakenIn(ctx, args, head, published);
134
+ if (rekeyed)
135
+ return rekeyed;
136
+ }
96
137
  const stopped = await fastForward(ctx, args, head, published);
97
138
  if (stopped)
98
139
  return stopped;
@@ -285,61 +326,184 @@ async function fastForward(ctx, args, from, to) {
285
326
  await moveClone(git, branchPath, to);
286
327
  }
287
328
  catch (err) {
288
- if (!/would be overwritten/.test(getErrorMessage(err)))
329
+ if (!overwritesSaves(err))
289
330
  throw err;
290
331
  workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
291
332
  return { kind: 'wait' };
292
333
  }
293
334
  workerLog(` ${branchDir}: brought in commits from GitHub (now at ${to.slice(0, 7)})`);
335
+ // A merge this worker was landing, which the clone now holds, has landed.
336
+ const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
337
+ if (landing !== undefined && (await contains(git, to, landing))) {
338
+ await landed(ctx, args, landing).catch((err) => workerLogWarn(` ${branchDir}: what a merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
339
+ }
294
340
  return null;
295
341
  }
342
+ /**
343
+ * GitHub's commits brought in under the item keys a field merge gives, where a merge among them
344
+ * left an item under a key that names another item (branch-merge.ts `buildRekey`): one commit on
345
+ * `tip`, recorded, published and moved to in {@link mergeIntoBranch}'s order, its remaps moving the
346
+ * branch's comment threads once it lands. Null, for the caller to fast-forward to `tip` as git
347
+ * keyed it, where no item needs a new key, where the commit could not be published (gateway policy,
348
+ * {@link decidePublish}, a `remote.git` head it doesn't contain), or where the clone's saves would
349
+ * keep the clone from moving to it ({@link savesMeet}).
350
+ */
351
+ async function rekeyTakenIn(ctx, args, head, tip) {
352
+ const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
353
+ if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef))
354
+ return null;
355
+ const published = await readPublishedSha(ctx, branchRef);
356
+ if ((await decidePublish(git, { head, published })).kind !== 'publish')
357
+ return null;
358
+ const built = await rekeyOf(ctx, args, head, tip);
359
+ if (built === null)
360
+ return null;
361
+ const { sha } = built;
362
+ // A re-key the clone can't move to stays unpublished; the commits come in once the saves do.
363
+ if (args.dirty && (await savesMeet(git, head, sha)))
364
+ return null;
365
+ if (published !== null && !(await contains(git, sha, published)))
366
+ return null;
367
+ const refused = await refuseIncoming(args, head, sha);
368
+ if (refused)
369
+ return refused;
370
+ if (lockLost())
371
+ return { kind: 'wait' };
372
+ // Judged on content: the commit changes item keys only, which `isCleanMergeOf` sets aside.
373
+ if (await changesWhatWasReviewed(ctx, args, head, sha))
374
+ await returnToReview(ctx, args);
375
+ await recordMerge(ctx, args, built, { head, also: tip, fromGitHub: true });
376
+ if (lockLost())
377
+ return { kind: 'wait' };
378
+ await recordSeenOnGitHub(ctx, branchRef, tip);
379
+ if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha)))
380
+ return { kind: 'wait' };
381
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
382
+ if (lockLost())
383
+ return { kind: 'wait' };
384
+ try {
385
+ await moveClone(git, branchPath, sha);
386
+ }
387
+ catch (err) {
388
+ if (!overwritesSaves(err))
389
+ throw err;
390
+ // Unreached: `savesMeet` looked under the content-write lock this cycle still holds.
391
+ workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
392
+ return { kind: 'wait' };
393
+ }
394
+ await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this re-key moves is noted on the next cycle: ${getErrorMessage(err)}`));
395
+ await recordGitHubTip(ctx, args, tip);
396
+ workerLog(` ${branchDir}: brought in commits from GitHub, with the items its merges brought in keyed anew (now at ${sha.slice(0, 7)})`);
397
+ return { kind: 'merged', keptEntries: 0 };
398
+ }
399
+ /** {@link buildRekey} on GitHub's commits from `head` to `tip`, or null when none needs a new key. */
400
+ function rekeyOf(ctx, { git, branchPath, branchRef }, head, tip, unchangedIn) {
401
+ return buildRekey(git, branchPath, {
402
+ from: head,
403
+ tip,
404
+ contentRoot: ctx.contentRoot,
405
+ timeoutMs: ctx.taskTimeoutMs,
406
+ message: `Key the items that merges on GitHub brought into ${branchRef}`,
407
+ ...(unchangedIn && { unchangedIn }),
408
+ });
409
+ }
410
+ /**
411
+ * Whether the clone's saves (changes, deletions, new files) touch a path `from..to` changes: the
412
+ * clone can't move to `to` until they are committed, and that commit merges against `from`'s keys.
413
+ */
414
+ async function savesMeet(git, from, to) {
415
+ const saved = new Set((await git.status([...SHARED_REPO_STATUS_ARGS])).files.flatMap((f) => f.from ? [f.path, f.from] : [f.path]));
416
+ const changed = (await git.raw(['diff-tree', '-r', '-z', '--name-only', '--end-of-options', from, to]))
417
+ .split('\0')
418
+ .filter(Boolean);
419
+ return changed.some((file) => saved.has(file));
420
+ }
421
+ /** A move of the clone refused for saves it would overwrite, as git refuses a changed file. */
422
+ class SavesInTheWay extends Error {
423
+ }
424
+ /** Whether `err` is a move refused for the clone's saves: git's own refusal, or {@link moveClone}'s. */
425
+ const overwritesSaves = (err) => err instanceof SavesInTheWay || /would be overwritten/.test(getErrorMessage(err));
296
426
  /**
297
427
  * Fast-forward the clone to `to`. The caches' markers move after it, as for any bulk mutation, and
298
428
  * before it too: nothing repairs a marker lost after the move, as the next cycle sees a clone in
299
- * step, and the earlier bump leaves only a read racing the move able to stamp the old tree.
429
+ * step, and the earlier bump leaves only a read racing the move able to stamp the old tree. Git
430
+ * refuses to move over a saved change to a file the commits change, but writes a file deleted in
431
+ * the working tree back: an entry the editor deleted, or a renamed entry's old path. So a move over
432
+ * such a deletion is refused here alike ({@link SavesInTheWay}), until the deletion is submitted.
300
433
  */
301
434
  async function moveClone(git, branchPath, to) {
435
+ const deleted = (await git.status([...SHARED_REPO_STATUS_ARGS])).files.flatMap((f) => [
436
+ ...(f.working_dir === 'D' || f.index === 'D' ? [f.path] : []),
437
+ ...(f.from ? [f.from] : []),
438
+ ]);
439
+ if (deleted.some((file) => !isCanopyInternalPath(file))) {
440
+ const changed = new Set((await git.raw(['diff-tree', '-r', '-z', '--name-only', '--end-of-options', 'HEAD', to]))
441
+ .split('\0')
442
+ .filter(Boolean));
443
+ const lost = deleted.filter((file) => changed.has(file) && !isCanopyInternalPath(file));
444
+ if (lost.length > 0) {
445
+ throw new SavesInTheWay(`saved deletions would be brought back: ${listPaths(lost)}`);
446
+ }
447
+ }
302
448
  await invalidateBranchContentCaches(branchPath);
303
449
  await git.raw(['merge', '--ff-only', '--end-of-options', to]);
304
450
  await invalidateBranchContentCaches(branchPath);
305
451
  }
306
452
  /**
307
- * `other` merged into the branch: built, published to `remote.git`, queued for GitHub, and the
308
- * clone moved to it, in that order, so each step leaves what the next cycle picks up and only the
309
- * last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the base.
310
- * `stopOnConflict` leaves a branch in review as it is: nobody is at the editor to see what a kept
311
- * version replaced, and GitHub's own conflict notice stands on the pull request.
453
+ * `other` merged into the branch: built, recorded, published to `remote.git` and queued for GitHub
454
+ * unless it holds unsubmitted work (publish-decision.ts), and the clone moved to it, in that order,
455
+ * so each step leaves what the next cycle picks up and only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
456
+ * base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
457
+ * branch as it is and records nothing, and GitHub's own conflict notice stands on the pull request. A lock lost before the clone moves leaves it where it was: `wait`.
312
458
  */
313
459
  export async function mergeIntoBranch(ctx, args, merge) {
314
460
  const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
315
- const { head, other, message, fromGitHub, stopOnConflict } = merge;
316
- const built = await buildMergeCommit(git, branchPath, {
461
+ const { head, other, message, fromGitHub, inReview } = merge;
462
+ // GitHub's merges come in under the field merge's keys, as a fast-forward takes them in, for the
463
+ // entries the merge takes from GitHub whole: one the branch changed too merges by git's keys.
464
+ const bases = fromGitHub
465
+ ? (await git.raw(['merge-base', '--all', '--end-of-options', head, other]))
466
+ .split('\n')
467
+ .filter((line) => COMMIT_ID.test(line))
468
+ : [];
469
+ // With one merge base, as the field merge needs; none or several leave git's merge as it is.
470
+ const rekey = bases.length === 1 ? await rekeyOf(ctx, args, head, other, { head, since: bases[0] }) : null;
471
+ const into = rekey?.sha ?? other;
472
+ const plan = await planMerge(git, branchPath, {
317
473
  head,
318
- other,
319
- message,
474
+ other: into,
475
+ contentRoot: ctx.contentRoot,
320
476
  timeoutMs: ctx.taskTimeoutMs,
321
477
  });
322
- if (stopOnConflict && (built.kind === 'unsettled' || built.conflicted.length > 0)) {
478
+ // Nobody is at the editor of a branch in review to choose: a value or file kept over the other
479
+ // side's change would stand unseen in the pull request. Held before any commit is written, as a
480
+ // branch can stay held for days.
481
+ if (inReview &&
482
+ (plan.kind === 'unsettled' || plan.conflicts.length > 0 || plan.keptPaths.length > 0)) {
323
483
  workerLog(` ${branchDir}: conflicts with ${other.slice(0, 7)}, so it stays as it is`);
324
484
  return { kind: 'wait' };
325
485
  }
326
- if (built.kind === 'unsettled') {
486
+ if (plan.kind === 'unsettled') {
327
487
  return {
328
488
  kind: 'blocked',
329
- reason: `Git cannot merge ${other} into ${branchRef} by rule: ${built.conflicts.join(', ')}` +
330
- `${built.paths.length > 0 ? ` (${listPaths(built.paths)})` : ''}. Nothing was changed. ` +
489
+ reason: `Git cannot merge ${other} into ${branchRef} by rule: ${plan.conflicts.join(', ')}` +
490
+ `${plan.paths.length > 0 ? ` (${listPaths(plan.paths)})` : ''}. Nothing was changed. ` +
331
491
  `To continue, a developer merges them by hand.`,
332
492
  };
333
493
  }
334
- const { sha, conflicted } = built;
494
+ const merged = await commitMerge(git, plan, { head, other: into, message });
495
+ // The re-key's remaps cover entries the branch left alone, the merge's those both changed.
496
+ const built = rekey ? { ...merged, keyRemaps: [...rekey.keyRemaps, ...merged.keyRemaps] } : merged;
497
+ const { sha, conflicts } = built;
335
498
  const refused = await refuseIncoming(args, head, sha);
336
499
  if (refused)
337
500
  return refused;
338
501
  if (lockLost())
339
502
  return { kind: 'wait' };
340
- if (fromGitHub && (await changesWhatWasReviewed(ctx, args, head, sha))) {
503
+ // A field merge of the base settles what git could not, which no reviewer has seen either.
504
+ // Approving waits for the content-write lock the caller holds, so none lands after this check.
505
+ if (await changesWhatWasReviewed(ctx, args, head, sha))
341
506
  await returnToReview(ctx, args);
342
- }
343
507
  const published = await readPublishedSha(ctx, branchRef);
344
508
  const unpublishable = `This deployment's repository holds commits for ${branchRef} that neither this clone nor ` +
345
509
  `GitHub has, so a merge of the two cannot be published. Nothing was changed. Compare the ` +
@@ -348,29 +512,49 @@ export async function mergeIntoBranch(ctx, args, merge) {
348
512
  return { kind: 'blocked', reason: unpublishable };
349
513
  }
350
514
  // Recorded before the merge is visible anywhere: the next cycle only fast-forwards to it, and
351
- // the conflicts cannot be read back from the merge.
352
- if (conflicted.length > 0)
353
- await recordKeptVersions(ctx, args, conflicted, fromGitHub);
515
+ // the conflicts cannot be read back from the merge. A merge that never lands leaves records the
516
+ // next cycle's reconcile turns back.
517
+ await recordMerge(ctx, args, built, { head, ...(fromGitHub && { also: other }), fromGitHub });
518
+ if (lockLost())
519
+ return { kind: 'wait' };
354
520
  // Before `remote.git` holds it, as reconcile does: a crash past this point leaves GitHub's commit
355
521
  // in `remote.git`, and a removal then has to read as one (`rewrittenFrom`).
356
522
  if (fromGitHub)
357
523
  await recordSeenOnGitHub(ctx, branchRef, other);
358
- if (published !== null) {
524
+ const publish = await decidePublish(git, { head, published });
525
+ if (publish.kind === 'publish') {
359
526
  if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha))) {
360
527
  return { kind: 'blocked', reason: unpublishable };
361
528
  }
362
529
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
363
530
  }
364
- if (conflicted.length > 0 && fromGitHub) {
365
- await enqueueReviewBlockSync(ctx, branchRef, args.metaFile);
531
+ else if (publish.kind === 'hold') {
532
+ workerLog(` ${branchDir}: holds work not yet submitted, so the merge stays unpublished`);
366
533
  }
367
- await moveClone(git, branchPath, sha);
534
+ // Published and not yet in the clone: the next cycle brings it in, by `bringInGitHubCommits` or
535
+ // `catchUpWithPublished`.
536
+ if (lockLost())
537
+ return { kind: 'wait' };
538
+ try {
539
+ await moveClone(git, branchPath, sha);
540
+ }
541
+ catch (err) {
542
+ if (!overwritesSaves(err))
543
+ throw err;
544
+ // Callers move a clean tree. Published, the next cycle catches up with it once the saves are
545
+ // committed; held, reconcile turns it back.
546
+ workerLog(` ${branchDir}: local changes overlap the merge, which waits for a submit`);
547
+ return { kind: 'wait' };
548
+ }
549
+ // Landed: a failure here leaves the remaps and the stamp for the next cycle's reconcile.
550
+ await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
368
551
  if (fromGitHub)
369
552
  await recordGitHubTip(ctx, args, other);
370
- workerLog(conflicted.length > 0
371
- ? ` ${branchDir}: merged ${other.slice(0, 7)} (kept this branch's version of ${conflicted.length} file(s))`
553
+ const keptEntries = new Set(conflicts.map(({ contentId }) => contentId)).size;
554
+ workerLog(keptEntries > 0
555
+ ? ` ${branchDir}: merged ${other.slice(0, 7)} (${keptEntries} entr${keptEntries === 1 ? 'y' : 'ies'} with values for a person to choose: ${listPaths(conflicts.map(({ path }) => path))})`
372
556
  : ` ${branchDir}: merged ${other.slice(0, 7)}`);
373
- return { kind: 'merged' };
557
+ return { kind: 'merged', keptEntries };
374
558
  }
375
559
  /**
376
560
  * Whether this clone holds `sha`, on git's word for it. A killed `git` answers nothing at all,
@@ -396,55 +580,263 @@ async function holdsCommit(git, sha) {
396
580
  * failure throws, since it says nothing about either commit, and the caller's catch retries next
397
581
  * cycle: read as no, it would report a rewrite that never happened.
398
582
  */
399
- async function contains(git, commit, ancestor) {
583
+ export async function contains(git, commit, ancestor) {
400
584
  if (!(await holdsCommit(git, commit)) || !(await holdsCommit(git, ancestor)))
401
585
  return false;
402
586
  return (await commitsApart(git, ancestor, commit)).onlyA === 0;
403
587
  }
404
588
  /**
405
- * Whether moving an approved branch from `from` to `to` changes what was approved: yes unless `to`
406
- * is exactly `from` with the base up to where `to` has it, which leaves the pull request's diff as
407
- * it was reviewed. A hand-made change inside a merge commit therefore counts. Read fresh, so an
408
- * approval given since the cycle's start counts too.
589
+ * Whether moving an approved branch to `to` changes what was approved: yes unless `to` is exactly
590
+ * the approved commit with the base up to where `to` has it, which leaves the pull request's diff
591
+ * as it was reviewed. The approved commit is the one the approval recorded, so clean merges since
592
+ * it are judged together; `from`, the head before this move, stands in for an approval that
593
+ * recorded none. A recorded commit this clone doesn't hold counts as a change, as does a
594
+ * hand-made change inside a merge commit. Read fresh, so an approval given since the cycle's
595
+ * start counts too.
409
596
  */
410
597
  async function changesWhatWasReviewed(ctx, { git, branchPath }, from, to) {
411
598
  const fresh = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
412
599
  if (fresh?.status !== 'approved')
413
600
  return false;
601
+ const approved = fresh.review?.commit ?? from;
602
+ if (!(await holdsCommit(git, approved)))
603
+ return true;
414
604
  await fetchFromRemoteGit(git, ctx.remoteGitPath, fresh.baseBranch ?? ctx.baseBranch);
415
605
  const base = (await git.revparse(['FETCH_HEAD'])).trim();
416
606
  const forkPoint = (await git.raw(['merge-base', '--end-of-options', to, base])).trim();
417
- return forkPoint === '' || !(await isCleanMergeOf(git, to, from, forkPoint));
607
+ return forkPoint === '' || !(await isCleanMergeOf(git, to, approved, forkPoint, ctx.contentRoot));
418
608
  }
419
609
  /**
420
- * Send an approved branch back to review and clear the verdict from the PR body. Before the clone
421
- * moves, and a failed save throws, so a crash or a failure leaves the commits for the next cycle
422
- * to judge again rather than an approval standing over them.
610
+ * Send an approved branch back to review and clear the verdict from the PR body. Called before the
611
+ * clone moves, where a failed save throws, so a crash or a failure leaves the commits for the next
612
+ * cycle to judge again rather than an approval standing over them.
423
613
  */
424
614
  export async function returnToReview(ctx, { branchPath, branchDir, branchRef, metaFile }) {
425
615
  const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { status: 'submitted', review: undefined } }, (current) => current?.branch.name === branchRef && current.branch.status === 'approved');
426
616
  if (saved === null)
427
617
  return;
428
- workerLog(` ${branchDir}: back in review, as commits from GitHub changed what was approved`);
618
+ workerLog(` ${branchDir}: back in review, as what was approved changed`);
429
619
  await enqueueReviewBlockSync(ctx, branchRef, metaFile);
430
620
  }
431
- /** Record what a merge kept, for the editor and, for GitHub's commits, the PR body. */
432
- async function recordKeptVersions(ctx, { branchPath, branchRef }, conflicted, fromGitHub) {
433
- const ids = conflictFilesToContentIds(conflicted, ctx.contentRoot);
621
+ /**
622
+ * Record what a merge left for a person in merge-conflicts.json, with the item keys it changed for
623
+ * the branch's comment threads to take once it lands and the merge as `landing`, and the content
624
+ * IDs it names in `branch.json` `conflictFiles`. The record is first settled against `head`, which
625
+ * the merge builds on, so a record of a merge that never landed can't carry into this one. Before
626
+ * the merge is visible: one that never lands leaves records the next
627
+ * {@link reconcileMergeConflicts} turns back. A merge `also` holds, GitHub's tip the merge takes in,
628
+ * is landing rather than gone. An earlier landing either commit holds is published, and this one
629
+ * replaces it: what it kept over GitHub's commits is noted for the pull request body now.
630
+ */
631
+ async function recordMerge(ctx, args, built, merge) {
632
+ const { git, branchPath, branchRef } = args;
633
+ const { sha, conflicts, keptPaths, keyRemaps, paths } = built;
634
+ const inHead = await settleRecord(git, branchPath, merge.head, merge.also);
635
+ const earlier = (await new MergeConflictStore(branchPath).read())?.landing;
636
+ if (earlier && earlier.mergeSha !== sha && inHead(earlier.mergeSha)) {
637
+ await noteKeptOverGitHub(ctx, args, earlier.keptOverGitHub ?? []);
638
+ }
639
+ const state = await new MergeConflictStore(branchPath).update((current) => ({
640
+ entries: withFoundConflicts(withRemappedItemKeys(current ? withoutMergesGone(current.entries, inHead) : {}, keyRemaps, sha), conflicts, sha, paths),
641
+ remaps: [
642
+ ...(current?.remaps ?? []),
643
+ ...keyRemaps.map(({ entryPath, remaps }) => ({ mergeSha: sha, entryPath, remaps })),
644
+ ],
645
+ landing: {
646
+ mergeSha: sha,
647
+ ...(merge.fromGitHub && keptPaths.length > 0 && { keptOverGitHub: keptPaths }),
648
+ },
649
+ }));
650
+ if (state === null)
651
+ return;
652
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef ? { branch: conflictFilesOf(state.entries) } : null);
653
+ }
654
+ /**
655
+ * After the clone moved to `sha`, the merge being landed: the remaps of every merge it holds apply,
656
+ * in the order recorded, the paths it kept over GitHub's commits go in `keptOverGitHub` for the PR
657
+ * body, and it is no longer landing. A remap that fails waits for the next cycle, and keeps nothing
658
+ * else from being noted.
659
+ */
660
+ async function landed(ctx, { git, branchPath, branchRef, branchDir, metaFile, }, sha) {
661
+ const store = new MergeConflictStore(branchPath);
662
+ const current = await store.read();
663
+ if (current === null)
664
+ return;
665
+ const [inSha] = await mergesHeld(git, current, [sha]);
666
+ await applyRemaps(branchPath, (mergeSha) => inSha.has(mergeSha)).catch((err) => workerLogWarn(` ${branchDir}: comment threads keep their item keys for now: ${getErrorMessage(err)}`));
667
+ const landing = current.landing;
668
+ if (landing?.mergeSha !== sha)
669
+ return;
670
+ await noteKeptOverGitHub(ctx, { branchPath, branchRef, metaFile }, landing.keptOverGitHub ?? []);
671
+ await store.update((now) => {
672
+ if (now?.landing?.mergeSha !== sha)
673
+ return null;
674
+ const { landing: _done, ...rest } = now;
675
+ return rest;
676
+ });
677
+ }
678
+ /** `kept`, paths a merge kept over GitHub's commits, in `keptOverGitHub` for the PR body. */
679
+ async function noteKeptOverGitHub(ctx, { branchPath, branchRef, metaFile, }, kept) {
680
+ if (kept.length === 0)
681
+ return;
434
682
  await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
435
683
  ? {
436
684
  branch: {
437
- ...recordConflicts(existing.branch.conflictFiles, ids),
438
- ...(fromGitHub
439
- ? {
440
- keptOverGitHub: [
441
- ...new Set([...(existing.branch.keptOverGitHub ?? []), ...conflicted]),
442
- ],
443
- }
444
- : {}),
685
+ keptOverGitHub: [...new Set([...(existing.branch.keptOverGitHub ?? []), ...kept])],
445
686
  },
446
687
  }
447
688
  : null);
689
+ await enqueueReviewBlockSync(ctx, branchRef, metaFile);
690
+ }
691
+ /** Every merge a record names, its earlier versions' included. */
692
+ function mergesNamedBy(entry) {
693
+ return [
694
+ entry.mergeSha,
695
+ ...(entry.fields ?? []).map((f) => f.mergeSha),
696
+ ...(entry.previous ? mergesNamedBy(entry.previous) : []),
697
+ ];
698
+ }
699
+ /** The merges merge-conflicts.json names, and which of them each of `tips` holds. */
700
+ async function mergesHeld(git, state, tips) {
701
+ const shas = new Set([
702
+ ...Object.values(state.entries).flatMap(mergesNamedBy),
703
+ ...(state.remaps ?? []).map(({ mergeSha }) => mergeSha),
704
+ ...(state.landing ? [state.landing.mergeSha] : []),
705
+ ]);
706
+ const held = tips.map(() => new Set());
707
+ for (const sha of shas) {
708
+ for (const [n, tip] of tips.entries()) {
709
+ if (await contains(git, tip, sha))
710
+ held[n].add(sha);
711
+ }
712
+ }
713
+ return held;
714
+ }
715
+ /**
716
+ * The record as `head` has it, or `also` with it: comment threads take the item keys of merges
717
+ * `head` holds, the remaps of merges neither holds go, and records changed by merges neither holds
718
+ * give back the ones they replaced. Resolves whether either holds a merge the record named then; a
719
+ * merge recorded since counts as held, as nothing settled it.
720
+ */
721
+ async function settleRecord(git, branchPath, head, also) {
722
+ const store = new MergeConflictStore(branchPath);
723
+ const current = await store.read();
724
+ if (current === null)
725
+ return () => true;
726
+ const [held, alsoHeld] = await mergesHeld(git, current, also ? [head, also] : [head]);
727
+ const known = new Set([
728
+ ...Object.values(current.entries).flatMap(mergesNamedBy),
729
+ ...(current.remaps ?? []).map(({ mergeSha }) => mergeSha),
730
+ ]);
731
+ const inHead = (sha) => held.has(sha) || !known.has(sha);
732
+ const kept = (sha) => inHead(sha) || (alsoHeld?.has(sha) ?? false);
733
+ // Threads move only to merges the clone holds; the rest wait, unless neither commit holds them.
734
+ await applyRemaps(branchPath, inHead, (sha) => !kept(sha)).catch((err) => workerLogWarn(` comment threads keep their item keys for now: ${getErrorMessage(err)}`));
735
+ return kept;
736
+ }
737
+ /**
738
+ * Point the branch's comment threads at the item keys the merges in `landed` gave their items, in
739
+ * the order they were recorded, then drop those remaps from the record, and those `gone` names
740
+ * unapplied.
741
+ */
742
+ async function applyRemaps(branchPath, landed, gone = () => false) {
743
+ const store = new MergeConflictStore(branchPath);
744
+ const pending = (await store.read())?.remaps ?? [];
745
+ const due = pending.filter(({ mergeSha }) => landed(mergeSha));
746
+ if (due.length === 0 && !pending.some(({ mergeSha }) => gone(mergeSha)))
747
+ return;
748
+ const comments = new CommentStore(branchPath);
749
+ for (const { entryPath, remaps } of due)
750
+ await comments.remapItemKeys(entryPath, remaps);
751
+ await store.update((current) => current && {
752
+ ...current,
753
+ remaps: (current.remaps ?? []).filter(({ mergeSha }) => !landed(mergeSha) && !gone(mergeSha)),
754
+ });
755
+ }
756
+ /** `branch.json` `conflictFiles` as `entries` has them, written only when the two differ. */
757
+ async function followRecord(ctx, branchPath, branchDir, branchRef, entries) {
758
+ const { conflictFiles, conflictStatus } = conflictFilesOf(entries);
759
+ const same = (ids) => JSON.stringify([...(ids ?? [])].sort()) === JSON.stringify(conflictFiles);
760
+ // Read first: the locked update costs every branch a lock each cycle, and nearly all agree.
761
+ const read = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
762
+ if (read === undefined || (branchRef !== null && read.name !== branchRef))
763
+ return;
764
+ if (same(read.conflictFiles))
765
+ return;
766
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing !== null &&
767
+ (branchRef === null || existing.branch.name === branchRef) &&
768
+ !same(existing.branch.conflictFiles)
769
+ ? { branch: { conflictFiles, conflictStatus } }
770
+ : null, { keepUpdatedAt: true });
771
+ if (saved)
772
+ workerLog(` ${branchDir}: ${conflictFiles.length} entries with conflicts on record`);
773
+ }
774
+ /**
775
+ * Bring merge-conflicts.json in line with the branch's history: a record changed by a merge the
776
+ * branch never got gives back the one it replaced (`withoutMergesGone`), and `branch.json`
777
+ * `conflictFiles` follows the record when the two differ. A merge counts as held while the clone or
778
+ * `remote.git` holds it, since a clone behind its published merge catches up with it; comment
779
+ * threads take a merge's item keys only once the clone holds it. While either can't be read in the
780
+ * clone, nothing is judged. A record that does not parse is moved aside, so the branch's merges
781
+ * carry on with a new one. Each cycle, before the branch takes anything in. A clone with no record
782
+ * has no conflict open, so `conflictFiles` written before the record existed clears. Never throws.
783
+ */
784
+ export async function reconcileMergeConflicts(ctx, { git, branchPath, branchDir, branchRef, }) {
785
+ try {
786
+ const store = new MergeConflictStore(branchPath);
787
+ let current;
788
+ try {
789
+ current = await store.read();
790
+ }
791
+ catch (err) {
792
+ if (!(err instanceof MergeConflictsFileMalformedError))
793
+ throw err;
794
+ const aside = await store.quarantine();
795
+ if (aside !== null) {
796
+ workerLogWarn(` ${branchDir}: its conflict record does not parse; copied it to ${aside}`);
797
+ }
798
+ // The held record, or one another writer left in its place.
799
+ current = await store.read();
800
+ }
801
+ if (current === null) {
802
+ await followRecord(ctx, branchPath, branchDir, branchRef, {});
803
+ return;
804
+ }
805
+ const published = branchRef === null ? null : await readPublishedSha(ctx, branchRef);
806
+ if (published !== null && !(await holdsCommit(git, published))) {
807
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
808
+ }
809
+ const tips = ['HEAD', ...(published === null ? [] : [published])];
810
+ for (const tip of tips)
811
+ if (!(await holdsCommit(git, tip)))
812
+ return;
813
+ const [inHead, inPublished = new Set()] = await mergesHeld(git, current, tips);
814
+ const held = (sha) => inHead.has(sha) || inPublished.has(sha);
815
+ const entries = (await store.update((now) => {
816
+ if (now === null)
817
+ return null;
818
+ const kept = withoutMergesGone(now.entries, held);
819
+ const { landing, ...rest } = now;
820
+ // A clone catches up only with a merge `remote.git` holds; one it holds lands below.
821
+ // Without `remote.git`'s head to read, a published merge can't be told from a lost one.
822
+ const stays = landing !== undefined && (branchRef === null || held(landing.mergeSha));
823
+ if (JSON.stringify(kept) === JSON.stringify(now.entries) && (stays || !landing)) {
824
+ return null;
825
+ }
826
+ return { ...rest, entries: kept, ...(stays && { landing }) };
827
+ }))?.entries ?? current.entries;
828
+ await followRecord(ctx, branchPath, branchDir, branchRef, entries);
829
+ await applyRemaps(branchPath, (sha) => inHead.has(sha), (sha) => !held(sha));
830
+ if (current.landing && inHead.has(current.landing.mergeSha) && branchRef !== null) {
831
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
832
+ if (metaFile) {
833
+ await landed(ctx, { git, branchPath, branchRef, branchDir, metaFile }, current.landing.mergeSha);
834
+ }
835
+ }
836
+ }
837
+ catch (err) {
838
+ workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
839
+ }
448
840
  }
449
841
  /**
450
842
  * Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
@@ -0,0 +1,15 @@
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 declare function getHttpStatus(err: unknown): number | null;
7
+ /**
8
+ * Whether a 403 is a GitHub rate-limit response rather than a plain permission
9
+ * denial. GitHub signals rate limiting on 403s three ways: the primary limit
10
+ * zeroes `x-ratelimit-remaining`, secondary limits often carry `retry-after`,
11
+ * and both produce a message containing "rate limit".
12
+ */
13
+ export declare function isRateLimitSignal403(err: unknown): boolean;
14
+ /** A 403 that is GitHub denying the credential the operation, not a rate limit. */
15
+ export declare function isPermissionDenial(err: unknown): boolean;