canopycms 0.9.0 → 0.11.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 +74 -48
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +2 -0
- package/dist/cli/generate-ai-content.js +1 -0
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +49 -25
- package/dist/editor/PreviewFrame.d.ts +8 -1
- package/dist/editor/PreviewFrame.js +15 -2
- package/dist/editor/PreviewPane.d.ts +7 -2
- package/dist/editor/PreviewPane.js +6 -5
- package/dist/editor/PreviewToolbarTools.d.ts +6 -0
- package/dist/editor/PreviewToolbarTools.js +6 -1
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
- package/dist/editor/comments/CommentThreadsPopover.js +9 -1
- package/dist/editor/comments/FieldWrapper.js +3 -1
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
- package/dist/editor/hooks/usePreviewFollow.js +54 -0
- package/dist/editor/preview-bridge.d.ts +25 -0
- package/dist/editor/preview-bridge.js +175 -0
- package/dist/preview.d.ts +2 -2
- package/dist/preview.js +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from '../branch-archive.js';
|
|
3
|
+
import { deleteBranchWorkspace } from '../branch-delete.js';
|
|
4
|
+
import { buildUnmergedArchiveUpdate, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
5
|
+
import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
|
|
6
|
+
import { isSettingsBranchName } from '../paths/index.js';
|
|
7
|
+
import { getBranchProtection } from '../authorization/index.js';
|
|
8
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
9
|
+
import { defineEndpoint } from './route-builder.js';
|
|
10
|
+
import { branchNameSchema } from './validators.js';
|
|
11
|
+
const archiveBranchParamsSchema = z.object({ branch: branchNameSchema });
|
|
12
|
+
const archiveBranchHandler = async (gc, _ctx) => {
|
|
13
|
+
const { branchContext } = gc;
|
|
14
|
+
const name = branchContext.branch.name;
|
|
15
|
+
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
16
|
+
const archived = await meta.update((current) => current?.branch.name === name &&
|
|
17
|
+
current.branch.status !== 'archived' &&
|
|
18
|
+
current.branch.pullRequestState === 'closed'
|
|
19
|
+
? { branch: buildUnmergedArchiveUpdate() }
|
|
20
|
+
: null);
|
|
21
|
+
if (archived === null) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
status: 409,
|
|
25
|
+
error: 'Only a branch whose pull request closed without merging can be archived this way.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, status: 200, data: { branch: { name, status: 'archived' } } };
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Archive a branch whose PR closed without merging. A close never changes the status by itself,
|
|
32
|
+
* so this is an admin's decision that the work is abandoned. The handler's checks run on the
|
|
33
|
+
* branch.json read under its lock, where a reopened PR or a resubmit since the guard read refuses
|
|
34
|
+
* it. They also cover the base branch, which has no PR.
|
|
35
|
+
*/
|
|
36
|
+
const archiveBranch = defineEndpoint({
|
|
37
|
+
namespace: 'workflow',
|
|
38
|
+
name: 'archive',
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/:branch/archive',
|
|
41
|
+
params: archiveBranchParamsSchema,
|
|
42
|
+
responseType: 'BranchMergeResponse',
|
|
43
|
+
response: {},
|
|
44
|
+
defaultMockData: { branch: { name: 'test-branch', status: 'archived' } },
|
|
45
|
+
guards: ['admin', 'branch'],
|
|
46
|
+
handler: archiveBranchHandler,
|
|
47
|
+
});
|
|
48
|
+
export const ARCHIVE_BRANCH_ROUTES = { archive: archiveBranch };
|
|
49
|
+
/**
|
|
50
|
+
* Wall-clock budget for starting deletes, well inside the CMS Lambda's default 60 s timeout
|
|
51
|
+
* (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), so one slow `rm` can still finish. A Lambda
|
|
52
|
+
* killed in an `rm` costs nothing: the branch is already gone, and the worker sweeps what is left.
|
|
53
|
+
* @internal Exported for tests.
|
|
54
|
+
*/
|
|
55
|
+
export const DELETE_ARCHIVED_BUDGET_MS = 25_000;
|
|
56
|
+
const deleteArchivedBranchesBodySchema = z.object({
|
|
57
|
+
olderThanDays: z.number().int().min(1),
|
|
58
|
+
branches: z.array(z.string()),
|
|
59
|
+
});
|
|
60
|
+
/** @internal Exported for tests. */
|
|
61
|
+
export const deleteArchivedBranchesHandler = async (_gc, ctx, _req, body) => {
|
|
62
|
+
const { config, registry } = ctx.services;
|
|
63
|
+
if (!clientOperatingStrategy(config.mode).supportsBranching()) {
|
|
64
|
+
return { ok: false, status: 400, error: 'Cannot delete branches in this operating mode' };
|
|
65
|
+
}
|
|
66
|
+
if (!registry) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 400,
|
|
70
|
+
error: 'Branch registry not initialized — ensure the workspace has been initialized',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const cutoff = archivedCutoff(body.olderThanDays, startedAt);
|
|
75
|
+
const settingsBranch = operatingStrategy(config.mode).getSettingsBranchName(config);
|
|
76
|
+
const old = (await registry.list()).filter(({ branch }) => archivedBefore(branch, cutoff) &&
|
|
77
|
+
!isSettingsBranchName(branch.name, settingsBranch) &&
|
|
78
|
+
!getBranchProtection(config, branch.name, branch.baseBranch).isProtected);
|
|
79
|
+
const confirmed = new Set(body.branches);
|
|
80
|
+
const due = old.filter(({ branch }) => deletableByAge(branch, cutoff) && confirmed.has(branch.name));
|
|
81
|
+
const heldFor = (hold) => old.filter(({ branch }) => ageDeletionHold(branch) === hold).map(({ branch }) => branch.name);
|
|
82
|
+
const data = {
|
|
83
|
+
deleted: [],
|
|
84
|
+
skipped: { notMerged: heldFor('not-merged'), unpublished: heldFor('unpublished') },
|
|
85
|
+
failed: [],
|
|
86
|
+
remaining: 0,
|
|
87
|
+
warnings: [],
|
|
88
|
+
};
|
|
89
|
+
for (const [index, context] of due.entries()) {
|
|
90
|
+
if (Date.now() - startedAt > DELETE_ARCHIVED_BUDGET_MS) {
|
|
91
|
+
data.remaining = due.length - index;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const name = context.branch.name;
|
|
95
|
+
try {
|
|
96
|
+
const outcome = await deleteBranchWorkspace({
|
|
97
|
+
context,
|
|
98
|
+
registry,
|
|
99
|
+
stillWanted: (current) => current.name === name && deletableByAge(current, cutoff),
|
|
100
|
+
});
|
|
101
|
+
if (outcome.kind === 'deleted') {
|
|
102
|
+
data.deleted.push(name);
|
|
103
|
+
if (outcome.cleanupWarning)
|
|
104
|
+
data.warnings.push(`${name}: ${outcome.cleanupWarning}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
data.failed.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error(`CanopyCMS: Failed to delete archived branch ${name}:`, getErrorMessage(err));
|
|
112
|
+
data.failed.push(name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Gone or changed since the admin confirmed them: reported, so nothing reads as deleted.
|
|
116
|
+
const listed = new Set(old.map(({ branch }) => branch.name));
|
|
117
|
+
data.failed.push(...[...confirmed].filter((name) => !listed.has(name)));
|
|
118
|
+
return { ok: true, status: 200, data };
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Delete merged, archived branches older than a number of days, by the rule the retention sweep
|
|
122
|
+
* uses. Takes the editor's copy only (`deleteBranchWorkspace`'s `remoteCopies`).
|
|
123
|
+
* Here rather than queued for the worker so the admin gets the answer, and so it works where no
|
|
124
|
+
* worker runs.
|
|
125
|
+
*/
|
|
126
|
+
const deleteArchivedBranches = defineEndpoint({
|
|
127
|
+
namespace: 'admin',
|
|
128
|
+
name: 'deleteArchivedBranches',
|
|
129
|
+
method: 'POST',
|
|
130
|
+
path: '/admin/archived-branches/delete',
|
|
131
|
+
body: deleteArchivedBranchesBodySchema,
|
|
132
|
+
bodyType: 'DeleteArchivedBranchesBody',
|
|
133
|
+
responseType: 'DeleteArchivedBranchesResponse',
|
|
134
|
+
response: {},
|
|
135
|
+
defaultMockData: {
|
|
136
|
+
deleted: [],
|
|
137
|
+
skipped: { notMerged: [], unpublished: [] },
|
|
138
|
+
failed: [],
|
|
139
|
+
remaining: 0,
|
|
140
|
+
warnings: [],
|
|
141
|
+
},
|
|
142
|
+
clientTimeout: 'long',
|
|
143
|
+
guards: ['admin'],
|
|
144
|
+
handler: deleteArchivedBranchesHandler,
|
|
145
|
+
});
|
|
146
|
+
export const ADMIN_ARCHIVED_BRANCH_ROUTES = { deleteArchivedBranches };
|
package/dist/api/branch-merge.js
CHANGED
|
@@ -27,6 +27,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
27
27
|
error: 'Cannot mark as merged: no pull request associated with this branch',
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
let verifiedMerged = false;
|
|
30
31
|
if (ctx.services.githubService) {
|
|
31
32
|
try {
|
|
32
33
|
const pr = await ctx.services.githubService.getPullRequest(branchContext.branch.pullRequestNumber);
|
|
@@ -37,6 +38,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
37
38
|
error: `Cannot mark as merged: PR #${branchContext.branch.pullRequestNumber} is not merged on GitHub`,
|
|
38
39
|
};
|
|
39
40
|
}
|
|
41
|
+
verifiedMerged = true;
|
|
40
42
|
}
|
|
41
43
|
catch (err) {
|
|
42
44
|
console.error(`CanopyCMS: Failed to verify PR merge status for ${branchName}:`, err);
|
|
@@ -45,7 +47,20 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
45
47
|
}
|
|
46
48
|
// Update branch status to 'archived'
|
|
47
49
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
48
|
-
await meta.update((current) => current === null
|
|
50
|
+
const saved = await meta.update((current) => current === null ||
|
|
51
|
+
(current.branch.pullRequestState === 'closed' && !verifiedMerged) ||
|
|
52
|
+
(current.branch.status !== 'submitted' && current.branch.status !== 'approved')
|
|
53
|
+
? null
|
|
54
|
+
: { branch: buildMergedBranchUpdate(current.branch) });
|
|
55
|
+
// `mergedAt` is what lets a branch be deleted by age (branch-archive.ts), so a branch GitHub
|
|
56
|
+
// reported closed unmerged never takes one on an admin's word.
|
|
57
|
+
if (saved === null) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
status: 409,
|
|
61
|
+
error: 'Cannot mark as merged: its pull request was closed without merging, or its status changed. Archive the branch instead.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
49
64
|
// Optionally delete remote branch (disabled by default for safety)
|
|
50
65
|
// if (ctx.services.githubService && req.body?.deleteRemoteBranch) {
|
|
51
66
|
// try {
|
|
@@ -23,6 +23,17 @@ declare const submitBranchBodySchema: z.ZodOptional<z.ZodObject<{
|
|
|
23
23
|
/** The submit request body. */
|
|
24
24
|
export type SubmitBranchBody = z.infer<typeof submitBranchBodySchema>;
|
|
25
25
|
export declare const WORKFLOW_ROUTES: {
|
|
26
|
+
readonly archive: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
27
|
+
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
branch: string & {
|
|
30
|
+
readonly __brand: "BranchName";
|
|
31
|
+
};
|
|
32
|
+
}, {
|
|
33
|
+
branch: string & {
|
|
34
|
+
readonly __brand: "BranchName";
|
|
35
|
+
};
|
|
36
|
+
}>, undefined, import("./branch-merge.js").BranchMergeResponse>;
|
|
26
37
|
readonly getStatus: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
27
38
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
28
39
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -5,6 +5,7 @@ import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
|
5
5
|
import { withdrawBranch } from './branch-withdraw.js';
|
|
6
6
|
import { requestChanges, approveBranch } from './branch-review.js';
|
|
7
7
|
import { markAsMerged } from './branch-merge.js';
|
|
8
|
+
import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
|
|
8
9
|
import { defineEndpoint } from './route-builder.js';
|
|
9
10
|
import { canPerformWorkflowAction, getBranchProtection, isAdmin } from '../authorization/index.js';
|
|
10
11
|
import { syncSubmitPr } from './github-sync.js';
|
|
@@ -277,4 +278,5 @@ export const WORKFLOW_ROUTES = {
|
|
|
277
278
|
requestChanges: requestChanges,
|
|
278
279
|
approve: approveBranch,
|
|
279
280
|
markMerged: markAsMerged,
|
|
281
|
+
...ARCHIVE_BRANCH_ROUTES,
|
|
280
282
|
};
|
|
@@ -28,7 +28,7 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
28
28
|
// non-'editing' status blocks content writes, and a raw workflow.submit would
|
|
29
29
|
// discard the approval, which the submit status gate refuses (api/branch-status.ts).
|
|
30
30
|
//
|
|
31
|
-
// 'archived' stays refused:
|
|
31
|
+
// 'archived' stays refused: an archive is the end of the branch, merged or not.
|
|
32
32
|
const status = branchContext.branch.status;
|
|
33
33
|
if (status !== 'submitted' && status !== 'approved') {
|
|
34
34
|
return {
|
package/dist/api/branch.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import { BranchWorkspaceManager } from '../branch-workspace.js';
|
|
5
|
-
import { BranchDirOccupiedError, BranchProvisioningBusyError
|
|
4
|
+
import { BranchDirOccupiedError, BranchProvisioningBusyError } from '../branch-provisioning.js';
|
|
5
|
+
import { deleteBranchWorkspace } from '../branch-delete.js';
|
|
6
6
|
import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
7
|
-
import { withOccFileLock } from '../utils/occ-json-write.js';
|
|
8
7
|
import { isCreatorsRecentBranch } from './branch-create-window.js';
|
|
9
8
|
import { defineEndpoint } from './route-builder.js';
|
|
10
9
|
import { createDebugLogger } from '../utils/debug.js';
|
|
11
10
|
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
12
|
-
import {
|
|
11
|
+
import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
|
|
12
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
13
13
|
import { filePathExists } from '../utils/fs.js';
|
|
14
14
|
import { isNetworkRemoteUrl } from '../utils/git.js';
|
|
15
|
-
import { parseCreatedBranchName, sanitizeBranchName,
|
|
15
|
+
import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
|
|
16
16
|
import { GitManager } from '../git-manager.js';
|
|
17
17
|
import { syncDeleteRemoteBranch } from './github-sync.js';
|
|
18
18
|
import { branchNameSchema, branchParamSchema } from './validators.js';
|
|
@@ -86,7 +86,7 @@ export const canCreateBranch = (user, pathPermissions) => {
|
|
|
86
86
|
* being avoided, so it has no read-only equivalent here.
|
|
87
87
|
*
|
|
88
88
|
* Shared by createBranchHandler (create-time collision check against the
|
|
89
|
-
* mirror) and
|
|
89
|
+
* mirror) and remoteCopiesOf (removing the deleted branch's stale local
|
|
90
90
|
* head from the mirror) -- the two halves of the same lifecycle: what create
|
|
91
91
|
* checks, delete must clean up.
|
|
92
92
|
*/
|
|
@@ -137,7 +137,9 @@ export const toBranchListItem = (config, branch, viewer) => {
|
|
|
137
137
|
: !inReview
|
|
138
138
|
? branch.unpublishedAtMerge
|
|
139
139
|
? { kind: 'unpublished-at-merge' }
|
|
140
|
-
: undefined
|
|
140
|
+
: branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined
|
|
141
|
+
? { kind: 'rewritten-at-merge' }
|
|
142
|
+
: undefined
|
|
141
143
|
: branch.submitUnfinished
|
|
142
144
|
? {
|
|
143
145
|
kind: 'submit-unfinished',
|
|
@@ -367,7 +369,7 @@ export const createBranchHandler = async (ctx, req, body) => {
|
|
|
367
369
|
// No collision: GitHub does not have this name. Clear any STALE local
|
|
368
370
|
// head the mirror still carries for it, so this new branch's first
|
|
369
371
|
// publish can't be rejected non-fast-forward against a leftover tip.
|
|
370
|
-
//
|
|
372
|
+
// remoteCopiesOf's cleanup (below) handles the common case, but
|
|
371
373
|
// cannot cover every ordering: when the branch still existed on
|
|
372
374
|
// GitHub at editor-delete time, the sync loop's reconcile re-creates
|
|
373
375
|
// the local head from GitHub's branch within a cycle, and once the
|
|
@@ -529,6 +531,9 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
|
|
|
529
531
|
},
|
|
530
532
|
};
|
|
531
533
|
};
|
|
534
|
+
/** A person's delete, as deletion by age never does, can take commits only the branch still has. */
|
|
535
|
+
const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
|
|
536
|
+
(branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
|
|
532
537
|
/** @internal Exported for tests. */
|
|
533
538
|
export const canDeleteBranch = (user, branchContext) => {
|
|
534
539
|
if (isAdmin(user.groups)) {
|
|
@@ -594,106 +599,9 @@ export const deleteBranchHandler = async (ctx, req, params) => {
|
|
|
594
599
|
}
|
|
595
600
|
// It may hold the only copy of commits the merge left out, and deleting it would drop the alert
|
|
596
601
|
// that says so.
|
|
597
|
-
if (branchContext.branch
|
|
598
|
-
return {
|
|
599
|
-
ok: false,
|
|
600
|
-
status: 409,
|
|
601
|
-
error: 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.',
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
// The branch directory leaves its name by one rename, under the same
|
|
605
|
-
// server-enforced lockfile branch-metadata saves hold (see
|
|
606
|
-
// utils/occ-json-write.ts), so a save() that reaches that lock after the
|
|
607
|
-
// rename fails rather than recreating the tree, and a process killed during
|
|
608
|
-
// the `rm` leaves
|
|
609
|
-
// only a `.deleting-*` directory the worker sweeps, never residue under a
|
|
610
|
-
// name that may be created again.
|
|
611
|
-
const metadataFile = path.join(branchContext.branchRoot, '.canopy-meta', 'branch.json');
|
|
612
|
-
let cleanupWarning;
|
|
613
|
-
let deletingPath;
|
|
614
|
-
try {
|
|
615
|
-
await withOccFileLock(metadataFile, async () => {
|
|
616
|
-
if (branchContext.branchRoot === branchContext.baseRoot) {
|
|
617
|
-
await fs.unlink(metadataFile).catch((err) => {
|
|
618
|
-
if (!isNotFoundError(err))
|
|
619
|
-
throw err;
|
|
620
|
-
});
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
const target = path.join(branchContext.baseRoot, deletingDirName(path.basename(branchContext.branchRoot)));
|
|
624
|
-
try {
|
|
625
|
-
await fs.rename(branchContext.branchRoot, target);
|
|
626
|
-
deletingPath = target;
|
|
627
|
-
}
|
|
628
|
-
catch (err) {
|
|
629
|
-
if (isNotFoundError(err))
|
|
630
|
-
return;
|
|
631
|
-
// Still deleted logically: without branch.json the branch is unlisted,
|
|
632
|
-
// and the worker quarantines what is left.
|
|
633
|
-
await fs.unlink(metadataFile).catch(() => { });
|
|
634
|
-
// [REDACT] Returned to the browser; the console line keeps the path.
|
|
635
|
-
cleanupWarning = `Failed to remove branch directory: ${sanitizeErrorMessage(getErrorMessage(err))}`;
|
|
636
|
-
console.error(`CanopyCMS: Failed to move branch directory for ${branchName} aside:`, getErrorMessage(err));
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
catch (err) {
|
|
641
|
-
// Lock acquisition failed (e.g. contention past the retry budget) —
|
|
642
|
-
// surface as an error rather than silently skipping the delete.
|
|
643
|
-
return {
|
|
644
|
-
ok: false,
|
|
645
|
-
status: 409,
|
|
646
|
-
error: `Branch is busy, try again: ${getErrorMessage(err)}`,
|
|
647
|
-
};
|
|
602
|
+
if (mayHoldLostCommits(branchContext.branch) && !isAdmin(req.user.groups)) {
|
|
603
|
+
return { ok: false, status: 409, error: DELETE_NEEDS_ADMIN_REASON };
|
|
648
604
|
}
|
|
649
|
-
if (deletingPath)
|
|
650
|
-
await removeLeftoverDir(deletingPath);
|
|
651
|
-
// Also delete the branch's local head from the remote.git mirror (the
|
|
652
|
-
// deployment's local origin). The sync loop's reconcileTrackedBranches
|
|
653
|
-
// removes a head only once GitHub no longer has its branch, so while GitHub
|
|
654
|
-
// does, THIS is the path that removes it -- without this, the head outlives
|
|
655
|
-
// the branch, and reusing the name after the GitHub side is gone (e.g. a
|
|
656
|
-
// squash-merged PR with auto-delete) has the reused branch's first publish
|
|
657
|
-
// rejected non-fast-forward against the stale old tip: a permanent,
|
|
658
|
-
// misleading 409 -- and a retried submit would then ship the STALE head to
|
|
659
|
-
// GitHub as an apparent success (see GitManager.deleteBareRemoteHead's doc
|
|
660
|
-
// comment for the full trace). The tracking ref is deliberately left
|
|
661
|
-
// alone: while the branch still exists on GitHub, the create-time
|
|
662
|
-
// collision check SHOULD keep matching it.
|
|
663
|
-
//
|
|
664
|
-
// Best-effort, same as the directory removal above: the branch is already
|
|
665
|
-
// logically deleted; a mirror we can't reach/write just leaves today's
|
|
666
|
-
// status quo behind (with a warning), it must not fail the delete.
|
|
667
|
-
const strategy = operatingStrategy(operatingMode);
|
|
668
|
-
const mirrorPath = resolveReadOnlyMirrorPath(ctx, strategy);
|
|
669
|
-
const sanitizedDeleted = sanitizeBranchName(branchContext.branch.name);
|
|
670
|
-
// Defense-in-depth only -- isProtected above already rejects the base
|
|
671
|
-
// branch, and settings branches never resolve through getBranchContext.
|
|
672
|
-
const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config));
|
|
673
|
-
const deletableHead = sanitizedDeleted !== sanitizedBase &&
|
|
674
|
-
!sanitizedDeleted.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
|
|
675
|
-
if (deletableHead &&
|
|
676
|
-
mirrorPath &&
|
|
677
|
-
!isNetworkRemoteUrl(mirrorPath) &&
|
|
678
|
-
(await filePathExists(mirrorPath))) {
|
|
679
|
-
try {
|
|
680
|
-
await GitManager.deleteBareRemoteHead(mirrorPath, branchContext.branch.name);
|
|
681
|
-
}
|
|
682
|
-
catch (err) {
|
|
683
|
-
// Client-facing copy is sanitized (API-H2 — no absolute EFS paths);
|
|
684
|
-
// the console line keeps the raw detail for server logs.
|
|
685
|
-
const warning = `Failed to remove the deleted branch's head from the local mirror: ${sanitizeErrorMessage(getErrorMessage(err))}`;
|
|
686
|
-
cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
|
|
687
|
-
console.warn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${branchContext.branch.name}, mirror ${mirrorPath}): ${getErrorMessage(err)}`);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
// `branchContext` was read before the delete, so it still carries the PR number this gates on.
|
|
691
|
-
if (deletableHead) {
|
|
692
|
-
const warning = await syncDeleteRemoteBranch(ctx, branchContext);
|
|
693
|
-
if (warning)
|
|
694
|
-
cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
|
|
695
|
-
}
|
|
696
|
-
// Invalidate registry cache so next list() will regenerate without this branch
|
|
697
605
|
if (!ctx.services.registry) {
|
|
698
606
|
return {
|
|
699
607
|
ok: false,
|
|
@@ -701,13 +609,43 @@ export const deleteBranchHandler = async (ctx, req, params) => {
|
|
|
701
609
|
error: 'Branch registry not initialized — ensure the workspace has been initialized',
|
|
702
610
|
};
|
|
703
611
|
}
|
|
704
|
-
await
|
|
612
|
+
const outcome = await deleteBranchWorkspace({
|
|
613
|
+
context: branchContext,
|
|
614
|
+
registry: ctx.services.registry,
|
|
615
|
+
remoteCopies: remoteCopiesOf(ctx, branchContext),
|
|
616
|
+
});
|
|
617
|
+
if (outcome.kind !== 'deleted') {
|
|
618
|
+
const detail = outcome.kind === 'busy' ? outcome.error : 'it is already being deleted';
|
|
619
|
+
return { ok: false, status: 409, error: `Branch is busy, try again: ${detail}` };
|
|
620
|
+
}
|
|
621
|
+
const { cleanupWarning } = outcome;
|
|
705
622
|
return {
|
|
706
623
|
ok: true,
|
|
707
624
|
status: 200,
|
|
708
625
|
data: { deleted: true, ...(cleanupWarning && { cleanupWarning }) },
|
|
709
626
|
};
|
|
710
627
|
};
|
|
628
|
+
/**
|
|
629
|
+
* A person's delete also takes the branch off GitHub and the mirror: the sync loop's
|
|
630
|
+
* reconcileTrackedBranches removes a head only once GitHub no longer has its branch, so while
|
|
631
|
+
* GitHub does, this is the path that removes it. Without it the head outlives the branch, and
|
|
632
|
+
* reusing the name after the GitHub side is gone has the reused branch's first publish rejected
|
|
633
|
+
* non-fast-forward against the stale tip, and a retried submit ship that stale head to GitHub as
|
|
634
|
+
* an apparent success (see GitManager.deleteBareRemoteHead). The tracking ref stays: while the
|
|
635
|
+
* branch still exists on GitHub, the create-time collision check should keep matching it.
|
|
636
|
+
* `branchContext` was read before the delete, so it still carries the PR number GitHub's delete
|
|
637
|
+
* gates on.
|
|
638
|
+
*/
|
|
639
|
+
const remoteCopiesOf = (ctx, branchContext) => ({
|
|
640
|
+
baseBranch: branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config),
|
|
641
|
+
deleteMirrorHead: async () => {
|
|
642
|
+
const mirrorPath = resolveReadOnlyMirrorPath(ctx, operatingStrategy(ctx.services.config.mode));
|
|
643
|
+
if (mirrorPath && !isNetworkRemoteUrl(mirrorPath) && (await filePathExists(mirrorPath))) {
|
|
644
|
+
await GitManager.deleteBareRemoteHead(mirrorPath, branchContext.branch.name);
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
deleteOnGitHub: () => syncDeleteRemoteBranch(ctx, branchContext),
|
|
648
|
+
});
|
|
711
649
|
/** @internal Exported for tests. */
|
|
712
650
|
export const canModifyBranchAccess = (user, branchContext) => {
|
|
713
651
|
if (isAdmin(user.groups)) {
|
package/dist/api/client.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse,
|
|
|
12
12
|
import type { BranchChangesResponse } from './branch-changes.js';
|
|
13
13
|
import type { BranchEntryChangeResponse } from './branch-entry-changes.js';
|
|
14
14
|
import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
|
|
15
|
+
import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
|
|
15
16
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
16
17
|
import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
|
|
17
18
|
import type { ReferenceOptionsResponse } from './reference-options.js';
|
|
@@ -23,7 +24,6 @@ import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGrou
|
|
|
23
24
|
import type { UserInfoResponse } from './user.js';
|
|
24
25
|
import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
|
|
25
26
|
import type { CreateCollectionInput, CreateEntryTypeInput, UpdateCollectionInput, UpdateEntryTypeInput } from './../schema/schema-store-types.js';
|
|
26
|
-
import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
|
|
27
27
|
/** Options for {@link createApiClient}. */
|
|
28
28
|
export interface ApiClientOptions {
|
|
29
29
|
/** Base URL for API requests. Defaults to '/api/canopycms'. */
|
|
@@ -102,11 +102,35 @@ export declare class CanopyApiClient {
|
|
|
102
102
|
approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
103
103
|
/** POST /:branch/mark-merged */
|
|
104
104
|
markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
|
|
105
|
+
/** POST /:branch/archive */
|
|
106
|
+
archive: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
|
|
105
107
|
/** GET /:branch/status */
|
|
106
108
|
getStatus: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
107
109
|
/** POST /:branch/submit */
|
|
108
110
|
submit: (params: Record<string, string>, body: SubmitBranchBody, options?: RequestOptions) => Promise<SubmitBranchResponse>;
|
|
109
111
|
};
|
|
112
|
+
readonly admin: {
|
|
113
|
+
/** POST /admin/archived-branches/delete */
|
|
114
|
+
deleteArchivedBranches: (body: DeleteArchivedBranchesBody, options?: RequestOptions) => Promise<DeleteArchivedBranchesResponse>;
|
|
115
|
+
/** GET /admin/branch-health */
|
|
116
|
+
branchHealth: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchHealthResponse>;
|
|
117
|
+
/** POST /admin/branch-dirs/:dirName/purge */
|
|
118
|
+
purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
|
|
119
|
+
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
|
120
|
+
repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
|
|
121
|
+
/** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
|
|
122
|
+
dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
|
|
123
|
+
/** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
|
|
124
|
+
repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
|
|
125
|
+
/** GET /admin/status */
|
|
126
|
+
status: (options?: RequestOptions) => Promise<AdminStatusResponse>;
|
|
127
|
+
/** GET /admin/tasks/:status */
|
|
128
|
+
listTasks: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminTasksResponse>;
|
|
129
|
+
/** POST /admin/tasks/:taskId/retry */
|
|
130
|
+
retryTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminRetryTaskResponse>;
|
|
131
|
+
/** DELETE /admin/tasks/:status/:fileName */
|
|
132
|
+
deleteTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminDeleteTaskResponse>;
|
|
133
|
+
};
|
|
110
134
|
readonly comments: {
|
|
111
135
|
/** GET /:branch/comments */
|
|
112
136
|
list: (params: Record<string, string>, options?: RequestOptions) => Promise<CommentsResponse>;
|
|
@@ -199,26 +223,6 @@ export declare class CanopyApiClient {
|
|
|
199
223
|
/** POST /:branch/schema/invalidate-cache */
|
|
200
224
|
invalidateSchemaCache: (params: Record<string, string>, options?: RequestOptions) => Promise<InvalidateSchemaCacheApiResponse>;
|
|
201
225
|
};
|
|
202
|
-
readonly admin: {
|
|
203
|
-
/** GET /admin/branch-health */
|
|
204
|
-
branchHealth: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchHealthResponse>;
|
|
205
|
-
/** POST /admin/branch-dirs/:dirName/purge */
|
|
206
|
-
purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
|
|
207
|
-
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
|
208
|
-
repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
|
|
209
|
-
/** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
|
|
210
|
-
dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
|
|
211
|
-
/** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
|
|
212
|
-
repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
|
|
213
|
-
/** GET /admin/status */
|
|
214
|
-
status: (options?: RequestOptions) => Promise<AdminStatusResponse>;
|
|
215
|
-
/** GET /admin/tasks/:status */
|
|
216
|
-
listTasks: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminTasksResponse>;
|
|
217
|
-
/** POST /admin/tasks/:taskId/retry */
|
|
218
|
-
retryTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminRetryTaskResponse>;
|
|
219
|
-
/** DELETE /admin/tasks/:status/:fileName */
|
|
220
|
-
deleteTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminDeleteTaskResponse>;
|
|
221
|
-
};
|
|
222
226
|
constructor(options?: ApiClientOptions);
|
|
223
227
|
private buildPath;
|
|
224
228
|
/** One API call, 429 resends included, under the deadline `timeout` and `options` give it. */
|
package/dist/api/client.js
CHANGED
|
@@ -68,6 +68,10 @@ export class CanopyApiClient {
|
|
|
68
68
|
markMerged: (params, options) => {
|
|
69
69
|
return this.request('POST', this.buildPath('/:branch/mark-merged', params), undefined, 'standard', options);
|
|
70
70
|
},
|
|
71
|
+
/** POST /:branch/archive */
|
|
72
|
+
archive: (params, options) => {
|
|
73
|
+
return this.request('POST', this.buildPath('/:branch/archive', params), undefined, 'standard', options);
|
|
74
|
+
},
|
|
71
75
|
/** GET /:branch/status */
|
|
72
76
|
getStatus: (params, options) => {
|
|
73
77
|
return this.request('GET', this.buildPath('/:branch/status', params), undefined, 'standard', options);
|
|
@@ -77,6 +81,48 @@ export class CanopyApiClient {
|
|
|
77
81
|
return this.request('POST', this.buildPath('/:branch/submit', params), body, 'long', options);
|
|
78
82
|
},
|
|
79
83
|
};
|
|
84
|
+
this.admin = {
|
|
85
|
+
/** POST /admin/archived-branches/delete */
|
|
86
|
+
deleteArchivedBranches: (body, options) => {
|
|
87
|
+
return this.request('POST', '/admin/archived-branches/delete', body, 'long', options);
|
|
88
|
+
},
|
|
89
|
+
/** GET /admin/branch-health */
|
|
90
|
+
branchHealth: (params, options) => {
|
|
91
|
+
return this.request('GET', this.buildPath('/admin/branch-health', params), undefined, 'long', options);
|
|
92
|
+
},
|
|
93
|
+
/** POST /admin/branch-dirs/:dirName/purge */
|
|
94
|
+
purgeBranchDir: (params, options) => {
|
|
95
|
+
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/purge', params), undefined, 'long', options);
|
|
96
|
+
},
|
|
97
|
+
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
|
98
|
+
repairBranchDir: (params, options) => {
|
|
99
|
+
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
|
|
100
|
+
},
|
|
101
|
+
/** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
|
|
102
|
+
dismissUnpublished: (params, options) => {
|
|
103
|
+
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
|
|
104
|
+
},
|
|
105
|
+
/** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
|
|
106
|
+
repairContentDuplicates: (params, options) => {
|
|
107
|
+
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
|
|
108
|
+
},
|
|
109
|
+
/** GET /admin/status */
|
|
110
|
+
status: (options) => {
|
|
111
|
+
return this.request('GET', '/admin/status', undefined, 'standard', options);
|
|
112
|
+
},
|
|
113
|
+
/** GET /admin/tasks/:status */
|
|
114
|
+
listTasks: (params, options) => {
|
|
115
|
+
return this.request('GET', this.buildPath('/admin/tasks/:status', params), undefined, 'standard', options);
|
|
116
|
+
},
|
|
117
|
+
/** POST /admin/tasks/:taskId/retry */
|
|
118
|
+
retryTask: (params, options) => {
|
|
119
|
+
return this.request('POST', this.buildPath('/admin/tasks/:taskId/retry', params), undefined, 'standard', options);
|
|
120
|
+
},
|
|
121
|
+
/** DELETE /admin/tasks/:status/:fileName */
|
|
122
|
+
deleteTask: (params, options) => {
|
|
123
|
+
return this.request('DELETE', this.buildPath('/admin/tasks/:status/:fileName', params), undefined, 'standard', options);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
80
126
|
this.comments = {
|
|
81
127
|
/** GET /:branch/comments */
|
|
82
128
|
list: (params, options) => {
|
|
@@ -246,44 +292,6 @@ export class CanopyApiClient {
|
|
|
246
292
|
return this.request('POST', this.buildPath('/:branch/schema/invalidate-cache', params), undefined, 'standard', options);
|
|
247
293
|
},
|
|
248
294
|
};
|
|
249
|
-
this.admin = {
|
|
250
|
-
/** GET /admin/branch-health */
|
|
251
|
-
branchHealth: (params, options) => {
|
|
252
|
-
return this.request('GET', this.buildPath('/admin/branch-health', params), undefined, 'long', options);
|
|
253
|
-
},
|
|
254
|
-
/** POST /admin/branch-dirs/:dirName/purge */
|
|
255
|
-
purgeBranchDir: (params, options) => {
|
|
256
|
-
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/purge', params), undefined, 'long', options);
|
|
257
|
-
},
|
|
258
|
-
/** POST /admin/branch-dirs/:dirName/repair-metadata */
|
|
259
|
-
repairBranchDir: (params, options) => {
|
|
260
|
-
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
|
|
261
|
-
},
|
|
262
|
-
/** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
|
|
263
|
-
dismissUnpublished: (params, options) => {
|
|
264
|
-
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
|
|
265
|
-
},
|
|
266
|
-
/** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
|
|
267
|
-
repairContentDuplicates: (params, options) => {
|
|
268
|
-
return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
|
|
269
|
-
},
|
|
270
|
-
/** GET /admin/status */
|
|
271
|
-
status: (options) => {
|
|
272
|
-
return this.request('GET', '/admin/status', undefined, 'standard', options);
|
|
273
|
-
},
|
|
274
|
-
/** GET /admin/tasks/:status */
|
|
275
|
-
listTasks: (params, options) => {
|
|
276
|
-
return this.request('GET', this.buildPath('/admin/tasks/:status', params), undefined, 'standard', options);
|
|
277
|
-
},
|
|
278
|
-
/** POST /admin/tasks/:taskId/retry */
|
|
279
|
-
retryTask: (params, options) => {
|
|
280
|
-
return this.request('POST', this.buildPath('/admin/tasks/:taskId/retry', params), undefined, 'standard', options);
|
|
281
|
-
},
|
|
282
|
-
/** DELETE /admin/tasks/:status/:fileName */
|
|
283
|
-
deleteTask: (params, options) => {
|
|
284
|
-
return this.request('DELETE', this.buildPath('/admin/tasks/:status/:fileName', params), undefined, 'standard', options);
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
295
|
this.baseUrl = options.baseUrl ?? '/api/canopycms';
|
|
288
296
|
// An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
|
|
289
297
|
this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BranchMetadata } from './types.js';
|
|
2
|
+
type ArchiveFields = Pick<BranchMetadata, 'status' | 'archivedAt' | 'mergedAt' | 'unpublishedAtMerge' | 'rewrittenOnGitHub'>;
|
|
3
|
+
/** When the branch was archived, or undefined for one that records neither time. */
|
|
4
|
+
export declare const archivedTime: (branch: ArchiveFields) => number | undefined;
|
|
5
|
+
/** Archived before `cutoffMs`. `updatedAt` is no clock: any metadata write moves it. */
|
|
6
|
+
export declare const archivedBefore: (branch: ArchiveFields, cutoffMs: number) => boolean;
|
|
7
|
+
/** Why deletion by age leaves a branch for a person to delete. */
|
|
8
|
+
export type AgeDeletionHold = 'not-merged' | 'unpublished';
|
|
9
|
+
/**
|
|
10
|
+
* What keeps a branch from deletion by age, past any age: one archived without merging holds work
|
|
11
|
+
* that reached no base branch, and one merged with an unfinished submit, or rewritten on GitHub,
|
|
12
|
+
* may hold the only copy of commits the merge left out.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ageDeletionHold: (branch: ArchiveFields) => AgeDeletionHold | undefined;
|
|
15
|
+
/** The one rule the retention sweep and the admin bulk delete both select by. */
|
|
16
|
+
export declare const deletableByAge: (branch: ArchiveFields, cutoffMs: number) => boolean;
|
|
17
|
+
/** `now` less `days`, as the cutoff the functions above take. */
|
|
18
|
+
export declare const archivedCutoff: (days: number, now: number) => number;
|
|
19
|
+
export {};
|