canopycms 0.7.1 → 0.8.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 +187 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.js +23 -16
- package/dist/context.js +8 -7
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +40 -25
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
|
@@ -114,10 +114,9 @@ function isRateLimitSignal403(err) {
|
|
|
114
114
|
}
|
|
115
115
|
// Payload validation helpers — fail fast with clear errors instead of silent `as` casts
|
|
116
116
|
/**
|
|
117
|
-
* Whether `branch` carries the reserved settings-branch prefix
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* content branch's.
|
|
117
|
+
* Whether `branch` carries the reserved settings-branch prefix, which routes a submit to a push
|
|
118
|
+
* with no pull request. A configured settings name without the prefix is not matched here; the
|
|
119
|
+
* gateway refuses its pull request (`BranchPolicy.assertPullRequestHeadAllowed`).
|
|
121
120
|
*/
|
|
122
121
|
function isSettingsBranch(branch) {
|
|
123
122
|
return branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
|
|
@@ -290,7 +289,7 @@ export async function executeTask(ctx, task, signal) {
|
|
|
290
289
|
const pr = await ctx.github().createPullRequest({
|
|
291
290
|
head: branch,
|
|
292
291
|
base: optionalString(payload, 'baseBranch', ctx.baseBranch),
|
|
293
|
-
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
292
|
+
title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
|
|
294
293
|
body: optionalString(payload, 'body', ''),
|
|
295
294
|
}, signal);
|
|
296
295
|
workerLog(`Created PR #${pr.number} for ${branch}`);
|
|
@@ -301,7 +300,7 @@ export async function executeTask(ctx, task, signal) {
|
|
|
301
300
|
const prNumber = requireNumber(payload, 'pullRequestNumber');
|
|
302
301
|
await ctx.pushBranchToGitHub(branch, signal);
|
|
303
302
|
await ctx.github().updatePullRequest(prNumber, {
|
|
304
|
-
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
303
|
+
title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
|
|
305
304
|
body: optionalString(payload, 'body', ''),
|
|
306
305
|
}, signal);
|
|
307
306
|
workerLog(`Updated PR #${prNumber} for ${branch}`);
|
|
@@ -337,7 +336,7 @@ export async function executeTask(ctx, task, signal) {
|
|
|
337
336
|
const body = mergeSectionIntoBody
|
|
338
337
|
? await refreshCommentsBlock({
|
|
339
338
|
branchRoot: ctx.branchWorkspacePath(branch),
|
|
340
|
-
branch,
|
|
339
|
+
branch: editorBranchOf(payload, branch),
|
|
341
340
|
editorUrl: typeof payload.editorUrl === 'string' ? payload.editorUrl : undefined,
|
|
342
341
|
section: queuedBody,
|
|
343
342
|
warn: workerLogWarn,
|
|
@@ -348,7 +347,7 @@ export async function executeTask(ctx, task, signal) {
|
|
|
348
347
|
result = await ctx.github().createOrUpdatePullRequest({
|
|
349
348
|
head: branch,
|
|
350
349
|
base,
|
|
351
|
-
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
350
|
+
title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
|
|
352
351
|
body,
|
|
353
352
|
// Content submits (api/github-sync.ts) set both.
|
|
354
353
|
markReadyIfDraft: payload.markReadyIfDraft === true,
|
|
@@ -447,27 +446,39 @@ export async function executeTask(ctx, task, signal) {
|
|
|
447
446
|
const pr = await ctx.github().getPullRequest(prNumber, signal);
|
|
448
447
|
if (pr.state !== 'open')
|
|
449
448
|
return { updated: false, skipped: 'not-open' };
|
|
450
|
-
//
|
|
449
|
+
// Withheld: GitHub reports a head the gateway would not update the pull request of
|
|
450
|
+
// (`LocalGitHubGateway.getPullRequest`): a settings branch, one outside the namespace and not a
|
|
451
|
+
// `legacyBranches` entry, a protected one, or one in another repository.
|
|
452
|
+
if (pr.body === null) {
|
|
453
|
+
workerLog(`Not updating PR #${prNumber} for ${branch}: the gateway withholds its body`);
|
|
454
|
+
return { updated: false, skipped: 'body-withheld' };
|
|
455
|
+
}
|
|
456
|
+
// A corrupt file leaves its block as it is and the other block still updates; any other read
|
|
457
|
+
// error throws, so the task is retried.
|
|
451
458
|
const body = await renderPrCommentsBody({
|
|
452
459
|
branchRoot: branchPath,
|
|
453
|
-
branch,
|
|
460
|
+
branch: editorBranchOf(payload, branch),
|
|
454
461
|
editorUrl,
|
|
455
462
|
body: pr.body,
|
|
456
|
-
|
|
457
|
-
if (err instanceof SyntaxError)
|
|
458
|
-
throw new PermanentTaskError(getErrorMessage(err));
|
|
459
|
-
throw err;
|
|
463
|
+
warn: workerLogWarn,
|
|
460
464
|
});
|
|
461
465
|
if (body === undefined)
|
|
462
466
|
return { updated: false, skipped: 'unchanged' };
|
|
463
467
|
await ctx.github().updatePullRequest(prNumber, { body }, signal);
|
|
464
|
-
workerLog(`Updated the
|
|
468
|
+
workerLog(`Updated the comment and review blocks of PR #${prNumber} for ${branch}`);
|
|
465
469
|
return { updated: true };
|
|
466
470
|
}
|
|
467
471
|
default:
|
|
468
472
|
throw new PermanentTaskError(`Unknown task action: ${action}`);
|
|
469
473
|
}
|
|
470
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* The name links and a fallback title give the branch: the short name the API queued
|
|
477
|
+
* (branch-param.ts), else the ref, which the editor resolves too.
|
|
478
|
+
*/
|
|
479
|
+
function editorBranchOf(payload, branch) {
|
|
480
|
+
return optionalString(payload, 'editorBranch', branch);
|
|
481
|
+
}
|
|
471
482
|
/**
|
|
472
483
|
* The branch whose metadata records a task's outcome, or null when none does. A
|
|
473
484
|
* delete-remote-branch task names a branch already deleted here, so a workspace under that
|
|
@@ -479,7 +490,17 @@ function metadataBranchOf(task) {
|
|
|
479
490
|
return typeof task.payload.branch === 'string' ? task.payload.branch : null;
|
|
480
491
|
}
|
|
481
492
|
/**
|
|
482
|
-
*
|
|
493
|
+
* Logs an outcome write skipped because `branch`'s workspace has no branch.json whose `name` is
|
|
494
|
+
* `branch`: none at all, or another branch's that shares its key.
|
|
495
|
+
*/
|
|
496
|
+
function noMetadataToRecord(branch, what) {
|
|
497
|
+
workerLogWarn(`Not recording the ${what} for ${branch}: its workspace has no branch.json for it`);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Update branch metadata after successful task completion. Like every outcome write here, it
|
|
501
|
+
* updates only a branch.json whose `name` is the task's ref: a workspace without one is not a
|
|
502
|
+
* branch whose name could be recorded, and one naming another ref is that branch's
|
|
503
|
+
* (`noMetadataToRecord`).
|
|
483
504
|
*/
|
|
484
505
|
export async function updateBranchMetadata(ctx, task, result) {
|
|
485
506
|
const branch = metadataBranchOf(task);
|
|
@@ -494,32 +515,36 @@ export async function updateBranchMetadata(ctx, task, result) {
|
|
|
494
515
|
}
|
|
495
516
|
try {
|
|
496
517
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
497
|
-
const
|
|
498
|
-
name
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
518
|
+
const saved = await meta.update((existing) => {
|
|
519
|
+
if (existing?.branch.name !== branch)
|
|
520
|
+
return null;
|
|
521
|
+
const updates = {
|
|
522
|
+
syncStatus: 'synced',
|
|
523
|
+
// save()'s merge only overwrites keys present in this update, so a prior
|
|
524
|
+
// syncFailureReason would survive this successful sync forever unless
|
|
525
|
+
// cleared explicitly (same pattern as rebaseFailure elsewhere).
|
|
526
|
+
syncFailureReason: undefined,
|
|
527
|
+
};
|
|
528
|
+
if (result.prUrl)
|
|
529
|
+
updates.pullRequestUrl = result.prUrl;
|
|
530
|
+
if (result.prNumber) {
|
|
531
|
+
updates.pullRequestNumber = result.prNumber;
|
|
532
|
+
// As soon as the PR exists the field reads 'open', rather than staying
|
|
533
|
+
// absent until the next poll cycle observes it. Exception: the git-sync
|
|
534
|
+
// loop may have archived this branch (PR merged/closed) while the task
|
|
535
|
+
// was in flight, and a late completion must not downgrade that terminal
|
|
536
|
+
// state back to 'open'.
|
|
537
|
+
const terminal = existing.branch.status === 'archived' ||
|
|
538
|
+
existing.branch.pullRequestState === 'merged' ||
|
|
539
|
+
existing.branch.pullRequestState === 'closed';
|
|
540
|
+
if (!terminal) {
|
|
541
|
+
updates.pullRequestState = 'open';
|
|
542
|
+
}
|
|
520
543
|
}
|
|
521
|
-
|
|
522
|
-
|
|
544
|
+
return { branch: updates };
|
|
545
|
+
});
|
|
546
|
+
if (saved === null)
|
|
547
|
+
noMetadataToRecord(branch, `${task.action} outcome`);
|
|
523
548
|
}
|
|
524
549
|
catch (err) {
|
|
525
550
|
workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
|
|
@@ -548,7 +573,7 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
|
|
|
548
573
|
}
|
|
549
574
|
try {
|
|
550
575
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
551
|
-
const failed = {
|
|
576
|
+
const failed = { syncStatus: 'sync-failed', syncFailureReason: error };
|
|
552
577
|
const submittedAt = task.payload.submittedAt;
|
|
553
578
|
const unlocked = options.unlock &&
|
|
554
579
|
typeof submittedAt === 'string' &&
|
|
@@ -558,11 +583,15 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
|
|
|
558
583
|
status: 'editing',
|
|
559
584
|
syncFailureReason: `${error} The branch is unlocked for editing; save a change, then submit again.`,
|
|
560
585
|
},
|
|
561
|
-
}, (existing) => existing?.branch.
|
|
586
|
+
}, (existing) => existing?.branch.name === branch &&
|
|
587
|
+
existing.branch.status === 'submitted' &&
|
|
562
588
|
existing.branch.pullRequestNumber === undefined &&
|
|
563
589
|
existing.branch.submittedAt === submittedAt)) !== null;
|
|
564
|
-
if (!unlocked)
|
|
565
|
-
await meta.
|
|
590
|
+
if (!unlocked) {
|
|
591
|
+
const saved = await meta.saveIf({ branch: failed }, (existing) => existing?.branch.name === branch);
|
|
592
|
+
if (saved === null)
|
|
593
|
+
noMetadataToRecord(branch, `${task.action} failure`);
|
|
594
|
+
}
|
|
566
595
|
}
|
|
567
596
|
catch (err) {
|
|
568
597
|
workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
|
|
@@ -708,9 +737,9 @@ async function recordPushedToGitHub(ctx, branchPath, branch) {
|
|
|
708
737
|
return;
|
|
709
738
|
}
|
|
710
739
|
try {
|
|
711
|
-
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).
|
|
712
|
-
|
|
713
|
-
|
|
740
|
+
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { pushedToGitHubAt: new Date().toISOString() } }, (existing) => existing?.branch.name === branch);
|
|
741
|
+
if (saved === null)
|
|
742
|
+
noMetadataToRecord(branch, 'GitHub push');
|
|
714
743
|
}
|
|
715
744
|
catch (err) {
|
|
716
745
|
workerLogError(`Failed to record the GitHub push for ${branch}:`, getErrorMessage(err));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { UnpublishableBranchesReport, WorkerStatusReport } from '../types.js';
|
|
2
|
+
import { type BranchPolicyDescription } from './branch-policy.js';
|
|
3
|
+
import type { WorkerContext } from './worker-context.js';
|
|
4
|
+
/**
|
|
5
|
+
* The most branches a report lists; its `count` says how many there are.
|
|
6
|
+
* @internal Exported for tests.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_UNPUBLISHABLE_LISTED = 50;
|
|
9
|
+
/**
|
|
10
|
+
* The editor branches the gateway that described its policy as `policy` would refuse to push.
|
|
11
|
+
* Each is the `name` its branch.json records, the git ref: the directory holding it is only the
|
|
12
|
+
* ref sanitized, and `cms/prod/x` and a legacy `cms-prod-x` share one. Only a directory can be a
|
|
13
|
+
* branch: a regular file, such as `branches.json` or a registry write's temp file, or a symlink,
|
|
14
|
+
* is skipped. A directory with no branch.json, or a corrupt one, is no branch. Throws when the
|
|
15
|
+
* branches directory or a branch directory's branch.json cannot be read, so the caller keeps the
|
|
16
|
+
* last report.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findUnpublishableBranches(ctx: Pick<WorkerContext, 'contentBranchesPath' | 'sanitizedBaseBranch'>, policy: BranchPolicyDescription): Promise<Omit<UnpublishableBranchesReport, 'at'>>;
|
|
19
|
+
/**
|
|
20
|
+
* The report in the status file a previous worker process left, so a restarted worker shows it
|
|
21
|
+
* until its first sync can ask the gateway. Tolerant: an unreadable or malformed file carries
|
|
22
|
+
* nothing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readCarriedUnpublishableBranches(taskDir: string): Promise<UnpublishableBranchesReport | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* The gateway's namespace and protected names from the status file a previous worker process
|
|
27
|
+
* left, so branch creation, which waits for the namespace, keeps working across a restart until
|
|
28
|
+
* the first sync asks the gateway again. The Lambda can write the status file, so they are carried
|
|
29
|
+
* only in the shape git sync writes: a namespace `branchNamespaceProblem` takes, and at most one
|
|
30
|
+
* more name than `protectedBranches` holds, each exact and no longer than a branch name. Tolerant:
|
|
31
|
+
* no namespace, or one in any other shape, carries nothing, and a list in any other shape
|
|
32
|
+
* carries the namespace alone.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readCarriedGatewayPolicy(taskDir: string): Promise<Pick<WorkerStatusReport, 'gatewayBranchNamespace' | 'gatewayProtectedBranches'>>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BranchMetadataCorruptError, BranchMetadataFileManager } from '../branch-metadata.js';
|
|
4
|
+
import { branchNamespaceProblem } from '../operating-mode/branch-namespace.js';
|
|
5
|
+
import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
|
|
6
|
+
import { isNodeError } from '../utils/error.js';
|
|
7
|
+
import { MAX_BRANCH_NAME_LENGTH, MAX_PROTECTED_BRANCHES, legacyBranchProblem, publishesBranch, } from './branch-policy.js';
|
|
8
|
+
/**
|
|
9
|
+
* The most branches a report lists; its `count` says how many there are.
|
|
10
|
+
* @internal Exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_UNPUBLISHABLE_LISTED = 50;
|
|
13
|
+
/**
|
|
14
|
+
* Why a `legacyBranches` entry would not publish `name` in `namespace`, or undefined when one
|
|
15
|
+
* would. The ancestor test is the `BranchPolicy` constructor's and `namespaceBlocker`'s.
|
|
16
|
+
*/
|
|
17
|
+
function reasonFor(name, namespace) {
|
|
18
|
+
if (namespace.startsWith(`${name}/`))
|
|
19
|
+
return 'blocks-namespace';
|
|
20
|
+
if (legacyBranchProblem(name) !== null)
|
|
21
|
+
return 'unlistable';
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The editor branches the gateway that described its policy as `policy` would refuse to push.
|
|
26
|
+
* Each is the `name` its branch.json records, the git ref: the directory holding it is only the
|
|
27
|
+
* ref sanitized, and `cms/prod/x` and a legacy `cms-prod-x` share one. Only a directory can be a
|
|
28
|
+
* branch: a regular file, such as `branches.json` or a registry write's temp file, or a symlink,
|
|
29
|
+
* is skipped. A directory with no branch.json, or a corrupt one, is no branch. Throws when the
|
|
30
|
+
* branches directory or a branch directory's branch.json cannot be read, so the caller keeps the
|
|
31
|
+
* last report.
|
|
32
|
+
*/
|
|
33
|
+
export async function findUnpublishableBranches(ctx, policy) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// `withFileTypes` may lstat each entry, so an entry removed mid-listing rejects with its own
|
|
40
|
+
// ENOENT: only the branches directory's absence means no branches.
|
|
41
|
+
if (!isNodeError(err) || err.code !== 'ENOENT')
|
|
42
|
+
throw err;
|
|
43
|
+
if (typeof err.path !== 'string' ||
|
|
44
|
+
path.resolve(err.path) !== path.resolve(ctx.contentBranchesPath)) {
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
entries = [];
|
|
48
|
+
}
|
|
49
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
50
|
+
const branches = [];
|
|
51
|
+
for (const dir of dirs.sort()) {
|
|
52
|
+
// The base branch is protected and never published; the `.trash-*` directories purged
|
|
53
|
+
// branches wait in are not branches.
|
|
54
|
+
if (dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
|
|
55
|
+
continue;
|
|
56
|
+
let meta;
|
|
57
|
+
try {
|
|
58
|
+
meta = await BranchMetadataFileManager.loadOnly(path.join(ctx.contentBranchesPath, dir));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Corrupt metadata is no branch, and a directory removed since the listing holds none.
|
|
62
|
+
const notABranch = err instanceof BranchMetadataCorruptError || (isNodeError(err) && err.code === 'ENOENT');
|
|
63
|
+
if (!notABranch)
|
|
64
|
+
throw err;
|
|
65
|
+
meta = null;
|
|
66
|
+
}
|
|
67
|
+
const branch = meta?.branch;
|
|
68
|
+
if (!branch || branch.status === 'archived')
|
|
69
|
+
continue;
|
|
70
|
+
if (publishesBranch(policy, branch.name))
|
|
71
|
+
continue;
|
|
72
|
+
const reason = reasonFor(branch.name, policy.namespace);
|
|
73
|
+
branches.push(reason ? { name: branch.name, reason } : { name: branch.name });
|
|
74
|
+
}
|
|
75
|
+
return { branches: branches.slice(0, MAX_UNPUBLISHABLE_LISTED), count: branches.length };
|
|
76
|
+
}
|
|
77
|
+
const REASONS = new Set([
|
|
78
|
+
'blocks-namespace',
|
|
79
|
+
'unlistable',
|
|
80
|
+
]);
|
|
81
|
+
function isReason(value) {
|
|
82
|
+
return REASONS.has(value);
|
|
83
|
+
}
|
|
84
|
+
/** The most branches a carried report may claim to hold; a real workspace has far fewer. */
|
|
85
|
+
const MAX_CARRIED_COUNT = 100_000;
|
|
86
|
+
/** The longest timestamp a carried report's `at` may be; an ISO 8601 instant is 24. */
|
|
87
|
+
const MAX_CARRIED_AT_LENGTH = 32;
|
|
88
|
+
/**
|
|
89
|
+
* The report `value` holds, rebuilt from the fields `findUnpublishableBranches` writes, or
|
|
90
|
+
* undefined when `value` is not one it could have made: at most MAX_UNPUBLISHABLE_LISTED names,
|
|
91
|
+
* none longer than a branch name can be, a whole `count` no smaller than the list and at most
|
|
92
|
+
* MAX_CARRIED_COUNT, and an `at` that parses as a date within MAX_CARRIED_AT_LENGTH characters.
|
|
93
|
+
* The Lambda can write the status file, so nothing else it holds is carried.
|
|
94
|
+
*/
|
|
95
|
+
function carriedReport(value) {
|
|
96
|
+
if (typeof value !== 'object' || value === null)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (!('branches' in value) || !('count' in value) || !('at' in value))
|
|
99
|
+
return undefined;
|
|
100
|
+
const { branches, count, at } = value;
|
|
101
|
+
if (typeof at !== 'string' ||
|
|
102
|
+
at.length > MAX_CARRIED_AT_LENGTH ||
|
|
103
|
+
!Number.isFinite(Date.parse(at)) ||
|
|
104
|
+
!Array.isArray(branches) ||
|
|
105
|
+
branches.length > MAX_UNPUBLISHABLE_LISTED ||
|
|
106
|
+
typeof count !== 'number' ||
|
|
107
|
+
!Number.isInteger(count) ||
|
|
108
|
+
count < branches.length ||
|
|
109
|
+
count > MAX_CARRIED_COUNT) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const carried = [];
|
|
113
|
+
for (const entry of branches) {
|
|
114
|
+
if (typeof entry !== 'object' ||
|
|
115
|
+
entry === null ||
|
|
116
|
+
!('name' in entry) ||
|
|
117
|
+
typeof entry.name !== 'string' ||
|
|
118
|
+
entry.name.length > MAX_BRANCH_NAME_LENGTH) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!('reason' in entry)) {
|
|
122
|
+
carried.push({ name: entry.name });
|
|
123
|
+
}
|
|
124
|
+
else if (isReason(entry.reason)) {
|
|
125
|
+
carried.push({ name: entry.name, reason: entry.reason });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { branches: carried, count, at };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The report in the status file a previous worker process left, so a restarted worker shows it
|
|
135
|
+
* until its first sync can ask the gateway. Tolerant: an unreadable or malformed file carries
|
|
136
|
+
* nothing.
|
|
137
|
+
*/
|
|
138
|
+
export async function readCarriedUnpublishableBranches(taskDir) {
|
|
139
|
+
const parsed = await readStatusObject(taskDir);
|
|
140
|
+
if (parsed === undefined || !('unpublishableBranches' in parsed))
|
|
141
|
+
return undefined;
|
|
142
|
+
return carriedReport(parsed.unpublishableBranches);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The gateway's namespace and protected names from the status file a previous worker process
|
|
146
|
+
* left, so branch creation, which waits for the namespace, keeps working across a restart until
|
|
147
|
+
* the first sync asks the gateway again. The Lambda can write the status file, so they are carried
|
|
148
|
+
* only in the shape git sync writes: a namespace `branchNamespaceProblem` takes, and at most one
|
|
149
|
+
* more name than `protectedBranches` holds, each exact and no longer than a branch name. Tolerant:
|
|
150
|
+
* no namespace, or one in any other shape, carries nothing, and a list in any other shape
|
|
151
|
+
* carries the namespace alone.
|
|
152
|
+
*/
|
|
153
|
+
export async function readCarriedGatewayPolicy(taskDir) {
|
|
154
|
+
const parsed = await readStatusObject(taskDir);
|
|
155
|
+
if (parsed === undefined || !('gatewayBranchNamespace' in parsed))
|
|
156
|
+
return {};
|
|
157
|
+
const namespace = parsed.gatewayBranchNamespace;
|
|
158
|
+
if (typeof namespace !== 'string' || branchNamespaceProblem(namespace) !== null)
|
|
159
|
+
return {};
|
|
160
|
+
const names = 'gatewayProtectedBranches' in parsed ? parsed.gatewayProtectedBranches : undefined;
|
|
161
|
+
return isExactNameList(names)
|
|
162
|
+
? { gatewayBranchNamespace: namespace, gatewayProtectedBranches: names }
|
|
163
|
+
: { gatewayBranchNamespace: namespace };
|
|
164
|
+
}
|
|
165
|
+
function isExactNameList(value) {
|
|
166
|
+
return (Array.isArray(value) &&
|
|
167
|
+
value.length <= MAX_PROTECTED_BRANCHES + 1 &&
|
|
168
|
+
value.every((name) => typeof name === 'string' &&
|
|
169
|
+
name.length > 0 &&
|
|
170
|
+
name.length <= MAX_BRANCH_NAME_LENGTH &&
|
|
171
|
+
!name.includes('*')));
|
|
172
|
+
}
|
|
173
|
+
/** The status file's top-level object, or undefined when it is missing, unreadable or not one. */
|
|
174
|
+
async function readStatusObject(taskDir) {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(await fs.readFile(path.join(taskDir, WORKER_STATUS_FILE), 'utf-8'));
|
|
177
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
"content-disposition": "^2.0.1",
|
|
127
127
|
"dataloader": "^2.2.3",
|
|
128
128
|
"dayjs": "1.11.21",
|
|
129
|
+
"diff": "9.0.0",
|
|
129
130
|
"fast-deep-equal": "^3.1.3",
|
|
130
131
|
"file-type": "^21.3.2",
|
|
131
132
|
"gray-matter": "^4.0.3",
|