canopycms 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type GitHubAuthConfig } from './github-auth.js';
|
|
2
2
|
import { type GitHubGateway } from './github-gateway.js';
|
|
3
3
|
import type { WorkerStatusReport } from '../types.js';
|
|
4
|
+
import { type RebaseSummary } from './rebase.js';
|
|
4
5
|
export { PermanentTaskError, isPermanentTaskFailure } from './task-runner.js';
|
|
5
6
|
export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from './log.js';
|
|
6
7
|
export { resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, type GitHubAppAuth, type GitHubAuthConfig, } from './github-auth.js';
|
|
@@ -205,6 +206,10 @@ export declare class CmsWorker {
|
|
|
205
206
|
private log;
|
|
206
207
|
private statusReport?;
|
|
207
208
|
private warnedDivergences;
|
|
209
|
+
private conflictsStatusesSet;
|
|
210
|
+
private branchSyncRequests;
|
|
211
|
+
private gitSyncRequested;
|
|
212
|
+
private wakeGitSync;
|
|
208
213
|
private lockLostMessage?;
|
|
209
214
|
private statusWriterAtLock?;
|
|
210
215
|
private settleSelfStopped;
|
|
@@ -333,8 +338,18 @@ export declare class CmsWorker {
|
|
|
333
338
|
* Run `fn` repeatedly, the next invocation starting `interval` ms after the
|
|
334
339
|
* previous one COMPLETES. setTimeout chaining rather than setInterval, so
|
|
335
340
|
* executions cannot overlap when one runs longer than the interval.
|
|
341
|
+
*
|
|
342
|
+
* `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
|
|
343
|
+
* shorter: `setWake`
|
|
344
|
+
* receives a function that does so while the loop rests, and undefined while a run is in
|
|
345
|
+
* flight; a request made during a run shows in `requested()` when it ends. Any number of
|
|
346
|
+
* requests coalesce into one early run.
|
|
336
347
|
*/
|
|
337
348
|
private scheduleLoop;
|
|
349
|
+
/** See {@link WorkerContext.requestBranchSync}. */
|
|
350
|
+
private requestBranchSync;
|
|
351
|
+
/** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
|
|
352
|
+
private takeBranchSyncRequests;
|
|
338
353
|
private get baseBranch();
|
|
339
354
|
private get sanitizedBaseBranch();
|
|
340
355
|
private requireBaseBranch;
|
|
@@ -477,9 +492,9 @@ export declare class CmsWorker {
|
|
|
477
492
|
*/
|
|
478
493
|
private branchWorkspacePath;
|
|
479
494
|
/**
|
|
480
|
-
* Test-only seam: awaited inside `rebaseActiveBranches()`'s
|
|
481
|
-
* after `git rebase` reported conflicted files and BEFORE the
|
|
482
|
-
*
|
|
495
|
+
* Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
|
|
496
|
+
* after `git rebase` reported conflicted files and BEFORE the rebase is
|
|
497
|
+
* aborted for a merge. No-op in production.
|
|
483
498
|
*
|
|
484
499
|
* A test subclass overrides this to land a real `ContentStore` write at
|
|
485
500
|
* exactly the instant the rebase is mid-flight, with no sleeps or shell
|
|
@@ -497,6 +512,13 @@ export declare class CmsWorker {
|
|
|
497
512
|
*/
|
|
498
513
|
protected afterRebaseCompletedForTesting(): Promise<void>;
|
|
499
514
|
private rebaseActiveBranches;
|
|
515
|
+
/**
|
|
516
|
+
* Bring the base into one branch now and answer its request for the latest changes
|
|
517
|
+
* (rebase.ts `syncBranchNow`).
|
|
518
|
+
*/
|
|
519
|
+
syncBranchNow(branchName: string): Promise<'answered' | 'busy'>;
|
|
520
|
+
/** Bring the base into every branch now, answering their requests from this run's start. */
|
|
521
|
+
syncBranchesNow(): Promise<RebaseSummary>;
|
|
500
522
|
syncGit(): Promise<void>;
|
|
501
523
|
private pushSettingsBranches;
|
|
502
524
|
private refreshBaseBranchWorkspace;
|
|
@@ -504,3 +526,15 @@ export declare class CmsWorker {
|
|
|
504
526
|
private pollMergeState;
|
|
505
527
|
refreshAuthCache(): Promise<void>;
|
|
506
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
|
|
531
|
+
* "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
|
|
532
|
+
* never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
|
|
533
|
+
* GitHub only about a branch's pull request, which dev never opens.
|
|
534
|
+
*/
|
|
535
|
+
export declare function createLocalWorker(options: {
|
|
536
|
+
workspacePath: string;
|
|
537
|
+
baseBranch: string;
|
|
538
|
+
contentRoot: string;
|
|
539
|
+
settingsBranch?: string;
|
|
540
|
+
}): CmsWorker;
|
|
@@ -20,7 +20,7 @@ import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.j
|
|
|
20
20
|
import { readCarriedGatewayPolicy, readCarriedUnpublishableBranches, } from './unpublishable-branches.js';
|
|
21
21
|
import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
|
|
22
22
|
import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
|
|
23
|
-
import { pollMergeState, runRebaseCycle } from './rebase.js';
|
|
23
|
+
import { pollMergeState, runRebaseCycle, syncBranchNow } from './rebase.js';
|
|
24
24
|
import { cleanupTrashedBranchDirs, pushSettingsBranches, refreshBaseBranchWorkspace, syncGit, } from './git-sync.js';
|
|
25
25
|
// Re-exported because this module is the package's advertised worker
|
|
26
26
|
// entrypoint (`canopycms/worker/cms-worker`).
|
|
@@ -227,6 +227,11 @@ export async function recordWorkerStartupFailure(options) {
|
|
|
227
227
|
await release?.().catch(() => { });
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* The shortest rest before a run `scheduleLoop`'s `early` starts, so a request repeated while a
|
|
232
|
+
* branch stays locked cannot keep the git sync, and its GitHub fetch, running back to back.
|
|
233
|
+
*/
|
|
234
|
+
const EARLY_RUN_MIN_REST_MS = 10_000;
|
|
230
235
|
export class CmsWorker {
|
|
231
236
|
constructor(config) {
|
|
232
237
|
this.config = config;
|
|
@@ -241,6 +246,11 @@ export class CmsWorker {
|
|
|
241
246
|
this.releaseLockFn = null;
|
|
242
247
|
this.log = cmsTaskQueueLogger;
|
|
243
248
|
this.warnedDivergences = new Map();
|
|
249
|
+
this.conflictsStatusesSet = new Map();
|
|
250
|
+
// Branches asked for since the git sync last took them (requestBranchSync), by directory name.
|
|
251
|
+
this.branchSyncRequests = new Set();
|
|
252
|
+
// Set by any request; cleared by the next cycle, which takes it.
|
|
253
|
+
this.gitSyncRequested = false;
|
|
244
254
|
/**
|
|
245
255
|
* Settles once the worker has stopped for a reason it chose itself (its lock was
|
|
246
256
|
* compromised), after the drain and its record in worker-status.json. Never settles for a
|
|
@@ -356,6 +366,9 @@ export class CmsWorker {
|
|
|
356
366
|
ensureStatusReport: () => this.ensureStatusReport(),
|
|
357
367
|
ensureSettingsBranch: () => this.ensureSettingsBranch(),
|
|
358
368
|
warnedDivergences: () => this.warnedDivergences,
|
|
369
|
+
conflictsStatusesSet: () => this.conflictsStatusesSet,
|
|
370
|
+
requestBranchSync: (branchRef) => this.requestBranchSync(branchRef),
|
|
371
|
+
takeBranchSyncRequests: () => this.takeBranchSyncRequests(),
|
|
359
372
|
afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
|
|
360
373
|
afterRebaseCompletedForTesting: () => this.afterRebaseCompletedForTesting(),
|
|
361
374
|
};
|
|
@@ -487,7 +500,12 @@ export class CmsWorker {
|
|
|
487
500
|
const taskInterval = this.config.taskPollInterval ?? 5_000;
|
|
488
501
|
const gitInterval = this.gitSyncIntervalMs;
|
|
489
502
|
this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
|
|
490
|
-
this.scheduleLoop('git sync', () => this.syncGit(), gitInterval
|
|
503
|
+
this.scheduleLoop('git sync', () => this.syncGit(), gitInterval, {
|
|
504
|
+
requested: () => this.gitSyncRequested,
|
|
505
|
+
setWake: (wake) => {
|
|
506
|
+
this.wakeGitSync = wake;
|
|
507
|
+
},
|
|
508
|
+
});
|
|
491
509
|
if (this.config.refreshAuthCache) {
|
|
492
510
|
const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
|
|
493
511
|
this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
|
|
@@ -695,22 +713,53 @@ export class CmsWorker {
|
|
|
695
713
|
* Run `fn` repeatedly, the next invocation starting `interval` ms after the
|
|
696
714
|
* previous one COMPLETES. setTimeout chaining rather than setInterval, so
|
|
697
715
|
* executions cannot overlap when one runs longer than the interval.
|
|
716
|
+
*
|
|
717
|
+
* `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
|
|
718
|
+
* shorter: `setWake`
|
|
719
|
+
* receives a function that does so while the loop rests, and undefined while a run is in
|
|
720
|
+
* flight; a request made during a run shows in `requested()` when it ends. Any number of
|
|
721
|
+
* requests coalesce into one early run.
|
|
698
722
|
*/
|
|
699
|
-
scheduleLoop(label, fn, interval) {
|
|
700
|
-
|
|
723
|
+
scheduleLoop(label, fn, interval, early) {
|
|
724
|
+
// `delay` counts from `restStartedAt`, so a later wake never pushes the next run out.
|
|
725
|
+
const run = (delay, restStartedAt = Date.now()) => {
|
|
701
726
|
if (!this.running)
|
|
702
727
|
return;
|
|
703
|
-
const
|
|
728
|
+
const fire = async () => {
|
|
704
729
|
this.activeTimeouts.delete(timeout);
|
|
730
|
+
early?.setWake(undefined);
|
|
705
731
|
const operation = fn().catch((err) => {
|
|
706
732
|
workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
|
|
707
733
|
});
|
|
708
734
|
await this.trackOperation(label, operation);
|
|
709
|
-
run();
|
|
710
|
-
}
|
|
735
|
+
run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
|
|
736
|
+
};
|
|
737
|
+
const timeout = setTimeout(fire, Math.max(0, delay - (Date.now() - restStartedAt)));
|
|
711
738
|
this.activeTimeouts.add(timeout);
|
|
739
|
+
early?.setWake(() => {
|
|
740
|
+
const rested = Date.now() - restStartedAt;
|
|
741
|
+
const sooner = Math.max(0, Math.min(interval, EARLY_RUN_MIN_REST_MS) - rested);
|
|
742
|
+
if (sooner >= delay - rested || !this.activeTimeouts.delete(timeout))
|
|
743
|
+
return;
|
|
744
|
+
clearTimeout(timeout);
|
|
745
|
+
run(rested + sooner, restStartedAt);
|
|
746
|
+
});
|
|
712
747
|
};
|
|
713
|
-
run();
|
|
748
|
+
run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
|
|
749
|
+
}
|
|
750
|
+
/** See {@link WorkerContext.requestBranchSync}. */
|
|
751
|
+
requestBranchSync(branchRef) {
|
|
752
|
+
if (branchRef !== undefined)
|
|
753
|
+
this.branchSyncRequests.add(sanitizeBranchName(branchRef));
|
|
754
|
+
this.gitSyncRequested = true;
|
|
755
|
+
this.wakeGitSync?.();
|
|
756
|
+
}
|
|
757
|
+
/** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
|
|
758
|
+
takeBranchSyncRequests() {
|
|
759
|
+
const taken = { branches: this.branchSyncRequests, requested: this.gitSyncRequested };
|
|
760
|
+
this.branchSyncRequests = new Set();
|
|
761
|
+
this.gitSyncRequested = false;
|
|
762
|
+
return taken;
|
|
714
763
|
}
|
|
715
764
|
get baseBranch() {
|
|
716
765
|
return this.requireBaseBranch().name;
|
|
@@ -1232,9 +1281,9 @@ export class CmsWorker {
|
|
|
1232
1281
|
return path.join(this.contentBranchesPath, sanitizeBranchName(branchRefName));
|
|
1233
1282
|
}
|
|
1234
1283
|
/**
|
|
1235
|
-
* Test-only seam: awaited inside `rebaseActiveBranches()`'s
|
|
1236
|
-
* after `git rebase` reported conflicted files and BEFORE the
|
|
1237
|
-
*
|
|
1284
|
+
* Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
|
|
1285
|
+
* after `git rebase` reported conflicted files and BEFORE the rebase is
|
|
1286
|
+
* aborted for a merge. No-op in production.
|
|
1238
1287
|
*
|
|
1239
1288
|
* A test subclass overrides this to land a real `ContentStore` write at
|
|
1240
1289
|
* exactly the instant the rebase is mid-flight, with no sleeps or shell
|
|
@@ -1258,11 +1307,20 @@ export class CmsWorker {
|
|
|
1258
1307
|
// directly and that calls `pollMergeState` directly. Neither is on
|
|
1259
1308
|
// WorkerContext, so a stub installed on either is a no-op for production.
|
|
1260
1309
|
//
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1310
|
+
async rebaseActiveBranches(gatewayPolicy, gitHub, requested) {
|
|
1311
|
+
return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub, requested);
|
|
1312
|
+
}
|
|
1313
|
+
// --- Runs with no loop ({@link createLocalWorker}) -----------------------
|
|
1314
|
+
/**
|
|
1315
|
+
* Bring the base into one branch now and answer its request for the latest changes
|
|
1316
|
+
* (rebase.ts `syncBranchNow`).
|
|
1317
|
+
*/
|
|
1318
|
+
async syncBranchNow(branchName) {
|
|
1319
|
+
return syncBranchNow(this.ctx(), branchName);
|
|
1320
|
+
}
|
|
1321
|
+
/** Bring the base into every branch now, answering their requests from this run's start. */
|
|
1322
|
+
async syncBranchesNow() {
|
|
1323
|
+
return runRebaseCycle(this.ctx(), undefined, undefined, new Set(), { fetchedAt: Date.now() }, true);
|
|
1266
1324
|
}
|
|
1267
1325
|
// --- Git-sync cluster (worker/git-sync.ts) -----------------------------
|
|
1268
1326
|
//
|
|
@@ -1302,3 +1360,17 @@ export class CmsWorker {
|
|
|
1302
1360
|
}
|
|
1303
1361
|
}
|
|
1304
1362
|
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
|
|
1365
|
+
* "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
|
|
1366
|
+
* never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
|
|
1367
|
+
* GitHub only about a branch's pull request, which dev never opens.
|
|
1368
|
+
*/
|
|
1369
|
+
export function createLocalWorker(options) {
|
|
1370
|
+
return new CmsWorker({
|
|
1371
|
+
...options,
|
|
1372
|
+
githubOwner: 'local',
|
|
1373
|
+
githubRepo: 'local',
|
|
1374
|
+
githubToken: 'local',
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ConflictsStatusFailure } from '../types.js';
|
|
2
|
+
import type { GitHubConflictsStatus } from './github-gateway.js';
|
|
3
|
+
import type { WorkerContext } from './worker-context.js';
|
|
4
|
+
export type ConflictsStatusContext = Pick<WorkerContext, 'contentBranchesPath' | 'sanitizedBaseBranch' | 'github' | 'conflictsStatusesSet' | 'shutdownSignal' | 'taskTimeoutMs' | 'requestBranchSync'>;
|
|
5
|
+
/** The state and description a branch's conflict record calls for. */
|
|
6
|
+
type ConflictsStatusText = Pick<GitHubConflictsStatus, 'state' | 'description'>;
|
|
7
|
+
/**
|
|
8
|
+
* What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
|
|
9
|
+
* the record is held after a damaged one was set aside, or does not parse; success otherwise.
|
|
10
|
+
* @internal Exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare function conflictsStatusOf(branchRoot: string): Promise<ConflictsStatusText>;
|
|
13
|
+
/**
|
|
14
|
+
* Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
|
|
15
|
+
* fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
|
|
16
|
+
* when every status was set, or null when the stage could not say: the branches could not be
|
|
17
|
+
* listed, or a drain cut it off. Never throws.
|
|
18
|
+
*/
|
|
19
|
+
export declare function setConflictsStatuses(ctx: ConflictsStatusContext, githubTips: ReadonlyMap<string, string>): Promise<ConflictsStatusFailure | undefined | null>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BranchMetadataFileManager } from '../branch-metadata.js';
|
|
4
|
+
import { BranchMetadataCorruptError } from '../branch-metadata-file.js';
|
|
5
|
+
import { editorDeepLink, resolveEditorUrl } from '../editor-url.js';
|
|
6
|
+
import { MergeConflictStore, MergeConflictsFileFormatError, MergeConflictsFileMalformedError, openConflictCount, } from '../merge-conflicts.js';
|
|
7
|
+
import { getErrorMessage, isNodeError, sanitizeErrorMessage } from '../utils/error.js';
|
|
8
|
+
import { escapeForLog } from '../utils/escape-text.js';
|
|
9
|
+
import { GatewayRefusalError, MAX_COMMIT_STATUS_URL_LENGTH } from './branch-policy.js';
|
|
10
|
+
import { getHttpStatus, isPermissionDenial } from './github-errors.js';
|
|
11
|
+
import { workerLog, workerLogWarn } from './log.js';
|
|
12
|
+
/** The most branch names a failure report lists. */
|
|
13
|
+
const MAX_REPORTED_BRANCHES = 50;
|
|
14
|
+
/**
|
|
15
|
+
* What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
|
|
16
|
+
* the record is held after a damaged one was set aside, or does not parse; success otherwise.
|
|
17
|
+
* @internal Exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
export async function conflictsStatusOf(branchRoot) {
|
|
20
|
+
try {
|
|
21
|
+
const record = await new MergeConflictStore(branchRoot).read();
|
|
22
|
+
if (record?.held) {
|
|
23
|
+
return {
|
|
24
|
+
state: 'failure',
|
|
25
|
+
description: 'Conflict record set aside: an admin must release the branch in the editor',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const open = record ? openConflictCount(record.entries) : 0;
|
|
29
|
+
if (open === 0)
|
|
30
|
+
return { state: 'success', description: 'No conflicting changes to resolve' };
|
|
31
|
+
return {
|
|
32
|
+
state: 'failure',
|
|
33
|
+
description: open === 1
|
|
34
|
+
? '1 conflicting change to resolve in the editor'
|
|
35
|
+
: `${open} conflicting changes to resolve in the editor`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof MergeConflictsFileMalformedError ||
|
|
40
|
+
err instanceof MergeConflictsFileFormatError) {
|
|
41
|
+
return { state: 'failure', description: "Couldn't read this branch's conflicts" };
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
|
|
48
|
+
* fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
|
|
49
|
+
* when every status was set, or null when the stage could not say: the branches could not be
|
|
50
|
+
* listed, or a drain cut it off. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
export async function setConflictsStatuses(ctx, githubTips) {
|
|
53
|
+
let due;
|
|
54
|
+
let unreadable;
|
|
55
|
+
try {
|
|
56
|
+
;
|
|
57
|
+
({ due, unreadable } = await dueStatuses(ctx, githubTips));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
workerLogWarn(`Could not read the branches' conflicts for their status: ${getErrorMessage(err)}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const set = ctx.conflictsStatusesSet();
|
|
64
|
+
const failed = [];
|
|
65
|
+
let failure;
|
|
66
|
+
for (const [index, { branch, status, key }] of due.entries()) {
|
|
67
|
+
if (ctx.shutdownSignal().aborted)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
// Bounded, so a GitHub that stalls holds the sync for one timeout, not one per branch.
|
|
71
|
+
await ctx
|
|
72
|
+
.github()
|
|
73
|
+
.setConflictsStatus(status, AbortSignal.any([ctx.shutdownSignal(), AbortSignal.timeout(ctx.taskTimeoutMs)]));
|
|
74
|
+
set.set(branch, key);
|
|
75
|
+
workerLog(`Set the conflicts status of PR #${status.pullRequest} (${escapeForLog(branch)}) at ` +
|
|
76
|
+
`${status.sha.slice(0, 7)}: ${status.state}`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (ctx.shutdownSignal().aborted)
|
|
80
|
+
return null;
|
|
81
|
+
if (err instanceof GatewayRefusalError && err.reason === 'not-head') {
|
|
82
|
+
// GitHub moves a pull request's head a moment after the push, so this cycle's tip can be
|
|
83
|
+
// ahead of it: one more sync for that tip, and no loop if the two never agree.
|
|
84
|
+
const asked = `not-head:${branch}`;
|
|
85
|
+
if (set.get(asked) !== status.sha) {
|
|
86
|
+
set.set(asked, status.sha);
|
|
87
|
+
ctx.requestBranchSync(branch);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const message = sanitizeErrorMessage(getErrorMessage(err));
|
|
92
|
+
failed.push(branch);
|
|
93
|
+
workerLogWarn(`Could not set the conflicts status of ${escapeForLog(branch)}: ${message}`);
|
|
94
|
+
// The gateway's refusals for the pull request itself report as `other`, retried each cycle.
|
|
95
|
+
const reason = err instanceof GatewayRefusalError
|
|
96
|
+
? 'other'
|
|
97
|
+
: isPermissionDenial(err)
|
|
98
|
+
? 'permission'
|
|
99
|
+
: isUnreachable(err)
|
|
100
|
+
? 'unreachable'
|
|
101
|
+
: 'other';
|
|
102
|
+
// A refused credential names its remedy over any other failure.
|
|
103
|
+
if (reason === 'permission' || !failure)
|
|
104
|
+
failure = { reason, message };
|
|
105
|
+
// Every later write would meet the same GitHub; the next cycle tries again.
|
|
106
|
+
if (reason !== 'other') {
|
|
107
|
+
failed.push(...due.slice(index + 1).map((d) => d.branch));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// After GitHub's answers, whose reason and remedy come first.
|
|
113
|
+
for (const u of unreadable)
|
|
114
|
+
failed.push(u.branch);
|
|
115
|
+
failure ??= unreadable[0] && { reason: 'other', message: unreadable[0].message };
|
|
116
|
+
if (!failure)
|
|
117
|
+
return undefined;
|
|
118
|
+
return {
|
|
119
|
+
...failure,
|
|
120
|
+
branches: failed.slice(0, MAX_REPORTED_BRANCHES),
|
|
121
|
+
count: failed.length,
|
|
122
|
+
at: new Date().toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A failure to reach GitHub or the gateway, or GitHub busy: no status at all, a timeout, a rate
|
|
127
|
+
* limit (a 403 that is not a permission denial), or a server error.
|
|
128
|
+
*/
|
|
129
|
+
function isUnreachable(err) {
|
|
130
|
+
const status = getHttpStatus(err);
|
|
131
|
+
return status === null || status === 403 || status === 408 || status === 429 || status >= 500;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Each branch with a pull request whose head lacks the status it calls for, and each whose conflict
|
|
135
|
+
* record could not be read, which costs only its own status. A branch.json that cannot be read says
|
|
136
|
+
* nothing of a pull request, so it is logged and skipped. Throws only when the branches directory
|
|
137
|
+
* cannot be listed.
|
|
138
|
+
*/
|
|
139
|
+
async function dueStatuses(ctx, githubTips) {
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
146
|
+
return { due: [], unreadable: [] };
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const set = ctx.conflictsStatusesSet();
|
|
150
|
+
const due = [];
|
|
151
|
+
const unreadable = [];
|
|
152
|
+
const cannotRead = (branch, err) => {
|
|
153
|
+
const message = sanitizeErrorMessage(getErrorMessage(err));
|
|
154
|
+
workerLogWarn(`Could not read ${escapeForLog(branch)} for its conflicts status: ${message}`);
|
|
155
|
+
unreadable.push({ branch, message });
|
|
156
|
+
};
|
|
157
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
158
|
+
const dir = entry.name;
|
|
159
|
+
// The base branch has no pull request; `.trash-*` directories hold purged branches.
|
|
160
|
+
if (!entry.isDirectory() || dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
|
|
161
|
+
continue;
|
|
162
|
+
const branchRoot = path.join(ctx.contentBranchesPath, dir);
|
|
163
|
+
let meta;
|
|
164
|
+
try {
|
|
165
|
+
meta = await BranchMetadataFileManager.loadOnly(branchRoot);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (!(err instanceof BranchMetadataCorruptError) &&
|
|
169
|
+
!(isNodeError(err) && err.code === 'ENOENT'))
|
|
170
|
+
workerLogWarn(`Could not read ${escapeForLog(dir)}'s branch.json for its conflicts status: ` +
|
|
171
|
+
sanitizeErrorMessage(getErrorMessage(err)));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const branch = meta?.branch;
|
|
175
|
+
if (!branch || branch.status === 'archived' || branch.pullRequestNumber === undefined)
|
|
176
|
+
continue;
|
|
177
|
+
if (branch.pullRequestState === 'merged' || branch.pullRequestState === 'closed')
|
|
178
|
+
continue;
|
|
179
|
+
const sha = githubTips.get(branch.name);
|
|
180
|
+
if (sha === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
let text;
|
|
183
|
+
try {
|
|
184
|
+
text = await conflictsStatusOf(branchRoot);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
cannotRead(branch.name, err);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const editorUrl = branch.editorUrl && resolveEditorUrl({ configured: branch.editorUrl });
|
|
191
|
+
const link = editorUrl && editorDeepLink(editorUrl, branch.name);
|
|
192
|
+
const status = {
|
|
193
|
+
pullRequest: branch.pullRequestNumber,
|
|
194
|
+
sha,
|
|
195
|
+
...text,
|
|
196
|
+
// A link the gateway would refuse is left off, so it never costs the status.
|
|
197
|
+
...(link && link.length <= MAX_COMMIT_STATUS_URL_LENGTH && { targetUrl: link }),
|
|
198
|
+
};
|
|
199
|
+
const key = JSON.stringify(status);
|
|
200
|
+
if (set.get(branch.name) !== key)
|
|
201
|
+
due.push({ branch: branch.name, status, key });
|
|
202
|
+
}
|
|
203
|
+
return { due, unreadable };
|
|
204
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SimpleGit } from 'simple-git';
|
|
2
|
+
import type { FlatSchemaItem } from '../config/types.js';
|
|
3
|
+
import { type KeyRemap } from '../merge/entry-merge.js';
|
|
4
|
+
import { type FoundEntryConflict } from '../merge-conflicts.js';
|
|
5
|
+
import type { ContentId } from '../paths/types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The worker's side of the field merge: which files of a merge the engine (`merge/entry-merge.ts`)
|
|
8
|
+
* decides, and the blobs it decides them with. A merge's three versions are the merge base
|
|
9
|
+
* (ancestor), the commit coming in (base) and the branch's own (branch). Every entry file and
|
|
10
|
+
* `.collection.json` changed on both sides goes through the engine, whether or not git found a
|
|
11
|
+
* conflict in it: a merge git calls clean can still be wrong, such as a duplicate frontmatter key.
|
|
12
|
+
*
|
|
13
|
+
* Entries are paired by the content ID in their file names, and `.collection.json` files by their
|
|
14
|
+
* collection's, not by path, so a rename on one side meets an edit on the other whatever git's
|
|
15
|
+
* rename detection makes of it. A collection directory one side moved moves the other side's files
|
|
16
|
+
* in it too. An entry changed on one side only takes that side's version: where git's rename
|
|
17
|
+
* detection put something else there, the pairing by ID puts it right.
|
|
18
|
+
*/
|
|
19
|
+
/** A file in one version of the tree. */
|
|
20
|
+
export interface TreeFile {
|
|
21
|
+
path: string;
|
|
22
|
+
oid: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
}
|
|
25
|
+
/** What the resolver makes of a merge, for `planMerge` to stage. */
|
|
26
|
+
export interface FieldMergeResult {
|
|
27
|
+
/** Paths whose blob in the merge replaces git's: a file, or null for none. */
|
|
28
|
+
updates: Map<string, TreeFile | null>;
|
|
29
|
+
/** Every path of the units the resolver decided: git's conflicts on them are settled. */
|
|
30
|
+
covered: Set<string>;
|
|
31
|
+
conflicts: FoundEntryConflict[];
|
|
32
|
+
/** Item keys the merge changed, by the logical path the branch's comment threads name. */
|
|
33
|
+
keyRemaps: Array<{
|
|
34
|
+
entryPath: string;
|
|
35
|
+
contentId?: string;
|
|
36
|
+
remaps: KeyRemap[];
|
|
37
|
+
}>;
|
|
38
|
+
/** Where each entry or collection with a conflict on record may now be: its path in the merge. */
|
|
39
|
+
paths: Map<string, string>;
|
|
40
|
+
/** Why git's merge can't be settled by rule, where it can't. */
|
|
41
|
+
unsettled?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface FieldMergeArgs {
|
|
44
|
+
git: SimpleGit;
|
|
45
|
+
clonePath: string;
|
|
46
|
+
ancestor: string;
|
|
47
|
+
head: string;
|
|
48
|
+
other: string;
|
|
49
|
+
/** The tree git's merge wrote. */
|
|
50
|
+
mergedTree: string;
|
|
51
|
+
/** Paths git left conflicted. */
|
|
52
|
+
conflicted: ReadonlySet<string>;
|
|
53
|
+
contentRoot: string;
|
|
54
|
+
/** The branch's entry types, which name conflicts as the "Show changes" drawer does. */
|
|
55
|
+
flatSchema: readonly FlatSchemaItem[] | undefined;
|
|
56
|
+
/** The content ID a `.collection.json` path belongs to. */
|
|
57
|
+
collectionIdOf: (file: string) => ContentId | undefined;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
/**
|
|
60
|
+
* Git's merge stands, and only the item keys of an entry both sides changed change: to the field
|
|
61
|
+
* merge's, where it holds the same content with no conflict ({@link rekeyAsMerged}).
|
|
62
|
+
*/
|
|
63
|
+
rekeyOnly?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
|
|
66
|
+
export declare function changedFiles(git: SimpleGit, from: string, to: string): Promise<Array<{
|
|
67
|
+
path: string;
|
|
68
|
+
before?: TreeFile;
|
|
69
|
+
after?: TreeFile;
|
|
70
|
+
}>>;
|
|
71
|
+
/**
|
|
72
|
+
* Whether two versions of one entry file under `contentRoot` differ in item keys alone: what a
|
|
73
|
+
* re-key changes. False for anything else, a file's mode or path included.
|
|
74
|
+
*/
|
|
75
|
+
export declare function differsInItemKeysOnly(git: SimpleGit, x: TreeFile, y: TreeFile, contentRoot: string): Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
|
|
78
|
+
* result says which blobs replace git's, and which conflicts a person must resolve.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveFieldMerge(args: FieldMergeArgs): Promise<FieldMergeResult>;
|