canopycms 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +211 -49
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +112 -23
- package/dist/api/branch-status.d.ts +11 -2
- package/dist/api/branch-status.js +3 -2
- package/dist/api/branch-update-from-base.js +57 -6
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +27 -2
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.js +2 -2
- package/dist/api/content.js +1 -1
- package/dist/api/merge-conflicts.d.ts +10 -1
- package/dist/api/merge-conflicts.js +42 -7
- package/dist/api/types.d.ts +3 -2
- package/dist/base-update.d.ts +6 -0
- package/dist/branch-metadata-file.d.ts +15 -0
- package/dist/branch-metadata-file.js +5 -1
- package/dist/cli/cli.js +238 -108
- package/dist/cli/generate-ai-content.js +20 -6
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +7 -1
- package/dist/editor/Editor.js +39 -16
- 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 +4 -0
- package/dist/editor/base-update-wait.js +7 -0
- package/dist/editor/components/ConflictsGroup.d.ts +13 -1
- package/dist/editor/components/ConflictsGroup.js +52 -12
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +11 -7
- package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
- package/dist/editor/components/EntryChangesDrawer.js +58 -6
- package/dist/editor/conflict-cards.d.ts +4 -1
- package/dist/editor/conflict-cards.js +14 -9
- package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
- package/dist/editor/hooks/useBaseUpdate.js +12 -2
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +80 -6
- package/dist/editor/hooks/useDraftManager.d.ts +8 -1
- package/dist/editor/hooks/useDraftManager.js +16 -1
- package/dist/editor/hooks/useEntryManager.d.ts +8 -3
- package/dist/editor/hooks/useEntryManager.js +6 -5
- package/dist/git-manager.d.ts +1 -0
- package/dist/git-manager.js +8 -3
- package/dist/merge/entry-merge.js +4 -3
- package/dist/merge/list-alignment.d.ts +9 -6
- package/dist/merge/list-alignment.js +65 -16
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/operating-mode/client-safe-strategy.js +1 -1
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +10 -2
- package/dist/services.d.ts +12 -4
- package/dist/services.js +56 -0
- package/dist/types.d.ts +35 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +27 -3
- package/dist/worker/branch-merge.js +106 -5
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +21 -0
- package/dist/worker/cms-worker.js +29 -4
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +17 -2
- package/dist/worker/field-merge.js +95 -2
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +9 -0
- package/dist/worker/github-commits.d.ts +3 -3
- package/dist/worker/github-commits.js +192 -40
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +7 -0
- package/dist/worker/github-gateway-wire.d.ts +30 -10
- package/dist/worker/github-gateway-wire.js +14 -2
- package/dist/worker/github-gateway.d.ts +17 -0
- package/dist/worker/github-gateway.js +37 -1
- 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 +25 -5
- package/dist/worker/rebase.js +225 -81
- package/dist/worker/task-runner.js +16 -39
- package/dist/worker/worker-context.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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 {};
|
|
@@ -12,10 +12,67 @@ import { getErrorMessage } from '../utils/error.js';
|
|
|
12
12
|
import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
|
|
13
13
|
import { getBranchProtection, isAdmin } from '../authorization/index.js';
|
|
14
14
|
import { sanitizeDisplayName } from '../submission-attribution.js';
|
|
15
|
-
|
|
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();
|
|
16
18
|
const requestChangesBodySchema = z
|
|
17
|
-
.object({
|
|
19
|
+
.object({
|
|
20
|
+
note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional(),
|
|
21
|
+
headSha: headShaSchema,
|
|
22
|
+
})
|
|
18
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
|
+
};
|
|
19
76
|
const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
|
|
20
77
|
/**
|
|
21
78
|
* The 409 for a branch that left review before this request arrived, because the reviewer's page
|
|
@@ -43,38 +100,62 @@ const approveConflict = (status) => ({
|
|
|
43
100
|
* The verdict `user` gives. The reviewer guard admits only authenticated users, and the record is
|
|
44
101
|
* always written, so a new verdict replaces whatever the last one left.
|
|
45
102
|
*/
|
|
46
|
-
const reviewBy = (user, state) => ({
|
|
103
|
+
const reviewBy = (user, state, commit) => ({
|
|
47
104
|
state,
|
|
48
105
|
by: {
|
|
49
106
|
userId: user.userId,
|
|
50
107
|
name: user.type === 'authenticated' ? sanitizeDisplayName(user.name) : undefined,
|
|
51
108
|
},
|
|
52
109
|
at: new Date().toISOString(),
|
|
110
|
+
commit,
|
|
53
111
|
});
|
|
54
112
|
const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
55
113
|
const { branchContext } = gc;
|
|
56
114
|
if (!canRequestChangesFrom(branchContext.branch.status)) {
|
|
57
115
|
return requestChangesConflict(branchContext.branch.status);
|
|
58
116
|
}
|
|
59
|
-
|
|
117
|
+
const headSha = body?.headSha;
|
|
60
118
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
61
119
|
// The status the guard read can be stale by now, so the save checks the status on disk, in the
|
|
62
|
-
// same step that writes. A branch that left review
|
|
63
|
-
//
|
|
64
|
-
// 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.
|
|
65
122
|
let found;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
155
|
if (!updated)
|
|
77
156
|
return requestChangesConflict(found);
|
|
157
|
+
// Best-effort: syncConvertToDraft only logs a failure.
|
|
158
|
+
await syncConvertToDraft(ctx, { ...branchContext, branch: updated.branch });
|
|
78
159
|
// The note is written after the status change so a failed write never blocks the branch from
|
|
79
160
|
// returning to editing; the caller is warned instead.
|
|
80
161
|
const note = body?.note;
|
|
@@ -106,8 +187,9 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
|
106
187
|
},
|
|
107
188
|
};
|
|
108
189
|
};
|
|
109
|
-
const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
190
|
+
const approveBranchHandler = async (gc, ctx, req, _params, body) => {
|
|
110
191
|
const { branchContext } = gc;
|
|
192
|
+
const headSha = body?.headSha;
|
|
111
193
|
// Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
|
|
112
194
|
// an author's approval review, so nothing is sent to GitHub.
|
|
113
195
|
if (branchContext.branch.createdBy === req.user.userId) {
|
|
@@ -132,22 +214,23 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
132
214
|
};
|
|
133
215
|
}
|
|
134
216
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
135
|
-
// As for request changes, the status on disk
|
|
136
|
-
// unfinished submit: what's approved may not be what the pull request holds. So do
|
|
137
|
-
// merge of GitHub's commits can record some on a branch in review, under the
|
|
138
|
-
// held here, so none lands between the check and the write.
|
|
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.
|
|
139
221
|
let found;
|
|
140
222
|
let unfinished = false;
|
|
141
223
|
let notQueued = false;
|
|
142
224
|
let updated;
|
|
143
225
|
try {
|
|
144
226
|
updated = await withContentWriteLock(branchContext.branchRoot, async () => {
|
|
227
|
+
const commit = await assertReviewedHead(ctx, branchContext, headSha);
|
|
145
228
|
await assertNoOpenConflicts(branchContext.branchRoot);
|
|
146
229
|
return meta.saveIf({
|
|
147
230
|
branch: {
|
|
148
231
|
name: branchContext.branch.name,
|
|
149
232
|
status: 'approved',
|
|
150
|
-
review: reviewBy(req.user, 'approved'),
|
|
233
|
+
review: reviewBy(req.user, 'approved', commit),
|
|
151
234
|
},
|
|
152
235
|
}, (existing) => {
|
|
153
236
|
found = existing?.branch.status;
|
|
@@ -158,6 +241,10 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
158
241
|
});
|
|
159
242
|
}
|
|
160
243
|
catch (err) {
|
|
244
|
+
if (err instanceof BranchMovedError)
|
|
245
|
+
return branchMoved;
|
|
246
|
+
if (err instanceof HeadUnreadableError)
|
|
247
|
+
return headUnreadable;
|
|
161
248
|
if (err instanceof OpenMergeConflictsError) {
|
|
162
249
|
return {
|
|
163
250
|
ok: false,
|
|
@@ -231,6 +318,8 @@ export const approveBranch = defineEndpoint({
|
|
|
231
318
|
method: 'POST',
|
|
232
319
|
path: '/:branch/approve',
|
|
233
320
|
params: branchParamSchema,
|
|
321
|
+
body: approveBodySchema,
|
|
322
|
+
bodyType: 'ApproveBody',
|
|
234
323
|
responseType: 'BranchListItemResponse',
|
|
235
324
|
response: {},
|
|
236
325
|
defaultMockData: {
|
|
@@ -3,7 +3,7 @@ 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
7
|
export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
|
|
8
8
|
/**
|
|
9
9
|
* A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
|
|
@@ -98,9 +98,12 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
98
98
|
};
|
|
99
99
|
}>, z.ZodOptional<z.ZodObject<{
|
|
100
100
|
note: z.ZodOptional<z.ZodString>;
|
|
101
|
+
headSha: z.ZodOptional<z.ZodString>;
|
|
101
102
|
}, "strip", z.ZodTypeAny, {
|
|
103
|
+
headSha?: string | undefined;
|
|
102
104
|
note?: string | undefined;
|
|
103
105
|
}, {
|
|
106
|
+
headSha?: string | undefined;
|
|
104
107
|
note?: string | undefined;
|
|
105
108
|
}>>, BranchListItemResponse>;
|
|
106
109
|
readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -113,7 +116,13 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
113
116
|
branch: string & {
|
|
114
117
|
readonly __brand: "BranchName";
|
|
115
118
|
};
|
|
116
|
-
}>,
|
|
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>;
|
|
117
126
|
readonly markMerged: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
118
127
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
119
128
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -208,8 +208,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
208
208
|
status: 'submitted',
|
|
209
209
|
submittedAt,
|
|
210
210
|
...pushed,
|
|
211
|
-
//
|
|
212
|
-
|
|
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 } : {}),
|
|
213
214
|
syncFailureReason: prResult.syncFailureReason,
|
|
214
215
|
pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
|
|
215
216
|
pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
|
|
@@ -3,7 +3,7 @@ import { toBranchListItem } from './branch.js';
|
|
|
3
3
|
import { defineEndpoint } from './route-builder.js';
|
|
4
4
|
import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
|
|
5
5
|
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
6
|
-
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
6
|
+
import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
|
|
7
7
|
import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
|
|
8
8
|
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
9
9
|
import { BASE_UPDATE_GIVE_UP_MS, BASE_UPDATE_REQUEST_INTERVAL_MS, SYNC_BRANCH_ACTION, isBaseUpdatePending, } from '../base-update.js';
|
|
@@ -17,6 +17,9 @@ import { baseBranchOf } from '../utils/base-branch.js';
|
|
|
17
17
|
*
|
|
18
18
|
* The request is recorded on branch.json, which is what the worker answers, so a lost task or a
|
|
19
19
|
* worker restart costs only the wait for its next regular cycle. The task only wakes it early.
|
|
20
|
+
* Where the mode runs no worker loop (operating-mode `baseUpdateRunsInRequest`), a new request runs
|
|
21
|
+
* the worker's merge itself and is answered before the response; one it joins is answered by the
|
|
22
|
+
* run that made it.
|
|
20
23
|
* A request joins the last one, answering with its time: while that is unanswered, under
|
|
21
24
|
* BASE_UPDATE_GIVE_UP_MS old and either under BASE_UPDATE_REQUEST_INTERVAL_MS old or still queued;
|
|
22
25
|
* or once it was answered under BASE_UPDATE_REQUEST_INTERVAL_MS ago without failing. Anything
|
|
@@ -26,6 +29,7 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
|
|
|
26
29
|
const { branchContext } = gc;
|
|
27
30
|
const { config } = ctx.services;
|
|
28
31
|
const { branch } = branchContext;
|
|
32
|
+
const runsInRequest = operatingStrategy(config.mode).baseUpdateRunsInRequest();
|
|
29
33
|
if (!clientOperatingStrategy(config.mode).syncsBranchesWithBase()) {
|
|
30
34
|
return {
|
|
31
35
|
ok: false,
|
|
@@ -86,7 +90,8 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
|
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
const taskDir = getTaskQueueDir(config);
|
|
89
|
-
const queued =
|
|
93
|
+
const queued = !runsInRequest &&
|
|
94
|
+
(await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
|
|
90
95
|
const now = Date.now();
|
|
91
96
|
let requestedAt = new Date(now).toISOString();
|
|
92
97
|
let joined = false;
|
|
@@ -105,9 +110,11 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
|
|
|
105
110
|
const answer = existing.branch.lastBaseUpdate;
|
|
106
111
|
const pending = isBaseUpdatePending(existing.branch);
|
|
107
112
|
// An unanswered request is joined until the editor stops waiting on it. An answer under the
|
|
108
|
-
// interval old stands for a new click too, except a failure, whose Retry must ask again
|
|
113
|
+
// interval old stands for a new click too, except a failure, whose Retry must ask again, and
|
|
114
|
+
// except where the request runs the merge itself, which costs no worker cycle.
|
|
109
115
|
const joinsPending = pending && age < BASE_UPDATE_GIVE_UP_MS && (queued || age < BASE_UPDATE_REQUEST_INTERVAL_MS);
|
|
110
|
-
const answeredRecently = !
|
|
116
|
+
const answeredRecently = !runsInRequest &&
|
|
117
|
+
!pending &&
|
|
111
118
|
answer !== undefined &&
|
|
112
119
|
answer.outcome !== 'failed' &&
|
|
113
120
|
now - Date.parse(answer.at) < BASE_UPDATE_REQUEST_INTERVAL_MS;
|
|
@@ -127,12 +134,56 @@ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
|
|
|
127
134
|
: "This branch can't be edited, so it can't get the latest changes.",
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
|
-
|
|
137
|
+
let current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
|
|
131
138
|
if (current?.branch.name !== branch.name) {
|
|
132
139
|
return { ok: false, status: 404, error: 'This branch no longer exists.' };
|
|
133
140
|
}
|
|
141
|
+
// A joined request is answered by the run that made it.
|
|
142
|
+
if (runsInRequest && !joined) {
|
|
143
|
+
// Answered here where the run couldn't, as nothing else would: an open request holds the form
|
|
144
|
+
// read-only.
|
|
145
|
+
const answerFailed = () => meta
|
|
146
|
+
.update((existing) => existing?.branch.name === branch.name &&
|
|
147
|
+
existing.branch.baseUpdateRequestedAt === requestedAt &&
|
|
148
|
+
isBaseUpdatePending(existing.branch)
|
|
149
|
+
? { branch: { lastBaseUpdate: { at: requestedAt, outcome: 'failed' } } }
|
|
150
|
+
: null, { keepUpdatedAt: true })
|
|
151
|
+
.catch(() => undefined);
|
|
152
|
+
let run;
|
|
153
|
+
try {
|
|
154
|
+
run = await ctx.services.runBaseUpdateNow(branchContext);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error(`CanopyCMS: Could not bring the latest changes into ${branch.name}:`, redactCredentials(getErrorMessage(err)));
|
|
158
|
+
await answerFailed();
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 500,
|
|
162
|
+
error: "The latest changes couldn't be added. Your work is safe.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (run === 'other-remote') {
|
|
166
|
+
await answerFailed();
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 409,
|
|
170
|
+
error: "This branch uses a git remote other than the local one, so its latest changes can't be brought in here.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (run === 'busy') {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
status: 409,
|
|
177
|
+
error: "This branch is busy with a save or another update, so the latest changes weren't added. Try again in a moment.",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
current = await BranchMetadataFileManager.loadOnly(branchContext.branchRoot);
|
|
181
|
+
if (current?.branch.name !== branch.name) {
|
|
182
|
+
return { ok: false, status: 404, error: 'This branch no longer exists.' };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
134
185
|
// A wake-up already queued serves the new request too.
|
|
135
|
-
if (!joined && !queued) {
|
|
186
|
+
if (!runsInRequest && !joined && !queued) {
|
|
136
187
|
try {
|
|
137
188
|
await enqueueTask(taskDir, { action: SYNC_BRANCH_ACTION, payload: { branch: branch.name } });
|
|
138
189
|
}
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOve
|
|
|
60
60
|
users: number;
|
|
61
61
|
groups: number;
|
|
62
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* The commit a branch in review held when it was listed, which Approve and Request changes send
|
|
65
|
+
* back to say what the reviewer saw (api/branch-review.ts). Only on a listing.
|
|
66
|
+
*/
|
|
67
|
+
headSha?: string;
|
|
63
68
|
}
|
|
64
69
|
/**
|
|
65
70
|
* Response type for branch creation and the workflow transitions. Carries the
|
package/dist/api/branch.js
CHANGED
|
@@ -11,7 +11,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
|
11
11
|
import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
|
|
12
12
|
import { getErrorMessage } from '../utils/error.js';
|
|
13
13
|
import { filePathExists } from '../utils/fs.js';
|
|
14
|
-
import { isNetworkRemoteUrl } from '../utils/git.js';
|
|
14
|
+
import { isNetworkRemoteUrl, readHeadCommitFromFiles } from '../utils/git.js';
|
|
15
15
|
import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
|
|
16
16
|
import { GitManager } from '../git-manager.js';
|
|
17
17
|
import { syncDeleteRemoteBranch } from './github-sync.js';
|
|
@@ -521,17 +521,42 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
|
|
|
521
521
|
console.error('CanopyCMS: Failed to look up branch owners:', getErrorMessage(error));
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
|
+
const heads = await Promise.all(branches.map((context) => reviewedHead(ctx, context)));
|
|
524
525
|
return {
|
|
525
526
|
ok: true,
|
|
526
527
|
status: 200,
|
|
527
528
|
data: {
|
|
528
|
-
branches: branches.map((context) =>
|
|
529
|
+
branches: branches.map((context, i) => {
|
|
530
|
+
const item = toBranchListItem(ctx.services.config, context.branch, req.user);
|
|
531
|
+
const headSha = heads[i];
|
|
532
|
+
return headSha ? { ...item, headSha } : item;
|
|
533
|
+
}),
|
|
529
534
|
defaultBranch,
|
|
530
535
|
users,
|
|
531
536
|
serverTime: Date.now(),
|
|
532
537
|
},
|
|
533
538
|
};
|
|
534
539
|
};
|
|
540
|
+
/**
|
|
541
|
+
* A branch in review's head commit, from the clone's files, so a listing of many spawns no git;
|
|
542
|
+
* git reads only a layout the files can't answer. Undefined when unreadable: the editor then has
|
|
543
|
+
* nothing to send, and the review is refused until a listing reads it.
|
|
544
|
+
*/
|
|
545
|
+
const reviewedHead = async (ctx, { branch, branchRoot }) => {
|
|
546
|
+
if (branch.status !== 'submitted' && branch.status !== 'approved')
|
|
547
|
+
return undefined;
|
|
548
|
+
try {
|
|
549
|
+
const fromFiles = await readHeadCommitFromFiles(branchRoot);
|
|
550
|
+
if (fromFiles)
|
|
551
|
+
return fromFiles;
|
|
552
|
+
const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
|
|
553
|
+
return await git.headSha();
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
535
560
|
/** A person's delete, as deletion by age never does, can take commits only the branch still has. */
|
|
536
561
|
const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
|
|
537
562
|
(branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
|
package/dist/api/client.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse,
|
|
|
12
12
|
import type { BranchChangesResponse } from './branch-changes.js';
|
|
13
13
|
import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
|
|
14
14
|
import type { EntryConflictResponse, MergeConflictsResponse, ReleaseConflictsHoldResponse, ResolveEntryConflictBody, ResolveEntryConflictResponse } from './merge-conflicts.js';
|
|
15
|
-
import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
|
|
15
|
+
import type { ApproveBody, BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
|
|
16
16
|
import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
|
|
17
17
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
18
18
|
import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
|
|
@@ -114,7 +114,7 @@ export declare class CanopyApiClient {
|
|
|
114
114
|
/** POST /:branch/request-changes */
|
|
115
115
|
requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
116
116
|
/** POST /:branch/approve */
|
|
117
|
-
approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
117
|
+
approve: (params: Record<string, string>, body: ApproveBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
118
118
|
/** POST /:branch/mark-merged */
|
|
119
119
|
markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
|
|
120
120
|
/** POST /:branch/archive */
|
package/dist/api/client.js
CHANGED
|
@@ -87,8 +87,8 @@ export class CanopyApiClient {
|
|
|
87
87
|
return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
|
|
88
88
|
},
|
|
89
89
|
/** POST /:branch/approve */
|
|
90
|
-
approve: (params, options) => {
|
|
91
|
-
return this.request('POST', this.buildPath('/:branch/approve', params),
|
|
90
|
+
approve: (params, body, options) => {
|
|
91
|
+
return this.request('POST', this.buildPath('/:branch/approve', params), body, 'standard', options);
|
|
92
92
|
},
|
|
93
93
|
/** POST /:branch/mark-merged */
|
|
94
94
|
markMerged: (params, options) => {
|
package/dist/api/content.js
CHANGED
|
@@ -493,7 +493,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
493
493
|
if (!id || resolving.length === 0)
|
|
494
494
|
return;
|
|
495
495
|
try {
|
|
496
|
-
resolvedConflicts = await clearFieldConflicts(branchContext, id, resolving);
|
|
496
|
+
resolvedConflicts = await clearFieldConflicts(ctx.services.config, branchContext, id, resolving);
|
|
497
497
|
}
|
|
498
498
|
catch (err) {
|
|
499
499
|
canopyLogWarn(`CanopyCMS: saved ${logicalPathSegments.join('/')} but couldn't mark its conflicts resolved:`, getErrorMessage(err));
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import type { BranchContext } from '../types.js';
|
|
9
9
|
import type { ApiResponse } from './types.js';
|
|
10
|
+
import type { CanopyConfig } from '../config/index.js';
|
|
10
11
|
import type { DataPathSegment } from '../editor/entry-diff.js';
|
|
11
12
|
import type { FieldConflictKind, SidePaths } from '../merge/entry-merge.js';
|
|
12
13
|
import { type MergeConflictKind, type MergeSides, type ResolvedFieldConflict } from '../merge-conflicts.js';
|
|
@@ -105,7 +106,7 @@ export declare const resolvedConflictsSchema: z.ZodArray<z.ZodObject<{
|
|
|
105
106
|
* call once the entry is written, inside its locks: the record's own locks come after them in
|
|
106
107
|
* the one lock order (docs/concurrency.md).
|
|
107
108
|
*/
|
|
108
|
-
export declare function clearFieldConflicts(branchContext: BranchContext, contentId: string, resolved: readonly ResolvedFieldConflict[]): Promise<ResolvedFieldConflict[]>;
|
|
109
|
+
export declare function clearFieldConflicts(config: Pick<CanopyConfig, 'mode'>, branchContext: BranchContext, contentId: string, resolved: readonly ResolvedFieldConflict[]): Promise<ResolvedFieldConflict[]>;
|
|
109
110
|
declare const resolveBodySchema: z.ZodObject<{
|
|
110
111
|
/** The content id the record names the entry by. */
|
|
111
112
|
id: z.ZodType<ContentId, z.ZodTypeDef, ContentId>;
|
|
@@ -118,18 +119,22 @@ declare const resolveBodySchema: z.ZodObject<{
|
|
|
118
119
|
* field by field with the latest version (`unreadable`).
|
|
119
120
|
*/
|
|
120
121
|
choice: z.ZodEnum<["keep", "delete", "restore", "use-latest"]>;
|
|
122
|
+
/** The card promised the entry stays deleted: a `keep` is refused if it is in the tree now. */
|
|
123
|
+
entryGone: z.ZodOptional<z.ZodLiteral<true>>;
|
|
121
124
|
}, "strip", z.ZodTypeAny, {
|
|
122
125
|
id: string & {
|
|
123
126
|
readonly __brand: "ContentId";
|
|
124
127
|
};
|
|
125
128
|
mergeSha: string;
|
|
126
129
|
choice: "delete" | "restore" | "keep" | "use-latest";
|
|
130
|
+
entryGone?: true | undefined;
|
|
127
131
|
}, {
|
|
128
132
|
id: string & {
|
|
129
133
|
readonly __brand: "ContentId";
|
|
130
134
|
};
|
|
131
135
|
mergeSha: string;
|
|
132
136
|
choice: "delete" | "restore" | "keep" | "use-latest";
|
|
137
|
+
entryGone?: true | undefined;
|
|
133
138
|
}>;
|
|
134
139
|
export type ResolveEntryConflictBody = z.infer<typeof resolveBodySchema>;
|
|
135
140
|
export type ReleaseConflictsHoldResponse = ApiResponse<{
|
|
@@ -209,18 +214,22 @@ export declare const MERGE_CONFLICT_ROUTES: {
|
|
|
209
214
|
* field by field with the latest version (`unreadable`).
|
|
210
215
|
*/
|
|
211
216
|
choice: z.ZodEnum<["keep", "delete", "restore", "use-latest"]>;
|
|
217
|
+
/** The card promised the entry stays deleted: a `keep` is refused if it is in the tree now. */
|
|
218
|
+
entryGone: z.ZodOptional<z.ZodLiteral<true>>;
|
|
212
219
|
}, "strip", z.ZodTypeAny, {
|
|
213
220
|
id: string & {
|
|
214
221
|
readonly __brand: "ContentId";
|
|
215
222
|
};
|
|
216
223
|
mergeSha: string;
|
|
217
224
|
choice: "delete" | "restore" | "keep" | "use-latest";
|
|
225
|
+
entryGone?: true | undefined;
|
|
218
226
|
}, {
|
|
219
227
|
id: string & {
|
|
220
228
|
readonly __brand: "ContentId";
|
|
221
229
|
};
|
|
222
230
|
mergeSha: string;
|
|
223
231
|
choice: "delete" | "restore" | "keep" | "use-latest";
|
|
232
|
+
entryGone?: true | undefined;
|
|
224
233
|
}>, ResolveEntryConflictResponse>;
|
|
225
234
|
};
|
|
226
235
|
export {};
|