canopycms 0.8.1 → 0.8.3

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +120 -47
  2. package/dist/ai/resolve-branch.js +2 -1
  3. package/dist/api/admin-branch-health.d.ts +11 -0
  4. package/dist/api/admin-branch-health.js +36 -0
  5. package/dist/api/admin.d.ts +8 -1
  6. package/dist/api/branch-merge.js +1 -1
  7. package/dist/api/branch-review.js +20 -3
  8. package/dist/api/branch-status.js +30 -19
  9. package/dist/api/branch.d.ts +8 -3
  10. package/dist/api/branch.js +32 -3
  11. package/dist/api/client.d.ts +3 -1
  12. package/dist/api/client.js +4 -0
  13. package/dist/api/github-sync.d.ts +2 -0
  14. package/dist/api/github-sync.js +6 -1
  15. package/dist/api/pr-comment-sync.js +3 -1
  16. package/dist/authorization/branch.d.ts +10 -4
  17. package/dist/authorization/branch.js +24 -4
  18. package/dist/authorization/types.d.ts +1 -1
  19. package/dist/branch-metadata-file.d.ts +66 -0
  20. package/dist/branch-metadata-file.js +14 -0
  21. package/dist/branch-metadata.d.ts +4 -2
  22. package/dist/branch-metadata.js +10 -2
  23. package/dist/cli/cli.js +1913 -1606
  24. package/dist/cli/configured-asset-store.js +2 -8
  25. package/dist/cli/generate-ai-content.js +17 -4
  26. package/dist/cli/init.js +2 -2
  27. package/dist/cli/site-config.d.ts +9 -0
  28. package/dist/cli/site-config.js +17 -0
  29. package/dist/cli/sync.js +226 -74
  30. package/dist/editor/BranchManager.d.ts +8 -4
  31. package/dist/editor/BranchManager.js +43 -18
  32. package/dist/editor/Editor.js +3 -2
  33. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  34. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  35. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  36. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  37. package/dist/editor/admin/useSystemHealth.js +21 -0
  38. package/dist/editor/branch-lock.d.ts +2 -0
  39. package/dist/editor/branch-lock.js +5 -3
  40. package/dist/editor/branch-status.d.ts +25 -1
  41. package/dist/editor/branch-status.js +75 -0
  42. package/dist/editor/components/EditorHeader.d.ts +6 -2
  43. package/dist/editor/components/EditorHeader.js +51 -30
  44. package/dist/editor/components/EntryHeader.d.ts +3 -1
  45. package/dist/editor/components/EntryHeader.js +2 -1
  46. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  47. package/dist/editor/components/TooltipDetail.js +18 -0
  48. package/dist/editor/copy.d.ts +7 -0
  49. package/dist/editor/copy.js +9 -0
  50. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  51. package/dist/editor/hooks/useBranchManager.js +48 -5
  52. package/dist/pr-comments.d.ts +2 -0
  53. package/dist/pr-comments.js +20 -14
  54. package/dist/resource-generation.js +2 -3
  55. package/dist/services.d.ts +3 -2
  56. package/dist/submission-attribution.d.ts +1 -1
  57. package/dist/submission-attribution.js +20 -2
  58. package/dist/system-users.d.ts +2 -0
  59. package/dist/system-users.js +6 -1
  60. package/dist/task-queue/task-queue.d.ts +5 -1
  61. package/dist/task-queue/task-queue.js +4 -2
  62. package/dist/types.d.ts +61 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/worker/branch-merge.d.ts +68 -0
  66. package/dist/worker/branch-merge.js +228 -0
  67. package/dist/worker/canopy-state.d.ts +2 -0
  68. package/dist/worker/canopy-state.js +1 -1
  69. package/dist/worker/cms-worker.d.ts +1 -0
  70. package/dist/worker/cms-worker.js +5 -3
  71. package/dist/worker/git-sync.js +15 -8
  72. package/dist/worker/github-commits.d.ts +106 -0
  73. package/dist/worker/github-commits.js +485 -0
  74. package/dist/worker/github-gateway-http.js +3 -2
  75. package/dist/worker/github-gateway-wire.d.ts +22 -13
  76. package/dist/worker/github-gateway-wire.js +4 -1
  77. package/dist/worker/github-gateway.d.ts +16 -5
  78. package/dist/worker/github-gateway.js +22 -5
  79. package/dist/worker/github-mirror.d.ts +8 -0
  80. package/dist/worker/github-mirror.js +12 -1
  81. package/dist/worker/github-transfer.d.ts +3 -1
  82. package/dist/worker/github-transfer.js +3 -2
  83. package/dist/worker/history-rewrite.d.ts +13 -1
  84. package/dist/worker/history-rewrite.js +27 -3
  85. package/dist/worker/rebase.d.ts +13 -7
  86. package/dist/worker/rebase.js +351 -95
  87. package/dist/worker/task-runner.d.ts +1 -1
  88. package/dist/worker/task-runner.js +172 -34
  89. package/dist/worker/worker-context.d.ts +2 -0
  90. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
