canopycms 0.5.0 → 0.7.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 (107) hide show
  1. package/CHANGELOG.md +185 -46
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +704 -131
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +15 -2
  31. package/dist/cli/template-files/cms-stack.ts.template +3 -0
  32. package/dist/cli/templates.js +5 -1
  33. package/dist/comment-store.d.ts +5 -0
  34. package/dist/comment-store.js +24 -0
  35. package/dist/config/helpers.js +1 -3
  36. package/dist/config/schemas/collection.d.ts +2 -2
  37. package/dist/config/schemas/config.d.ts +3 -0
  38. package/dist/config/schemas/config.js +2 -1
  39. package/dist/config/schemas/url.d.ts +6 -0
  40. package/dist/config/schemas/url.js +16 -0
  41. package/dist/config/types.d.ts +12 -1
  42. package/dist/content-id-index.d.ts +9 -0
  43. package/dist/content-id-index.js +31 -0
  44. package/dist/editor/BranchManager.d.ts +3 -0
  45. package/dist/editor/BranchManager.js +75 -44
  46. package/dist/editor/CanopyEditor.js +2 -2
  47. package/dist/editor/CanopyEditorPage.js +3 -4
  48. package/dist/editor/Editor.js +47 -8
  49. package/dist/editor/branch-lock.d.ts +7 -0
  50. package/dist/editor/branch-lock.js +9 -2
  51. package/dist/editor/client-reference-resolver.d.ts +5 -3
  52. package/dist/editor/client-reference-resolver.js +10 -5
  53. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  54. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  55. package/dist/editor/components/EditorHeader.d.ts +10 -4
  56. package/dist/editor/components/EditorHeader.js +108 -28
  57. package/dist/editor/components/EntryHeader.d.ts +5 -1
  58. package/dist/editor/components/EntryHeader.js +3 -1
  59. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  60. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  61. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  62. package/dist/editor/components/ReviewDialogs.js +41 -0
  63. package/dist/editor/copy.d.ts +4 -0
  64. package/dist/editor/copy.js +4 -0
  65. package/dist/editor/fields/MarkdownField.js +1 -1
  66. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  67. package/dist/editor/hooks/create-branch-request.js +7 -2
  68. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  69. package/dist/editor/hooks/useBranchManager.js +252 -37
  70. package/dist/editor-url.d.ts +20 -0
  71. package/dist/editor-url.js +71 -0
  72. package/dist/git-manager.js +10 -6
  73. package/dist/github-service.d.ts +2 -2
  74. package/dist/github-service.js +4 -2
  75. package/dist/pr-comments.d.ts +33 -0
  76. package/dist/pr-comments.js +41 -0
  77. package/dist/schema/schema-store.d.ts +8 -8
  78. package/dist/services.d.ts +2 -17
  79. package/dist/services.js +38 -1
  80. package/dist/settings-workspace.d.ts +21 -7
  81. package/dist/settings-workspace.js +91 -16
  82. package/dist/submission-attribution.d.ts +38 -1
  83. package/dist/submission-attribution.js +197 -18
  84. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  85. package/dist/types.d.ts +6 -0
  86. package/dist/utils/content-serialize.js +7 -6
  87. package/dist/utils/markdown-body-splice.d.ts +15 -10
  88. package/dist/utils/markdown-body-splice.js +71 -28
  89. package/dist/utils/markdown-export-options.d.ts +13 -0
  90. package/dist/utils/markdown-export-options.js +13 -0
  91. package/dist/utils/markdown-restyle.d.ts +58 -0
  92. package/dist/utils/markdown-restyle.js +482 -0
  93. package/dist/version.d.ts +1 -1
  94. package/dist/version.js +1 -1
  95. package/dist/worker/bundle-header.d.ts +13 -9
  96. package/dist/worker/bundle-header.js +17 -11
  97. package/dist/worker/git-process.d.ts +2 -2
  98. package/dist/worker/github-gateway-http.js +5 -4
  99. package/dist/worker/github-gateway-wire.d.ts +3 -0
  100. package/dist/worker/github-gateway-wire.js +1 -0
  101. package/dist/worker/github-gateway.d.ts +1 -1
  102. package/dist/worker/github-gateway.js +2 -2
  103. package/dist/worker/github-transfer.js +72 -2
  104. package/dist/worker/task-runner.js +53 -3
  105. package/package.json +2 -2
  106. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  107. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -20,14 +20,50 @@ import { isNonApiResponse } from '../../api/client.js';
