canopycms 0.9.0 → 0.10.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 +60 -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 +5 -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/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/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
|
@@ -9,14 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
9
9
|
* only for admins (see isAdmin(userContext?.groups)); this component does not
|
|
10
10
|
* re-check that itself, same as GroupManager and PermissionManager.
|
|
11
11
|
*/
|
|
12
|
-
import { useRef } from 'react';
|
|
13
|
-
import { Alert, Badge, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
|
|
12
|
+
import { useRef, useState } from 'react';
|
|
13
|
+
import { Alert, Badge, Button, Code, Group, Loader, Modal, NumberInput, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
|
|
14
14
|
import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
|
|
15
15
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
16
16
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
17
17
|
import { formatRelativeTime } from '../relative-time.js';
|
|
18
18
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
19
19
|
import { useSystemHealth, } from './useSystemHealth.js';
|
|
20
|
+
import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from '../../branch-archive.js';
|
|
20
21
|
import { escapeForLog } from '../../utils/escape-text.js';
|
|
21
22
|
import { TooltipDetail } from '../components/TooltipDetail.js';
|
|
22
23
|
import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
|
|
@@ -256,6 +257,7 @@ function duplicateScanSummary(health, healthyDirNames) {
|
|
|
256
257
|
return { text: 'No duplicate content IDs found.', color: 'dimmed' };
|
|
257
258
|
return { text: parts.join(' '), color: 'orange' };
|
|
258
259
|
}
|
|
260
|
+
const DEFAULT_CLEANUP_DAYS = 90;
|
|
259
261
|
const PURGE_CONFIRM_TEXT = 'The directory is moved to a hidden trash name and kept for 30 days, then deleted. Any git work inside was never pushed and will be lost when the trash is swept.';
|
|
260
262
|
export function SystemHealthPanel({ opened, onClose, returnFocusTo }) {
|
|
261
263
|
const health = useSystemHealth({ isOpen: opened });
|
|
@@ -348,6 +350,55 @@ function TasksTab({ health }) {
|
|
|
348
350
|
? corruptRows.map((file) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: file.fileName, children: truncate(file.fileName, 30) }) }), _jsxs(Table.Td, { children: [file.size, " bytes"] }), _jsx(Table.Td, { children: file.mtime }), _jsx(Table.Td, { children: _jsx(Code, { block: true, style: { maxWidth: 320, whiteSpace: 'pre-wrap' }, children: file.rawSnippet }) }), _jsx(Table.Td, { children: _jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${file.fileName}`, onClick: () => handleDeleteClick('corrupt', file.fileName), children: "Delete" }) })] }, file.fileName)))
|
|
349
351
|
: taskRows.map((task) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: task.id, children: truncate(task.id, 12) }) }), _jsx(Table.Td, { children: task.action }), _jsx(Table.Td, { children: task.createdAt }), _jsx(Table.Td, { children: task.retryCount ?? 0 }), _jsx(Table.Td, { children: task.error ? (_jsx(Tooltip, { label: task.error, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", style: { cursor: 'help' }, children: truncate(task.error, 50) }) })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", children: [taskStatus === 'failed' && (_jsx(Button, { variant: "light", "data-testid": `retry-task-${task.id}`, onClick: () => handleRetryClick(task), children: "Retry" })), canDelete && (_jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${task.id}`, onClick: () => handleDeleteClick(taskStatus, `${task.id}.json`), children: "Delete" }))] }) })] }, task.id))) })] }) }))] }));
|
|
350
352
|
}
|
|
353
|
+
/** What `lastGitSync.archivedSweep` left undone, or nothing when it finished clean. */
|
|
354
|
+
const sweepTrouble = ({ failed = [], warnings = [] } = {}) => [
|
|
355
|
+
failed.length === 0
|
|
356
|
+
? ''
|
|
357
|
+
: ` The last sweep could not delete ${failed.map((f) => f.branch).join(', ')} and tries again next sync.`,
|
|
358
|
+
...warnings.map((warning) => ` ${endSentence(warning)}`),
|
|
359
|
+
].join('');
|
|
360
|
+
const dayCount = (days) => `${days} ${days === 1 ? 'day' : 'days'}`;
|
|
361
|
+
/** The deployment's retention, and the admin's own delete by the same rule. */
|
|
362
|
+
function ArchivedBranchesCleanup({ health }) {
|
|
363
|
+
const [days, setDays] = useState(DEFAULT_CLEANUP_DAYS);
|
|
364
|
+
const workerStatus = health.status?.workerStatus;
|
|
365
|
+
const generatedAt = health.branchHealth?.generatedAt;
|
|
366
|
+
const olderThanDays = typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : null;
|
|
367
|
+
// The scan's own clock, which keeps this render pure and matches the listing it counts.
|
|
368
|
+
const cutoff = olderThanDays !== null && generatedAt
|
|
369
|
+
? archivedCutoff(olderThanDays, Date.parse(generatedAt))
|
|
370
|
+
: null;
|
|
371
|
+
const candidates = (health.branchHealth?.entries ?? []).filter((e) => !e.isBaseBranch);
|
|
372
|
+
const branchesMatching = (matches) => cutoff === null
|
|
373
|
+
? []
|
|
374
|
+
: candidates.flatMap((e) => (e.branch && matches(e.branch, cutoff) ? [e.branch] : []));
|
|
375
|
+
const due = branchesMatching(deletableByAge);
|
|
376
|
+
const old = branchesMatching(archivedBefore);
|
|
377
|
+
const heldFor = (hold) => old.filter((branch) => ageDeletionHold(branch) === hold).map((branch) => branch.name);
|
|
378
|
+
const notMerged = heldFor('not-merged');
|
|
379
|
+
const unpublished = heldFor('unpublished');
|
|
380
|
+
const anyDeletable = candidates.some((e) => e.branch && deletableByAge(e.branch, Number.POSITIVE_INFINITY));
|
|
381
|
+
const dueLabel = `${due.length} archived ${due.length === 1 ? 'branch' : 'branches'}`;
|
|
382
|
+
const blockedReason = olderThanDays === null
|
|
383
|
+
? 'Enter a whole number of days.'
|
|
384
|
+
: due.length === 0
|
|
385
|
+
? `No merged archived branches are older than ${dayCount(olderThanDays)}.${olderThanDays > 1 && anyDeletable ? ' Lower the number of days to include newer ones.' : ''}${old.length > 0 ? ' Older branches kept from this delete go one at a time, from the "Branches" drawer.' : ''}`
|
|
386
|
+
: undefined;
|
|
387
|
+
const handleDeleteClick = () => {
|
|
388
|
+
if (blockedReason || olderThanDays === null)
|
|
389
|
+
return;
|
|
390
|
+
openConfirm({
|
|
391
|
+
title: 'Delete archived branches',
|
|
392
|
+
children: (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", children: ["Delete ", dueLabel, " and their comments from the editor? Their GitHub branches stay. This cannot be undone."] }), _jsx(Text, { size: "xs", "data-testid": "delete-archived-names", children: due.map((branch) => branch.name).join(', ') }), notMerged.length > 0 && (_jsxs(Text, { size: "xs", "data-testid": "delete-archived-not-merged", children: ["Kept, as they were not merged: ", notMerged.join(', '), "."] })), unpublished.length > 0 && (_jsxs(Text, { size: "xs", "data-testid": "delete-archived-unpublished", children: ["Kept, as they may hold changes missing from GitHub: ", unpublished.join(', '), "."] }))] })),
|
|
393
|
+
labels: { confirm: 'Delete branches', cancel: 'Cancel' },
|
|
394
|
+
confirmProps: { color: 'red', 'data-testid': 'confirm-delete-archived' },
|
|
395
|
+
onConfirm: () => health.deleteArchivedBranches(olderThanDays, due.map((branch) => branch.name)),
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
return (_jsxs(Stack, { gap: 4, mb: "sm", "data-testid": "archived-branches-cleanup", children: [_jsx(Text, { size: "sm", fw: 600, children: "Archived branches" }), _jsxs(Group, { align: "flex-end", gap: "xs", children: [_jsx(NumberInput, { label: "Delete merged archived branches older than", suffix: days === 1 ? ' day' : ' days', size: "xs", w: 280, min: 1, allowDecimal: false, allowNegative: false, value: days, onChange: setDays, "data-testid": "archived-days-input" }), _jsx(Tooltip, { label: blockedReason, disabled: !blockedReason, multiline: true, maw: 280, position: "top-end", children: _jsxs(Button, { size: "xs", variant: "light", color: "red", onClick: handleDeleteClick, loading: health.deletingArchivedBranches, "data-disabled": blockedReason ? true : undefined, "aria-disabled": blockedReason ? true : undefined, "data-testid": "delete-archived-button", children: ["Delete ", blockedReason ? 'archived branches' : dueLabel] }) })] }), workerStatus?.lastGitSync && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "archived-retention-note", children: [workerStatus.archivedBranchRetentionDays === undefined
|
|
399
|
+
? 'This deployment keeps archived branches until someone deletes them.'
|
|
400
|
+
: `This deployment deletes merged branches ${dayCount(workerStatus.archivedBranchRetentionDays)} after archiving.`, sweepTrouble(workerStatus.lastGitSync?.archivedSweep)] }))] }));
|
|
401
|
+
}
|
|
351
402
|
function BranchesTab({ health }) {
|
|
352
403
|
const { branchHealth, branchHealthLoading } = health;
|
|
353
404
|
const entries = branchHealth?.entries ?? [];
|
|
@@ -399,7 +450,7 @@ function BranchesTab({ health }) {
|
|
|
399
450
|
if (entries.length === 0) {
|
|
400
451
|
return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
|
|
401
452
|
}
|
|
402
|
-
return (_jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: duplicateSummary.color, "data-testid": "duplicate-id-summary", children: duplicateSummary.text }), _jsx(Button, { size: "compact-xs", variant: "subtle", loading: health.duplicateIdScanLoading, onClick: () => health.checkDuplicateIds(), children: "Check again" })] }), _jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, duplicateIdScan: health.duplicateIdScan
|
|
453
|
+
return (_jsxs(Stack, { gap: "xs", children: [_jsx(ArchivedBranchesCleanup, { health: health }), _jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: duplicateSummary.color, "data-testid": "duplicate-id-summary", children: duplicateSummary.text }), _jsx(Button, { size: "compact-xs", variant: "subtle", loading: health.duplicateIdScanLoading, onClick: () => health.checkDuplicateIds(), children: "Check again" })] }), _jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, duplicateIdScan: health.duplicateIdScan
|
|
403
454
|
? (health.duplicateIdScan.byDir[entry.dirName] ?? null)
|
|
404
455
|
: undefined, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick, onDuplicateIds: handleDuplicateIdsClick }, entry.dirName))) })] }) })] }));
|
|
405
456
|
}
|
|
@@ -419,7 +470,7 @@ const firstFailed = (iso) => {
|
|
|
419
470
|
function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, onRepair, onPurge, onDuplicateIds, }) {
|
|
420
471
|
if (entry.kind === 'healthy' && entry.branch) {
|
|
421
472
|
const b = entry.branch;
|
|
422
|
-
// The worker looks at every branch but 'archived' (
|
|
473
|
+
// The worker looks at every branch but 'archived' (finished): one in review is not
|
|
423
474
|
// rebased, but commits from GitHub are still brought into it. An exclusion, not a list of
|
|
424
475
|
// statuses, so a status added later still shows its failures by default.
|
|
425
476
|
const showRebaseFailure = b.status !== 'archived' && !!b.rebaseFailure;
|
|
@@ -429,7 +480,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
|
|
|
429
480
|
: !inReview
|
|
430
481
|
? b.unpublishedAtMerge
|
|
431
482
|
? { kind: 'unpublished-at-merge' }
|
|
432
|
-
: undefined
|
|
483
|
+
: b.status === 'archived' && b.rewrittenOnGitHub !== undefined
|
|
484
|
+
? { kind: 'rewritten-at-merge', commit: b.rewrittenOnGitHub }
|
|
485
|
+
: undefined
|
|
433
486
|
: b.submitUnfinished
|
|
434
487
|
? {
|
|
435
488
|
kind: 'submit-unfinished',
|
|
@@ -437,7 +490,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
|
|
|
437
490
|
notQueued: b.submitNotQueued,
|
|
438
491
|
}
|
|
439
492
|
: b.pullRequestHold;
|
|
440
|
-
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') &&
|
|
493
|
+
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') &&
|
|
494
|
+
!!b.pullRequestNumber &&
|
|
495
|
+
b.pullRequestState !== 'closed';
|
|
441
496
|
return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { preLine: true, detail: syncFailedDetail(b.syncFailureReason, hold?.kind !== 'rewritten' &&
|
|
442
497
|
b.status !== 'archived' &&
|
|
443
498
|
!(b.submitUnfinished && b.submitNotQueued)), children: _jsx(Badge, { color: "red", variant: "light", children: "Sync failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "Pending sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), hold && (_jsx(TooltipDetail, { detail: branchHoldMessage(hold, {
|
|
@@ -51,6 +51,9 @@ export interface UseSystemHealthReturn {
|
|
|
51
51
|
purgeDir: (dirName: string) => Promise<void>;
|
|
52
52
|
repairDir: (dirName: string) => Promise<void>;
|
|
53
53
|
markMerged: (branchName: string) => Promise<void>;
|
|
54
|
+
/** Deletes those of `branches` still past `olderThanDays`, then says what it could not. */
|
|
55
|
+
deleteArchivedBranches: (olderThanDays: number, branches: string[]) => Promise<void>;
|
|
56
|
+
deletingArchivedBranches: boolean;
|
|
54
57
|
/** Stops listing a branch merged with an unfinished submit, a warning that doesn't come back. */
|
|
55
58
|
dismissUnpublished: (dirName: string) => Promise<void>;
|
|
56
59
|
}
|
|
@@ -16,7 +16,16 @@ import { useSWRConfig } from 'swr';
|
|
|
16
16
|
import { notifications } from '@mantine/notifications';
|
|
17
17
|
import { useApiClient } from '../context/index.js';
|
|
18
18
|
import { BRANCHES_KEY } from '../hooks/useBranchesData.js';
|
|
19
|
+
import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
|
|
19
20
|
const POLL_INTERVAL_MS = 30_000;
|
|
21
|
+
const EMPTY_DELETE = {
|
|
22
|
+
deleted: [],
|
|
23
|
+
skipped: { notMerged: [], unpublished: [] },
|
|
24
|
+
failed: [],
|
|
25
|
+
remaining: 0,
|
|
26
|
+
warnings: [],
|
|
27
|
+
};
|
|
28
|
+
const countBranches = (count) => `${count} archived ${count === 1 ? 'branch' : 'branches'}`;
|
|
20
29
|
/**
|
|
21
30
|
* Crash-loop rider window: a worker that fatally exits and restarts
|
|
22
31
|
* repeatedly can flap between 'absent' and 'alive' and never sit still long
|
|
@@ -38,6 +47,7 @@ export function useSystemHealth(options) {
|
|
|
38
47
|
const [duplicateIdScan, setDuplicateIdScan] = useState(null);
|
|
39
48
|
const [duplicateIdScanLoading, setDuplicateIdScanLoading] = useState(false);
|
|
40
49
|
const [duplicateIdScanError, setDuplicateIdScanError] = useState(null);
|
|
50
|
+
const [deletingArchivedBranches, setDeletingArchivedBranches] = useState(false);
|
|
41
51
|
const [error, setError] = useState(null);
|
|
42
52
|
// refresh() has no status argument, so it needs the CURRENT taskStatus
|
|
43
53
|
// without depending on it (that would tear down/rebuild the poll interval
|
|
@@ -266,6 +276,53 @@ export function useSystemHealth(options) {
|
|
|
266
276
|
notifications.show({ message, color: 'red' });
|
|
267
277
|
}
|
|
268
278
|
}, [apiClient, refresh]);
|
|
279
|
+
const deleteArchivedBranches = useCallback(async (olderThanDays, branches) => {
|
|
280
|
+
setDeletingArchivedBranches(true);
|
|
281
|
+
try {
|
|
282
|
+
const result = await apiClient.admin.deleteArchivedBranches({ olderThanDays, branches });
|
|
283
|
+
if (isGatewayFailure(result))
|
|
284
|
+
throw new UnansweredError();
|
|
285
|
+
if (!result.ok)
|
|
286
|
+
throw new Error(result.error || 'Failed to delete archived branches');
|
|
287
|
+
const { deleted, skipped, failed, remaining, warnings } = result.data ?? EMPTY_DELETE;
|
|
288
|
+
const left = [
|
|
289
|
+
failed.length > 0
|
|
290
|
+
? `${countBranches(failed.length)} not deleted: busy, or changed since listed`
|
|
291
|
+
: '',
|
|
292
|
+
remaining > 0 ? `${countBranches(remaining)} not reached, run this again` : '',
|
|
293
|
+
skipped.notMerged.length > 0
|
|
294
|
+
? `${countBranches(skipped.notMerged.length)} kept: not merged`
|
|
295
|
+
: '',
|
|
296
|
+
skipped.unpublished.length > 0
|
|
297
|
+
? `${countBranches(skipped.unpublished.length)} kept: may hold unpublished changes`
|
|
298
|
+
: '',
|
|
299
|
+
...warnings,
|
|
300
|
+
].filter(Boolean);
|
|
301
|
+
const message = `Deleted ${countBranches(deleted.length)}`;
|
|
302
|
+
notifications.show(left.length > 0
|
|
303
|
+
? { message: `${message}. ${left.join('. ')}.`, color: 'yellow', autoClose: false }
|
|
304
|
+
: { message, color: 'green' });
|
|
305
|
+
await mutate(BRANCHES_KEY);
|
|
306
|
+
await refresh();
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (isUnanswered(err)) {
|
|
310
|
+
notifications.show({
|
|
311
|
+
message: 'Deleting archived branches may have gone through even though the server didn\'t answer. Check the "Branches" tab.',
|
|
312
|
+
color: 'yellow',
|
|
313
|
+
autoClose: false,
|
|
314
|
+
});
|
|
315
|
+
await mutate(BRANCHES_KEY);
|
|
316
|
+
await refresh();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const message = err instanceof Error ? err.message : 'Failed to delete archived branches';
|
|
320
|
+
notifications.show({ message, color: 'red', autoClose: false });
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
setDeletingArchivedBranches(false);
|
|
324
|
+
}
|
|
325
|
+
}, [apiClient, mutate, refresh]);
|
|
269
326
|
return {
|
|
270
327
|
status,
|
|
271
328
|
statusLoading,
|
|
@@ -288,6 +345,8 @@ export function useSystemHealth(options) {
|
|
|
288
345
|
purgeDir,
|
|
289
346
|
repairDir,
|
|
290
347
|
markMerged,
|
|
348
|
+
deleteArchivedBranches,
|
|
349
|
+
deletingArchivedBranches,
|
|
291
350
|
dismissUnpublished,
|
|
292
351
|
};
|
|
293
352
|
}
|
|
@@ -77,6 +77,15 @@ export function branchHoldMessage(hold, viewer) {
|
|
|
77
77
|
: '';
|
|
78
78
|
return `${stuck} ${retry}${instead}.`;
|
|
79
79
|
}
|
|
80
|
+
case 'rewritten-at-merge': {
|
|
81
|
+
const commit = hold.commit
|
|
82
|
+
? ` GitHub no longer contains commit ${escapeForLog(hold.commit)}.`
|
|
83
|
+
: '';
|
|
84
|
+
const stuck = `This branch was changed on GitHub before it was archived, so some changes here may be missing from GitHub.${commit}`;
|
|
85
|
+
return viewer.isAdmin
|
|
86
|
+
? `${stuck} Check them before deleting the branch.`
|
|
87
|
+
: `${stuck} Ask an admin to check it.`;
|
|
88
|
+
}
|
|
80
89
|
case 'unpublished-at-merge': {
|
|
81
90
|
const stuck = 'This pull request merged before the last submit finished, so some changes may not be in the merge.';
|
|
82
91
|
if (viewer.inSystemHealth)
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const EDITOR_ACTIONS: {
|
|
|
14
14
|
readonly requestChanges: "Request changes";
|
|
15
15
|
readonly switchBranch: "Switch branch";
|
|
16
16
|
readonly newBranch: "New branch";
|
|
17
|
+
readonly archiveBranch: "Archive branch";
|
|
17
18
|
readonly remove: "Remove";
|
|
18
19
|
readonly restore: "Restore";
|
|
19
20
|
readonly clear: "Clear";
|
|
@@ -34,5 +35,7 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
|
|
|
34
35
|
* says how to finish the submit, where it can be retried at all.
|
|
35
36
|
*/
|
|
36
37
|
export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
|
|
38
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
39
|
+
export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
|
|
37
40
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
38
41
|
export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
|
package/dist/editor/copy.js
CHANGED
|
@@ -14,6 +14,7 @@ export const EDITOR_ACTIONS = {
|
|
|
14
14
|
requestChanges: 'Request changes',
|
|
15
15
|
switchBranch: 'Switch branch',
|
|
16
16
|
newBranch: 'New branch',
|
|
17
|
+
archiveBranch: 'Archive branch',
|
|
17
18
|
remove: 'Remove',
|
|
18
19
|
restore: 'Restore',
|
|
19
20
|
clear: 'Clear',
|
|
@@ -36,5 +37,7 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
|
|
|
36
37
|
* says how to finish the submit, where it can be retried at all.
|
|
37
38
|
*/
|
|
38
39
|
export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
|
|
40
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
41
|
+
export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
|
|
39
42
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
40
43
|
export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
|
|
@@ -28,8 +28,11 @@ export declare const CREATED_BRANCH_GRACE_MS = 120000;
|
|
|
28
28
|
interface BranchSummary {
|
|
29
29
|
name: string;
|
|
30
30
|
shortName?: string;
|
|
31
|
+
title?: string;
|
|
31
32
|
status: string;
|
|
32
33
|
createdBy?: string;
|
|
34
|
+
/** The creator's display name, when the listing carried it. */
|
|
35
|
+
ownerName?: string;
|
|
33
36
|
updatedAt?: string;
|
|
34
37
|
access: {
|
|
35
38
|
users: string[] | undefined;
|
|
@@ -126,6 +129,8 @@ export interface UseBranchManagerReturn {
|
|
|
126
129
|
/** Confirms first, taking an optional note; focus returns as for `handleApprove`. */
|
|
127
130
|
handleRequestChanges: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
|
|
128
131
|
handleDelete: (branchName: string) => Promise<void>;
|
|
132
|
+
/** Admin only: confirms first, then archives a branch whose pull request closed unmerged. */
|
|
133
|
+
handleArchive: (branchName: string) => Promise<void>;
|
|
129
134
|
handleReloadBranchData: () => Promise<void>;
|
|
130
135
|
loadBranches: () => Promise<void>;
|
|
131
136
|
}
|
|
@@ -201,7 +201,7 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
201
201
|
: undefined,
|
|
202
202
|
'Discard any unsaved or unmerged changes',
|
|
203
203
|
mayHoldUnpublished
|
|
204
|
-
? 'Remove the only copy of any changes
|
|
204
|
+
? 'Remove the only copy of any changes missing from GitHub, and the warning about them'
|
|
205
205
|
: undefined,
|
|
206
206
|
].filter((b) => b !== undefined);
|
|
207
207
|
openConfirm({
|
|
@@ -217,6 +217,16 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
217
217
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
218
218
|
});
|
|
219
219
|
};
|
|
220
|
+
/** Archiving is a one-way status change on a branch whose review is over. */
|
|
221
|
+
const showArchiveConfirmation = (branchName, inReview, onConfirm, onDismiss) => {
|
|
222
|
+
openConfirm({
|
|
223
|
+
title: EDITOR_ACTIONS.archiveBranch,
|
|
224
|
+
children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Archive “${branchName}”?\n\nIts pull request was closed without merging. This will:\n• Make the branch read-only and move it to “Archived”\n• Keep it marked as not merged\n\nAn archived branch can't go back to editing. This one stays until someone deletes it: automatic and bulk deletes only take merged branches.${inReview ? ` To keep working on it, use “${EDITOR_ACTIONS.withdrawFromReview}” instead.` : ''}` })),
|
|
225
|
+
labels: { confirm: EDITOR_ACTIONS.archiveBranch, cancel: 'Cancel' },
|
|
226
|
+
confirmProps: { color: 'orange', 'data-testid': 'confirm-archive-branch' },
|
|
227
|
+
...confirmModalHandlers(onConfirm, onDismiss),
|
|
228
|
+
});
|
|
229
|
+
};
|
|
220
230
|
/**
|
|
221
231
|
* How long a branch this session created stays overlaid on listings. Each warm
|
|
222
232
|
* server container caches the shared filesystem separately, so for its
|
|
@@ -381,8 +391,10 @@ export function useBranchManager(options) {
|
|
|
381
391
|
return {
|
|
382
392
|
name: b.name,
|
|
383
393
|
shortName: b.shortName,
|
|
394
|
+
title: b.title,
|
|
384
395
|
status: b.status,
|
|
385
396
|
createdBy: b.createdBy,
|
|
397
|
+
ownerName: branchesData?.ownerNames?.[b.createdBy],
|
|
386
398
|
updatedAt: b.updatedAt,
|
|
387
399
|
access: {
|
|
388
400
|
users: b.access.allowedUsers,
|
|
@@ -423,7 +435,7 @@ export function useBranchManager(options) {
|
|
|
423
435
|
submitBlocked: b.submitBlocked ?? true,
|
|
424
436
|
};
|
|
425
437
|
});
|
|
426
|
-
}, [branches, currentBranchName, options.comments]);
|
|
438
|
+
}, [branches, branchesData?.ownerNames, currentBranchName, options.comments]);
|
|
427
439
|
// Explicit reload: always issues a fresh, independent fetch (raw call, not
|
|
428
440
|
// SWR's `mutate()` revalidate path) so callers requesting a reload right
|
|
429
441
|
// after mount aren't coalesced against the still-in-flight automatic load
|
|
@@ -730,6 +742,45 @@ export function useBranchManager(options) {
|
|
|
730
742
|
}, () => resolve(), returnFocusTo);
|
|
731
743
|
}));
|
|
732
744
|
};
|
|
745
|
+
const handleArchive = (requested) => {
|
|
746
|
+
const branchNameToArchive = listedName(requested);
|
|
747
|
+
return singleFlight('archive', branchNameToArchive, () => new Promise((resolve, reject) => {
|
|
748
|
+
const status = latest.current.branches.find((b) => b.name === branchNameToArchive)?.status;
|
|
749
|
+
showArchiveConfirmation(shownName(branchNameToArchive), status === 'submitted' || status === 'approved', async () => {
|
|
750
|
+
options.setBusy(true);
|
|
751
|
+
try {
|
|
752
|
+
const result = await apiClient.workflow.archive({
|
|
753
|
+
branch: sendName(branchNameToArchive),
|
|
754
|
+
});
|
|
755
|
+
if (!result.ok)
|
|
756
|
+
throw actionError(result, 'Failed to archive branch');
|
|
757
|
+
notifications.show({ message: 'Branch archived', color: 'blue' });
|
|
758
|
+
forgetCreatedBranch(branchNameToArchive);
|
|
759
|
+
await loadBranches();
|
|
760
|
+
resolve();
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
if (err instanceof RefusedError && err.status === 409) {
|
|
764
|
+
// Nothing is lost, and the list says what changed.
|
|
765
|
+
notifications.show({
|
|
766
|
+
message: err.message,
|
|
767
|
+
color: 'yellow',
|
|
768
|
+
autoClose: false,
|
|
769
|
+
withCloseButton: true,
|
|
770
|
+
});
|
|
771
|
+
await reloadBranches();
|
|
772
|
+
resolve();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
await reportActionFailure(err, 'Failed to archive branch', EDITOR_ACTIONS.archiveBranch);
|
|
776
|
+
reject(err);
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
options.setBusy(false);
|
|
780
|
+
}
|
|
781
|
+
}, () => resolve());
|
|
782
|
+
}));
|
|
783
|
+
};
|
|
733
784
|
const handleApprove = (requested, returnFocusTo) => {
|
|
734
785
|
const branchNameToApprove = listedName(requested);
|
|
735
786
|
return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
|
|
@@ -838,8 +889,7 @@ export function useBranchManager(options) {
|
|
|
838
889
|
const branchNameToDelete = listedName(requested);
|
|
839
890
|
const listedBefore = latest.current.branches;
|
|
840
891
|
return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
|
|
841
|
-
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind
|
|
842
|
-
'unpublished-at-merge', async () => {
|
|
892
|
+
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), ['unpublished-at-merge', 'rewritten-at-merge'].includes(listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ?? ''), async () => {
|
|
843
893
|
options.setBusy(true);
|
|
844
894
|
try {
|
|
845
895
|
const result = await apiClient.branches.delete({
|
|
@@ -960,6 +1010,7 @@ export function useBranchManager(options) {
|
|
|
960
1010
|
handleApprove,
|
|
961
1011
|
handleRequestChanges,
|
|
962
1012
|
handleDelete,
|
|
1013
|
+
handleArchive,
|
|
963
1014
|
handleReloadBranchData,
|
|
964
1015
|
loadBranches,
|
|
965
1016
|
};
|
|
@@ -19,6 +19,8 @@ export declare const BRANCHES_KEY = "canopy:branches";
|
|
|
19
19
|
export interface BranchesData {
|
|
20
20
|
branches: BranchListItem[];
|
|
21
21
|
defaultBranch?: string;
|
|
22
|
+
/** Display names of the listed branches' creators, by user id. */
|
|
23
|
+
ownerNames?: Readonly<Record<string, string>>;
|
|
22
24
|
/**
|
|
23
25
|
* Client clock when this listing arrived. It makes listings fetched at
|
|
24
26
|
* different moments distinct `data` values even when their branches match,
|
|
@@ -33,10 +33,12 @@ export async function fetchBranches(apiClient, swr) {
|
|
|
33
33
|
if (!result.ok) {
|
|
34
34
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
const users = result.data?.users ?? [];
|
|
37
|
+
await seedUserProfiles(swr, users);
|
|
37
38
|
return {
|
|
38
39
|
branches: result.data?.branches ?? [],
|
|
39
40
|
defaultBranch: result.data?.defaultBranch,
|
|
41
|
+
ownerNames: Object.fromEntries(users.map((user) => [user.id, user.name])),
|
|
40
42
|
receivedAt: Date.now(),
|
|
41
43
|
};
|
|
42
44
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,22 @@ export type CanopyGroupId = string;
|
|
|
12
12
|
* semantics (worker rebase skip list, a set/unset endpoint, UI).
|
|
13
13
|
*/
|
|
14
14
|
export type BranchStatus = 'editing' | 'submitted' | 'approved' | 'archived';
|
|
15
|
+
/** One cycle of the worker's retention sweep (worker/archived-sweep.ts). */
|
|
16
|
+
export interface ArchivedSweepReport {
|
|
17
|
+
/** Full names of the branches deleted. */
|
|
18
|
+
deleted: string[];
|
|
19
|
+
/** Due, but another process held the branch's provisioning lock; retried next cycle. */
|
|
20
|
+
skippedLocked: string[];
|
|
21
|
+
/** [REDACT] Served to the admin panel, so `error` is sanitized. */
|
|
22
|
+
failed: {
|
|
23
|
+
branch: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}[];
|
|
26
|
+
/** Due, beyond this cycle's cap; the next cycle takes the oldest of them. */
|
|
27
|
+
deferred: number;
|
|
28
|
+
/** Deleted, with something left behind, sanitized as `failed` is ([REDACT]). */
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
15
31
|
export type SyncStatus = 'synced' | 'pending-sync' | 'sync-failed';
|
|
16
32
|
export type ConflictStatus = 'clean' | 'conflicts-detected';
|
|
17
33
|
export type PullRequestState = 'open' | 'closed' | 'merged';
|
|
@@ -57,6 +73,14 @@ export type BranchHold = PullRequestHold | {
|
|
|
57
73
|
/** Admins see what to do in System health. */
|
|
58
74
|
| {
|
|
59
75
|
kind: 'unpublished-at-merge';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* An archived branch rewritten on GitHub, which may hold commits GitHub dropped. `commit`, the
|
|
79
|
+
* one GitHub no longer contains, is for System health; the API leaves it out.
|
|
80
|
+
*/
|
|
81
|
+
| {
|
|
82
|
+
kind: 'rewritten-at-merge';
|
|
83
|
+
commit?: string;
|
|
60
84
|
};
|
|
61
85
|
export interface BranchMetadata {
|
|
62
86
|
name: string;
|
|
@@ -106,6 +130,11 @@ export interface BranchMetadata {
|
|
|
106
130
|
* (worker auto-poll or markAsMerged). Absent for branches archived any other way.
|
|
107
131
|
*/
|
|
108
132
|
mergedAt?: string;
|
|
133
|
+
/**
|
|
134
|
+
* When the branch was archived, merged or not: the clock retention counts from. A branch
|
|
135
|
+
* archived before this field existed has only `mergedAt` (branch-archive.ts).
|
|
136
|
+
*/
|
|
137
|
+
archivedAt?: string;
|
|
109
138
|
/**
|
|
110
139
|
* Set by the worker when a rebase cycle fails for this branch; cleared on
|
|
111
140
|
* the next successful cycle (and on submit). firstAt survives repeated
|
|
@@ -292,6 +321,8 @@ export interface WorkerStatusReport {
|
|
|
292
321
|
updatedAt: string;
|
|
293
322
|
lastTaskCycleAt?: string;
|
|
294
323
|
lastGitSyncAt?: string;
|
|
324
|
+
/** The deployment's `archivedBranchRetentionDays`; absent when retention is off. */
|
|
325
|
+
archivedBranchRetentionDays?: number;
|
|
295
326
|
lastGitSyncError?: {
|
|
296
327
|
message: string;
|
|
297
328
|
at: string;
|
|
@@ -314,6 +345,8 @@ export interface WorkerStatusReport {
|
|
|
314
345
|
}[];
|
|
315
346
|
/** Optional for the same reason as `tracked` below. */
|
|
316
347
|
baseRefresh?: BaseRefreshReport;
|
|
348
|
+
/** The retention sweep's work this cycle; absent when it found nothing to do. */
|
|
349
|
+
archivedSweep?: ArchivedSweepReport;
|
|
317
350
|
/**
|
|
318
351
|
* Outcome of reconciling remote.git's `refs/heads/*` against GitHub's fetched
|
|
319
352
|
* tips, never moving a head the worker finds editor work in (worker/git-sync.ts's
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.10.0";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.10.0';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ArchivedSweepReport } from '../types.js';
|
|
2
|
+
import type { WorkerContext } from './worker-context.js';
|
|
3
|
+
export type ArchivedSweepContext = Pick<WorkerContext, 'archivedBranchRetentionDays' | 'contentBranchesPath' | 'ensureSettingsBranch' | 'isDraining' | 'sanitizedBaseBranch'>;
|
|
4
|
+
/**
|
|
5
|
+
* The most branches one cycle deletes, oldest first: turning retention on over a long backlog must
|
|
6
|
+
* not stall the sync loop. The rest wait for the next cycle.
|
|
7
|
+
* @internal Exported for tests.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_ARCHIVED_SWEEP_PER_CYCLE = 25;
|
|
10
|
+
/**
|
|
11
|
+
* Delete merged, archived branches past `archivedBranchRetentionDays`, oldest first, through the
|
|
12
|
+
* one delete path (branch-delete.ts). Off unless the deployment set that.
|
|
13
|
+
*
|
|
14
|
+
* Each branch is held by its provisioning lock, zero-retry as everywhere in the sync loop, and the
|
|
15
|
+
* selection is decided again under the lock the delete takes, from a fresh read.
|
|
16
|
+
*
|
|
17
|
+
* Takes the editor's copy only: `remoteCopies` in branch-delete.ts says why, and reconcile prunes
|
|
18
|
+
* a head no workspace holds once GitHub drops it (stale-mirror.ts).
|
|
19
|
+
*
|
|
20
|
+
* Never throws, and one branch's failure costs only that branch, which the next cycle retries. A
|
|
21
|
+
* kill past the rename leaves a `.deleting-*` directory `sweepProvisioningLeftovers` clears.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sweepArchivedBranches(ctx: ArchivedSweepContext, now?: number): Promise<ArchivedSweepReport | undefined>;
|