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
@@ -9,11 +9,12 @@ import { getErrorMessage } from '../utils/error.js';
9
9
  import { escapeForLog } from '../utils/escape-text.js';
10
10
  import { isCanopyInternalPath } from '../utils/git.js';
11
11
  import { publishesBranch } from './branch-policy.js';
12
- import { commitMerge, commitsApart, isCleanMergeOf, planMerge, publishPlainToLocalRemote, } from './branch-merge.js';
12
+ import { buildRekey, commitMerge, commitsApart, isCleanMergeOf, planMerge, publishPlainToLocalRemote, } from './branch-merge.js';
13
13
  import { MAX_REPORTED_PATHS, RETIRED_SCHEMA_CACHE_PATH, TRACKED_CANOPY_STATE_FIX, } from './canopy-state.js';
14
14
  import { enqueueGitHubPush, readPublishedSha, recordSeenOnGitHub } from './history-rewrite.js';
15
15
  import { workerLog, workerLogWarn } from './log.js';
16
- 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';
17
18
  export const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
18
19
  const MAX_LISTED_PATH_LENGTH = 200;
19
20
  /** Why a push was refused while a rewrite stands (task-runner.ts); editors read it as a failed sync. */
@@ -63,7 +64,10 @@ export async function catchUpWithPublished(ctx, args) {
63
64
  const parents = (await git.raw(['rev-list', '--parents', '-n', '1', '--end-of-options', published]))
64
65
  .trim()
65
66
  .split(' ');
66
- if (parents.length !== 3 || parents[1] !== head)
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)
67
71
  return null;
68
72
  return fastForward(ctx, args, head, published);
69
73
  }
@@ -124,6 +128,12 @@ export async function bringInGitHubCommits(ctx, args) {
124
128
  // below builds on remote.git's head whenever it can.
125
129
  if (published !== null && published !== head) {
126
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
+ }
127
137
  const stopped = await fastForward(ctx, args, head, published);
128
138
  if (stopped)
129
139
  return stopped;
@@ -316,7 +326,7 @@ async function fastForward(ctx, args, from, to) {
316
326
  await moveClone(git, branchPath, to);
317
327
  }
318
328
  catch (err) {
319
- if (!/would be overwritten/.test(getErrorMessage(err)))
329
+ if (!overwritesSaves(err))
320
330
  throw err;
321
331
  workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
322
332
  return { kind: 'wait' };
@@ -329,29 +339,139 @@ async function fastForward(ctx, args, from, to) {
329
339
  }
330
340
  return null;
331
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));
332
426
  /**
333
427
  * Fast-forward the clone to `to`. The caches' markers move after it, as for any bulk mutation, and
334
428
  * before it too: nothing repairs a marker lost after the move, as the next cycle sees a clone in
335
- * 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.
336
433
  */
337
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
+ }
338
448
  await invalidateBranchContentCaches(branchPath);
339
449
  await git.raw(['merge', '--ff-only', '--end-of-options', to]);
340
450
  await invalidateBranchContentCaches(branchPath);
341
451
  }
342
452
  /**
343
- * `other` merged into the branch: built, recorded, published to `remote.git`, queued for GitHub,
344
- * and the clone moved to it, in that order, so each step leaves what the next cycle picks up and
345
- * only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
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
346
456
  * base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
347
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`.
348
458
  */
349
459
  export async function mergeIntoBranch(ctx, args, merge) {
350
460
  const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
351
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;
352
472
  const plan = await planMerge(git, branchPath, {
353
473
  head,
354
- other,
474
+ other: into,
355
475
  contentRoot: ctx.contentRoot,
356
476
  timeoutMs: ctx.taskTimeoutMs,
357
477
  });
@@ -371,7 +491,9 @@ export async function mergeIntoBranch(ctx, args, merge) {
371
491
  `To continue, a developer merges them by hand.`,
372
492
  };
373
493
  }
374
- const built = await commitMerge(git, plan, { head, other, message });
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;
375
497
  const { sha, conflicts } = built;
376
498
  const refused = await refuseIncoming(args, head, sha);
377
499
  if (refused)
@@ -392,24 +514,38 @@ export async function mergeIntoBranch(ctx, args, merge) {
392
514
  // Recorded before the merge is visible anywhere: the next cycle only fast-forwards to it, and
393
515
  // the conflicts cannot be read back from the merge. A merge that never lands leaves records the
394
516
  // next cycle's reconcile turns back.
395
- await recordMerge(ctx, args, built, { head, fromGitHub });
517
+ await recordMerge(ctx, args, built, { head, ...(fromGitHub && { also: other }), fromGitHub });
396
518
  if (lockLost())
397
519
  return { kind: 'wait' };
398
520
  // Before `remote.git` holds it, as reconcile does: a crash past this point leaves GitHub's commit
399
521
  // in `remote.git`, and a removal then has to read as one (`rewrittenFrom`).
400
522
  if (fromGitHub)
401
523
  await recordSeenOnGitHub(ctx, branchRef, other);
402
- if (published !== null) {
524
+ const publish = await decidePublish(git, { head, published });
525
+ if (publish.kind === 'publish') {
403
526
  if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha))) {
404
527
  return { kind: 'blocked', reason: unpublishable };
405
528
  }
