canopycms 0.8.0 → 0.8.2
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 +112 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/cli/cli.js +91 -18
- package/dist/cli/generate-ai-content.js +72 -11
- package/dist/cli/init.js +2 -2
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +43 -6
- package/dist/context.js +43 -41
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +24 -15
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -9,8 +9,8 @@ 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 {
|
|
13
|
-
import {
|
|
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';
|
|
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';
|
|
@@ -18,7 +18,9 @@ import { formatRelativeTime } from '../relative-time.js';
|
|
|
18
18
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
19
19
|
import { useSystemHealth, } from './useSystemHealth.js';
|
|
20
20
|
import { escapeForLog } from '../../utils/escape-text.js';
|
|
21
|
-
import {
|
|
21
|
+
import { TooltipDetail } from '../components/TooltipDetail.js';
|
|
22
|
+
import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
|
|
23
|
+
import { HOLD_BADGE, branchHoldMessage, branchStatusPresentation, endSentence, } from '../branch-status.js';
|
|
22
24
|
// ============================================================================
|
|
23
25
|
// Small pure helpers
|
|
24
26
|
// ============================================================================
|
|
@@ -104,6 +106,14 @@ const BASE_REFRESH_LABELS = {
|
|
|
104
106
|
'skipped-not-provisioned': 'not yet provisioned',
|
|
105
107
|
failed: 'refresh failed',
|
|
106
108
|
};
|
|
109
|
+
/**
|
|
110
|
+
* A failed sync's detail, with the advice to retry only when a retry can help. The worker refuses
|
|
111
|
+
* every push while a rewrite stands, an archived branch has nothing left to finish, and a submit
|
|
112
|
+
* that was never queued has no task.
|
|
113
|
+
*/
|
|
114
|
+
function syncFailedDetail(reason = 'GitHub sync failed.', retryable) {
|
|
115
|
+
return retryable ? `${endSentence(reason)}\nRetry it from the "Tasks" tab.` : reason;
|
|
116
|
+
}
|
|
107
117
|
/**
|
|
108
118
|
* The worker holding the base branch for an editor deploy, or the cycle it stopped waiting.
|
|
109
119
|
* The rule and its bound live in worker/schema-gate.ts.
|
|
@@ -121,7 +131,7 @@ function BaseHoldAlert({ hold }) {
|
|
|
121
131
|
* and never listed. Names come from GitHub and are attacker-chosen, so each is shown escaped.
|
|
122
132
|
*/
|
|
123
133
|
function DivergedBranchesAlert({ names }) {
|
|
124
|
-
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
|
|
134
|
+
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
|
|
125
135
|
}
|
|
126
136
|
/**
|
|
127
137
|
* The branch namespace the CMS creates branches in, against the one the GitHub gateway writes. A
|
|
@@ -249,10 +259,32 @@ function duplicateScanSummary(health, healthyDirNames) {
|
|
|
249
259
|
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.';
|
|
250
260
|
export function SystemHealthPanel({ opened, onClose, returnFocusTo }) {
|
|
251
261
|
const health = useSystemHealth({ isOpen: opened });
|
|
252
|
-
|
|
262
|
+
const overviewTabRef = useRef(null);
|
|
263
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: "System health", size: "xl", ...focusReturnProps(returnFocusTo), children: _jsxs(Tabs, { defaultValue: "overview", children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { ref: overviewTabRef, value: "overview", children: "Overview" }), _jsx(Tabs.Tab, { value: "tasks", children: "Tasks" }), _jsx(Tabs.Tab, { value: "branches", children: "Branches" })] }), _jsx(Tabs.Panel, { value: "overview", pt: "md", children: _jsx(OverviewTab, { health: health, overviewTabRef: overviewTabRef }) }), _jsx(Tabs.Panel, { value: "tasks", pt: "md", children: _jsx(TasksTab, { health: health }) }), _jsx(Tabs.Panel, { value: "branches", pt: "md", children: _jsx(BranchesTab, { health: health }) })] }) }));
|
|
253
264
|
}
|
|
254
|
-
function OverviewTab({ health }) {
|
|
265
|
+
function OverviewTab({ health, overviewTabRef, }) {
|
|
255
266
|
const { status, statusLoading, isRecentFatalError, error, refresh } = health;
|
|
267
|
+
const unpublished = (health.branchHealth?.entries ?? []).flatMap((entry) => entry.kind === 'healthy' && entry.branch?.unpublishedAtMerge
|
|
268
|
+
? [
|
|
269
|
+
{
|
|
270
|
+
dirName: entry.dirName,
|
|
271
|
+
name: entry.branch.name,
|
|
272
|
+
pullRequestUrl: entry.branch.pullRequestUrl,
|
|
273
|
+
},
|
|
274
|
+
]
|
|
275
|
+
: []);
|
|
276
|
+
// The row goes with the dismissal, so focus moves to the next one, else to the tab.
|
|
277
|
+
const overviewRef = useRef(null);
|
|
278
|
+
const handleDismissUnpublished = async (dirName) => {
|
|
279
|
+
await health.dismissUnpublished(dirName);
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
const overview = overviewRef.current;
|
|
282
|
+
if (!overview || overview.contains(document.activeElement))
|
|
283
|
+
return;
|
|
284
|
+
const next = overview.querySelector(`[${DISMISS_UNPUBLISHED_ATTRIBUTE}]`);
|
|
285
|
+
(next ?? overviewTabRef.current)?.focus();
|
|
286
|
+
});
|
|
287
|
+
};
|
|
256
288
|
if (!status && statusLoading) {
|
|
257
289
|
return (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading status..." })] }));
|
|
258
290
|
}
|
|
@@ -271,9 +303,9 @@ function OverviewTab({ health }) {
|
|
|
271
303
|
const versionSkew = status.worker.state === 'alive' &&
|
|
272
304
|
workerVersion !== undefined &&
|
|
273
305
|
workerVersion !== build.canopycmsVersion;
|
|
274
|
-
return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
|
|
306
|
+
return (_jsxs(Stack, { ref: overviewRef, gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
|
|
275
307
|
? 'The worker failed to start'
|
|
276
|
-
: 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
|
|
308
|
+
: 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
|
|
277
309
|
` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
|
|
278
310
|
lastShutdown.drainMs !== undefined &&
|
|
279
311
|
` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
|
|
@@ -384,33 +416,35 @@ const firstFailed = (iso) => {
|
|
|
384
416
|
const date = new Date(iso);
|
|
385
417
|
return Number.isNaN(date.getTime()) ? iso : `${formatRelativeTime(iso)}, ${date.toLocaleString()}`;
|
|
386
418
|
};
|
|
387
|
-
/**
|
|
388
|
-
* A detail that only a Tooltip would show: hidden text for assistive tech, plus a Tooltip that
|
|
389
|
-
* opens on hover, focus and touch. The trigger is a focusable span, not a button: it does nothing
|
|
390
|
-
* when pressed, so it must not be announced as one. A trigger with no text of its own, such as an
|
|
391
|
-
* icon, takes its name from `name`.
|
|
392
|
-
*/
|
|
393
|
-
function TooltipDetail({ detail, name, maw = 320, preLine = false, children, }) {
|
|
394
|
-
const detailId = useId();
|
|
395
|
-
return (_jsxs(_Fragment, { children: [_jsx(Tooltip
|
|
396
|
-
// A sighted user sees only the icon, so the tooltip names it too.
|
|
397
|
-
, {
|
|
398
|
-
// A sighted user sees only the icon, so the tooltip names it too.
|
|
399
|
-
label: name ? `${name}: ${detail}` : detail, multiline: true, maw: maw, style: preLine ? { whiteSpace: 'pre-line' } : undefined, events: { hover: true, focus: true, touch: true }, children: _jsx(Box, { component: "span", tabIndex: 0, role: name ? 'img' : undefined, "aria-label": name, "aria-describedby": detailId, className: "mantine-focus-auto", children: children }) }), _jsx(VisuallyHidden, { id: detailId, children: detail })] }));
|
|
400
|
-
}
|
|
401
419
|
function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, onRepair, onPurge, onDuplicateIds, }) {
|
|
402
420
|
if (entry.kind === 'healthy' && entry.branch) {
|
|
403
421
|
const b = entry.branch;
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
422
|
+
// The worker looks at every branch but 'archived' (already merged): one in review is not
|
|
423
|
+
// rebased, but commits from GitHub are still brought into it. An exclusion, not a list of
|
|
424
|
+
// statuses, so a status added later still shows its failures by default.
|
|
425
|
+
const showRebaseFailure = b.status !== 'archived' && !!b.rebaseFailure;
|
|
426
|
+
const inReview = b.status === 'submitted' || b.status === 'approved';
|
|
427
|
+
const hold = b.rewrittenOnGitHub !== undefined && b.status !== 'archived'
|
|
428
|
+
? { kind: 'rewritten' }
|
|
429
|
+
: !inReview
|
|
430
|
+
? b.unpublishedAtMerge
|
|
431
|
+
? { kind: 'unpublished-at-merge' }
|
|
432
|
+
: undefined
|
|
433
|
+
: b.submitUnfinished
|
|
434
|
+
? {
|
|
435
|
+
kind: 'submit-unfinished',
|
|
436
|
+
opened: b.pullRequestNumber !== undefined,
|
|
437
|
+
notQueued: b.submitNotQueued,
|
|
438
|
+
}
|
|
439
|
+
: b.pullRequestHold;
|
|
410
440
|
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
|
|
411
|
-
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, { detail: b.syncFailureReason
|
|
412
|
-
|
|
413
|
-
|
|
441
|
+
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
|
+
b.status !== 'archived' &&
|
|
443
|
+
!(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, {
|
|
444
|
+
role: 'none',
|
|
445
|
+
isAdmin: true,
|
|
446
|
+
inSystemHealth: true,
|
|
447
|
+
}), children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-pr-${entry.dirName}`, children: HOLD_BADGE.label }) })), showRebaseFailure && b.rebaseFailure && (_jsx(TooltipDetail, { name: "Rebase failing", detail: `${b.rebaseFailure.message} (first failed ${firstFailed(b.rebaseFailure.firstAt)})`, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(TooltipDetail, { name: "Base branch refresh failing", detail: baseWarning, maw: 420, preLine: true, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), duplicateIdScan?.state === 'found' && (_jsx(Tooltip, { label: "Review and fix", children: _jsx(Badge, { component: "button", type: "button", color: "orange", variant: "light", style: { cursor: 'pointer' }, "data-testid": `duplicate-content-ids-${entry.dirName}`, onClick: () => onDuplicateIds(entry.dirName, duplicateIdScan.duplicates), children: plural(duplicateIdScan.duplicates.length, 'duplicate ID') }) })), duplicateIdScan !== undefined &&
|
|
414
448
|
duplicateIdScan?.state !== 'found' &&
|
|
415
449
|
duplicateIdScan?.state !== 'none' && (_jsx(Tooltip, { label: duplicateUncheckedReason(duplicateIdScan), multiline: true, maw: 320, children: _jsx(Badge, { color: "gray", variant: "outline", "data-testid": `duplicate-ids-unchecked-${entry.dirName}`, children: "IDs not checked" }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
|
|
416
450
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Marks each row's Dismiss, for the panel to move focus to once a row has gone. */
|
|
2
|
+
export declare const DISMISS_UNPUBLISHED_ATTRIBUTE = "data-dismiss-unpublished";
|
|
3
|
+
export interface UnpublishedBranch {
|
|
4
|
+
dirName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
pullRequestUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Branches merged before a submit finished, listed until an admin dismisses them or deletes the
|
|
10
|
+
* branch: archiving leaves no retry or new submit to finish it. Names come from GitHub and are
|
|
11
|
+
* attacker-chosen, so each is shown escaped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function UnpublishedAtMergeAlert({ branches, onDismiss, }: {
|
|
14
|
+
branches: readonly UnpublishedBranch[];
|
|
15
|
+
onDismiss: (dirName: string) => Promise<void>;
|
|
16
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { Alert, Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
|
|
5
|
+
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
6
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
7
|
+
import { escapeForLog } from '../../utils/escape-text.js';
|
|
8
|
+
/** Marks each row's Dismiss, for the panel to move focus to once a row has gone. */
|
|
9
|
+
export const DISMISS_UNPUBLISHED_ATTRIBUTE = 'data-dismiss-unpublished';
|
|
10
|
+
/** One branch, whose Dismiss asks in its own row: System health is already a modal. */
|
|
11
|
+
function UnpublishedRow({ branch, onDismiss, }) {
|
|
12
|
+
const { dirName, pullRequestUrl } = branch;
|
|
13
|
+
const name = escapeForLog(branch.name);
|
|
14
|
+
const [asking, setAsking] = useState(false);
|
|
15
|
+
const [dismissing, setDismissing] = useState(false);
|
|
16
|
+
const dismissRef = useRef(null);
|
|
17
|
+
const confirmRef = useRef(null);
|
|
18
|
+
const asked = useRef(false);
|
|
19
|
+
const questionId = useId();
|
|
20
|
+
// The button pressed is replaced each way, so focus follows to its counterpart.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (asking)
|
|
23
|
+
confirmRef.current?.focus();
|
|
24
|
+
else if (asked.current)
|
|
25
|
+
dismissRef.current?.focus();
|
|
26
|
+
asked.current = asking;
|
|
27
|
+
}, [asking]);
|
|
28
|
+
const dismiss = async () => {
|
|
29
|
+
setDismissing(true);
|
|
30
|
+
try {
|
|
31
|
+
await onDismiss(dirName);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
setDismissing(false);
|
|
35
|
+
setAsking(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs(Group, { gap: "xs", justify: "space-between", "data-testid": `unpublished-branch-${dirName}`, children: [_jsxs(Group, { gap: "xs", style: { minWidth: 0, flex: '1 1 200px' }, children: [_jsx(Code, { style: { overflowWrap: 'anywhere', minWidth: 0 }, children: name }), pullRequestUrl && (_jsx(Anchor, { href: pullRequestUrl, target: "_blank", rel: "noopener noreferrer", size: "xs", c: "var(--mantine-color-text)", underline: "always", children: "View pull request" }))] }), asking ? (_jsxs(Group, { gap: 4, style: { maxWidth: '100%' }, children: [_jsx(Text, { id: questionId, size: "xs", children: "Dismiss this warning? It won't come back, and the branch can then be deleted." }), _jsx(Button, { ref: confirmRef, size: "compact-xs", variant: "default", loading: dismissing, "aria-label": `Dismiss the warning for ${name}`, "aria-describedby": questionId, "data-testid": `confirm-dismiss-unpublished-${dirName}`, onClick: () => void dismiss(), children: "Dismiss" }), _jsx(Button, { size: "compact-xs", variant: "subtle", color: "gray", disabled: dismissing, "aria-label": `Cancel dismissing the warning for ${name}`, "data-testid": `cancel-dismiss-unpublished-${dirName}`, onClick: () => setAsking(false), children: "Cancel" })] })) : (_jsx(Button, { ref: dismissRef, size: "compact-xs", variant: "default", style: { flexShrink: 0 }, "aria-label": `Dismiss the warning for ${name}`, "data-testid": `dismiss-unpublished-${dirName}`, [DISMISS_UNPUBLISHED_ATTRIBUTE]: true, onClick: () => setAsking(true), children: "Dismiss" }))] }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Branches merged before a submit finished, listed until an admin dismisses them or deletes the
|
|
42
|
+
* branch: archiving leaves no retry or new submit to finish it. Names come from GitHub and are
|
|
43
|
+
* attacker-chosen, so each is shown escaped.
|
|
44
|
+
*/
|
|
45
|
+
export function UnpublishedAtMergeAlert({ branches, onDismiss, }) {
|
|
46
|
+
const count = `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`;
|
|
47
|
+
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${count} merged with an unfinished submit`,
|
|
48
|
+
// The default clips focus rings at the edge, and scrolls sideways on Tab.
|
|
49
|
+
styles: { message: { overflow: 'visible' } }, "data-testid": "unpublished-at-merge-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "Merged on GitHub before the last submit finished, so commits from that submit may not be in the merge. They're still on this deployment's copy of the branch." }), _jsxs(Text, { size: "sm", children: ["Close System health and open the branch from \"", EDITOR_ACTIONS.switchBranch, "\u2026\", where it's read-only, then compare it with the pull request. Make anything that's missing on a new branch, then use \"Dismiss\"."] }), _jsx(Stack, { gap: 4, mt: 4, children: branches.map((branch) => (_jsx(UnpublishedRow, { branch: branch, onDismiss: onDismiss }, branch.dirName))) })] }) }));
|
|
50
|
+
}
|
|
@@ -51,6 +51,8 @@ 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
|
+
/** Stops listing a branch merged with an unfinished submit, a warning that doesn't come back. */
|
|
55
|
+
dismissUnpublished: (dirName: string) => Promise<void>;
|
|
54
56
|
}
|
|
55
57
|
export declare function useSystemHealth(options: UseSystemHealthOptions): UseSystemHealthReturn;
|
|
56
58
|
export {};
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* never on the poll.
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useSWRConfig } from 'swr';
|
|
15
16
|
import { notifications } from '@mantine/notifications';
|
|
16
17
|
import { useApiClient } from '../context/index.js';
|
|
18
|
+
import { BRANCHES_KEY } from '../hooks/useBranchesData.js';
|
|
17
19
|
const POLL_INTERVAL_MS = 30_000;
|
|
18
20
|
/**
|
|
19
21
|
* Crash-loop rider window: a worker that fatally exits and restarts
|
|
@@ -24,6 +26,7 @@ const POLL_INTERVAL_MS = 30_000;
|
|
|
24
26
|
const CRASH_LOOP_WINDOW_MS = 30 * 60_000;
|
|
25
27
|
export function useSystemHealth(options) {
|
|
26
28
|
const apiClient = useApiClient();
|
|
29
|
+
const { mutate } = useSWRConfig();
|
|
27
30
|
const [status, setStatus] = useState(null);
|
|
28
31
|
const [statusLoading, setStatusLoading] = useState(false);
|
|
29
32
|
const [isRecentFatalError, setIsRecentFatalError] = useState(false);
|
|
@@ -213,6 +216,23 @@ export function useSystemHealth(options) {
|
|
|
213
216
|
notifications.show({ message, color: 'red' });
|
|
214
217
|
}
|
|
215
218
|
}, [apiClient, refresh]);
|
|
219
|
+
const dismissUnpublished = useCallback(async (dirName) => {
|
|
220
|
+
try {
|
|
221
|
+
const result = await apiClient.admin.dismissUnpublished({ dirName });
|
|
222
|
+
if (!result.ok && result.status !== 409) {
|
|
223
|
+
throw new Error(result.error || 'Failed to dismiss the warning');
|
|
224
|
+
}
|
|
225
|
+
if (!result.ok)
|
|
226
|
+
notifications.show({ message: result.error, color: 'yellow' });
|
|
227
|
+
await refresh();
|
|
228
|
+
// The branch's own "Needs action" comes from the list.
|
|
229
|
+
await mutate(BRANCHES_KEY);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : 'Failed to dismiss the warning';
|
|
233
|
+
notifications.show({ message, color: 'red' });
|
|
234
|
+
}
|
|
235
|
+
}, [apiClient, mutate, refresh]);
|
|
216
236
|
const repairDuplicateIds = useCallback(async (dirName) => {
|
|
217
237
|
try {
|
|
218
238
|
const result = await apiClient.admin.repairContentDuplicates({ dirName });
|
|
@@ -268,5 +288,6 @@ export function useSystemHealth(options) {
|
|
|
268
288
|
purgeDir,
|
|
269
289
|
repairDir,
|
|
270
290
|
markMerged,
|
|
291
|
+
dismissUnpublished,
|
|
271
292
|
};
|
|
272
293
|
}
|
|
@@ -15,6 +15,8 @@ export interface BranchLockInput {
|
|
|
15
15
|
* way forward is Withdraw and the reason says why Approve is not offered.
|
|
16
16
|
*/
|
|
17
17
|
pullRequestClosed?: boolean;
|
|
18
|
+
/** The server refuses Approve on this branch, so no reason may offer it. */
|
|
19
|
+
approveBlocked?: boolean;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
@@ -30,9 +30,11 @@ export function describeBranchLock(lock) {
|
|
|
30
30
|
if (lock.pullRequestClosed)
|
|
31
31
|
return CLOSED_PULL_REQUEST_REASON;
|
|
32
32
|
if (lock.branchStatus === 'submitted')
|
|
33
|
-
return lock.reviewing
|
|
34
|
-
? 'This branch is in review.
|
|
35
|
-
:
|
|
33
|
+
return !lock.reviewing
|
|
34
|
+
? 'This branch is in review. Withdraw it to make changes.'
|
|
35
|
+
: lock.approveBlocked
|
|
36
|
+
? 'This branch is in review. Request changes to send it back to its editor.'
|
|
37
|
+
: 'This branch is in review. Approve it, or request changes to send it back to its editor.';
|
|
36
38
|
if (lock.branchStatus === 'approved')
|
|
37
39
|
return lock.reviewing
|
|
38
40
|
? 'This branch is approved. Request changes to send it back to its editor.'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BranchStatus } from '../types.js';
|
|
1
|
+
import type { BranchHold, BranchStatus } from '../types.js';
|
|
2
2
|
export interface BranchStatusPresentation {
|
|
3
3
|
label: string;
|
|
4
4
|
color: string;
|
|
@@ -15,3 +15,27 @@ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStat
|
|
|
15
15
|
* Returns undefined when there is nothing to show.
|
|
16
16
|
*/
|
|
17
17
|
export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
18
|
+
/** The badge for a branch something holds, in the bar, on the card and in System health. */
|
|
19
|
+
export declare const HOLD_BADGE: {
|
|
20
|
+
readonly label: "Needs action";
|
|
21
|
+
readonly color: "yellow";
|
|
22
|
+
};
|
|
23
|
+
/** Who is reading a hold, which decides the way forward it can offer them. */
|
|
24
|
+
export interface HoldViewer {
|
|
25
|
+
/** `withdraw`: may withdraw it. `review`: reviews someone else's. `none`: can do neither. */
|
|
26
|
+
role: 'withdraw' | 'review' | 'none';
|
|
27
|
+
isAdmin: boolean;
|
|
28
|
+
/** Reading it in System health, which can't send an admin to itself. */
|
|
29
|
+
inSystemHealth?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What's stuck and what this viewer can do about it. A retargeted base comes from GitHub and is
|
|
33
|
+
* attacker-chosen, so it's shown escaped.
|
|
34
|
+
*/
|
|
35
|
+
export declare function branchHoldMessage(hold: BranchHold, viewer: HoldViewer): string;
|
|
36
|
+
/** Ends `text` as a sentence, for a recorded reason that another sentence follows. */
|
|
37
|
+
export declare function endSentence(text: string): string;
|
|
38
|
+
/** Why a branch in review with a failed sync stays behind its base, after the failure itself. */
|
|
39
|
+
export declare const SYNC_FAILED_IN_REVIEW_NOTE = "Until it syncs, CanopyCMS won't update this pull request from its base.";
|
|
40
|
+
/** The failed-sync line when the worker recorded no reason. An archived branch has nothing to retry. */
|
|
41
|
+
export declare const syncFailedFallback: (isAdmin: boolean, status?: string) => string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeForLog } from '../utils/escape-text.js';
|
|
1
2
|
/** @internal Exported for tests. */
|
|
2
3
|
export const PROTECTED_BRANCH_PRESENTATION = {
|
|
3
4
|
label: 'Protected',
|
|
@@ -27,3 +28,77 @@ export function branchStatusPresentation(status, isProtected = false) {
|
|
|
27
28
|
return BRANCH_STATUS_PRESENTATION[status];
|
|
28
29
|
return { label: status, color: 'gray', variant: 'light' };
|
|
29
30
|
}
|
|
31
|
+
/** The badge for a branch something holds, in the bar, on the card and in System health. */
|
|
32
|
+
export const HOLD_BADGE = { label: 'Needs action', color: 'yellow' };
|
|
33
|
+
/**
|
|
34
|
+
* What's stuck and what this viewer can do about it. A retargeted base comes from GitHub and is
|
|
35
|
+
* attacker-chosen, so it's shown escaped.
|
|
36
|
+
*/
|
|
37
|
+
export function branchHoldMessage(hold, viewer) {
|
|
38
|
+
switch (hold.kind) {
|
|
39
|
+
case 'conflicts': {
|
|
40
|
+
const stuck = "This branch conflicts with its base branch, so its pull request can't be updated.";
|
|
41
|
+
if (viewer.role === 'review')
|
|
42
|
+
return `${stuck} Use "Request changes" to send it back to its editor.`;
|
|
43
|
+
if (viewer.role === 'none')
|
|
44
|
+
return `${stuck} Its editor needs to withdraw it from review.`;
|
|
45
|
+
return `${stuck} Use "Withdraw from review", check any flagged entries, then submit it again.`;
|
|
46
|
+
}
|
|
47
|
+
case 'retargeted':
|
|
48
|
+
return `This pull request now merges into ${escapeForLog(hold.base)} on GitHub, so CanopyCMS isn't updating it. Update it on the pull request${viewer.isAdmin ? '' : ', or ask an admin'}.`;
|
|
49
|
+
case 'rewritten': {
|
|
50
|
+
const stuck = "This branch was changed on GitHub in a way CanopyCMS can't combine with the work here, so changes here can't reach GitHub. Nothing is lost.";
|
|
51
|
+
if (viewer.inSystemHealth)
|
|
52
|
+
return `${stuck} A developer needs to merge the missing commit back into the branch on GitHub. The "Rebase failing" warning icon in this row names it.`;
|
|
53
|
+
return viewer.isAdmin
|
|
54
|
+
? `${stuck} See the "Branches" tab in "System health".`
|
|
55
|
+
: `${stuck} Ask an admin for help.`;
|
|
56
|
+
}
|
|
57
|
+
case 'submit-unfinished': {
|
|
58
|
+
const stuck = hold.opened
|
|
59
|
+
? "The last submit of this branch didn't finish, so its pull request may not have its latest changes."
|
|
60
|
+
: "The last submit of this branch didn't finish, so its pull request wasn't opened.";
|
|
61
|
+
if (hold.notQueued) {
|
|
62
|
+
if (viewer.role === 'review')
|
|
63
|
+
return `${stuck} Use "Request changes" to send it back to its editor.`;
|
|
64
|
+
return viewer.role === 'withdraw'
|
|
65
|
+
? `${stuck} Use "Withdraw from review", then submit the branch again.`
|
|
66
|
+
: `${stuck} Its editor needs to withdraw it from review and submit it again.`;
|
|
67
|
+
}
|
|
68
|
+
const retry = viewer.inSystemHealth
|
|
69
|
+
? 'Retry the submit from the "Tasks" tab'
|
|
70
|
+
: viewer.isAdmin
|
|
71
|
+
? 'Retry the submit from the "Tasks" tab in "System health"'
|
|
72
|
+
: 'Ask an admin to retry the submit';
|
|
73
|
+
const instead = viewer.role === 'withdraw'
|
|
74
|
+
? ', or use "Withdraw from review", then submit the branch again'
|
|
75
|
+
: viewer.role === 'review'
|
|
76
|
+
? ', or use "Request changes" to send it back to its editor'
|
|
77
|
+
: '';
|
|
78
|
+
return `${stuck} ${retry}${instead}.`;
|
|
79
|
+
}
|
|
80
|
+
case 'unpublished-at-merge': {
|
|
81
|
+
const stuck = 'This pull request merged before the last submit finished, so some changes may not be in the merge.';
|
|
82
|
+
if (viewer.inSystemHealth)
|
|
83
|
+
return `${stuck} The "Overview" tab says what to do.`;
|
|
84
|
+
return viewer.isAdmin
|
|
85
|
+
? `${stuck} See the "Overview" tab in "System health".`
|
|
86
|
+
: `${stuck} Ask an admin to check it.`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Ends `text` as a sentence, for a recorded reason that another sentence follows. */
|
|
91
|
+
export function endSentence(text) {
|
|
92
|
+
const trimmed = text.trimEnd();
|
|
93
|
+
return /[.!?:][)"']*$/.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
94
|
+
}
|
|
95
|
+
/** Why a branch in review with a failed sync stays behind its base, after the failure itself. */
|
|
96
|
+
export const SYNC_FAILED_IN_REVIEW_NOTE = "Until it syncs, CanopyCMS won't update this pull request from its base.";
|
|
97
|
+
/** The failed-sync line when the worker recorded no reason. An archived branch has nothing to retry. */
|
|
98
|
+
export const syncFailedFallback = (isAdmin, status) => {
|
|
99
|
+
if (status === 'archived')
|
|
100
|
+
return 'GitHub sync failed.';
|
|
101
|
+
return isAdmin
|
|
102
|
+
? 'GitHub sync failed. Retry it from the "Tasks" tab in "System health".'
|
|
103
|
+
: 'GitHub sync failed. Ask an admin to retry it.';
|
|
104
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Ref } from 'react';
|
|
2
|
-
import type { BranchStatus, PullRequestState } from '../../types.js';
|
|
2
|
+
import type { BranchHold, BranchStatus, PullRequestState } from '../../types.js';
|
|
3
3
|
import type { SaveAllProgress } from '../hooks/save-all.js';
|
|
4
4
|
export interface EditorHeaderProps {
|
|
5
5
|
branchName: string;
|
|
@@ -60,6 +60,10 @@ export interface EditorHeaderProps {
|
|
|
60
60
|
branchServerProtected?: boolean;
|
|
61
61
|
/** Whether the current branch is read-only (protected base branch in prod); disables Save all. */
|
|
62
62
|
branchReadOnly?: boolean;
|
|
63
|
+
/** What holds the branch (api/branch.ts). */
|
|
64
|
+
branchHold?: BranchHold;
|
|
65
|
+
/** The server refuses Approve on this branch. */
|
|
66
|
+
branchApproveBlocked?: boolean;
|
|
63
67
|
/** Server-computed: content writes are rejected on this branch; see `branchLockState`. */
|
|
64
68
|
branchWriteBlocked?: boolean;
|
|
65
69
|
/** No branch details yet: the fail-closed lock defaults are guesses the status doesn't name. */
|
|
@@ -74,4 +78,4 @@ export interface EditorHeaderProps {
|
|
|
74
78
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
75
79
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
76
80
|
*/
|
|
77
|
-
export declare function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
81
|
+
export declare function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchHold, branchApproveBlocked, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|