canopycms 0.6.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.
- package/CHANGELOG.md +174 -47
- package/dist/api/branch-review.d.ts +11 -1
- package/dist/api/branch-review.js +96 -18
- package/dist/api/branch-status.d.ts +26 -2
- package/dist/api/branch-status.js +38 -2
- package/dist/api/branch-withdraw.js +4 -7
- package/dist/api/branch.d.ts +2 -0
- package/dist/api/client.d.ts +3 -3
- package/dist/api/client.js +4 -4
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/comments.js +4 -0
- package/dist/api/content.d.ts +2 -2
- package/dist/api/github-sync.d.ts +4 -0
- package/dist/api/github-sync.js +38 -6
- package/dist/api/pr-comment-sync.d.ts +18 -0
- package/dist/api/pr-comment-sync.js +77 -0
- package/dist/api/request-deadline.d.ts +15 -7
- package/dist/api/request-deadline.js +15 -7
- package/dist/api/schema.d.ts +16 -16
- package/dist/authorization/review-role.d.ts +33 -0
- package/dist/authorization/review-role.js +17 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/cli/cli.js +699 -130
- package/dist/cli/generate-ai-content.js +524 -59
- package/dist/cli/github-app-output.d.ts +34 -0
- package/dist/cli/github-app-output.js +85 -0
- package/dist/cli/init-github-app.d.ts +18 -0
- package/dist/cli/init-github-app.js +66 -58
- package/dist/cli/init.js +10 -1
- package/dist/comment-store.d.ts +5 -0
- package/dist/comment-store.js +24 -0
- package/dist/config/helpers.js +1 -3
- package/dist/config/schemas/collection.d.ts +2 -2
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -1
- package/dist/config/schemas/url.d.ts +6 -0
- package/dist/config/schemas/url.js +16 -0
- package/dist/config/types.d.ts +12 -1
- package/dist/content-id-index.d.ts +9 -0
- package/dist/content-id-index.js +31 -0
- package/dist/editor/BranchManager.d.ts +3 -0
- package/dist/editor/BranchManager.js +75 -44
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditorPage.js +3 -4
- package/dist/editor/Editor.js +47 -8
- package/dist/editor/branch-lock.d.ts +7 -0
- package/dist/editor/branch-lock.js +9 -2
- package/dist/editor/client-reference-resolver.d.ts +5 -3
- package/dist/editor/client-reference-resolver.js +10 -5
- package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
- package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
- package/dist/editor/components/EditorHeader.d.ts +10 -4
- package/dist/editor/components/EditorHeader.js +108 -28
- package/dist/editor/components/EntryHeader.d.ts +5 -1
- package/dist/editor/components/EntryHeader.js +3 -1
- package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
- package/dist/editor/components/ReasonedMenuItem.js +2 -2
- package/dist/editor/components/ReviewDialogs.d.ts +19 -0
- package/dist/editor/components/ReviewDialogs.js +41 -0
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/fields/MarkdownField.js +1 -1
- package/dist/editor/hooks/create-branch-request.d.ts +6 -2
- package/dist/editor/hooks/create-branch-request.js +7 -2
- package/dist/editor/hooks/useBranchManager.d.ts +7 -2
- package/dist/editor/hooks/useBranchManager.js +252 -37
- package/dist/editor-url.d.ts +20 -0
- package/dist/editor-url.js +71 -0
- package/dist/git-manager.js +10 -6
- package/dist/github-service.d.ts +2 -2
- package/dist/github-service.js +4 -2
- package/dist/pr-comments.d.ts +33 -0
- package/dist/pr-comments.js +41 -0
- package/dist/schema/schema-store.d.ts +8 -8
- package/dist/services.d.ts +2 -17
- package/dist/services.js +38 -1
- package/dist/settings-workspace.d.ts +21 -7
- package/dist/settings-workspace.js +91 -16
- package/dist/submission-attribution.d.ts +38 -1
- package/dist/submission-attribution.js +197 -18
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/content-serialize.js +7 -6
- package/dist/utils/markdown-body-splice.d.ts +15 -10
- package/dist/utils/markdown-body-splice.js +71 -28
- package/dist/utils/markdown-export-options.d.ts +13 -0
- package/dist/utils/markdown-export-options.js +13 -0
- package/dist/utils/markdown-restyle.d.ts +58 -0
- package/dist/utils/markdown-restyle.js +482 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/bundle-header.d.ts +13 -9
- package/dist/worker/bundle-header.js +17 -11
- package/dist/worker/git-process.d.ts +2 -2
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +3 -0
- package/dist/worker/github-gateway-wire.js +1 -0
- package/dist/worker/github-gateway.d.ts +1 -1
- package/dist/worker/github-transfer.js +72 -2
- package/dist/worker/task-runner.js +53 -3
- package/package.json +2 -2
- package/dist/editor/fields/markdown-export-options.d.ts +0 -2
- 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
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
+
}
|
package/dist/git-manager.js
CHANGED
|
@@ -1052,11 +1052,12 @@ export class GitManager {
|
|
|
1052
1052
|
sourceRoot: options.sourceRoot,
|
|
1053
1053
|
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
|
|
1054
1054
|
});
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
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:
|
|
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 /
|
|
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
|
-
|
|
1634
|
-
//
|
|
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();
|
package/dist/github-service.d.ts
CHANGED
|
@@ -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'
|
|
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>;
|
package/dist/github-service.js
CHANGED
|
@@ -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
|
+
}
|