406
529
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
407
530
  }
531
+ else if (publish.kind === 'hold') {
532
+ workerLog(` ${branchDir}: holds work not yet submitted, so the merge stays unpublished`);
533
+ }
408
534
  // Published and not yet in the clone: the next cycle brings it in, by `bringInGitHubCommits` or
409
535
  // `catchUpWithPublished`.
410
536
  if (lockLost())
411
537
  return { kind: 'wait' };
412
- await moveClone(git, branchPath, sha);
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
+ }
413
549
  // Landed: a failure here leaves the remaps and the stamp for the next cycle's reconcile.
414
550
  await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
415
551
  if (fromGitHub)
@@ -450,19 +586,25 @@ export async function contains(git, commit, ancestor) {
450
586
  return (await commitsApart(git, ancestor, commit)).onlyA === 0;
451
587
  }
452
588
  /**
453
- * Whether moving an approved branch from `from` to `to` changes what was approved: yes unless `to`
454
- * is exactly `from` with the base up to where `to` has it, which leaves the pull request's diff as
455
- * it was reviewed. A hand-made change inside a merge commit therefore counts. Read fresh, so an
456
- * 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.
457
596
  */
458
597
  async function changesWhatWasReviewed(ctx, { git, branchPath }, from, to) {
459
598
  const fresh = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
460
599
  if (fresh?.status !== 'approved')
461
600
  return false;
601
+ const approved = fresh.review?.commit ?? from;
602
+ if (!(await holdsCommit(git, approved)))
603
+ return true;
462
604
  await fetchFromRemoteGit(git, ctx.remoteGitPath, fresh.baseBranch ?? ctx.baseBranch);
463
605
  const base = (await git.revparse(['FETCH_HEAD'])).trim();
464
606
  const forkPoint = (await git.raw(['merge-base', '--end-of-options', to, base])).trim();
465
- return forkPoint === '' || !(await isCleanMergeOf(git, to, from, forkPoint));
607
+ return forkPoint === '' || !(await isCleanMergeOf(git, to, approved, forkPoint, ctx.contentRoot));
466
608
  }
467
609
  /**
468
610
  * Send an approved branch back to review and clear the verdict from the PR body. Called before the
@@ -482,12 +624,18 @@ export async function returnToReview(ctx, { branchPath, branchDir, branchRef, me
482
624
  * IDs it names in `branch.json` `conflictFiles`. The record is first settled against `head`, which
483
625
  * the merge builds on, so a record of a merge that never landed can't carry into this one. Before
484
626
  * the merge is visible: one that never lands leaves records the next
485
- * {@link reconcileMergeConflicts} turns back.
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.
486
630
  */