4
+ import { ActionIcon, Anchor, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
5
5
  import { IconDots, IconExternalLink } from '@tabler/icons-react';
6
6
  import { BranchComments } from './comments/BranchComments.js';
7
7
  import { UserBadge } from './components/UserBadge.js';
@@ -13,9 +13,10 @@ import { parseCreatedBranchName } from '../paths/validation.js';
13
13
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
14
14
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
15
15
  import { RelativeTime } from './components/RelativeTime.js';
16
- import { branchStatusPresentation } from './branch-status.js';
16
+ import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence, syncFailedFallback, branchStatusPresentation, } from './branch-status.js';
17
+ import { TooltipDetail } from './components/TooltipDetail.js';
17
18
  import { branchAddress, displayBranchName } from './branch-names.js';
18
- import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
19
+ import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
19
20
  import { isReviewerOfOthersReview } from '../authorization/review-role.js';
20
21
  import { SYSTEM_USER_ID } from '../system-users.js';
21
22
  /**
@@ -51,8 +52,8 @@ export const getBranchPermissions = (branch, user) => {
51
52
  (userIsCreator || userInACL || isSystemBranch || userIsAdmin || userIsReviewer);
52
53
  // Submit: can perform workflow actions AND the server says submit isn't
53
54
  // blocked. `submitBlocked` is the server's own compound answer -- status
54
- // past 'editing' OR the protected base branch -- computed once in
55
- // `getBranchWriteProtection` (protected-branch.ts) and threaded through
55
+ // past 'editing', the protected base branch, or a rewrite on GitHub --
56
+ // computed once in `toBranchListItem` (api/branch.ts) and threaded through
56
57
  // unchanged.
57
58
  //
58
59
  // `?? true`, not a bare `!branch.submitBlocked`: the field is optional (like
@@ -80,10 +81,11 @@ export const getBranchPermissions = (branch, user) => {
80
81
  // Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
81
82
  // would only do what Withdraw does. Neither applies to the protected base branch, whose way
82
83
  // out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
83
- // a draft); Approve also needs the branch submitted, Request changes accepts approved too.
84
+ // a draft); Approve also needs the branch submitted and its last submit finished, Request changes
85
+ // accepts approved too.
84
86
  const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
85
87
  const prLive = branch.pullRequestState !== 'closed';
86
- const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
88
+ const canApprove = reviewsOthers && branch.status === 'submitted' && prLive && !branch.approveBlocked;
87
89
  const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
88
90
  return {
89
91
  hasWorkflowAccess: canPerformWorkflowActions,
@@ -102,7 +104,7 @@ const noUserLookup = async () => null;
102
104
  * author's way back. A user without workflow access is offered neither, since it
103
105
  * would never apply to them; a reason is given only for a block that depends on the branch's state.
104
106
  */
105
- const workflowAction = (b, perms, onSubmit, onWithdraw) => {
107
+ const workflowAction = (b, perms, onSubmit, onWithdraw, userIsAdmin) => {
106
108
  if (!perms.hasWorkflowAccess)
107
109
  return undefined;
108
110
  if (b.status === 'submitted' || b.status === 'approved') {
@@ -119,7 +121,11 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
119
121
  color: 'brand',
120
122
  testId: `submit-branch-button-${b.name}`,
121
123
  run: () => onSubmit?.(b.name),
122
- blockedReason: perms.canSubmit ? undefined : "This branch can't be submitted right now.",
124
+ blockedReason: perms.canSubmit
125
+ ? undefined
126
+ : b.hold?.kind === 'rewritten'
127
+ ? rewrittenSubmitReason(userIsAdmin)
128
+ : "This branch can't be submitted right now.",
123
129
  };
124
130
  }
125
131
  return undefined;
@@ -133,6 +139,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
133
139
  isProtected: b.isProtected,
134
140
  pullRequestState: b.pullRequestState,
135
141
  }, user);
142
+ /** Lets the card's details shrink beside its actions, which a long unbroken name would push out. */
143
+ const CARD_DETAILS = { minWidth: 0, flex: 1 };
144
+ const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
136
145
  /**
137
146
  * One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
138
147
  * shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
@@ -143,9 +152,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
143
152
  const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
144
153
  const statusBadge = branchStatusPresentation(b.status, b.isProtected);
145
154
  const perms = getBranchPermissions(b, user);
146
- const action = workflowAction(b, perms, onSubmit, onWithdraw);
147
- const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
148
155
  const userIsAdmin = isAdmin(user?.groups);
156
+ const action = workflowAction(b, perms, onSubmit, onWithdraw, userIsAdmin);
157
+ const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
149
158
  // The open branch holds back its review actions as it does its workflow action; the reason is
150
159
  // given only for the open branch.
151
160
  const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
@@ -157,7 +166,11 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
157
166
  color: 'brand',
158
167
  testId: `approve-branch-button-${b.name}`,
159
168
  run: () => onApprove?.(b.name),
160
- blockedReason: perms.canApprove ? undefined : CLOSED_PULL_REQUEST_REASON,
169
+ blockedReason: perms.canApprove
170
+ ? undefined
171
+ : b.pullRequestState === 'closed'
172
+ ? CLOSED_PULL_REQUEST_REASON
173
+ : UNFINISHED_SUBMIT_APPROVE_REASON,
161
174
  }
162
175
  : undefined);
163
176
  const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
@@ -178,24 +191,36 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
178
191
  // alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
179
192
  const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
180
193
  const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
181
- // Two visible actions, or the long "Withdraw from review", would crowd the title at the drawer's
182
- // width, so they take a row below it. Open and Review stay beside the title.
183
- const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview;
194
+ // Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
195
+ // crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
196
+ // the title.
197
+ const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
184
198
  const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
185
199
  const showViewPullRequest = !!b.pullRequestUrl;
186
200
  const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
187
201
  const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
188
202
  const menuLabel = `More actions for ${displayBranchName(b)}`;
189
203
  const [menuOpen, setMenuOpen] = useState(false);
190
- return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
204
+ const inReview = b.status === 'submitted' || b.status === 'approved';
205
+ const holdViewer = {
206
+ role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
207
+ isAdmin: userIsAdmin,
208
+ };
209
+ return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
191
210
  ? 'red'
192
211
  : b.pullRequestState === 'merged'
193
212
  ? 'violet'
194
- : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Tooltip, { label: "GitHub sync failed \u2014 an admin can retry it from System health.", disabled: Boolean(b.syncFailureReason), multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" }) })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(Tooltip, { label: "This branch's version was kept for conflicting entries during a rebase \u2014 review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.syncStatus === 'sync-failed' && b.syncFailureReason && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, "data-testid": `sync-failed-reason-${b.name}`, children: [b.syncFailureReason, b.status === 'submitted' && ' Withdraw it from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
213
+ : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
214
+ (b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
215
+ inReview &&
216
+ holdViewer.role === 'withdraw' &&
217
+ ' Withdraw the branch from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
195
218
  ? `View pull request #${b.pullRequestNumber}`
196
219
  : 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
197
220
  ? 'This branch is in review. Withdraw it from review before deleting it.'
198
- : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
221
+ : b.hold?.kind === 'unpublished-at-merge' && !userIsAdmin
222
+ ? 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.'
223
+ : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
199
224
  };
200
225
  const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
201
226
  if (!action.blockedReason)
@@ -1578,6 +1578,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1578
1578
  branchMissing,
1579
1579
  reviewing: branchReviewing,
1580
1580
  pullRequestClosed: branchPullRequestClosed,
1581
+ approveBlocked: currentBranch?.approveBlocked,
1581
1582
  }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
1582
1583
  ? {
1583
1584
  base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
@@ -1601,7 +1602,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1601
1602
  // branchWriteBlocked reuses branchContentLocked itself rather than
1602
1603
  // re-deriving `?? true` a second time here, so there is exactly one
1603
1604
  // fail-closed computation of "are writes blocked" in this component.
1604
- branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
1605
+ branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
1605
1606
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1606
1607
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
1607
1608
  // On any entry: EntryHeader's `saving` is the open entry's alone.
@@ -1634,7 +1635,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1634
1635
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1635
1636
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1636
1637
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1637
- hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1638
+ hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1638
1639
  ? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
1639
1640
  : undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1640
1641
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -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 { Alert, Badge, Box, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, VisuallyHidden, } from '@mantine/core';
13
- import { useId } from 'react';
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 { branchStatusPresentation } from '../branch-status.js';
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
- 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, { 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 }) }), _jsx(Tabs.Panel, { value: "tasks", pt: "md", children: _jsx(TasksTab, { health: health }) }), _jsx(Tabs.Panel, { value: "branches", pt: "md", children: _jsx(BranchesTab, { health: health }) })] }) }));
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
- // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
405
- // rebases every branch except 'submitted'/'approved' (under an active PR)
406
- // and 'archived' (already merged). An exclusion list, not `status ===
407
- // 'editing'`, so a status added later still shows its rebase failures by
408
- // default instead of silently hiding them.
409
- const showRebaseFailure = !['submitted', 'approved', 'archived'].includes(b.status) && !!b.rebaseFailure;
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
- ? `${b.syncFailureReason} — retry from the "Tasks" tab`
413
- : 'GitHub sync failed — retry from the "Tasks" tab', 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, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), 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 &&
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. Approve it, or request changes to send it back to its editor.'
35
- : 'This branch is in review. Withdraw it to make changes.';
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;