20
20
  import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
21
21
  import { describeUnsaved } from './useBranchActions.js';
22
22
  import { writeEditorUrl } from './editor-history.js';
23
+ import { approveMessage, NoteNotSavedMessage, RequestChangesForm, } from '../components/ReviewDialogs.js';
23
24
  const BRANCHES_LOAD_FAILED_TOAST_ID = 'canopy-branches-load-failed';
24
25
  const BRANCH_OUTCOME_UNKNOWN_TOAST_ID = 'canopy-branch-outcome-unknown';
25
- /** The error a refused branch action throws; a gateway's 5xx says nothing of whether it ran. */
26
- function actionError(result, fallback) {
27
- return isGatewayFailure(result) ? new UnansweredError() : new Error(result.error || fallback);
26
+ /** The API's 409: the branch is no longer in the state the action needs, so a retry cannot pass. */
27
+ class StaleStatusError extends Error {
28
+ constructor() {
29
+ super(...arguments);
30
+ this.name = 'StaleStatusError';
31
+ }
32
+ }
33
+ /** An answer that refused the action, with its HTTP status: a 4xx is a refusal no retry passes. */
34
+ class RefusedError extends Error {
35
+ constructor(message, status) {
36
+ super(message);
37
+ this.status = status;
38
+ this.name = 'RefusedError';
39
+ }
40
+ }
41
+ /**
42
+ * The error a refused branch action throws; a gateway's 5xx says nothing of whether it ran.
43
+ * With `staleIsConflict`, the API's 409 is a `StaleStatusError`.
44
+ */
45
+ function actionError(result, fallback, staleIsConflict = false) {
46
+ if (isGatewayFailure(result))
47
+ return new UnansweredError();
48
+ const message = result.error || fallback;
49
+ if (result.ok)
50
+ return new Error(message);
51
+ return staleIsConflict && result.status === 409 && !isNonApiResponse(result)
52
+ ? new StaleStatusError(message)
53
+ : new RefusedError(message, result.status);
28
54
  }
55
+ /** Whether a branch's status still lets the reviewer act: Approve needs submitted. */
56
+ const acceptsReview = (status, kind) => kind === 'approve' ? status === 'submitted' : status === 'submitted' || status === 'approved';
57
+ /** The line for a branch the reviewer's page still showed in review after it left. */
58
+ const leftReviewMessage = (status, kind) => status === 'editing'
59
+ ? "This branch is no longer in review: it's back with its editor."
60
+ : status === 'approved' && kind === 'approve'
61
+ ? 'This branch is already approved.'
62
+ : `This branch is ${status}, so ${kind === 'approve' ? "it can't be approved" : "changes can't be requested"}.`;
29
63
  /** @internal Exported for tests. */
30
- export const branchActionOutcomeUnknown = (action) => `${action} may have gone through even though the server didn't answer. Check the branch's status before trying again.`;
64
+ export const branchActionOutcomeUnknown = (action, check = "Check the branch's status before trying again.") => `${action} may have gone through even though the server didn't answer. ${check}`;
65
+ /** Where to look after a Request changes nobody answered: the status, and the note it carried. */
66
+ const REQUEST_CHANGES_CHECK = `Check the branch's status and its comments in "Comments".`;
31
67
  /**
32
68
  * Wire a confirm modal's exits so exactly ONE of {confirmed, dismissed} runs,
33
69
  * whichever happens first.
@@ -119,13 +155,32 @@ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFo
119
155
  ...focusReturnProps(returnFocusTo),
120
156
  });
121
157
  };
122
- const showRequestChangesConfirmation = (branchName, onConfirm, onDismiss) => {
158
+ const showApproveConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
159
+ openConfirm({
160
+ title: EDITOR_ACTIONS.approve,
161
+ children: _jsx(Text, { size: "sm", children: approveMessage(branchName, !!pr.pullRequestNumber) }),
162
+ labels: { confirm: EDITOR_ACTIONS.approve, cancel: 'Cancel' },
163
+ confirmProps: { color: 'brand', 'data-testid': 'confirm-approve-branch' },
164
+ ...confirmModalHandlers(onConfirm, onDismiss),
165
+ ...focusReturnProps(returnFocusTo),
166
+ });
167
+ };
168
+ /**
169
+ * `onConfirm` receives the note as typed, which the dialog starts with `initialNote` and reports
170
+ * through `onNoteChange` as it changes, so a draft outlives the dialog.
171
+ */
172
+ const showRequestChangesConfirmation = (branchName, pr, initialNote, onNoteChange, onConfirm, onDismiss, returnFocusTo) => {
173
+ let note = initialNote;
123
174
  openConfirm({
124
175
  title: EDITOR_ACTIONS.requestChanges,
125
- children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Send "${branchName}" back to its editor?\n\nThis will:\n• Change the branch status back to "editing"\n• Let its editor change it and submit again` })),
176
+ children: (_jsx(RequestChangesForm, { branchName: branchName, hasPullRequest: !!pr.pullRequestNumber, initialNote: initialNote, onNoteChange: (value) => {
177
+ note = value;
178
+ onNoteChange(value);
179
+ } })),
126
180
  labels: { confirm: EDITOR_ACTIONS.requestChanges, cancel: 'Cancel' },
127
181
  confirmProps: { color: 'red', 'data-testid': 'confirm-request-changes' },
128
- ...confirmModalHandlers(onConfirm, onDismiss),
182
+ ...confirmModalHandlers(() => onConfirm(note), onDismiss),
183
+ ...focusReturnProps(returnFocusTo),
129
184
  });
130
185
  };
131
186
  /**
@@ -392,24 +447,118 @@ export function useBranchManager(options) {
392
447
  };
393
448
  // A workflow action that got no answer may have gone through, so the list reloads to show
394
449
  // where the branch stands rather than inviting a blind retry.
395
- const showOutcomeUnknown = (action) => {
450
+ const showOutcomeUnknown = (action, check) => {
396
451
  notifications.hide(BRANCH_OUTCOME_UNKNOWN_TOAST_ID);
397
452
  notifications.show({
398
453
  id: BRANCH_OUTCOME_UNKNOWN_TOAST_ID,
399
- message: branchActionOutcomeUnknown(action),
454
+ message: branchActionOutcomeUnknown(action, check),
400
455
  color: 'yellow',
401
456
  autoClose: false,
402
457
  withCloseButton: true,
403
458
  });
404
459
  };
405
- const reportActionFailure = async (err, fallback, action) => {
460
+ const reportActionFailure = async (err, fallback, action, follow = {}) => {
406
461
  if (isUnanswered(err)) {
407
- showOutcomeUnknown(action);
408
- await loadBranches();
462
+ showOutcomeUnknown(action, follow.check);
463
+ const fresh = await reloadBranches();
464
+ follow.afterUnanswered?.(fresh);
409
465
  return;
410
466
  }
411
467
  notifications.show({ message: err instanceof Error ? err.message : fallback, color: 'red' });
412
468
  };
469
+ // A note typed in the Request changes dialog, by branch, until a request carries it or the
470
+ // branch leaves review.
471
+ const noteDrafts = useRef(new Map());
472
+ // The stale-status toasts that carry a note and stay up, by id: a plain one never replaces them.
473
+ const stickyStaleToasts = useRef(new Set());
474
+ /**
475
+ * A review action found its branch out of review, or the page already knew it was. A warning,
476
+ * not an error: nothing is lost yet. A typed note is shown to copy, so it stays up until
477
+ * dismissed, and a later toast without a note leaves it be. The note's draft is the caller's to
478
+ * judge: Request changes still takes a branch that is merely approved.
479
+ */
480
+ const showLeftReview = (branch, message, note) => {
481
+ const id = `canopy-review-stale-${branch}`;
482
+ if (!note && stickyStaleToasts.current.has(id))
483
+ return;
484
+ notifications.hide(id);
485
+ stickyStaleToasts.current.delete(id);
486
+ if (note)
487
+ stickyStaleToasts.current.add(id);
488
+ notifications.show({
489
+ id,
490
+ message: note ? (_jsx(NoteNotSavedMessage, { message: message, note: note, hint: "Copy it to keep it." })) : (message),
491
+ color: 'yellow',
492
+ withCloseButton: true,
493
+ ...(note ? { autoClose: false, onClose: () => stickyStaleToasts.current.delete(id) } : {}),
494
+ });
495
+ };
496
+ /** Forgets a branch's draft when the listing shows it out of review. */
497
+ const dropDraftsOutOfReview = (branches) => {
498
+ for (const { name, status } of branches) {
499
+ if (!acceptsReview(status, 'request-changes'))
500
+ noteDrafts.current.delete(name);
501
+ }
502
+ };
503
+ const reviewFailureId = (kind, branch) => `canopy-${kind}-failed-${branch}`;
504
+ /**
505
+ * Why an Approve or Request changes failed. Retry only on an API 5xx, 408, 429 or unsent request;
506
+ * a timeout or gateway 5xx may have landed, so it reloads and says what to check. Other 4xx: a
507
+ * plain message and a reload. A 409 means the branch left review (see `showLeftReview`).
508
+ */
509
+ const reportReviewFailure = async (err, kind, branch, details) => {
510
+ if (err instanceof StaleStatusError) {
511
+ showLeftReview(branch, err.message, details.note);
512
+ // What the branch is now decides whether a Request changes note is still wanted.
513
+ const fresh = await reloadBranches();
514
+ if (fresh)
515
+ dropDraftsOutOfReview(fresh.branches);
516
+ return;
517
+ }
518
+ if (isUnanswered(err)) {
519
+ await reportActionFailure(err, details.fallback, details.label, details);
520
+ return;
521
+ }
522
+ const message = err instanceof Error ? err.message : details.fallback;
523
+ const id = reviewFailureId(kind, branch);
524
+ notifications.hide(id);
525
+ // A 4xx is a refusal no retry passes, except for asking again later (408, 429).
526
+ if (err instanceof RefusedError &&
527
+ err.status < 500 &&
528
+ err.status !== 408 &&
529
+ err.status !== 429) {
530
+ notifications.show({
531
+ id,
532
+ message: details.note ? (_jsx(NoteNotSavedMessage, { message: message, note: details.note, hint: "Copy it to keep it." })) : (message),
533
+ color: 'red',
534
+ ...(details.note ? { autoClose: false } : {}),
535
+ });
536
+ await loadBranches();
537
+ return;
538
+ }
539
+ notifications.show({
540
+ id,
541
+ message: (_jsx(RetryToastMessage, { message: message, onRetry: () => {
542
+ notifications.hide(id);
543
+ void (async () => {
544
+ // The page may be stale by now: a branch that left review has nothing to retry.
545
+ // The listing is fetched first, so the decision is made on where the branch is now.
546
+ const fresh = await reloadBranches();
547
+ const status = (fresh?.branches ?? latest.current.branches).find((b) => b.name === branch)?.status;
548
+ if (status !== undefined && !acceptsReview(status, kind)) {
549
+ showLeftReview(branch, leftReviewMessage(status, kind), noteDrafts.current.get(branch));
550
+ // An approved branch still takes Request changes, so its draft stays.
551
+ if (!acceptsReview(status, 'request-changes'))
552
+ noteDrafts.current.delete(branch);
553
+ return;
554
+ }
555
+ details.retry();
556
+ })();
557
+ } })),
558
+ color: 'red',
559
+ autoClose: false,
560
+ });
561
+ };
413
562
  const handleSubmit = (requested, returnFocusTo) => {
414
563
  const branchNameToSubmit = listedName(requested);
415
564
  return singleFlight('submit', branchNameToSubmit, async () => {
@@ -440,9 +589,9 @@ export function useBranchManager(options) {
440
589
  }
441
590
  options.setBusy(true);
442
591
  try {
443
- const result = await apiClient.workflow.submit({
444
- branch: branchNameToSubmit,
445
- });
592
+ const result = await apiClient.workflow.submit({ branch: branchNameToSubmit },
593
+ // The PR body links back here; the server checks the host (editor-url.ts).
594
+ { editorUrl: `${window.location.origin}${window.location.pathname}` });
446
595
  if (!result.ok)
447
596
  throw actionError(result, 'Failed to submit branch');
448
597
  const stamped = result.data?.stamped;
@@ -497,28 +646,93 @@ export function useBranchManager(options) {
497
646
  }, () => resolve(), returnFocusTo);
498
647
  }));
499
648
  };
500
- const handleRequestChanges = (branchNameForChanges) => singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
501
- showRequestChangesConfirmation(branchNameForChanges, async () => {
502
- options.setBusy(true);
503
- try {
504
- const result = await apiClient.workflow.requestChanges({
505
- branch: branchNameForChanges,
506
- });
507
- if (!result.ok)
508
- throw actionError(result, 'Failed to request changes');
509
- notifications.show({ message: 'Changes requested', color: 'blue' });
510
- updateCreatedBranch(branchNameForChanges, result.data?.branch);
511
- await loadBranches();
512
- }
513
- catch (err) {
514
- await reportActionFailure(err, 'Failed to request changes', EDITOR_ACTIONS.requestChanges);
515
- }
516
- finally {
517
- options.setBusy(false);
518
- resolve();
519
- }
520
- }, () => resolve());
521
- }));
649
+ const handleApprove = (requested, returnFocusTo) => {
650
+ const branchNameToApprove = listedName(requested);
651
+ return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
652
+ showApproveConfirmation(branchNameToApprove, pullRequestOf(branchNameToApprove), async () => {
653
+ options.setBusy(true);
654
+ try {
655
+ const result = await apiClient.workflow.approve({
656
+ branch: branchNameToApprove,
657
+ });
658
+ if (!result.ok)
659
+ throw actionError(result, 'Failed to approve branch', true);
660
+ notifications.hide(reviewFailureId('approve', branchNameToApprove));
661
+ notifications.show({ message: 'Branch approved', color: 'green' });
662
+ updateCreatedBranch(branchNameToApprove, result.data?.branch);
663
+ await loadBranches();
664
+ resolve();
665
+ }
666
+ catch (err) {
667
+ await reportReviewFailure(err, 'approve', branchNameToApprove, {
668
+ fallback: 'Failed to approve branch',
669
+ label: `${EDITOR_ACTIONS.approve} "${branchNameToApprove}"`,
670
+ retry: () => void handleApprove(branchNameToApprove, returnFocusTo).catch(() => { }),
671
+ });
672
+ reject(err);
673
+ }
674
+ finally {
675
+ options.setBusy(false);
676
+ }
677
+ }, () => resolve(), returnFocusTo);
678
+ }));
679
+ };
680
+ const handleRequestChanges = (requested, returnFocusTo) => {
681
+ const branchNameForChanges = listedName(requested);
682
+ // A draft is for the review cycle it was typed in; a branch already out of review starts clean.
683
+ dropDraftsOutOfReview(latest.current.branches.filter((b) => b.name === branchNameForChanges));
684
+ return singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
685
+ showRequestChangesConfirmation(branchNameForChanges, pullRequestOf(branchNameForChanges), noteDrafts.current.get(branchNameForChanges) ?? '', (draft) => {
686
+ if (draft)
687
+ noteDrafts.current.set(branchNameForChanges, draft);
688
+ else
689
+ noteDrafts.current.delete(branchNameForChanges);
690
+ }, async (note) => {
691
+ options.setBusy(true);
692
+ try {
693
+ const result = await apiClient.workflow.requestChanges({ branch: branchNameForChanges }, { note: note.trim() || undefined });
694
+ if (!result.ok)
695
+ throw actionError(result, 'Failed to request changes', true);
696
+ noteDrafts.current.delete(branchNameForChanges);
697
+ notifications.hide(reviewFailureId('request-changes', branchNameForChanges));
698
+ notifications.show({ message: 'Changes requested', color: 'blue' });
699
+ // The note is saved apart from the status change, so it can fail on its own;
700
+ // the warning stays up, with the note to copy, because the words are not saved.
701
+ const warning = result.data?.warning;
702
+ if (warning) {
703
+ notifications.show({
704
+ message: _jsx(NoteNotSavedMessage, { message: warning, note: note.trim() }),
705
+ color: 'yellow',
706
+ autoClose: false,
707
+ withCloseButton: true,
708
+ });
709
+ }
710
+ updateCreatedBranch(branchNameForChanges, result.data?.branch);
711
+ await loadBranches();
712
+ options.onChangesRequested?.(branchNameForChanges);
713
+ }
714
+ catch (err) {
715
+ await reportReviewFailure(err, 'request-changes', branchNameForChanges, {
716
+ fallback: 'Failed to request changes',
717
+ label: `${EDITOR_ACTIONS.requestChanges} on "${branchNameForChanges}"`,
718
+ note: note.trim() || undefined,
719
+ retry: () => void handleRequestChanges(branchNameForChanges, returnFocusTo).catch(() => { }),
720
+ check: REQUEST_CHANGES_CHECK,
721
+ afterUnanswered: (fresh) => {
722
+ // The write may have landed along with its note.
723
+ options.onChangesRequested?.(branchNameForChanges);
724
+ if (fresh)
725
+ dropDraftsOutOfReview(fresh.branches);
726
+ },
727
+ });
728
+ }
729
+ finally {
730
+ options.setBusy(false);
731
+ resolve();
732
+ }
733
+ }, () => resolve(), returnFocusTo);
734
+ }));
735
+ };
522
736
  /** After a delete: drop it from the created-branch overlay, and leave it if it is open. */
523
737
  const leaveDeletedBranch = (deleted, fresh) => {
524
738
  forgetCreatedBranch(deleted);
@@ -612,6 +826,7 @@ export function useBranchManager(options) {
612
826
  addCreatedBranch,
613
827
  handleSubmit,
614
828
  handleWithdraw,
829
+ handleApprove,
615
830
  handleRequestChanges,
616
831
  handleDelete,
617
832
  handleReloadBranchData,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The editor's public URL, for links written into pull request bodies, and the deep links
3
+ * built on it. The editor reads `?branch=` and `&entry=` (an entry's logical path) on load.
4
+ */
5
+ export interface ResolveEditorUrlInput {
6
+ /** `config.editorUrl`, already validated by the config schema. */
7
+ configured?: string;
8
+ /** The page URL the editor client reported. */
9
+ claimed?: string;
10
+ /** The request's header getter. */
11
+ header?: (name: string) => string | null;
12
+ }
13
+ /**
14
+ * The editor URL for links: the configured one; else the client-reported page URL when its
15
+ * host is the one the request was addressed to (see requestHost); else undefined. The host
16
+ * check is what stops a request from naming an arbitrary site as the editor.
17
+ */
18
+ export declare function resolveEditorUrl(input: ResolveEditorUrlInput): string | undefined;
19
+ /** A link to `branch` in the editor at `editorUrl`, optionally opening the entry at `entryPath`. */
20
+ export declare function editorDeepLink(editorUrl: string, branch: string, entryPath?: string): string;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The editor's public URL, for links written into pull request bodies, and the deep links
3
+ * built on it. The editor reads `?branch=` and `&entry=` (an entry's logical path) on load.
4
+ */
5
+ /** Longest editor URL accepted, before and after URL encoding. */
6
+ const MAX_URL_LENGTH = 2048;
7
+ /** The editor page URL with no query or fragment, or undefined if it is not plain http(s). */
8
+ function editorPageUrl(raw) {
9
+ if (raw.length > MAX_URL_LENGTH)
10
+ return undefined;
11
+ let url;
12
+ try {
13
+ url = new URL(raw);
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
19
+ return undefined;
20
+ if (url.username || url.password)
21
+ return undefined;
22
+ const page = `${url.origin}${url.pathname}`;
23
+ return page.length > MAX_URL_LENGTH ? undefined : page;
24
+ }
25
+ /**
26
+ * The host the request was addressed to: the last `x-forwarded-host` value, else `Host`. A
27
+ * proxy that appends puts its own value last, after any the client sent. Behind canopycms-cdk's
28
+ * CloudFront the Lambda's own Host is the Function URL's, and the default viewer-request
29
+ * function overwrites `x-forwarded-host` with the viewer's Host.
30
+ */
31
+ function requestHost(header) {
32
+ const forwarded = header('x-forwarded-host')?.split(',').at(-1)?.trim();
33
+ const host = forwarded || header('host')?.trim();
34
+ return host || undefined;
35
+ }
36
+ /** `host` as `URL.host` spells it under `protocol`, or undefined if it is not a bare host[:port]. */
37
+ function normalizeHost(host, protocol) {
38
+ if (/[\s/\\@?#]/.test(host))
39
+ return undefined;
40
+ try {
41
+ return new URL(`${protocol}//${host}`).host;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ /**
48
+ * The editor URL for links: the configured one; else the client-reported page URL when its
49
+ * host is the one the request was addressed to (see requestHost); else undefined. The host
50
+ * check is what stops a request from naming an arbitrary site as the editor.
51
+ */
52
+ export function resolveEditorUrl(input) {
53
+ if (input.configured)
54
+ return editorPageUrl(input.configured);
55
+ if (!input.claimed || !input.header)
56
+ return undefined;
57
+ const claimed = editorPageUrl(input.claimed);
58
+ const host = requestHost(input.header);
59
+ if (!claimed || !host)
60
+ return undefined;
61
+ const url = new URL(claimed);
62
+ return normalizeHost(host, url.protocol) === url.host ? claimed : undefined;
63
+ }
64
+ /** A link to `branch` in the editor at `editorUrl`, optionally opening the entry at `entryPath`. */
65
+ export function editorDeepLink(editorUrl, branch, entryPath) {
66
+ const url = new URL(editorUrl);
67
+ url.searchParams.set('branch', branch);
68
+ if (entryPath !== undefined)
69
+ url.searchParams.set('entry', entryPath);
70
+ return url.href;
71
+ }
@@ -1052,11 +1052,12 @@ export class GitManager {
1052
1052
  sourceRoot: options.sourceRoot,
1053
1053
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
1054
1054
  });
1055
- // A content clone skips its own checkout because checkoutFreshClone runs
1056
- // the one checkout this workspace needs.
1055
+ // No clone checks out: the checkout step below is the one checkout each
1056
+ // workspace needs (checkoutFreshClone for content; createOrphanSettingsBranch
1057
+ // for settings, which writes only the settings files, never the base tree).
1057
1058
  await step('clone', () => GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
1058
1059
  remoteName,
1059
- noCheckout: options.branchType === 'content',
1060
+ noCheckout: true,
1060
1061
  config: identity,
1061
1062
  }));
1062
1063
  justCloned = true;
@@ -1590,7 +1591,7 @@ export class GitManager {
1590
1591
  await this.createOrphanSettingsBranchInner(branchName, initialFiles);
1591
1592
  }
1592
1593
  finally {
1593
- // Both branches of Inner swap the working tree (checkout / checkout --orphan)
1594
+ // Both branches of Inner swap the working tree (checkout / switch --orphan)
1594
1595
  await this.invalidateContentIndexes();
1595
1596
  }
1596
1597
  }
