canopycms 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
package/dist/api/admin.d.ts
CHANGED
|
@@ -91,10 +91,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
|
|
|
91
91
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
92
92
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
93
93
|
}, "strip", z.ZodTypeAny, {
|
|
94
|
-
status: "failed" | "pending" | "
|
|
94
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
95
95
|
limit?: number | undefined;
|
|
96
96
|
}, {
|
|
97
|
-
status: "failed" | "pending" | "
|
|
97
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
98
98
|
limit?: number | undefined;
|
|
99
99
|
}>;
|
|
100
100
|
export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
|
|
@@ -168,10 +168,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
168
168
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
169
169
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
170
170
|
}, "strip", z.ZodTypeAny, {
|
|
171
|
-
status: "failed" | "pending" | "
|
|
171
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
172
172
|
limit?: number | undefined;
|
|
173
173
|
}, {
|
|
174
|
-
status: "failed" | "pending" | "
|
|
174
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
175
175
|
limit?: number | undefined;
|
|
176
176
|
}>, undefined, AdminTasksResponse>;
|
|
177
177
|
readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { type BranchListItemResponse } from './branch.js';
|
|
3
|
-
|
|
3
|
+
/** What a verdict carries besides the branch: the commit the reviewer's page showed. */
|
|
4
|
+
interface ReviewedHead {
|
|
5
|
+
/** The listing's `headSha` (api/branch.ts) the page was showing. */
|
|
6
|
+
headSha?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RequestChangesBody extends ReviewedHead {
|
|
4
9
|
/** Saved as a branch comment; trimmed before its length is checked, and ignored when empty. */
|
|
5
10
|
note?: string;
|
|
6
11
|
}
|
|
12
|
+
export type ApproveBody = ReviewedHead;
|
|
13
|
+
/** @internal Exported for tests. */
|
|
14
|
+
export declare const BRANCH_MOVED_ERROR = "This branch changed since you opened it. Review the latest changes.";
|
|
7
15
|
export declare const requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
8
16
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
9
17
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -16,9 +24,12 @@ export declare const requestChanges: import("./route-builder.js").RouteDefinitio
|
|
|
16
24
|
};
|
|
17
25
|
}>, z.ZodOptional<z.ZodObject<{
|
|
18
26
|
note: z.ZodOptional<z.ZodString>;
|
|
27
|
+
headSha: z.ZodOptional<z.ZodString>;
|
|
19
28
|
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
headSha?: string | undefined;
|
|
20
30
|
note?: string | undefined;
|
|
21
31
|
}, {
|
|
32
|
+
headSha?: string | undefined;
|
|
22
33
|
note?: string | undefined;
|
|
23
34
|
}>>, BranchListItemResponse>;
|
|
24
35
|
export declare const approveBranch: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -31,4 +42,11 @@ export declare const approveBranch: import("./route-builder.js").RouteDefinition
|
|
|
31
42
|
branch: string & {
|
|
32
43
|
readonly __brand: "BranchName";
|
|
33
44
|
};
|
|
34
|
-
}>,
|
|
45
|
+
}>, z.ZodOptional<z.ZodObject<{
|
|
46
|
+
headSha: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, "strip", z.ZodTypeAny, {
|
|
48
|
+
headSha?: string | undefined;
|
|
49
|
+
}, {
|
|
50
|
+
headSha?: string | undefined;
|
|
51
|
+
}>>, BranchListItemResponse>;
|
|
52
|
+
export {};
|
|
@@ -6,14 +6,73 @@ import { defineEndpoint } from './route-builder.js';
|
|
|
6
6
|
import { syncConvertToDraft } from './github-sync.js';
|
|
7
7
|
import { syncPrComments } from './pr-comment-sync.js';
|
|
8
8
|
import { CommentStore } from '../comment-store.js';
|
|
9
|
+
import { assertNoOpenConflicts, OpenMergeConflictsError } from '../merge-conflicts.js';
|
|
10
|
+
import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
|
|
9
11
|
import { getErrorMessage } from '../utils/error.js';
|
|
10
12
|
import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
|
|
11
13
|
import { getBranchProtection, isAdmin } from '../authorization/index.js';
|
|
12
14
|
import { sanitizeDisplayName } from '../submission-attribution.js';
|
|
13
|
-
|
|
15
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
16
|
+
/** Optional on the wire, so a request without one gets {@link BRANCH_MOVED_ERROR}, not a 400. */
|
|
17
|
+
const headShaSchema = z.string().regex(FULL_SHA).optional();
|
|
14
18
|
const requestChangesBodySchema = z
|
|
15
|
-
.object({
|
|
19
|
+
.object({
|
|
20
|
+
note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional(),
|
|
21
|
+
headSha: headShaSchema,
|
|
22
|
+
})
|
|
16
23
|
.optional();
|
|
24
|
+
const approveBodySchema = z.object({ headSha: headShaSchema }).optional();
|
|
25
|
+
/** @internal Exported for tests. */
|
|
26
|
+
export const BRANCH_MOVED_ERROR = 'This branch changed since you opened it. Review the latest changes.';
|
|
27
|
+
const branchMoved = {
|
|
28
|
+
ok: false,
|
|
29
|
+
status: 409,
|
|
30
|
+
code: 'BRANCH_MOVED',
|
|
31
|
+
error: BRANCH_MOVED_ERROR,
|
|
32
|
+
};
|
|
33
|
+
class BranchMovedError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super(...arguments);
|
|
36
|
+
this.name = 'BranchMovedError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class HeadUnreadableError extends Error {
|
|
40
|
+
constructor() {
|
|
41
|
+
super(...arguments);
|
|
42
|
+
this.name = 'HeadUnreadableError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const headUnreadable = {
|
|
46
|
+
ok: false,
|
|
47
|
+
status: 503,
|
|
48
|
+
error: "Couldn't read this branch right now. Try again in a moment.",
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Throws unless the clone's HEAD is `sent`, the commit the reviewer saw, and returns it. A request
|
|
52
|
+
* naming none, from a page loaded before the editor sent one, is refused: what it showed is
|
|
53
|
+
* unknown. Called under the content-write lock: a submit and the worker move HEAD only while
|
|
54
|
+
* holding it (docs/concurrency.md, "Who takes it"), so the head checked is the one the verdict is
|
|
55
|
+
* written against.
|
|
56
|
+
*/
|
|
57
|
+
const assertReviewedHead = async (ctx, { branch, branchRoot }, sent) => {
|
|
58
|
+
if (!sent)
|
|
59
|
+
throw new BranchMovedError();
|
|
60
|
+
let head;
|
|
61
|
+
try {
|
|
62
|
+
const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
|
|
63
|
+
head = await git.headSha();
|
|
64
|
+
// A git that exits quietly answers nothing, which is no head at all.
|
|
65
|
+
if (!FULL_SHA.test(head))
|
|
66
|
+
throw new Error(`no commit in its answer (${head.length} chars)`);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
|
|
70
|
+
throw new HeadUnreadableError();
|
|
71
|
+
}
|
|
72
|
+
if (head !== sent)
|
|
73
|
+
throw new BranchMovedError();
|
|
74
|
+
return sent;
|
|
75
|
+
};
|
|
17
76
|
const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
|
|
18
77
|
/**
|
|
19
78
|
* The 409 for a branch that left review before this request arrived, because the reviewer's page
|
|
@@ -41,38 +100,62 @@ const approveConflict = (status) => ({
|
|
|
41
100
|
* The verdict `user` gives. The reviewer guard admits only authenticated users, and the record is
|
|
42
101
|
* always written, so a new verdict replaces whatever the last one left.
|
|
43
102
|
*/
|
|
44
|
-
const reviewBy = (user, state) => ({
|
|
103
|
+
const reviewBy = (user, state, commit) => ({
|
|
45
104
|
state,
|
|
46
105
|
by: {
|
|
47
106
|
userId: user.userId,
|
|
48
107
|
name: user.type === 'authenticated' ? sanitizeDisplayName(user.name) : undefined,
|
|
49
108
|
},
|
|
50
109
|
at: new Date().toISOString(),
|
|
110
|
+
commit,
|
|
51
111
|
});
|
|
52
112
|
const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
53
113
|
const { branchContext } = gc;
|
|
54
114
|
if (!canRequestChangesFrom(branchContext.branch.status)) {
|
|
55
115
|
return requestChangesConflict(branchContext.branch.status);
|
|
56
116
|
}
|
|
57
|
-
|
|
117
|
+
const headSha = body?.headSha;
|
|
58
118
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
59
119
|
// The status the guard read can be stale by now, so the save checks the status on disk, in the
|
|
60
|
-
// same step that writes. A branch that left review
|
|
61
|
-
//
|
|
62
|
-
// merged one cannot be converted.
|
|
120
|
+
// same step that writes, and the head under the lock with it. A branch that left review or moved
|
|
121
|
+
// meanwhile is refused before the pull request is touched.
|
|
63
122
|
let found;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
let updated;
|
|
124
|
+
try {
|
|
125
|
+
updated = await withContentWriteLock(branchContext.branchRoot, async () => {
|
|
126
|
+
const commit = await assertReviewedHead(ctx, branchContext, headSha);
|
|
127
|
+
return meta.saveIf({
|
|
128
|
+
branch: {
|
|
129
|
+
name: branchContext.branch.name,
|
|
130
|
+
status: 'editing',
|
|
131
|
+
review: reviewBy(req.user, 'changes-requested', commit),
|
|
132
|
+
},
|
|
133
|
+
}, (existing) => {
|
|
134
|
+
found = existing?.branch.status;
|
|
135
|
+
return canRequestChangesFrom(found);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof BranchMovedError)
|
|
141
|
+
return branchMoved;
|
|
142
|
+
if (err instanceof HeadUnreadableError)
|
|
143
|
+
return headUnreadable;
|
|
144
|
+
if (err instanceof ContentWriteLockBusyError) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
status: 409,
|
|
148
|
+
error: err.outcome === 'not-run'
|
|
149
|
+
? "This branch is being synced, so changes weren't requested. Try again in a moment."
|
|
150
|
+
: 'This branch was being synced while changes were requested, so the request may not have been recorded. Refresh to check.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
74
155
|
if (!updated)
|
|
75
156
|
return requestChangesConflict(found);
|
|
157
|
+
// Best-effort: syncConvertToDraft only logs a failure.
|
|
158
|
+
await syncConvertToDraft(ctx, { ...branchContext, branch: updated.branch });
|
|
76
159
|
// The note is written after the status change so a failed write never blocks the branch from
|
|
77
160
|
// returning to editing; the caller is warned instead.
|
|
78
161
|
const note = body?.note;
|
|
@@ -104,8 +187,9 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
|
104
187
|
},
|
|
105
188
|
};
|
|
106
189
|
};
|
|
107
|
-
const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
190
|
+
const approveBranchHandler = async (gc, ctx, req, _params, body) => {
|
|
108
191
|
const { branchContext } = gc;
|
|
192
|
+
const headSha = body?.headSha;
|
|
109
193
|
// Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
|
|
110
194
|
// an author's approval review, so nothing is sent to GitHub.
|
|
111
195
|
if (branchContext.branch.createdBy === req.user.userId) {
|
|
@@ -130,23 +214,58 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
130
214
|
};
|
|
131
215
|
}
|
|
132
216
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
133
|
-
// As for request changes, the status on disk
|
|
134
|
-
// unfinished submit: what's approved may not be what the pull request holds.
|
|
217
|
+
// As for request changes, the status on disk and the head decide, in the step that writes. So
|
|
218
|
+
// does an unfinished submit: what's approved may not be what the pull request holds. So do
|
|
219
|
+
// conflicts: a merge of GitHub's commits can record some on a branch in review, under the
|
|
220
|
+
// content-write lock held here, so none lands between the check and the write.
|
|
135
221
|
let found;
|
|
136
222
|
let unfinished = false;
|
|
137
223
|
let notQueued = false;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
224
|
+
let updated;
|
|
225
|
+
try {
|
|
226
|
+
updated = await withContentWriteLock(branchContext.branchRoot, async () => {
|
|
227
|
+
const commit = await assertReviewedHead(ctx, branchContext, headSha);
|
|
228
|
+
await assertNoOpenConflicts(branchContext.branchRoot);
|
|
229
|
+
return meta.saveIf({
|
|
230
|
+
branch: {
|
|
231
|
+
name: branchContext.branch.name,
|
|
232
|
+
status: 'approved',
|
|
233
|
+
review: reviewBy(req.user, 'approved', commit),
|
|
234
|
+
},
|
|
235
|
+
}, (existing) => {
|
|
236
|
+
found = existing?.branch.status;
|
|
237
|
+
unfinished = existing?.branch.submitUnfinished === true;
|
|
238
|
+
notQueued = existing?.branch.submitNotQueued === true;
|
|
239
|
+
return found === 'submitted' && !unfinished;
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (err instanceof BranchMovedError)
|
|
245
|
+
return branchMoved;
|
|
246
|
+
if (err instanceof HeadUnreadableError)
|
|
247
|
+
return headUnreadable;
|
|
248
|
+
if (err instanceof OpenMergeConflictsError) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
status: 409,
|
|
252
|
+
code: 'OPEN_CONFLICTS',
|
|
253
|
+
error: err.count === null
|
|
254
|
+
? err.message
|
|
255
|
+
: `${err.message} Use "Request changes" so its editor can choose what to keep.`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (err instanceof ContentWriteLockBusyError) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
status: 409,
|
|
262
|
+
error: err.outcome === 'not-run'
|
|
263
|
+
? 'This branch is being synced, or a save is in flight, so it was not approved. Try again in a moment.'
|
|
264
|
+
: 'This branch was being synced while it was approved, so the approval may not have been recorded. Refresh to check.',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
150
269
|
if (unfinished && found === 'submitted') {
|
|
151
270
|
return {
|
|
152
271
|
ok: false,
|
|
@@ -199,6 +318,8 @@ export const approveBranch = defineEndpoint({
|
|
|
199
318
|
method: 'POST',
|
|
200
319
|
path: '/:branch/approve',
|
|
201
320
|
params: branchParamSchema,
|
|
321
|
+
body: approveBodySchema,
|
|
322
|
+
bodyType: 'ApproveBody',
|
|
202
323
|
responseType: 'BranchListItemResponse',
|
|
203
324
|
response: {},
|
|
204
325
|
defaultMockData: {
|
|
@@ -3,7 +3,8 @@ import type { ApiResponse } from './types.js';
|
|
|
3
3
|
import { type BranchListItem, type BranchListItemResponse } from './branch.js';
|
|
4
4
|
import type { StampedEntry } from '../stamp-on-publish.js';
|
|
5
5
|
export type { BranchMergeResponse } from './branch-merge.js';
|
|
6
|
-
export type { RequestChangesBody } from './branch-review.js';
|
|
6
|
+
export type { ApproveBody, RequestChangesBody } from './branch-review.js';
|
|
7
|
+
export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
|
|
7
8
|
/**
|
|
8
9
|
* A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
|
|
9
10
|
* there were any, so the editor can carry its open forms and drafts onto their new versions.
|
|
@@ -74,6 +75,17 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
74
75
|
readonly __brand: "BranchName";
|
|
75
76
|
};
|
|
76
77
|
}>, undefined, BranchListItemResponse>;
|
|
78
|
+
readonly updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
79
|
+
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
80
|
+
}, "strip", z.ZodTypeAny, {
|
|
81
|
+
branch: string & {
|
|
82
|
+
readonly __brand: "BranchName";
|
|
83
|
+
};
|
|
84
|
+
}, {
|
|
85
|
+
branch: string & {
|
|
86
|
+
readonly __brand: "BranchName";
|
|
87
|
+
};
|
|
88
|
+
}>, undefined, import("./branch-update-from-base.js").UpdateFromBaseResponse>;
|
|
77
89
|
readonly requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
78
90
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
79
91
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -86,9 +98,12 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
86
98
|
};
|
|
87
99
|
}>, z.ZodOptional<z.ZodObject<{
|
|
88
100
|
note: z.ZodOptional<z.ZodString>;
|
|
101
|
+
headSha: z.ZodOptional<z.ZodString>;
|
|
89
102
|
}, "strip", z.ZodTypeAny, {
|
|
103
|
+
headSha?: string | undefined;
|
|
90
104
|
note?: string | undefined;
|
|
91
105
|
}, {
|
|
106
|
+
headSha?: string | undefined;
|
|
92
107
|
note?: string | undefined;
|
|
93
108
|
}>>, BranchListItemResponse>;
|
|
94
109
|
readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -101,7 +116,13 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
101
116
|
branch: string & {
|
|
102
117
|
readonly __brand: "BranchName";
|
|
103
118
|
};
|
|
104
|
-
}>,
|
|
119
|
+
}>, z.ZodOptional<z.ZodObject<{
|
|
120
|
+
headSha: z.ZodOptional<z.ZodString>;
|
|
121
|
+
}, "strip", z.ZodTypeAny, {
|
|
122
|
+
headSha?: string | undefined;
|
|
123
|
+
}, {
|
|
124
|
+
headSha?: string | undefined;
|
|
125
|
+
}>>, BranchListItemResponse>;
|
|
105
126
|
readonly markMerged: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
106
127
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
107
128
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -3,6 +3,7 @@ import { branchParamSchema } from './validators.js';
|
|
|
3
3
|
import { toBranchListItem } from './branch.js';
|
|
4
4
|
import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
5
5
|
import { withdrawBranch } from './branch-withdraw.js';
|
|
6
|
+
import { updateFromBase } from './branch-update-from-base.js';
|
|
6
7
|
import { requestChanges, approveBranch } from './branch-review.js';
|
|
7
8
|
import { markAsMerged } from './branch-merge.js';
|
|
8
9
|
import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
|
|
@@ -14,6 +15,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
|
|
|
14
15
|
import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
15
16
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
16
17
|
import { NothingToSubmitError } from '../services.js';
|
|
18
|
+
import { OpenMergeConflictsError } from '../merge-conflicts.js';
|
|
17
19
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
18
20
|
import { shortNameOf } from '../branch-param.js';
|
|
19
21
|
import { resolveEditorUrl } from '../editor-url.js';
|
|
@@ -118,6 +120,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
118
120
|
if (err instanceof NothingToSubmitError) {
|
|
119
121
|
return { ok: false, status: 400, error: err.message };
|
|
120
122
|
}
|
|
123
|
+
if (err instanceof OpenMergeConflictsError) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
status: 409,
|
|
127
|
+
code: 'OPEN_CONFLICTS',
|
|
128
|
+
error: err.count === null
|
|
129
|
+
? err.message
|
|
130
|
+
: `${err.message} Choose what to keep in "Show changes", then submit again.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
121
133
|
const shortName = shortNameOf(ctx.services.config, branchContext.branch.name);
|
|
122
134
|
// Retriable either way: a resubmit commits nothing new and pushes only
|
|
123
135
|
// what has not reached the remote.
|
|
@@ -196,8 +208,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
196
208
|
status: 'submitted',
|
|
197
209
|
submittedAt,
|
|
198
210
|
...pushed,
|
|
199
|
-
//
|
|
200
|
-
|
|
211
|
+
// config.editorUrl, when set, still wins in every later re-render here; the worker, which
|
|
212
|
+
// has no config, links the conflicts status with this.
|
|
213
|
+
...(editorUrl ? { editorUrl } : {}),
|
|
201
214
|
syncFailureReason: prResult.syncFailureReason,
|
|
202
215
|
pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
|
|
203
216
|
pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
|
|
@@ -275,6 +288,7 @@ export const WORKFLOW_ROUTES = {
|
|
|
275
288
|
getStatus: getBranchStatus,
|
|
276
289
|
submit: submitBranchForMerge,
|
|
277
290
|
withdraw: withdrawBranch,
|
|
291
|
+
updateFromBase,
|
|
278
292
|
requestChanges: requestChanges,
|
|
279
293
|
approve: approveBranch,
|
|
280
294
|
markMerged: markAsMerged,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { ApiResponse } from './types.js';
|
|
3
|
+
import { type BranchListItem } from './branch.js';
|
|
4
|
+
/**
|
|
5
|
+
* A request for the latest changes: when it was made, which the editor waits on until the
|
|
6
|
+
* branch's `lastBaseUpdate.at` reaches it, and the branch as it stands with the request recorded.
|
|
7
|
+
*/
|
|
8
|
+
export type UpdateFromBaseResponse = ApiResponse<{
|
|
9
|
+
requestedAt: string;
|
|
10
|
+
branch: BranchListItem;
|
|
11
|
+
}>;
|
|
12
|
+
/** Get the latest changes: bring the base branch into this one at the worker's next chance. */
|
|
13
|
+
export declare const updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
14
|
+
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
15
|
+
}, "strip", z.ZodTypeAny, {
|
|
16
|
+
branch: string & {
|
|
17
|
+
readonly __brand: "BranchName";
|
|
18
|
+
};
|
|
19
|
+
}, {
|
|
20
|
+
branch: string & {
|
|
21
|
+
readonly __brand: "BranchName";
|
|
22
|
+
};
|
|
23
|
+
}>, undefined, UpdateFromBaseResponse>;
|