canopycms 0.12.0 → 0.12.1
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 +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- 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 +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- 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 +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -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 +429 -52
- 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/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -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 +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -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 +90 -0
- package/dist/editor/conflict-cards.js +279 -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 +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- 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 +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- 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 +5 -0
- package/dist/git-manager.js +30 -0
- 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 +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -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/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- 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/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 +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ export declare class ProdClientSafeStrategy implements ClientSafeStrategy {
|
|
|
9
9
|
supportsBranching(): boolean;
|
|
10
10
|
supportsComments(): boolean;
|
|
11
11
|
supportsPullRequests(): boolean;
|
|
12
|
+
syncsBranchesWithBase(): boolean;
|
|
12
13
|
getPermissionsFileName(): string;
|
|
13
14
|
getGroupsFileName(): string;
|
|
14
15
|
shouldCommit(): boolean;
|
|
@@ -19,6 +20,7 @@ export declare class DevClientSafeStrategy implements ClientSafeStrategy {
|
|
|
19
20
|
supportsBranching(): boolean;
|
|
20
21
|
supportsComments(): boolean;
|
|
21
22
|
supportsPullRequests(): boolean;
|
|
23
|
+
syncsBranchesWithBase(): boolean;
|
|
22
24
|
getPermissionsFileName(): string;
|
|
23
25
|
getGroupsFileName(): string;
|
|
24
26
|
shouldCommit(): boolean;
|
|
@@ -16,6 +16,9 @@ export class ProdClientSafeStrategy {
|
|
|
16
16
|
supportsPullRequests() {
|
|
17
17
|
return true;
|
|
18
18
|
}
|
|
19
|
+
syncsBranchesWithBase() {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
19
22
|
getPermissionsFileName() {
|
|
20
23
|
return 'permissions.json';
|
|
21
24
|
}
|
|
@@ -42,6 +45,9 @@ export class DevClientSafeStrategy {
|
|
|
42
45
|
supportsPullRequests() {
|
|
43
46
|
return false; // No real GitHub in local dev mode
|
|
44
47
|
}
|
|
48
|
+
syncsBranchesWithBase() {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
45
51
|
getPermissionsFileName() {
|
|
46
52
|
return 'permissions.json';
|
|
47
53
|
}
|
|
@@ -33,6 +33,11 @@ export interface ClientSafeStrategy {
|
|
|
33
33
|
supportsBranching(): boolean;
|
|
34
34
|
supportsComments(): boolean;
|
|
35
35
|
supportsPullRequests(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a worker brings the base branch into editing branches, so a request for the latest
|
|
38
|
+
* changes (base-update.ts) gets an answer. Dev runs no git sync.
|
|
39
|
+
*/
|
|
40
|
+
syncsBranchesWithBase(): boolean;
|
|
36
41
|
/** Get the permissions file name (e.g., 'permissions.json' or 'permissions.local.json') */
|
|
37
42
|
getPermissionsFileName(): string;
|
|
38
43
|
/** Get the groups file name (e.g., 'groups.json' or 'groups.local.json') */
|
package/dist/services.d.ts
CHANGED
|
@@ -79,13 +79,28 @@ export interface CanopyServices {
|
|
|
79
79
|
files: string | string[];
|
|
80
80
|
message: string;
|
|
81
81
|
}) => Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Commit the branch's saved but uncommitted work in its clone, pushing nothing. A later submit
|
|
84
|
+
* pushes it, or, on a branch published before, the worker's merge of the base once it moves
|
|
85
|
+
* (worker/rebase.ts, `aheadOfPublished`). Its trailers name the editors who saved since the last
|
|
86
|
+
* commit.
|
|
87
|
+
* Holds the branch's content-write lock; rejects with `ContentWriteLockBusyError` when busy.
|
|
88
|
+
* Resolves whether there was anything to commit.
|
|
89
|
+
*/
|
|
90
|
+
commitSavedWork: (options: {
|
|
91
|
+
context: BranchContext;
|
|
92
|
+
message: string;
|
|
93
|
+
/** Names the editors; without it they are credited by id. */
|
|
94
|
+
lookupEditor?: EditorLookup;
|
|
95
|
+
}) => Promise<boolean>;
|
|
82
96
|
/**
|
|
83
97
|
* Submit branch: fill new entries' empty `stampOnPublish` fields
|
|
84
98
|
* (stamp-on-publish.ts), commit all changes (with the submitter's trailers)
|
|
85
99
|
* and push to remote. Resolves with every path the branch changes against its base,
|
|
86
100
|
* and with `stamped`, the entries this submit stamped.
|
|
87
101
|
* Holds the branch's content-write lock; rejects with
|
|
88
|
-
* `ContentWriteLockBusyError` when busy,
|
|
102
|
+
* `ContentWriteLockBusyError` when busy, with `OpenMergeConflictsError` before committing while
|
|
103
|
+
* a merge's conflicts are open (merge-conflicts.ts), and with `NothingToSubmitError`
|
|
89
104
|
* before pushing when the branch changes nothing.
|
|
90
105
|
*/
|
|
91
106
|
submitBranch: (options: {
|
package/dist/services.js
CHANGED
|
@@ -30,14 +30,15 @@ import { enqueueTask } from './task-queue/cms-task-queue.js';
|
|
|
30
30
|
import { getTaskQueueDir } from './task-queue/task-queue-config.js';
|
|
31
31
|
import { baseBranchOf } from './utils/base-branch.js';
|
|
32
32
|
import { shortNameOf } from './branch-param.js';
|
|
33
|
-
import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
|
|
33
|
+
import { detectHeadBranch, isCanopyInternalPath, isRebaseInProgress } from './utils/git.js';
|
|
34
34
|
import { readsFromCheckout } from './build-mode.js';
|
|
35
35
|
import { timeRequestPhase } from './utils/request-timing.js';
|
|
36
36
|
import { appendTrailers, buildEditorTrailers, describeEditors, MAX_LISTED_ENTRIES, } from './submission-attribution.js';
|
|
37
37
|
import { getBranchMetadataFileManager, recordBranchEditor } from './branch-metadata.js';
|
|
38
38
|
import { readBranchMetadataFile } from './branch-metadata-file.js';
|
|
39
39
|
import { getErrorMessage, redactCredentials } from './utils/error.js';
|
|
40
|
-
import { withContentWriteLock } from './utils/content-write-lock.js';
|
|
40
|
+
import { ContentWriteLockBusyError, withContentWriteLock } from './utils/content-write-lock.js';
|
|
41
|
+
import { assertNoOpenConflicts } from './merge-conflicts.js';
|
|
41
42
|
import { ContentStore } from './content-store.js';
|
|
42
43
|
import { entryLogicalPathOfFile } from './content-id-index.js';
|
|
43
44
|
import { stampNewEntries } from './stamp-on-publish.js';
|
|
@@ -213,10 +214,77 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
213
214
|
// lands on its detached head with the rebase's half-resolved index, and the
|
|
214
215
|
// rebase's `--abort` discards it.
|
|
215
216
|
await withContentWriteLock(options.context.branchRoot, async () => {
|
|
217
|
+
await refuseDuringRebase(options.context.branchRoot);
|
|
216
218
|
await git.add(options.files);
|
|
217
219
|
await git.commit(options.message);
|
|
218
220
|
});
|
|
219
221
|
};
|
|
222
|
+
/**
|
|
223
|
+
* Inside the content-write lock, before a commit: a rebase left by a worker that died holds no
|
|
224
|
+
* lock, and its recovery (`rebase --abort`, worker/rebase.ts) would reset the branch past the
|
|
225
|
+
* commit, silently. In prod the worker clears it on its next cycle, so this is a retry.
|
|
226
|
+
*/
|
|
227
|
+
const refuseDuringRebase = async (branchRoot) => {
|
|
228
|
+
if (await isRebaseInProgress(branchRoot))
|
|
229
|
+
throw new ContentWriteLockBusyError();
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Commit every saved change but canopycms's own state, if there is one, with trailers naming
|
|
233
|
+
* `credited` and then the editors who saved since the last commit. Runs inside the caller's
|
|
234
|
+
* content-write lock. Resolves whether it committed.
|
|
235
|
+
*/
|
|
236
|
+
const commitSavedChanges = async (git, status, commit) => {
|
|
237
|
+
if (!status.files.some((f) => !isCanopyInternalPath(f.path)))
|
|
238
|
+
return false;
|
|
239
|
+
await git.addAllExceptCanopyState();
|
|
240
|
+
const committers = commit.uncommitted.map((userId) => commit.named.get(userId) ?? { userId });
|
|
241
|
+
const trailers = buildEditorTrailers(commit.credited ? [commit.credited, ...committers] : committers, {
|
|
242
|
+
editedBy: config.gitEditedByTrailers ?? true,
|
|
243
|
+
coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
|
|
244
|
+
});
|
|
245
|
+
await git.commit(appendTrailers(commit.message, trailers));
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
/** Clear the editors a commit named; a failure leaves them for the next commit to name again. */
|
|
249
|
+
const markCommitted = async (context, uncommitted) => {
|
|
250
|
+
if (uncommitted.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
try {
|
|
253
|
+
await getBranchMetadataFileManager(context.branchRoot, context.baseRoot).markEditorsCommitted(uncommitted);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
console.warn(`CanopyCMS: Could not clear the committed editors of ${context.branch.name}:`, getErrorMessage(err));
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
/** The editors recorded on the branch, each with the name `lookupEditor` gives. */
|
|
260
|
+
const namedEditors = async (context, lookupEditor) => new Map((await describeEditors((await readRecordedEditors(context))?.editors ?? [], memoizeLookup(lookupEditor))).map((e) => [e.userId, e]));
|
|
261
|
+
const commitSavedWork = async (options) => {
|
|
262
|
+
const git = createGitManagerFor(options.context.branchRoot, {
|
|
263
|
+
baseBranch: options.context.branch.baseBranch,
|
|
264
|
+
});
|
|
265
|
+
await git.ensureAuthor({ name: config.gitBotAuthorName, email: config.gitBotAuthorEmail });
|
|
266
|
+
// Names are looked up before taking the lock, so a slow auth provider never holds it.
|
|
267
|
+
const named = await namedEditors(options.context, options.lookupEditor);
|
|
268
|
+
// [SYNC-C1] Locked for the same reason as submitBranch: a commit landing while a rebase is
|
|
269
|
+
// stopped on a conflict is discarded by its abort.
|
|
270
|
+
const committed = await withContentWriteLock(options.context.branchRoot, async () => {
|
|
271
|
+
await refuseDuringRebase(options.context.branchRoot);
|
|
272
|
+
await git.checkoutBranch(options.context.branch.name);
|
|
273
|
+
const status = await git.status();
|
|
274
|
+
const uncommitted = (await readRecordedEditors(options.context))?.uncommittedEditors ?? [];
|
|
275
|
+
const done = await commitSavedChanges(git, status, {
|
|
276
|
+
message: options.message,
|
|
277
|
+
uncommitted,
|
|
278
|
+
named,
|
|
279
|
+
});
|
|
280
|
+
// Inside the lock, as submitBranch does: a save after it would record its editor as still
|
|
281
|
+
// uncommitted, and this would then clear them with the save left out of the commit.
|
|
282
|
+
if (done)
|
|
283
|
+
await markCommitted(options.context, uncommitted);
|
|
284
|
+
return done;
|
|
285
|
+
});
|
|
286
|
+
return committed;
|
|
287
|
+
};
|
|
220
288
|
/** Runs inside submitBranch's content-write lock; a stamp that fails never blocks the submit. */
|
|
221
289
|
const stampNewEntriesOrWarn = async (context, git) => {
|
|
222
290
|
const contentRoot = services.config.contentRoot || 'content';
|
|
@@ -286,6 +354,9 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
286
354
|
let recorded = {};
|
|
287
355
|
let stamped = [];
|
|
288
356
|
const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
|
|
357
|
+
await refuseDuringRebase(options.context.branchRoot);
|
|
358
|
+
// In the lock, as the worker records a merge's conflicts in it before the merge lands.
|
|
359
|
+
await assertNoOpenConflicts(options.context.branchRoot);
|
|
289
360
|
await git.checkoutBranch(options.context.branch.name);
|
|
290
361
|
// Before the status read, so the stamp joins this commit even when an earlier attempt
|
|
291
362
|
// already committed the entry and only its push failed.
|
|
@@ -303,18 +374,13 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
303
374
|
// branch already had unpushed commits from an earlier attempt.
|
|
304
375
|
// canopycms's own state under .canopy-meta/ is never content, so it neither
|
|
305
376
|
// makes a commit worth creating nor gets staged into one.
|
|
306
|
-
let committed = false;
|
|
307
377
|
const preCommitSha = await git.headSha();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
});
|
|
315
|
-
await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
|
|
316
|
-
committed = true;
|
|
317
|
-
}
|
|
378
|
+
const committed = await commitSavedChanges(git, status, {
|
|
379
|
+
message: options.message ?? `Submit ${options.context.branch.name}`,
|
|
380
|
+
credited: options.submitter,
|
|
381
|
+
uncommitted,
|
|
382
|
+
named,
|
|
383
|
+
});
|
|
318
384
|
// The diff runs after the commit so it covers saved-but-uncommitted edits too, and
|
|
319
385
|
// before the push so a branch with nothing to submit never reaches the remote. A diff
|
|
320
386
|
// that cannot be computed lets the submit through: the list only feeds the PR body, and
|
|
@@ -333,15 +399,8 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
333
399
|
await git.resetKeepingChanges(preCommitSha);
|
|
334
400
|
throw new NothingToSubmitError(shortNameOf(config, options.context.branch.name), effectiveBase);
|
|
335
401
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
await getBranchMetadataFileManager(options.context.branchRoot, options.context.baseRoot).markEditorsCommitted(uncommitted);
|
|
340
|
-
}
|
|
341
|
-
catch (err) {
|
|
342
|
-
console.warn(`CanopyCMS: Could not clear the committed editors of ${options.context.branch.name}:`, getErrorMessage(err));
|
|
343
|
-
}
|
|
344
|
-
}
|
|
402
|
+
if (committed)
|
|
403
|
+
await markCommitted(options.context, uncommitted);
|
|
345
404
|
if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
|
|
346
405
|
await git.push(options.context.branch.name);
|
|
347
406
|
}
|
|
@@ -467,6 +526,7 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
467
526
|
githubService,
|
|
468
527
|
bootstrapAdminIds,
|
|
469
528
|
commitFiles,
|
|
529
|
+
commitSavedWork,
|
|
470
530
|
submitBranch,
|
|
471
531
|
commitToSettingsBranch,
|
|
472
532
|
getSettingsBranchRoot,
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Task, TaskQueueLogger } from './types.js';
|
|
7
7
|
/** Actions the EC2 worker can execute on behalf of Lambda. */
|
|
8
|
-
export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch' | 'sync-pr-comments'
|
|
8
|
+
export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch' | 'sync-pr-comments'
|
|
9
|
+
/** Wake the git sync early for `payload.branch` (base-update.ts). */
|
|
10
|
+
| 'sync-branch';
|
|
9
11
|
/** A task with a CMS-specific action. */
|
|
10
12
|
export type WorkerTask = Task & {
|
|
11
13
|
action: TaskAction;
|
package/dist/types.d.ts
CHANGED
|
@@ -115,9 +115,12 @@ export interface BranchMetadata {
|
|
|
115
115
|
editorUrl?: string;
|
|
116
116
|
/** Sync status for async GitHub operations (used when Lambda has no internet) */
|
|
117
117
|
syncStatus?: SyncStatus;
|
|
118
|
-
/** Whether
|
|
118
|
+
/** Whether {@link conflictFiles} names any entry */
|
|
119
119
|
conflictStatus?: ConflictStatus;
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* ContentIds of the entries with a merge conflict a person has yet to resolve, derived from the
|
|
122
|
+
* clone's merge-conflicts.json (merge-conflicts.ts); cleared when the branch merges
|
|
123
|
+
*/
|
|
121
124
|
conflictFiles?: ContentId[];
|
|
122
125
|
/**
|
|
123
126
|
* Lifecycle state of the branch's PR as last observed by the worker's merge-poll
|
|
@@ -217,6 +220,29 @@ export interface BranchMetadata {
|
|
|
217
220
|
uncommittedEditors?: CanopyUserId[];
|
|
218
221
|
/** The reviewer's verdict on the last submit; absent until a reviewer acts. */
|
|
219
222
|
review?: BranchReview;
|
|
223
|
+
/**
|
|
224
|
+
* When someone last asked for the latest changes (api/branch-update-from-base.ts). The request
|
|
225
|
+
* stands while this is newer than `lastBaseUpdate.at`; the editor holds the form read-only and
|
|
226
|
+
* polls meanwhile, and the worker answers it even after a restart lost its wake-up.
|
|
227
|
+
*/
|
|
228
|
+
baseUpdateRequestedAt?: string;
|
|
229
|
+
/** The worker's answer to the latest request for the latest changes (worker/rebase.ts). */
|
|
230
|
+
lastBaseUpdate?: LastBaseUpdate;
|
|
231
|
+
}
|
|
232
|
+
/** How a requested update from the base went. */
|
|
233
|
+
export type BaseUpdateOutcome = 'merged' | 'up-to-date' | 'conflicts' | 'failed';
|
|
234
|
+
export interface LastBaseUpdate {
|
|
235
|
+
/**
|
|
236
|
+
* When the GitHub fetch began that the branch was brought up to, as the gateway reports it
|
|
237
|
+
* (worker/github-gateway.ts, `GitHubFetchResult.startedAt`): a request is answered only from a
|
|
238
|
+
* fetch begun at or after it, so a cycle already running when it arrived never ends it.
|
|
239
|
+
*/
|
|
240
|
+
at: string;
|
|
241
|
+
outcome: BaseUpdateOutcome;
|
|
242
|
+
/** The base commit the branch now contains; absent when it failed before reading one. */
|
|
243
|
+
baseSha?: string;
|
|
244
|
+
/** For `conflicts`: how many entries the merge left a conflict in for a person. */
|
|
245
|
+
keptEntries?: number;
|
|
220
246
|
}
|
|
221
247
|
export interface BranchPaths {
|
|
222
248
|
/** Root where all branches live (e.g., /mnt/efs/site, ~/.canopycms/branches) */
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* (validation/entry-validator.ts) at the API boundary, not by a schema-blind serialiser.
|
|
18
18
|
*/
|
|
19
19
|
import type { FieldConfig } from '../config/index.js';
|
|
20
|
-
import {
|
|
20
|
+
import type { MarkdownBodyFormat } from './markdown-parse.js';
|
|
21
21
|
/**
|
|
22
22
|
* Serialise entry data as a YAML file, carrying the comments of `existingRaw` through.
|
|
23
23
|
*
|
|
@@ -39,3 +39,20 @@ export declare function serializeYaml(data: Record<string, unknown>, existingRaw
|
|
|
39
39
|
* what the read path reads, so a `date` field's bare date counts as the day it writes.
|
|
40
40
|
*/
|
|
41
41
|
export declare function serializeFrontmatter(editorBody: string, data: Record<string, unknown>, existingRaw: string | undefined, format: MarkdownBodyFormat, fields?: readonly FieldConfig[]): string;
|
|
42
|
+
/** What a save hands the serialiser: `ContentStore.write`'s input, less its version token. */
|
|
43
|
+
export type SerializableEntry = {
|
|
44
|
+
format: MarkdownBodyFormat;
|
|
45
|
+
data?: Record<string, unknown>;
|
|
46
|
+
body: string;
|
|
47
|
+
} | {
|
|
48
|
+
format: 'json';
|
|
49
|
+
data: Record<string, unknown>;
|
|
50
|
+
} | {
|
|
51
|
+
format: 'yaml';
|
|
52
|
+
data: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* An entry's file text as a save writes it: `entry` serialised onto `existingRaw`, the file's
|
|
56
|
+
* current text (absent for a new file). `fields` is the entry type's schema.
|
|
57
|
+
*/
|
|
58
|
+
export declare function serializeEntry(entry: SerializableEntry, existingRaw: string | undefined, fields: readonly FieldConfig[]): string;
|
|
@@ -24,6 +24,7 @@ import { ITEM_KEY, isItemKey } from '../item-keys.js';
|
|
|
24
24
|
import { withCalendarDays } from './calendar-day.js';
|
|
25
25
|
import { createDebugLogger } from './debug.js';
|
|
26
26
|
import { getErrorMessage } from './error.js';
|
|
27
|
+
import { serializeJson } from './json-source-splice.js';
|
|
27
28
|
import { preserveMarkdownSource } from './markdown-body-splice.js';
|
|
28
29
|
import { snapshotDocument, spliceSource, withSourceLineEndings } from './yaml-source-splice.js';
|
|
29
30
|
const log = createDebugLogger({ prefix: 'ContentSerialize' });
|
|
@@ -681,3 +682,14 @@ export function serializeFrontmatter(editorBody, data, existingRaw, format, fiel
|
|
|
681
682
|
log.debug('content-serialize', 'reconciled frontmatter reads back differently; writing as gray-matter prints it');
|
|
682
683
|
return matter.stringify(file, data);
|
|
683
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* An entry's file text as a save writes it: `entry` serialised onto `existingRaw`, the file's
|
|
687
|
+
* current text (absent for a new file). `fields` is the entry type's schema.
|
|
688
|
+
*/
|
|
689
|
+
export function serializeEntry(entry, existingRaw, fields) {
|
|
690
|
+
if (entry.format === 'json')
|
|
691
|
+
return serializeJson(entry.data, existingRaw);
|
|
692
|
+
if (entry.format === 'yaml')
|
|
693
|
+
return serializeYaml(entry.data, existingRaw);
|
|
694
|
+
return serializeFrontmatter(entry.body, entry.data ?? {}, existingRaw, entry.format, fields);
|
|
695
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Production runs a Lambda (API) and an EC2 worker over one EFS filesystem, so `ContentStore`'s
|
|
6
6
|
* in-process mutex (`utils/async-mutex`) does not cover the worker's rebase (`rebaseOneBranch` in
|
|
7
|
-
* worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started makes the
|
|
8
|
+
* next replayed commit fail, and the `git rebase --abort` that follows hard-resets the tree,
|
|
9
|
+
* discarding it -- and the editor already got its 200.
|
|
10
10
|
*
|
|
11
11
|
* **Asymmetric by design.** The worker retries every branch on its next sync cycle (~5 minutes)
|
|
12
12
|
* while the editor is a person waiting on a save, so the worker yields and the writer waits:
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Production runs a Lambda (API) and an EC2 worker over one EFS filesystem, so `ContentStore`'s
|
|
6
6
|
* in-process mutex (`utils/async-mutex`) does not cover the worker's rebase (`rebaseOneBranch` in
|
|
7
|
-
* worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started makes the
|
|
8
|
+
* next replayed commit fail, and the `git rebase --abort` that follows hard-resets the tree,
|
|
9
|
+
* discarding it -- and the editor already got its 200.
|
|
10
10
|
*
|
|
11
11
|
* **Asymmetric by design.** The worker retries every branch on its next sync cycle (~5 minutes)
|
|
12
12
|
* while the editor is a person waiting on a save, so the worker yields and the writer waits:
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* then both again from the edit as sent; failing all four, the restyled edit is written whole,
|
|
24
24
|
* or the edit as sent when it did not read back: the worst case is never different content.
|
|
25
25
|
*/
|
|
26
|
-
|
|
26
|
+
import { type MarkdownBodyFormat } from './markdown-parse.js';
|
|
27
27
|
/**
|
|
28
28
|
* `text`'s top-level blocks, each as its source text and its `canonical` form, or undefined when
|
|
29
29
|
* `text` does not parse: two bodies mean the same to the splice exactly when their `meaning`
|
|
@@ -23,32 +23,14 @@
|
|
|
23
23
|
* then both again from the edit as sent; failing all four, the restyled edit is written whole,
|
|
24
24
|
* or the edit as sent when it did not read back: the worst case is never different content.
|
|
25
25
|
*/
|
|
26
|
-
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
27
|
-
import { gfmFromMarkdown } from 'mdast-util-gfm';
|
|
28
|
-
import { mdxFromMarkdown } from 'mdast-util-mdx';
|
|
29
|
-
import { gfm } from 'micromark-extension-gfm';
|
|
30
|
-
import { mdxjs } from 'micromark-extension-mdxjs';
|
|
31
26
|
import { createDebugLogger } from './debug.js';
|
|
32
27
|
import { getErrorMessage } from './error.js';
|
|
28
|
+
import { parseMarkdownBody as parse, } from './markdown-parse.js';
|
|
33
29
|
import { orderedNumber, restyleMarkdown } from './markdown-restyle.js';
|
|
34
30
|
import { withSourceLineEndings } from './yaml-source-splice.js';
|
|
35
31
|
const log = createDebugLogger({ prefix: 'MarkdownBodySplice' });
|
|
36
32
|
/** Above this many LCS cells the middle of a sequence is treated as one changed run. */
|
|
37
33
|
const MAX_LCS_CELLS = 4_000_000;
|
|
38
|
-
function parse(text, format) {
|
|
39
|
-
try {
|
|
40
|
-
return format === 'mdx'
|
|
41
|
-
? fromMarkdown(text, {
|
|
42
|
-
extensions: [gfm(), mdxjs()],
|
|
43
|
-
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
|
|
44
|
-
})
|
|
45
|
-
: fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
|
|
46
|
-
}
|
|
47
|
-
catch (err) {
|
|
48
|
-
log.debug('markdown-body-splice', 'body does not parse', { error: getErrorMessage(err) });
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
34
|
/**
|
|
53
35
|
* A node's meaning as a string: its tree without source positions. `data` goes too, because
|
|
54
36
|
* the parser fills it only with derived information (an MDX expression's estree, which carries
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The md/mdx body parser the save path and the merge engine share, so both read a body the same
|
|
3
|
+
* way. Dependency-free of node built-ins: the merge engine runs in the browser bundle too.
|
|
4
|
+
*/
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
6
|
+
export type MarkdownBodyFormat = 'md' | 'mdx';
|
|
7
|
+
export type MarkdownRoot = ReturnType<typeof fromMarkdown>;
|
|
8
|
+
/** `text`'s mdast with GFM, and MDX for `mdx`; undefined when it does not parse. */
|
|
9
|
+
export declare function parseMarkdownBody(text: string, format: MarkdownBodyFormat): MarkdownRoot | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The md/mdx body parser the save path and the merge engine share, so both read a body the same
|
|
3
|
+
* way. Dependency-free of node built-ins: the merge engine runs in the browser bundle too.
|
|
4
|
+
*/
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
6
|
+
import { gfmFromMarkdown } from 'mdast-util-gfm';
|
|
7
|
+
import { mdxFromMarkdown } from 'mdast-util-mdx';
|
|
8
|
+
import { gfm } from 'micromark-extension-gfm';
|
|
9
|
+
import { mdxjs } from 'micromark-extension-mdxjs';
|
|
10
|
+
import { createDebugLogger } from './debug.js';
|
|
11
|
+
import { getErrorMessage } from './error.js';
|
|
12
|
+
const log = createDebugLogger({ prefix: 'MarkdownParse' });
|
|
13
|
+
/** `text`'s mdast with GFM, and MDX for `mdx`; undefined when it does not parse. */
|
|
14
|
+
export function parseMarkdownBody(text, format) {
|
|
15
|
+
try {
|
|
16
|
+
return format === 'mdx'
|
|
17
|
+
? fromMarkdown(text, {
|
|
18
|
+
extensions: [gfm(), mdxjs()],
|
|
19
|
+
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
|
|
20
|
+
})
|
|
21
|
+
: fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
log.debug('markdown-parse', 'body does not parse', { error: getErrorMessage(err) });
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.12.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.12.1";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.12.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.12.1';
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import type { SimpleGit } from 'simple-git';
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
2
|
+
import type { KeyRemap } from '../merge/entry-merge.js';
|
|
3
|
+
import type { FoundEntryConflict } from '../merge-conflicts.js';
|
|
4
4
|
import type { WorkerContext } from './worker-context.js';
|
|
5
|
-
/** What {@link
|
|
6
|
-
export type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
/** What {@link planMerge} made of the two commits. */
|
|
6
|
+
export type MergePlan = {
|
|
7
|
+
kind: 'planned';
|
|
8
|
+
/** The merge's tree. */
|
|
9
|
+
tree: string;
|
|
10
|
+
/** The tree git's own merge wrote, conflict markers and all. */
|
|
11
|
+
gitTree: string;
|
|
12
|
+
/** Paths git could not merge, whoever settled them. */
|
|
13
|
+
gitConflicted: string[];
|
|
14
|
+
/** What a person must still decide, entry by entry. */
|
|
15
|
+
conflicts: FoundEntryConflict[];
|
|
16
|
+
/** Paths where the branch's version stands over the other side's change, whole or in part. */
|
|
17
|
+
keptPaths: string[];
|
|
18
|
+
/** Git's own merge with nothing for a person: no conflict, and every file as git merged it. */
|
|
19
|
+
asGitDoes: boolean;
|
|
20
|
+
/** Item keys the merge changed, by the entry path the branch's comment threads name. */
|
|
21
|
+
keyRemaps: Array<{
|
|
22
|
+
entryPath: string;
|
|
23
|
+
contentId?: string;
|
|
24
|
+
remaps: KeyRemap[];
|
|
25
|
+
}>;
|
|
26
|
+
/** Where each entry the merge decided stands in it. */
|
|
27
|
+
paths: Map<string, string>;
|
|
12
28
|
}
|
|
13
29
|
/** Git met conflicts no rule settles safely. Nothing was built. */
|
|
14
30
|
| {
|
|
@@ -16,19 +32,40 @@ export type BuiltMerge =
|
|
|
16
32
|
conflicts: string[];
|
|
17
33
|
paths: string[];
|
|
18
34
|
};
|
|
35
|
+
/** {@link commitMerge}'s commit of a planned merge; `sha` is reachable from no ref yet. */
|
|
36
|
+
export type BuiltMerge = Omit<Extract<MergePlan, {
|
|
37
|
+
kind: 'planned';
|
|
38
|
+
}>, 'kind' | 'tree' | 'gitTree'> & {
|
|
39
|
+
kind: 'built';
|
|
40
|
+
sha: string;
|
|
41
|
+
};
|
|
19
42
|
/**
|
|
20
|
-
* The merge of `other` into `head`, as a
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
43
|
+
* The merge of `other` into `head`, as a tree. Every entry and `.collection.json` both sides
|
|
44
|
+
* changed is merged field by field (field-merge.ts), which settles git's conflicts there, whatever
|
|
45
|
+
* their kind. A path git cannot merge that is no entry keeps `head`'s side, stage 2, at the path
|
|
46
|
+
* git reports, or stays deleted when `head` has no such stage: `head` has the editor's work. With
|
|
47
|
+
* no single merge base, git's merge stands as that rule leaves it, since a field merge needs one
|
|
48
|
+
* version both sides started from.
|
|
25
49
|
*/
|
|
26
|
-
export declare function
|
|
50
|
+
export declare function planMerge(git: SimpleGit, clonePath: string, args: {
|
|
27
51
|
head: string;
|
|
28
52
|
other: string;
|
|
29
|
-
|
|
53
|
+
contentRoot: string;
|
|
30
54
|
timeoutMs: number;
|
|
55
|
+
}): Promise<MergePlan>;
|
|
56
|
+
/**
|
|
57
|
+
* {@link planMerge}'s merge as a commit no ref names, with `head` the first parent. The commit takes
|
|
58
|
+
* its identity from the clone's `user.*`, written when the clone is provisioned.
|
|
59
|
+
*/
|
|
60
|
+
export declare function commitMerge(git: SimpleGit, plan: Extract<MergePlan, {
|
|
61
|
+
kind: 'planned';
|
|
62
|
+
}>, { head, other, message }: {
|
|
63
|
+
head: string;
|
|
64
|
+
other: string;
|
|
65
|
+
message: string;
|
|
31
66
|
}): Promise<BuiltMerge>;
|
|
67
|
+
/** Whether `plan` is git's own merge with nothing for a person. */
|
|
68
|
+
export declare const mergesAsGitDoes: (plan: MergePlan) => boolean;
|
|
32
69
|
/**
|
|
33
70
|
* Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
|
|
34
71
|
* conflict counts as no: whoever settled it chose what `merged` holds.
|
|
@@ -45,24 +82,3 @@ export declare function commitsApart(git: SimpleGit, a: string, b: string): Prom
|
|
|
45
82
|
onlyA: number;
|
|
46
83
|
onlyB: number;
|
|
47
84
|
}>;
|
|
48
|
-
/**
|
|
49
|
-
* Map the file paths a rebase or merge kept the branch's version of onto the ContentIds the
|
|
50
|
-
* editor shows as conflicted, deduplicated. IDs rather than paths because they are
|
|
51
|
-
* immutable: a later slug rename must not orphan a conflict marker. Paths that
|
|
52
|
-
* carry no recoverable ID are dropped rather than guessed at.
|
|
53
|
-
*
|
|
54
|
-
* Pure.
|
|
55
|
-
*/
|
|
56
|
-
export declare function conflictFilesToContentIds(conflictedFiles: readonly string[], contentRoot: string): ContentId[];
|
|
57
|
-
/**
|
|
58
|
-
* The conflict record after a rebase or merge: the IDs already recorded plus
|
|
59
|
-
* its own. A conflict means this branch's version replaced base's change to
|
|
60
|
-
* that entry, which a later clean rebase does not undo, so the record only
|
|
61
|
-
* grows until the branch merges (buildMergedBranchUpdate clears it).
|
|
62
|
-
*
|
|
63
|
-
* Pure.
|
|
64
|
-
*/
|
|
65
|
-
export declare function recordConflicts(recorded: readonly ContentId[] | undefined, found: readonly ContentId[]): {
|
|
66
|
-
conflictStatus: ConflictStatus;
|
|
67
|
-
conflictFiles: ContentId[];
|
|
68
|
-
};
|