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.
- package/CHANGELOG.md +102 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +28 -8
- package/dist/cli/generate-ai-content.js +13 -3
- package/dist/cli/init.js +2 -2
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
/**
|
|
120
|
-
|
|
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
|
-
:
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
?
|
|
202
|
-
: !
|
|
203
|
-
?
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -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.";
|
package/dist/editor/copy.js
CHANGED
|
@@ -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
|
|
65
|
-
* branch,
|
|
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
|
|
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
|
|
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
|
-
|
|
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),
|
|
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
|
}
|
package/dist/pr-comments.d.ts
CHANGED
|
@@ -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
|
/**
|