487
631
  async function recordMerge(ctx, args, built, merge) {
488
632
  const { git, branchPath, branchRef } = args;
489
633
  const { sha, conflicts, keptPaths, keyRemaps, paths } = built;
490
- const inHead = await settleRecord(git, branchPath, merge.head);
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
+ }
491
639
  const state = await new MergeConflictStore(branchPath).update((current) => ({
492
640
  entries: withFoundConflicts(withRemappedItemKeys(current ? withoutMergesGone(current.entries, inHead) : {}, keyRemaps, sha), conflicts, sha, paths),
493
641
  remaps: [
@@ -519,17 +667,7 @@ async function landed(ctx, { git, branchPath, branchRef, branchDir, metaFile, },
519
667
  const landing = current.landing;
520
668
  if (landing?.mergeSha !== sha)
521
669
  return;
522
- const kept = landing.keptOverGitHub ?? [];
523
- if (kept.length > 0) {
524
- await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
525
- ? {
526
- branch: {
527
- keptOverGitHub: [...new Set([...(existing.branch.keptOverGitHub ?? []), ...kept])],
528
- },
529
- }
530
- : null);
531
- await enqueueReviewBlockSync(ctx, branchRef, metaFile);
532
- }
670
+ await noteKeptOverGitHub(ctx, { branchPath, branchRef, metaFile }, landing.keptOverGitHub ?? []);
533
671
  await store.update((now) => {
534
672
  if (now?.landing?.mergeSha !== sha)
535
673
  return null;
@@ -537,6 +675,19 @@ async function landed(ctx, { git, branchPath, branchRef, branchDir, metaFile, },
537
675
  return rest;
538
676
  });
539
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;
682
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
683
+ ? {
684
+ branch: {
685
+ keptOverGitHub: [...new Set([...(existing.branch.keptOverGitHub ?? []), ...kept])],
686
+ },
687
+ }
688
+ : null);
689
+ await enqueueReviewBlockSync(ctx, branchRef, metaFile);
690
+ }
540
691
  /** Every merge a record names, its earlier versions' included. */
541
692
  function mergesNamedBy(entry) {
542
693
  return [
@@ -562,25 +713,26 @@ async function mergesHeld(git, state, tips) {
562
713
  return held;
563
714
  }
564
715
  /**
565
- * The record as `head` has it: comment threads take the item keys of merges `head` holds, other
566
- * merges' remaps go, and records changed by merges it lacks give back the ones they replaced.
567
- * Resolves whether `head` holds a merge the record named then; a merge recorded since counts as
568
- * held, as nothing settled it.
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.
569
720
  */
570
- async function settleRecord(git, branchPath, head) {
721
+ async function settleRecord(git, branchPath, head, also) {
571
722
  const store = new MergeConflictStore(branchPath);
572
723
  const current = await store.read();
573
724
  if (current === null)
574
725
  return () => true;
575
- const [held] = await mergesHeld(git, current, [head]);
726
+ const [held, alsoHeld] = await mergesHeld(git, current, also ? [head, also] : [head]);
576
727
  const known = new Set([
577
728
  ...Object.values(current.entries).flatMap(mergesNamedBy),
578
729
  ...(current.remaps ?? []).map(({ mergeSha }) => mergeSha),
579
730
  ]);
580
731
  const inHead = (sha) => held.has(sha) || !known.has(sha);
581
- // The threads can wait: the remaps stay for the next cycle.
582
- await applyRemaps(branchPath, inHead, (sha) => !inHead(sha)).catch((err) => workerLogWarn(` comment threads keep their item keys for now: ${getErrorMessage(err)}`));
583
- return inHead;
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;
584
736
  }
585
737
  /**
586
738
  * Point the branch's comment threads at the item keys the merges in `landed` gave their items, in
@@ -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;
@@ -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
+ }
@@ -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 {};
@@ -824,6 +828,9 @@ class GitHubGatewayClient {
824
828
  async convertPullRequestToDraft(number, signal) {
825
829
  await this.call('convertPullRequestToDraft', { number }, signal);
826
830
  }
831
+ async setConflictsStatus(status, signal) {
832
+ await this.call('setConflictsStatus', status, signal);
833
+ }
827
834
  async deleteBranch(branch, signal) {
828
835
  await this.call('deleteBranch', { branch }, signal);
829
836
  }
@@ -119,6 +119,25 @@ export declare const requests: {
119
119
  }, {
120
120
  number: number;
121
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
+ }>;
122
141
  readonly deleteBranch: z.ZodObject<{
123
142
  branch: z.ZodString;
124
143
  }, "strict", z.ZodTypeAny, {
@@ -146,15 +165,15 @@ export declare const answers: {
146
165
  }, "strict", z.ZodTypeAny, {
147
166
  settingsBranch: string | null;
148
167
  namespace: string;
149
- legacy: string[];
150
168
  configured: string[];
151
169
  githubDefault: string | null;
170
+ legacy: string[];
152
171
  }, {
153
172
  settingsBranch: string | null;
154
173
  namespace: string;
155
- legacy: string[];
156
174
  configured: string[];
157
175
  githubDefault: string | null;
176
+ legacy: string[];
158
177
  }>;
159
178
  }, "strict", z.ZodTypeAny, {
160
179
  ready: boolean;
@@ -164,9 +183,9 @@ export declare const answers: {
164
183
  branchPolicy: {
165
184
  settingsBranch: string | null;
166
185
  namespace: string;
167
- legacy: string[];
168
186
  configured: string[];
169
187
  githubDefault: string | null;
188
+ legacy: string[];
170
189
  };
171
190
  }, {
172
191
  ready: boolean;
@@ -176,9 +195,9 @@ export declare const answers: {
176
195
  branchPolicy: {
177
196
  settingsBranch: string | null;
178
197
  namespace: string;
179
- legacy: string[];
180
198
  configured: string[];
181
199
  githubDefault: string | null;
200
+ legacy: string[];
182
201
  };
183
202
  }>;
184
203
  readonly defaultBranch: z.ZodObject<{
@@ -249,6 +268,7 @@ export declare const answers: {
249
268
  url: string;
250
269
  }>;
251
270
  readonly convertPullRequestToDraft: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
271
+ readonly setConflictsStatus: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
252
272
  readonly deleteBranch: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
253
273
  readonly authSnapshot: z.ZodObject<{
254
274
  users: z.ZodArray<z.ZodObject<{
@@ -432,24 +452,24 @@ export declare function readAnswerBody(json: unknown): {
432
452
  declare const errorEnvelope: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
433
453
  kind: z.ZodLiteral<"refusal">;
434
454
  push: z.ZodBoolean;
435
- operation: z.ZodEnum<["push", "delete", "pull-request"]>;
455
+ operation: z.ZodEnum<["push", "delete", "pull-request", "commit-status"]>;
436
456
  branch: z.ZodString;
437
- 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"]>;
438
458
  message: z.ZodString;
439
459
  }, "strict", z.ZodTypeAny, {
440
460
  branch: string;
441
461
  push: boolean;
442
462
  message: string;
443
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
463
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long" | "not-head";
444
464
  kind: "refusal";
445
- operation: "push" | "delete" | "pull-request";
465
+ operation: "push" | "delete" | "pull-request" | "commit-status";
446
466
  }, {
447
467
  branch: string;
448
468
  push: boolean;
449
469
  message: string;
450
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
470
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long" | "not-head";
451
471
  kind: "refusal";
452
- operation: "push" | "delete" | "pull-request";
472
+ operation: "push" | "delete" | "pull-request" | "commit-status";
453
473
  }>, z.ZodObject<{
454
474
  kind: z.ZodLiteral<"push">;
455
475
  pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease", "seen-commit-gone"]>;
@@ -77,6 +77,15 @@ export const requests = {
77
77
  })
78
78
  .strict(),
79
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(),
80
89
  deleteBranch: z.object({ branch: name }).strict(),
81
90
  authSnapshot: z.object({}).strict(),
82
91
  termination: z.object({}).strict(),
@@ -121,6 +130,7 @@ export const answers = {
121
130
  .strict(),
122
131
  createOrUpdatePullRequest: pullRequestRef.extend({ created: z.boolean() }).strict(),
123
132
  convertPullRequestToDraft: empty,
133
+ setConflictsStatus: empty,
124
134
  deleteBranch: empty,
125
135
  authSnapshot: z
126
136
  .object({
@@ -168,6 +178,7 @@ export const JSON_ROUTES = {
168
178
  getPullRequest: `${WIRE_PREFIX}/pull-request/get`,
169
179
  createOrUpdatePullRequest: `${WIRE_PREFIX}/pull-request/create-or-update`,
170
180
  convertPullRequestToDraft: `${WIRE_PREFIX}/pull-request/convert-to-draft`,
181
+ setConflictsStatus: `${WIRE_PREFIX}/pull-request/conflicts-status`,
171
182
  deleteBranch: `${WIRE_PREFIX}/branch/delete`,
172
183
  authSnapshot: `${WIRE_PREFIX}/auth-snapshot`,
173
184
  termination: `${WIRE_PREFIX}/termination`,
@@ -240,7 +251,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
240
251
  .object({
241
252
  kind: z.literal('refusal'),
242
253
  push: z.boolean(),
243
- operation: z.enum(['push', 'delete', 'pull-request']),
254
+ operation: z.enum(['push', 'delete', 'pull-request', 'commit-status']),
244
255
  branch: name,
245
256
  reason: z.enum([
246
257
  'invalid',
@@ -250,6 +261,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
250
261
  'outside-namespace',
251
262
  'not-a-commit',
252
263
  'too-long',
264
+ 'not-head',
253
265
  ]),
254
266
  message,
255
267
  })
@@ -461,7 +473,7 @@ export function decodeError(value) {
461
473
  const { push, operation, branch, reason } = envelope;
462
474
  if (!push)
463
475
  return new GatewayRefusalError(operation, branch, reason, envelope.message);
464
- if (operation !== 'push' || reason === 'too-long') {
476
+ if (operation !== 'push' || reason === 'too-long' || reason === 'not-head') {
465
477
  return new GitHubGatewayProtocolError(`The GitHub gateway answered with a push refusal for ${operation}, reason ${reason}`);
466
478
  }
467
479
  const err = new RefusedPushError(branch, reason);
@@ -89,6 +89,16 @@ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['re
89
89
  /** The pull request's head commit, read with `mergeable_state`. */
90
90
  headSha?: string;
91
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
+ }
92
102
  export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
93
103
  /**
94
104
  * Everything the worker does with GitHub, and the only place the credential and Octokit are used.
@@ -166,6 +176,13 @@ export interface GitHubGateway {
166
176
  created: boolean;
167
177
  }>;
168
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>;
169
186
  deleteBranch(branch: string, signal?: AbortSignal): Promise<void>;
170
187
  /**
171
188
  * The auth provider's users, groups and memberships, which the worker writes to the auth cache: