canopycms 0.8.1 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +102 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/cli/cli.js +28 -8
  20. package/dist/cli/generate-ai-content.js +13 -3
  21. package/dist/cli/init.js +2 -2
  22. package/dist/editor/BranchManager.d.ts +8 -4
  23. package/dist/editor/BranchManager.js +43 -18
  24. package/dist/editor/Editor.js +3 -2
  25. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  26. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  27. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  28. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  29. package/dist/editor/admin/useSystemHealth.js +21 -0
  30. package/dist/editor/branch-lock.d.ts +2 -0
  31. package/dist/editor/branch-lock.js +5 -3
  32. package/dist/editor/branch-status.d.ts +25 -1
  33. package/dist/editor/branch-status.js +75 -0
  34. package/dist/editor/components/EditorHeader.d.ts +6 -2
  35. package/dist/editor/components/EditorHeader.js +51 -30
  36. package/dist/editor/components/EntryHeader.d.ts +3 -1
  37. package/dist/editor/components/EntryHeader.js +2 -1
  38. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  39. package/dist/editor/components/TooltipDetail.js +18 -0
  40. package/dist/editor/copy.d.ts +7 -0
  41. package/dist/editor/copy.js +9 -0
  42. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  43. package/dist/editor/hooks/useBranchManager.js +48 -5
  44. package/dist/pr-comments.d.ts +2 -0
  45. package/dist/pr-comments.js +20 -14
  46. package/dist/resource-generation.js +2 -3
  47. package/dist/submission-attribution.d.ts +1 -1
  48. package/dist/submission-attribution.js +20 -2
  49. package/dist/task-queue/task-queue.d.ts +5 -1
  50. package/dist/task-queue/task-queue.js +4 -2
  51. package/dist/types.d.ts +61 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/dist/worker/branch-merge.d.ts +68 -0
  55. package/dist/worker/branch-merge.js +228 -0
  56. package/dist/worker/canopy-state.d.ts +2 -0
  57. package/dist/worker/canopy-state.js +1 -1
  58. package/dist/worker/cms-worker.d.ts +1 -0
  59. package/dist/worker/cms-worker.js +5 -3
  60. package/dist/worker/git-sync.js +15 -8
  61. package/dist/worker/github-commits.d.ts +106 -0
  62. package/dist/worker/github-commits.js +485 -0
  63. package/dist/worker/github-gateway-http.js +3 -2
  64. package/dist/worker/github-gateway-wire.d.ts +22 -13
  65. package/dist/worker/github-gateway-wire.js +4 -1
  66. package/dist/worker/github-gateway.d.ts +16 -5
  67. package/dist/worker/github-gateway.js +22 -5
  68. package/dist/worker/github-mirror.d.ts +8 -0
  69. package/dist/worker/github-mirror.js +12 -1
  70. package/dist/worker/github-transfer.d.ts +3 -1
  71. package/dist/worker/github-transfer.js +3 -2
  72. package/dist/worker/history-rewrite.d.ts +13 -1
  73. package/dist/worker/history-rewrite.js +27 -3
  74. package/dist/worker/rebase.d.ts +13 -7
  75. package/dist/worker/rebase.js +351 -95
  76. package/dist/worker/task-runner.d.ts +1 -1
  77. package/dist/worker/task-runner.js +172 -34
  78. package/dist/worker/worker-context.d.ts +2 -0
  79. package/package.json +1 -1
@@ -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;
@@ -1,15 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useRef } from 'react';
3
- import { Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
3
+ import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
4
4
  import { useMediaQuery, useMergedRef } from '@mantine/hooks';
5
5
  import { IconChevronDown, IconLock } from '@tabler/icons-react';
6
6
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
7
7
  import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
8
- import { EDITOR_ACTIONS } from '../copy.js';
8
+ import { HOLD_BADGE, branchHoldMessage } from '../branch-status.js';
9
+ import { EDITOR_ACTIONS, UNFINISHED_SUBMIT_APPROVE_REASON, rewrittenSubmitReason } from '../copy.js';
9
10
  import { reviewRoleOf } from '../../authorization/review-role.js';
10
11
  import { SYSTEM_USER_ID } from '../../system-users.js';
11
12
  import { TopBar } from '../shell/TopBar.js';
12
13
  import { BranchPill } from './BranchPill.js';
14
+ import { TooltipDetail } from './TooltipDetail.js';
13
15
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
14
16
  import { UnsavedMark } from './UnsavedMark.js';
15
17
  import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
@@ -19,7 +21,7 @@ import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.j
19
21
  * warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
20
22
  * submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
21
23
  */
22
- export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
24
+ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
23
25
  const reviewRole = reviewRoleOf({
24
26
  status: branchStatus,
25
27
  createdBy: branchCreatedBy,
@@ -38,6 +40,8 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
38
40
  branchIsProtected,
39
41
  reviewRole,
40
42
  savingAll: !!saveAllProgress,
43
+ rewritten: branchHold?.kind === 'rewritten',
44
+ approveBlocked: branchApproveBlocked,
41
45
  });
42
46
  const lock = {
43
47
  branchStatus,
@@ -47,9 +51,16 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
47
51
  branchMissing,
48
52
  reviewing: workflow?.kind === 'review',
49
53
  pullRequestClosed: reviewRole === 'reviewing-closed',
54
+ approveBlocked: branchApproveBlocked,
50
55
  };
51
56
  const branchLockReason = describeBranchLock(lock);
52
57
  const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
58
+ const holdMessage = branchName && branchHold && status.value !== 'loading'
59
+ ? branchHoldMessage(branchHold, {
60
+ role: workflow?.kind === 'review' ? 'review' : workflow?.permitted ? 'withdraw' : 'none',
61
+ isAdmin: isAdmin(userContext?.groups ?? []),
62
+ })
63
+ : undefined;
53
64
  const theme = useMantineTheme();
54
65
  // Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
55
66
  const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
@@ -81,7 +92,7 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
81
92
  returnFocusTo: () => focusTarget(2),
82
93
  };
83
94
  const caretOnWay = narrow && !!barAction && !!workflow;
84
- return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
95
+ return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
85
96
  // Its reserved width follows this branch's counts, not the session's largest.
86
97
  , { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
87
98
  (!branchName
@@ -116,14 +127,17 @@ function unsavedTooltip(status, count, mayWithdraw) {
116
127
  return `${edits} This branch won't become editable, so they can only be discarded.`;
117
128
  return `${edits} They can be saved once the branch is editable again.`;
118
129
  }
119
- /** The branch's state beside the pill, heard in words with its reason, but not while it loads. */
120
- function BranchStatusLabel({ status, reason }) {
130
+ /**
131
+ * The branch's state beside the pill, heard in words with its reason, but not while it loads. A
132
+ * hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
133
+ */
134
+ function BranchStatusLabel({ status, reason, holdMessage, }) {
121
135
  const spoken = status.label && status.value !== 'loading'
122
136
  ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
123
137
  : '';
124
138
  return (
125
139
  // Always mounted, so a lock that arrives is announced.
126
- _jsxs(Box, { role: "status", style: status.label ? { flexShrink: 0 } : HIDDEN_STYLE, children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), _jsx(VisuallyHidden, { children: spoken })] }));
140
+ _jsxs(Box, { role: "status", style: status.label || holdMessage ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: HOLD_BADGE.label }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }));
127
141
  }
128
142
  const WAY_LABEL = {
129
143
  newBranch: `${EDITOR_ACTIONS.newBranch}…`,
@@ -140,7 +154,7 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
140
154
  * Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
141
155
  * changes to a reviewer of someone else's submission; null when none shows.
142
156
  */
143
- function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, }) {
157
+ function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, }) {
144
158
  const isSubmitted = branchStatus === 'submitted';
145
159
  const isEditing = branchStatus === 'editing';
146
160
  // 'approved' withdraws too -- it is the author's way back from
@@ -162,6 +176,7 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
162
176
  blocked: !branchName || busy || savingAll,
163
177
  permitted: true,
164
178
  reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
179
+ approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : undefined,
165
180
  };
166
181
  }
167
182
  // Who may submit/withdraw: creator, ACL access, the system branch, or an
@@ -186,22 +201,24 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
186
201
  return {
187
202
  kind: isWithdrawable ? 'withdraw' : 'submit',
188
203
  // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
189
- blocked: !branchName || busy || savingAll || !canPerformAction,
204
+ blocked: !branchName || busy || savingAll || !canPerformAction || (rewritten && isEditing),
190
205
  permitted: canPerformAction,
191
206
  reason: savingAll
192
207
  ? 'Wait for Save all to finish.'
193
- : // No branch data (`branchStatus === undefined`) must not be
194
- // interpolated raw into this label. Guarded explicitly here rather
195
- // than left to the early return above, which hides the action
196
- // today only as a side effect of `branchIsProtected`'s `?? true`
197
- // default -- not a guarantee this arm stays unreachable.
198
- branchStatus === undefined
199
- ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
200
- : !statusHasAction
201
- ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
202
- : !userHasPermission
203
- ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
204
- : '',
208
+ : rewritten && isEditing
209
+ ? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
210
+ : // No branch data (`branchStatus === undefined`) must not be
211
+ // interpolated raw into this label. Guarded explicitly here rather
212
+ // than left to the early return above, which hides the action
213
+ // today only as a side effect of `branchIsProtected`'s `?? true`
214
+ // default -- not a guarantee this arm stays unreachable.
215
+ branchStatus === undefined
216
+ ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
217
+ : !statusHasAction
218
+ ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
219
+ : !userHasPermission
220
+ ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
221
+ : '',
205
222
  };
206
223
  }
207
224
  function WorkflowButton({ narrow, ...props }) {
@@ -209,7 +226,8 @@ function WorkflowButton({ narrow, ...props }) {
209
226
  }
210
227
  /**
211
228
  * Approve (leading) and Request changes. Below `md`, Approve is a split button whose caret holds
212
- * Request changes; with nothing to approve, Request changes stands alone.
229
+ * Request changes, the other way round while Approve is blocked, so the lead is what can act; with
230
+ * nothing to approve, Request changes stands alone.
213
231
  */
214
232
  function ReviewButtons({ action, onApprove, onRequestChanges, controlRef, secondaryRef, returnFocusTo, narrow, ...rest }) {
215
233
  // The bar's hand-off slot goes to Approve while there is one, in either layout, so focus on it
@@ -219,19 +237,22 @@ function ReviewButtons({ action, onApprove, onRequestChanges, controlRef, second
219
237
  const approveEl = useRef(null);
220
238
  const requestChangesEl = useRef(null);
221
239
  const approveRef = useMergedRef(approveEl, action.offersApprove ? controlRef : undefined);
222
- const requestChangesRef = useMergedRef(requestChangesEl, action.offersApprove ? (narrow ? undefined : secondaryRef) : controlRef);
240
+ const requestChangesLeads = !action.offersApprove || (narrow && !!action.approveReason);
241
+ const requestChangesRef = useMergedRef(requestChangesEl, requestChangesLeads ? controlRef : narrow ? undefined : secondaryRef);
223
242
  // Each returns focus to its own button while that is mounted, else to the control left.
224
243
  const approveReturn = () => (approveEl.current?.isConnected ? approveEl.current : returnFocusTo());
225
244
  const requestChangesReturn = () => requestChangesEl.current?.isConnected ? requestChangesEl.current : returnFocusTo();
226
- const approve = (_jsx(ReviewButton, { buttonRef: approveRef, action: action, color: "brand", testId: "approve-button", label: EDITOR_ACTIONS.approve, onClick: () => onApprove(approveReturn) }));
245
+ const approve = (_jsx(ReviewButton, { buttonRef: approveRef, action: approveAction(action), color: "brand", testId: "approve-button", label: EDITOR_ACTIONS.approve, onClick: () => onApprove(approveReturn) }));
227
246
  const requestChanges = (_jsx(ReviewButton, { buttonRef: requestChangesRef, action: action, color: "orange", testId: "request-changes-button", label: EDITOR_ACTIONS.requestChanges, onClick: () => onRequestChanges(requestChangesReturn) }));
228
247
  if (!action.offersApprove)
229
248
  return requestChanges;
230
249
  if (!narrow) {
231
250
  return (_jsxs(Group, { gap: 8, wrap: "nowrap", style: { flexShrink: 0 }, children: [requestChanges, approve] }));
232
251
  }
233
- return (_jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => _jsx("div", { ...props, role: "group", "aria-label": "Review controls" }), children: [approve, _jsx(WorkflowCaret, { ...rest, action: action, onApprove: onApprove, onRequestChanges: onRequestChanges, returnFocusTo: returnFocusTo, includeApprove: false, color: "brand", controlRef: secondaryRef })] }));
252
+ return (_jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => _jsx("div", { ...props, role: "group", "aria-label": "Review controls" }), children: [requestChangesLeads ? requestChanges : approve, _jsx(WorkflowCaret, { ...rest, action: action, onApprove: onApprove, onRequestChanges: onRequestChanges, returnFocusTo: returnFocusTo, includeApprove: requestChangesLeads, includeRequestChanges: !requestChangesLeads, color: requestChangesLeads ? 'orange' : 'brand', controlRef: secondaryRef })] }));
234
253
  }
254
+ /** The review action as Approve reads it, which has a block of its own. */
255
+ const approveAction = (action) => action.approveReason ? { ...action, blocked: true, reason: action.approveReason } : action;
235
256
  /** One review button, blocked as a whole with the action's reason. */
236
257
  function ReviewButton({ action, buttonRef, color, testId, label, onClick, }) {
237
258
  return (_jsx(Tooltip, { label: action.reason, disabled: !action.reason, multiline: true, maw: 320, children: _jsx(Button, { ref: buttonRef, size: "sm", variant: "light", color: color, style: { flexShrink: 0 }, onClick: () => {
@@ -254,15 +275,15 @@ function SubmitOrWithdrawButton({ action, onSubmit, onWithdraw, controlRef, retu
254
275
  }
255
276
  /**
256
277
  * The workflow action's caret below `md`, on Save all or the way forward or, for a review, beside
257
- * Approve. `includeApprove` is false there, where Approve is already a button.
278
+ * the action that leads, which `includeApprove` or `includeRequestChanges` leaves out.
258
279
  */
259
- function WorkflowCaret({ action, onSubmit, onWithdraw, onApprove, onRequestChanges, controlRef, returnFocusTo, includeApprove = true, color, }) {
280
+ function WorkflowCaret({ action, onSubmit, onWithdraw, onApprove, onRequestChanges, controlRef, returnFocusTo, includeApprove = true, includeRequestChanges = true, color, }) {
260
281
  const caret = useRef(null);
261
282
  const caretRef = useMergedRef(caret, controlRef);
262
283
  const menuFocus = useMenuItemFocusReturn(caret);
263
284
  const withdraw = action.kind === 'withdraw';
264
- // Beside Approve it holds only Request changes, and a dialog it opens returns to the caret.
265
- const reviewCaret = action.kind === 'review' && !includeApprove;
285
+ // Beside the lead it holds one action, and a dialog it opens returns to the caret.
286
+ const reviewCaret = action.kind === 'review' && !(includeApprove && includeRequestChanges);
266
287
  const caretLabel = reviewCaret ? 'More review actions' : 'Branch actions';
267
288
  const returnToCaret = reviewCaret
268
289
  ? () => (caret.current?.isConnected ? caret.current : returnFocusTo())
@@ -270,7 +291,7 @@ function WorkflowCaret({ action, onSubmit, onWithdraw, onApprove, onRequestChang
270
291
  const blockedReason = action.blocked ? action.reason || BUSY_REASON : undefined;
271
292
  return (
272
293
  // Bounded, so a blocked item's reason wraps on a phone rather than run off the screen.
273
- _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: caretLabel, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", color: color, size: "sm", px: 8, "aria-label": caretLabel, "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: action.kind === 'review' ? (_jsxs(_Fragment, { children: [action.offersApprove && includeApprove && (_jsx(ReasonedMenuItem, { "data-testid": "approve-menu-item", blockedReason: blockedReason, onClick: () => onApprove(returnToCaret), children: EDITOR_ACTIONS.approve })), _jsx(ReasonedMenuItem, { "data-testid": "request-changes-menu-item", blockedReason: blockedReason, onClick: () => onRequestChanges(returnToCaret), children: EDITOR_ACTIONS.requestChanges })] })) : (_jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: blockedReason, onClick: () => {
294
+ _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: caretLabel, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", color: color, size: "sm", px: 8, "aria-label": caretLabel, "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: action.kind === 'review' ? (_jsxs(_Fragment, { children: [action.offersApprove && includeApprove && (_jsx(ReasonedMenuItem, { "data-testid": "approve-menu-item", blockedReason: action.approveReason ?? blockedReason, onClick: () => onApprove(returnToCaret), children: EDITOR_ACTIONS.approve })), includeRequestChanges && (_jsx(ReasonedMenuItem, { "data-testid": "request-changes-menu-item", blockedReason: blockedReason, onClick: () => onRequestChanges(returnToCaret), children: EDITOR_ACTIONS.requestChanges }))] })) : (_jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: blockedReason, onClick: () => {
274
295
  if (withdraw)
275
296
  onWithdraw(returnFocusTo);
276
297
  else
@@ -67,6 +67,8 @@ export interface EntryHeaderProps {
67
67
  branchReviewing?: boolean;
68
68
  /** The user would review this branch but its pull request was closed; the lock reason says so. */
69
69
  branchPullRequestClosed?: boolean;
70
+ /** The server refuses Approve on this branch (`describeBranchLock`). */
71
+ branchApproveBlocked?: boolean;
70
72
  }
71
73
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
72
- export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
74
+ export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, branchApproveBlocked, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -28,7 +28,7 @@ const ShowChangesButton = ({ onClick, buttonRef, }) => {
28
28
  return (_jsx(Button, { ref: placeholder ? undefined : buttonRef, size: "compact-sm", variant: "subtle", leftSection: icon, onClick: onClick, "data-testid": placeholder ? 'entry-show-changes-pending' : 'entry-show-changes', ...hold, children: EDITOR_ACTIONS.showChanges }));
29
29
  };
30
30
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
31
- export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, }) {
31
+ export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, branchApproveBlocked = false, }) {
32
32
  const branchLockReason = describeBranchLock({
33
33
  branchStatus,
34
34
  branchReadOnly,
@@ -37,6 +37,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
37
37
  branchMissing,
38
38
  reviewing: branchReviewing,
39
39
  pullRequestClosed: branchPullRequestClosed,
40
+ approveBlocked: branchApproveBlocked,
40
41
  });
41
42
  const entryLockReason = entryReadOnly
42
43
  ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * A detail that only a Tooltip would show: hidden text for assistive tech, plus a Tooltip that
4
+ * opens on hover, focus and touch. The trigger is a focusable span, not a button: it does nothing
5
+ * when pressed, so it must not be announced as one. A trigger with no text of its own, such as an
6
+ * icon, takes its name from `name`.
7
+ */
8
+ export declare function TooltipDetail({ detail, name, maw, preLine, children, }: {
9
+ detail: string;
10
+ name?: string;
11
+ maw?: number;
12
+ preLine?: boolean;
13
+ children: ReactNode;
14
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { Box, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ /**
6
+ * A detail that only a Tooltip would show: hidden text for assistive tech, plus a Tooltip that
7
+ * opens on hover, focus and touch. The trigger is a focusable span, not a button: it does nothing
8
+ * when pressed, so it must not be announced as one. A trigger with no text of its own, such as an
9
+ * icon, takes its name from `name`.
10
+ */
11
+ export function TooltipDetail({ detail, name, maw = 320, preLine = false, children, }) {
12
+ const detailId = useId();
13
+ return (_jsxs(_Fragment, { children: [_jsx(Tooltip
14
+ // A sighted user sees only the icon, so the tooltip names it too.
15
+ , {
16
+ // A sighted user sees only the icon, so the tooltip names it too.
17
+ 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", display: "inline-flex", style: { borderRadius: 'var(--mantine-radius-xl)' }, children: children }) }), _jsx(VisuallyHidden, { id: detailId, children: detail })] }));
18
+ }
@@ -27,5 +27,12 @@ export declare const EDITOR_ACTIONS: {
27
27
  * `one` and `many` name what the list holds.
28
28
  */
29
29
  export declare const listFullReason: (maxItems: number, one?: string, many?: string) => string;
30
+ /** Why Submit for review can't act: the worker pushes nothing for a branch rewritten on GitHub. */
31
+ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
32
+ /**
33
+ * Why Approve can't act: what's approved may not be what the pull request holds. The branch's hold
34
+ * says how to finish the submit, where it can be retried at all.
35
+ */
36
+ export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
30
37
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
31
38
  export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
@@ -27,5 +27,14 @@ export const EDITOR_ACTIONS = {
27
27
  * `one` and `many` name what the list holds.
28
28
  */
29
29
  export const listFullReason = (maxItems, one = 'item', many = 'items') => `This list holds at most ${maxItems} ${maxItems === 1 ? one : many}. Remove one to add another.`;
30
+ /** Why Submit for review can't act: the worker pushes nothing for a branch rewritten on GitHub. */
31
+ export const rewrittenSubmitReason = (isAdmin) => isAdmin
32
+ ? 'This branch can\'t be submitted until the changes made to it on GitHub are sorted out. See the "Branches" tab in "System health".'
33
+ : "This branch can't be submitted until an admin sorts out the changes made to it on GitHub.";
34
+ /**
35
+ * Why Approve can't act: what's approved may not be what the pull request holds. The branch's hold
36
+ * says how to finish the submit, where it can be retried at all.
37
+ */
38
+ export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
30
39
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
31
40
  export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
@@ -1,4 +1,4 @@
1
- import type { ConflictStatus, PullRequestState, SyncStatus } from '../../types.js';
1
+ import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '../../types.js';
2
2
  import type { OperatingMode } from '../../operating-mode/index.js';
3
3
  import type { CommentThread } from '../../comment-store.js';
4
4
  import type { BranchListItem } from '../../api/branch.js';
@@ -50,6 +50,8 @@ interface BranchSummary {
50
50
  conflictStatus?: ConflictStatus;
51
51
  /** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
52
52
  conflictFiles?: string[];
53
+ /** What is stuck and needs a person (api/branch.ts). */
54
+ hold?: BranchHold;
53
55
  commentCount: number;
54
56
  isProtected: boolean;
55
57
  readOnly: boolean;
@@ -61,8 +63,9 @@ interface BranchSummary {
61
63
  writeBlocked: boolean;
62
64
  /**
63
65
  * Server-computed compound submit rule: true when the branch can never be
64
- * submitted for review, for EITHER reason -- it is the protected base
65
- * branch, or its workflow status has moved past 'editing'. Mirrors
66
+ * submitted for review, for ANY reason -- it is the protected base
67
+ * branch, its workflow status has moved past 'editing', or it was rewritten
68
+ * on GitHub (`toBranchListItem`). Mirrors
66
69
  * `writeBlocked`'s shape (a base-branch half + a status half, combined),
67
70
  * except this one is built from `isProtected`/`submitBlocked` (both modes)
68
71
  * rather than `readOnly` (prod-only) -- see
@@ -72,6 +75,8 @@ interface BranchSummary {
72
75
  * re-derive the status/protection halves client-side.
73
76
  */
74
77
  submitBlocked: boolean;
78
+ /** Server-computed: the API refuses Approve, as the last submit didn't finish. */
79
+ approveBlocked?: boolean;
75
80
  }
76
81
  export interface UseBranchManagerOptions {
77
82
  initialBranch: string;
@@ -25,7 +25,7 @@ import { writeEditorUrl } from './editor-history.js';
25
25
  import { approveMessage, NoteNotSavedMessage, RequestChangesForm, } from '../components/ReviewDialogs.js';
26
26
  const BRANCHES_LOAD_FAILED_TOAST_ID = 'canopy-branches-load-failed';
27
27
  const BRANCH_OUTCOME_UNKNOWN_TOAST_ID = 'canopy-branch-outcome-unknown';
28
- /** The API's 409: the branch is no longer in the state the action needs, so a retry cannot pass. */
28
+ /** The API's 409: the branch isn't in the state the action needs, so a retry cannot pass. */
29
29
  class StaleStatusError extends Error {
30
30
  constructor() {
31
31
  super(...arguments);
@@ -191,7 +191,7 @@ const showRequestChangesConfirmation = (branchName, pr, initialNote, onNoteChang
191
191
  * delete menu item calls `onDelete` directly, so this dialog is the only confirmation; `color:
192
192
  * 'red'` matches that item.
193
193
  */
194
- const showDeleteConfirmation = (branchName, pr, onConfirm, onDismiss) => {
194
+ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, onDismiss) => {
195
195
  // The server deletes the GitHub branch only for a branch with a PR (api/github-sync.ts's
196
196
  // syncDeleteRemoteBranch); GitHub closes a PR whose branch is deleted.
197
197
  const bullets = [
@@ -200,6 +200,9 @@ const showDeleteConfirmation = (branchName, pr, onConfirm, onDismiss) => {
200
200
  ? `Delete its branch on GitHub too, which closes pull request #${pr.pullRequestNumber} if it is still open`
201
201
  : undefined,
202
202
  'Discard any unsaved or unmerged changes',
203
+ mayHoldUnpublished
204
+ ? 'Remove the only copy of any changes that never reached GitHub, and the warning about them'
205
+ : undefined,
203
206
  ].filter((b) => b !== undefined);
204
207
  openConfirm({
205
208
  title: 'Delete branch',
@@ -396,6 +399,8 @@ export function useBranchManager(options) {
396
399
  syncFailureReason: b.syncFailureReason,
397
400
  conflictStatus: b.conflictStatus,
398
401
  conflictFiles: b.conflictFiles,
402
+ hold: b.hold,
403
+ approveBlocked: b.approveBlocked,
399
404
  commentCount: unresolvedCount,
400
405
  // Fail CLOSED (`?? true`): these flags are optional on the wire (see
401
406
  // BranchListItem's doc comment in api/branch.ts) so a version-skewed
@@ -560,10 +565,13 @@ export function useBranchManager(options) {
560
565
  }
561
566
  };
562
567
  const reviewFailureId = (kind, branch) => `canopy-${kind}-failed-${branch}`;
568
+ const submitRefusedId = (branch) => `canopy-submit-refused-${branch}`;
569
+ const deleteRefusedId = (branch) => `canopy-delete-refused-${branch}`;
563
570
  /**
564
571
  * Why an Approve or Request changes failed. Retry only on an API 5xx, 408, 429 or unsent request;
565
572
  * a timeout or gateway 5xx may have landed, so it reloads and says what to check. Other 4xx: a
566
- * plain message and a reload. A 409 means the branch left review (see `showLeftReview`).
573
+ * plain message and a reload. A 409 means the branch left review, or for Approve that its last
574
+ * submit didn't finish (see `showLeftReview`, which shows the server's reason either way).
567
575
  */
568
576
  const reportReviewFailure = async (err, kind, branch, details) => {
569
577
  if (err instanceof StaleStatusError) {
@@ -657,6 +665,7 @@ export function useBranchManager(options) {
657
665
  if (stamped && branchNameToSubmit === openListedName()) {
658
666
  options.onStamped?.(stamped);
659
667
  }
668
+ notifications.hide(submitRefusedId(branchNameToSubmit));
660
669
  notifications.show({
661
670
  message: submittedMessage(stamped?.length ?? 0),
662
671
  color: 'green',
@@ -666,7 +675,23 @@ export function useBranchManager(options) {
666
675
  resolve();
667
676
  }
668
677
  catch (err) {
669
- await reportActionFailure(err, 'Failed to submit branch', EDITOR_ACTIONS.submitForReview);
678
+ if (err instanceof RefusedError && err.status === 409) {
679
+ // Nothing is lost, and it says what to do, so it stays until read. The reload shows
680
+ // whatever the page hadn't heard yet, such as what's holding the branch.
681
+ const id = submitRefusedId(branchNameToSubmit);
682
+ notifications.hide(id);
683
+ notifications.show({
684
+ id,
685
+ message: err.message,
686
+ color: 'yellow',
687
+ autoClose: false,
688
+ withCloseButton: true,
689
+ });
690
+ await loadBranches();
691
+ }
692
+ else {
693
+ await reportActionFailure(err, 'Failed to submit branch', EDITOR_ACTIONS.submitForReview);
694
+ }
670
695
  reject(err);
671
696
  }
672
697
  finally {
@@ -813,7 +838,8 @@ export function useBranchManager(options) {
813
838
  const branchNameToDelete = listedName(requested);
814
839
  const listedBefore = latest.current.branches;
815
840
  return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
816
- showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), async () => {
841
+ showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ===
842
+ 'unpublished-at-merge', async () => {
817
843
  options.setBusy(true);
818
844
  try {
819
845
  const result = await apiClient.branches.delete({
@@ -822,6 +848,7 @@ export function useBranchManager(options) {
822
848
  // The API's own 404: the branch is already gone, by an earlier unanswered delete or
823
849
  // another user's.
824
850
  if (!result.ok && result.status === 404 && !isNonApiResponse(result)) {
851
+ notifications.hide(deleteRefusedId(branchNameToDelete));
825
852
  notifications.show({ message: 'Branch deleted', color: 'green' });
826
853
  leaveDeletedBranch(branchNameToDelete, listedBefore, await reloadBranches());
827
854
  return;
@@ -829,6 +856,7 @@ export function useBranchManager(options) {
829
856
  if (!result.ok)
830
857
  throw actionError(result, 'Failed to delete branch');
831
858
  const cleanupWarning = result.data?.cleanupWarning;
859
+ notifications.hide(deleteRefusedId(branchNameToDelete));
832
860
  notifications.show(cleanupWarning
833
861
  ? {
834
862
  message: `Branch deleted, with a warning: ${cleanupWarning}`,
@@ -839,6 +867,20 @@ export function useBranchManager(options) {
839
867
  leaveDeletedBranch(branchNameToDelete, listedBefore, await reloadBranches());
840
868
  }
841
869
  catch (err) {
870
+ if (err instanceof RefusedError && err.status === 409) {
871
+ // Nothing is lost, and the list says what changed.
872
+ const id = deleteRefusedId(branchNameToDelete);
873
+ notifications.hide(id);
874
+ notifications.show({
875
+ id,
876
+ message: err.message,
877
+ color: 'yellow',
878
+ autoClose: false,
879
+ withCloseButton: true,
880
+ });
881
+ await reloadBranches();
882
+ return;
883
+ }
842
884
  if (!isUnanswered(err)) {
843
885
  await reportActionFailure(err, 'Failed to delete branch', 'Delete branch');
844
886
  return;
@@ -848,6 +890,7 @@ export function useBranchManager(options) {
848
890
  const recent = pendingBranches.filter((p) => Date.now() - p.addedAt <= CREATED_BRANCH_GRACE_MS);
849
891
  const listed = mergePendingBranches(fresh?.branches ?? [], recent).some((b) => sanitizeBranchName(b.name) === sanitizeBranchName(branchNameToDelete));
850
892
  if (fresh && !listed) {
893
+ notifications.hide(deleteRefusedId(branchNameToDelete));
851
894
  notifications.show({ message: 'Branch deleted', color: 'green' });
852
895
  leaveDeletedBranch(branchNameToDelete, listedBefore, fresh);
853
896
  }
@@ -17,6 +17,8 @@ export declare function renderPrCommentsBody(options: {
17
17
  branch: string;
18
18
  editorUrl?: string;
19
19
  body: string;
20
+ /** Leaves the comment summary as it is, for a caller without the editor URL its links need. */
21
+ reviewBlockOnly?: boolean;
20
22
  warn: (message: string) => void;
21
23
  }): Promise<string | undefined>;
22
24
  /**