@@ -1630,8 +1631,11 @@ export class GitManager {
1630
1631
  // branchName is consumed as --orphan's literal argument value (like -b/-B
1631
1632
  // above), so it can't be reinterpreted as a flag; git's own ref-name
1632
1633
  // validation additionally rejects a leading-hyphen value here.
1633
- await this.git.raw(['checkout', '--orphan', branchName]);
1634
- // The orphan starts from the base branch's index. `--sparse` also removes entries outside a
1634
+ // `switch`, not `checkout`: `checkout --orphan` keeps HEAD's tree, and on a `--no-checkout`
1635
+ // clone (no index) writes every base file into the working tree; `switch --orphan` starts
1636
+ // from the empty tree either way.
1637
+ await this.git.raw(['switch', '--orphan', branchName]);
1638
+ // A backstop for an index `switch` left behind. `--sparse` also removes entries outside a
1635
1639
  // sparse cone, which a plain `rm` leaves in the index, and so in the first commit.
1636
1640
  await this.git.raw(['rm', '-r', '-f', '-q', '--sparse', '--ignore-unmatch', '--', '.']);
1637
1641
  const leftover = (await this.git.raw(['ls-files'])).trim();
@@ -159,7 +159,7 @@ export declare class GitHubService {
159
159
  number: number;
160
160
  url: string;
161
161
  }>;
162
- updatePullRequest(prNumber: number, options: Partial<Pick<PullRequestOptions, 'title' | 'body'>>): Promise<void>;
162
+ updatePullRequest(prNumber: number, options: Partial<Pick<PullRequestOptions, 'title' | 'body'>>, signal?: AbortSignal): Promise<void>;
163
163
  /**
164
164
  * Idempotent create-or-update, bound to this instance's octokit/owner/repo.
165
165
  * See {@link createOrUpdatePullRequest}.
@@ -177,7 +177,7 @@ export declare class GitHubService {
177
177
  number: number;
178
178
  url: string;
179
179
  }>;
180
- getPullRequest(prNumber: number): Promise<PullRequestDetails>;
180
+ getPullRequest(prNumber: number, signal?: AbortSignal): Promise<PullRequestDetails>;
181
181
  convertToDraft(prNumber: number): Promise<void>;
182
182
  convertToReady(prNumber: number): Promise<void>;
183
183
  closePullRequest(prNumber: number): Promise<void>;
@@ -258,13 +258,14 @@ export class GitHubService {
258
258
  url: response.data.html_url,
259
259
  };
260
260
  }
261
- async updatePullRequest(prNumber, options) {
261
+ async updatePullRequest(prNumber, options, signal) {
262
262
  await this.octokit.pulls.update({
263
263
  owner: this.owner,
264
264
  repo: this.repo,
265
265
  pull_number: prNumber,
266
266
  title: options.title,
267
267
  body: options.body,
268
+ ...(signal ? { request: { signal } } : {}),
268
269
  });
269
270
  }
270
271
  /**
@@ -285,11 +286,12 @@ export class GitHubService {
285
286
  });
286
287
  return { number: result.number, url: result.url };
287
288
  }
288
- async getPullRequest(prNumber) {
289
+ async getPullRequest(prNumber, signal) {
289
290
  const response = await this.octokit.pulls.get({
290
291
  owner: this.owner,
291
292
  repo: this.repo,
292
293
  pull_number: prNumber,
294
+ ...(signal ? { request: { signal } } : {}),
293
295
  });
294
296
  return {
295
297
  number: response.data.number,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The worker action that re-renders a PR's editor-comment summary: enqueued by
3
+ * api/pr-comment-sync.ts, run by worker/task-runner.ts.
4
+ */
5
+ export declare const SYNC_PR_COMMENTS_ACTION = "sync-pr-comments";
6
+ /**
7
+ * `body` with its comment summary re-rendered from `branchRoot`'s comments.json, or undefined
8
+ * when there is nothing to write: no summary block to replace, or no change.
9
+ */
10
+ export declare function renderPrCommentsBody(options: {
11
+ branchRoot: string;
12
+ branch: string;
13
+ editorUrl?: string;
14
+ body: string | null | undefined;
15
+ }): Promise<string | undefined>;
16
+ /**
17
+ * The in-process lock every direct-path write of a branch's PR body holds (submit and comment
18
+ * sync), so neither writes back a body read before the other's write.
19
+ */
20
+ export declare function prBodyLockKey(branchRoot: string): string;
21
+ /**
22
+ * A submit's section with its comment block re-rendered from comments.json, for a queued submit
23
+ * whose block was rendered when it was queued: a comment change since then, or a sync task that
24
+ * ran while this one waited out a retry, would otherwise be overwritten. If comments.json cannot
25
+ * be read, the section keeps the block it was queued with.
26
+ */
27
+ export declare function refreshCommentsBlock(options: {
28
+ branchRoot: string;
29
+ branch: string;
30
+ editorUrl?: string;
31
+ section: string;
32
+ warn: (message: string) => void;
33
+ }): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { CommentStore } from './comment-store.js';
2
+ import { getErrorMessage } from './utils/error.js';
3
+ import { buildCommentsBlock, mergeCommentsBlock } from './submission-attribution.js';
4
+ /**
5
+ * The worker action that re-renders a PR's editor-comment summary: enqueued by
6
+ * api/pr-comment-sync.ts, run by worker/task-runner.ts.
7
+ */
8
+ export const SYNC_PR_COMMENTS_ACTION = 'sync-pr-comments';
9
+ /**
10
+ * `body` with its comment summary re-rendered from `branchRoot`'s comments.json, or undefined
11
+ * when there is nothing to write: no summary block to replace, or no change.
12
+ */
13
+ export async function renderPrCommentsBody(options) {
14
+ const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
15
+ const next = mergeCommentsBlock(options.body, buildCommentsBlock(threads, options.branch, options.editorUrl));
16
+ return next === undefined || next === options.body ? undefined : next;
17
+ }
18
+ /**
19
+ * The in-process lock every direct-path write of a branch's PR body holds (submit and comment
20
+ * sync), so neither writes back a body read before the other's write.
21
+ */
22
+ export function prBodyLockKey(branchRoot) {
23
+ return `pr-body:${branchRoot}`;
24
+ }
25
+ /**
26
+ * A submit's section with its comment block re-rendered from comments.json, for a queued submit
27
+ * whose block was rendered when it was queued: a comment change since then, or a sync task that
28
+ * ran while this one waited out a retry, would otherwise be overwritten. If comments.json cannot
29
+ * be read, the section keeps the block it was queued with.
30
+ */
31
+ export async function refreshCommentsBlock(options) {
32
+ try {
33
+ const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
34
+ const block = buildCommentsBlock(threads, options.branch, options.editorUrl);
35
+ return mergeCommentsBlock(options.section, block) ?? options.section;
36
+ }
37
+ catch (err) {
38
+ options.warn(`Could not re-read the editor comments of ${options.branch}; the PR gets the summary from submit time: ${getErrorMessage(err)}`);
39
+ return options.section;
40
+ }
41
+ }