canopycms 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/cli/cli.js +91 -18
- package/dist/cli/generate-ai-content.js +72 -11
- package/dist/cli/init.js +2 -2
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +43 -6
- package/dist/context.js +43 -41
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +24 -15
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
package/dist/content-reader.js
CHANGED
|
@@ -16,6 +16,11 @@ import { CONTENT_READER_USER_ID } from './system-users.js';
|
|
|
16
16
|
* reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
|
|
17
17
|
*/
|
|
18
18
|
export const createContentReader = (options) => {
|
|
19
|
+
const { read } = createContentReaderInternal(options);
|
|
20
|
+
return { read };
|
|
21
|
+
};
|
|
22
|
+
/** @internal {@link createContentReader} plus `readFirst`, for context.ts. */
|
|
23
|
+
export const createContentReaderInternal = (options) => {
|
|
19
24
|
const services = options.services;
|
|
20
25
|
const operatingMode = services.config.mode;
|
|
21
26
|
const basePathOverride = options.basePathOverride;
|
|
@@ -127,9 +132,9 @@ export const createContentReader = (options) => {
|
|
|
127
132
|
const url = encodedSlug ? `${trimmed}/${encodedSlug}` : trimmed || '/';
|
|
128
133
|
return appendBranch(url);
|
|
129
134
|
};
|
|
130
|
-
const readDocument = async (input) => {
|
|
131
|
-
const { entryPath, slug,
|
|
132
|
-
const { context, branchRoot, store } =
|
|
135
|
+
const readDocument = async (input, resolved) => {
|
|
136
|
+
const { entryPath, slug, user } = resolveTarget(input);
|
|
137
|
+
const { context, branchRoot, store } = resolved;
|
|
133
138
|
// Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
|
|
134
139
|
// are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
|
|
135
140
|
// URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
|
|
@@ -216,9 +221,9 @@ export const createContentReader = (options) => {
|
|
|
216
221
|
throw err;
|
|
217
222
|
}
|
|
218
223
|
};
|
|
219
|
-
const
|
|
224
|
+
const readResolved = async (input, resolved, message) => {
|
|
220
225
|
const { entryPath, slug, branchName } = resolveTarget(input);
|
|
221
|
-
const result = await readDocument(input);
|
|
226
|
+
const result = await readDocument(input, resolved);
|
|
222
227
|
if (!result || typeof result.doc !== 'object' || !('data' in result.doc)) {
|
|
223
228
|
const defaultMessage = `Content not found for ${entryPath}${slug ? `/${slug}` : ''} on branch ${branchName}`;
|
|
224
229
|
throw new ContentStoreError(message ?? defaultMessage, 'NOT_FOUND');
|
|
@@ -242,5 +247,37 @@ export const createContentReader = (options) => {
|
|
|
242
247
|
});
|
|
243
248
|
return { data, path, meta: { physicalPath, entryType, entryId, slug: entrySlug, urlPath } };
|
|
244
249
|
};
|
|
245
|
-
|
|
250
|
+
const read = async (input, message) => {
|
|
251
|
+
const { branchName } = resolveTarget(input);
|
|
252
|
+
return readResolved(input, await resolveStore(branchName), message);
|
|
253
|
+
};
|
|
254
|
+
const readFirst = async (inputs, isMiss) => {
|
|
255
|
+
const [first] = inputs;
|
|
256
|
+
if (!first)
|
|
257
|
+
return null;
|
|
258
|
+
if (inputs.some((input) => input.branch !== first.branch || input.user !== first.user)) {
|
|
259
|
+
throw new Error('readFirst: every input must name the same branch and user');
|
|
260
|
+
}
|
|
261
|
+
let resolved;
|
|
262
|
+
try {
|
|
263
|
+
resolved = await resolveStore(resolveTarget(first).branchName);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (isMiss(err, first))
|
|
267
|
+
return null;
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
for (const input of inputs) {
|
|
271
|
+
try {
|
|
272
|
+
return await readResolved(input, resolved);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
if (isMiss(err, input))
|
|
276
|
+
continue;
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
};
|
|
282
|
+
return { read, readFirst };
|
|
246
283
|
};
|
package/dist/context.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
2
|
-
import {
|
|
2
|
+
import { createContentReaderInternal } from './content-reader.js';
|
|
3
3
|
import { ContentStoreError } from './content-store.js';
|
|
4
4
|
import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
|
|
5
5
|
import { resolveUrlPathCandidates } from './url-path-resolver.js';
|
|
@@ -129,7 +129,7 @@ export function createCanopyContext(options) {
|
|
|
129
129
|
};
|
|
130
130
|
// The reader resolves every branch through resolveBranch; a null reads as
|
|
131
131
|
// NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
|
|
132
|
-
const baseReader =
|
|
132
|
+
const baseReader = createContentReaderInternal({
|
|
133
133
|
services,
|
|
134
134
|
defaultBranch: activeBranch,
|
|
135
135
|
getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
|
|
@@ -140,7 +140,7 @@ export function createCanopyContext(options) {
|
|
|
140
140
|
// function rather than an optional second parameter on the
|
|
141
141
|
// `CanopyContext['read']`-typed closure, so the two call sites stay visible
|
|
142
142
|
// and nobody widens the public API by accident.
|
|
143
|
-
const
|
|
143
|
+
const toReadInput = (input, extra) => {
|
|
144
144
|
const entryPath = createLogicalPath(input.entryPath);
|
|
145
145
|
let slug;
|
|
146
146
|
if (input.slug) {
|
|
@@ -161,53 +161,55 @@ export function createCanopyContext(options) {
|
|
|
161
161
|
resolveReferences: input.resolveReferences ?? true,
|
|
162
162
|
...extra,
|
|
163
163
|
};
|
|
164
|
-
return
|
|
164
|
+
return readInput;
|
|
165
165
|
};
|
|
166
|
-
const read = (input) =>
|
|
166
|
+
const read = async (input) => baseReader.read(toReadInput(input));
|
|
167
167
|
const readByUrlPath = async (urlPath, options) => {
|
|
168
168
|
const contentRoot = services.config.contentRoot || 'content';
|
|
169
169
|
const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
|
|
170
170
|
if (candidates.length === 0)
|
|
171
171
|
return null;
|
|
172
172
|
const { branch, resolveReferences } = options ?? {};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
// A read BY PUBLISHED URL accepts only what listEntries publishes.
|
|
187
|
-
// See ReadContentInput.urlAddressableOnly for the two rules, and
|
|
188
|
-
// why they live in the reader (which holds the branch-correct
|
|
189
|
-
// schema) rather than the candidate builder (pure and schema-free).
|
|
190
|
-
{ urlAddressableOnly: true });
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
// Swallow a candidate path's "not found", and FORBIDDEN so a denied
|
|
194
|
-
// or anonymous read renders as the adopter's 404 rather than an
|
|
195
|
-
// unhandled 500. Real errors (validation, corruption, anything not a
|
|
196
|
-
// ContentStoreError) rethrow.
|
|
197
|
-
if (err instanceof ContentStoreError && isPageSwallowable(err)) {
|
|
198
|
-
if (err.code === 'FORBIDDEN') {
|
|
199
|
-
log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
|
|
200
|
-
urlPath,
|
|
201
|
-
entryPath: candidate.entryPath,
|
|
202
|
-
slug: candidate.slug,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
throw err;
|
|
173
|
+
// Swallow a candidate path's "not found", and FORBIDDEN so a denied or
|
|
174
|
+
// anonymous read renders as the adopter's 404 rather than an unhandled
|
|
175
|
+
// 500. Real errors (validation, corruption, anything not a
|
|
176
|
+
// ContentStoreError) rethrow.
|
|
177
|
+
const isMiss = (err, input) => {
|
|
178
|
+
if (!(err instanceof ContentStoreError && isPageSwallowable(err)))
|
|
179
|
+
return false;
|
|
180
|
+
if (err.code === 'FORBIDDEN') {
|
|
181
|
+
log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
|
|
182
|
+
urlPath,
|
|
183
|
+
entryPath: input?.entryPath,
|
|
184
|
+
slug: input?.slug,
|
|
185
|
+
});
|
|
208
186
|
}
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
// Converting every candidate up front throws nothing a candidate-by-candidate
|
|
190
|
+
// loop would not: a valid slug holds no `..`, so the index candidate's path
|
|
191
|
+
// fails createLogicalPath only when the entry candidate's does.
|
|
192
|
+
let inputs;
|
|
193
|
+
try {
|
|
194
|
+
inputs = candidates
|
|
195
|
+
// Skip a candidate whose slug isn't valid (/favicon.ico, Next internals
|
|
196
|
+
// the [...slug] route catches). None can match an entry, so treat it as
|
|
197
|
+
// a miss rather than let read() throw "Invalid slug".
|
|
198
|
+
.filter((candidate) => parseSlug(candidate.slug).ok)
|
|
199
|
+
.map((candidate) => toReadInput({ entryPath: candidate.entryPath, slug: candidate.slug, branch, resolveReferences },
|
|
200
|
+
// A read BY PUBLISHED URL accepts only what listEntries publishes.
|
|
201
|
+
// See ReadContentInput.urlAddressableOnly for the two rules, and
|
|
202
|
+
// why they live in the reader (which holds the branch-correct
|
|
203
|
+
// schema) rather than the candidate builder (pure and schema-free).
|
|
204
|
+
{ urlAddressableOnly: true }));
|
|
209
205
|
}
|
|
210
|
-
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (isMiss(err))
|
|
208
|
+
return null;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
// One branch and schema resolve for every candidate.
|
|
212
|
+
return baseReader.readFirst(inputs, isMiss);
|
|
211
213
|
};
|
|
212
214
|
/**
|
|
213
215
|
* What a batch read (listEntries / buildContentTree) lists from: the branch's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
3
|
-
import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
3
|
+
import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
4
4
|
import type { CommentThread } from '../comment-store.js';
|
|
5
5
|
import type { UserSearchResult } from '../auth/types.js';
|
|
6
6
|
import type { CreateRefusal } from './hooks/create-branch-request.js';
|
|
@@ -32,17 +32,21 @@ export interface BranchSummary {
|
|
|
32
32
|
conflictStatus?: ConflictStatus;
|
|
33
33
|
/** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
|
|
34
34
|
conflictFiles?: string[];
|
|
35
|
+
/** What is stuck and needs a person (api/branch.ts). */
|
|
36
|
+
hold?: BranchHold;
|
|
35
37
|
commentCount?: number;
|
|
36
38
|
isProtected?: boolean;
|
|
37
39
|
readOnly?: boolean;
|
|
38
40
|
/**
|
|
39
|
-
* Server-computed compound submit rule (base-branch
|
|
40
|
-
* status) -- see `useBranchManager.tsx`'s
|
|
41
|
-
* `BranchWriteProtection.submitBlockedIncludingStatus` for the full
|
|
41
|
+
* Server-computed compound submit rule (base-branch, non-'editing'
|
|
42
|
+
* status, or rewritten on GitHub) -- see `useBranchManager.tsx`'s
|
|
43
|
+
* `BranchSummary.submitBlocked` and `BranchWriteProtection.submitBlockedIncludingStatus` for the full
|
|
42
44
|
* rationale. `getBranchPermissions`'s `canSubmit` consumes this directly
|
|
43
45
|
* instead of re-deriving `status === 'editing' && !isProtected`.
|
|
44
46
|
*/
|
|
45
47
|
submitBlocked?: boolean;
|
|
48
|
+
/** Server-computed: the API refuses Approve, as the last submit didn't finish. */
|
|
49
|
+
approveBlocked?: boolean;
|
|
46
50
|
}
|
|
47
51
|
export interface UserContext {
|
|
48
52
|
userId: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Anchor, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
5
5
|
import { IconDots, IconExternalLink } from '@tabler/icons-react';
|
|
6
6
|
import { BranchComments } from './comments/BranchComments.js';
|
|
7
7
|
import { UserBadge } from './components/UserBadge.js';
|
|
@@ -13,9 +13,10 @@ import { parseCreatedBranchName } from '../paths/validation.js';
|
|
|
13
13
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
14
14
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
15
15
|
import { RelativeTime } from './components/RelativeTime.js';
|
|
16
|
-
import { branchStatusPresentation } from './branch-status.js';
|
|
16
|
+
import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence, syncFailedFallback, branchStatusPresentation, } from './branch-status.js';
|
|
17
|
+
import { TooltipDetail } from './components/TooltipDetail.js';
|
|
17
18
|
import { branchAddress, displayBranchName } from './branch-names.js';
|
|
18
|
-
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
|
|
19
|
+
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
|
|
19
20
|
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
20
21
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
21
22
|
/**
|
|
@@ -51,8 +52,8 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
51
52
|
(userIsCreator || userInACL || isSystemBranch || userIsAdmin || userIsReviewer);
|
|
52
53
|
// Submit: can perform workflow actions AND the server says submit isn't
|
|
53
54
|
// blocked. `submitBlocked` is the server's own compound answer -- status
|
|
54
|
-
// past 'editing'
|
|
55
|
-
// `
|
|
55
|
+
// past 'editing', the protected base branch, or a rewrite on GitHub --
|
|
56
|
+
// computed once in `toBranchListItem` (api/branch.ts) and threaded through
|
|
56
57
|
// unchanged.
|
|
57
58
|
//
|
|
58
59
|
// `?? true`, not a bare `!branch.submitBlocked`: the field is optional (like
|
|
@@ -80,10 +81,11 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
80
81
|
// Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
|
|
81
82
|
// would only do what Withdraw does. Neither applies to the protected base branch, whose way
|
|
82
83
|
// out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
|
|
83
|
-
// a draft); Approve also needs the branch submitted, Request changes
|
|
84
|
+
// a draft); Approve also needs the branch submitted and its last submit finished, Request changes
|
|
85
|
+
// accepts approved too.
|
|
84
86
|
const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
|
|
85
87
|
const prLive = branch.pullRequestState !== 'closed';
|
|
86
|
-
const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
|
|
88
|
+
const canApprove = reviewsOthers && branch.status === 'submitted' && prLive && !branch.approveBlocked;
|
|
87
89
|
const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
|
|
88
90
|
return {
|
|
89
91
|
hasWorkflowAccess: canPerformWorkflowActions,
|
|
@@ -102,7 +104,7 @@ const noUserLookup = async () => null;
|
|
|
102
104
|
* author's way back. A user without workflow access is offered neither, since it
|
|
103
105
|
* would never apply to them; a reason is given only for a block that depends on the branch's state.
|
|
104
106
|
*/
|
|
105
|
-
const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
107
|
+
const workflowAction = (b, perms, onSubmit, onWithdraw, userIsAdmin) => {
|
|
106
108
|
if (!perms.hasWorkflowAccess)
|
|
107
109
|
return undefined;
|
|
108
110
|
if (b.status === 'submitted' || b.status === 'approved') {
|
|
@@ -119,7 +121,11 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
|
119
121
|
color: 'brand',
|
|
120
122
|
testId: `submit-branch-button-${b.name}`,
|
|
121
123
|
run: () => onSubmit?.(b.name),
|
|
122
|
-
blockedReason: perms.canSubmit
|
|
124
|
+
blockedReason: perms.canSubmit
|
|
125
|
+
? undefined
|
|
126
|
+
: b.hold?.kind === 'rewritten'
|
|
127
|
+
? rewrittenSubmitReason(userIsAdmin)
|
|
128
|
+
: "This branch can't be submitted right now.",
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
return undefined;
|
|
@@ -133,6 +139,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
|
|
|
133
139
|
isProtected: b.isProtected,
|
|
134
140
|
pullRequestState: b.pullRequestState,
|
|
135
141
|
}, user);
|
|
142
|
+
/** Lets the card's details shrink beside its actions, which a long unbroken name would push out. */
|
|
143
|
+
const CARD_DETAILS = { minWidth: 0, flex: 1 };
|
|
144
|
+
const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
|
|
136
145
|
/**
|
|
137
146
|
* One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
|
|
138
147
|
* shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
|
|
@@ -143,9 +152,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
|
|
|
143
152
|
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
144
153
|
const statusBadge = branchStatusPresentation(b.status, b.isProtected);
|
|
145
154
|
const perms = getBranchPermissions(b, user);
|
|
146
|
-
const action = workflowAction(b, perms, onSubmit, onWithdraw);
|
|
147
|
-
const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
|
|
148
155
|
const userIsAdmin = isAdmin(user?.groups);
|
|
156
|
+
const action = workflowAction(b, perms, onSubmit, onWithdraw, userIsAdmin);
|
|
157
|
+
const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
|
|
149
158
|
// The open branch holds back its review actions as it does its workflow action; the reason is
|
|
150
159
|
// given only for the open branch.
|
|
151
160
|
const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
|
|
@@ -157,7 +166,11 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
157
166
|
color: 'brand',
|
|
158
167
|
testId: `approve-branch-button-${b.name}`,
|
|
159
168
|
run: () => onApprove?.(b.name),
|
|
160
|
-
blockedReason: perms.canApprove
|
|
169
|
+
blockedReason: perms.canApprove
|
|
170
|
+
? undefined
|
|
171
|
+
: b.pullRequestState === 'closed'
|
|
172
|
+
? CLOSED_PULL_REQUEST_REASON
|
|
173
|
+
: UNFINISHED_SUBMIT_APPROVE_REASON,
|
|
161
174
|
}
|
|
162
175
|
: undefined);
|
|
163
176
|
const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
|
|
@@ -178,24 +191,36 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
178
191
|
// alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
|
|
179
192
|
const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
|
|
180
193
|
const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
|
|
181
|
-
// Two visible actions,
|
|
182
|
-
// width, so they take a row below
|
|
183
|
-
|
|
194
|
+
// Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
|
|
195
|
+
// crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
|
|
196
|
+
// the title.
|
|
197
|
+
const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
|
|
184
198
|
const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
|
|
185
199
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
186
200
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
187
201
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
188
202
|
const menuLabel = `More actions for ${displayBranchName(b)}`;
|
|
189
203
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
190
|
-
|
|
204
|
+
const inReview = b.status === 'submitted' || b.status === 'approved';
|
|
205
|
+
const holdViewer = {
|
|
206
|
+
role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
|
|
207
|
+
isAdmin: userIsAdmin,
|
|
208
|
+
};
|
|
209
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
|
|
191
210
|
? 'red'
|
|
192
211
|
: b.pullRequestState === 'merged'
|
|
193
212
|
? 'violet'
|
|
194
|
-
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(
|
|
213
|
+
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
214
|
+
(b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
|
|
215
|
+
inReview &&
|
|
216
|
+
holdViewer.role === 'withdraw' &&
|
|
217
|
+
' Withdraw the branch from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
|
|
195
218
|
? `View pull request #${b.pullRequestNumber}`
|
|
196
219
|
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
197
220
|
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
198
|
-
:
|
|
221
|
+
: b.hold?.kind === 'unpublished-at-merge' && !userIsAdmin
|
|
222
|
+
? 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.'
|
|
223
|
+
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
199
224
|
};
|
|
200
225
|
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
201
226
|
if (!action.blockedReason)
|
package/dist/editor/Editor.js
CHANGED
|
@@ -1578,6 +1578,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1578
1578
|
branchMissing,
|
|
1579
1579
|
reviewing: branchReviewing,
|
|
1580
1580
|
pullRequestClosed: branchPullRequestClosed,
|
|
1581
|
+
approveBlocked: currentBranch?.approveBlocked,
|
|
1581
1582
|
}), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
|
|
1582
1583
|
? {
|
|
1583
1584
|
base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
|
|
@@ -1601,7 +1602,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1601
1602
|
// branchWriteBlocked reuses branchContentLocked itself rather than
|
|
1602
1603
|
// re-deriving `?? true` a second time here, so there is exactly one
|
|
1603
1604
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1604
|
-
branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
|
|
1605
|
+
branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
|
|
1605
1606
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1606
1607
|
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
|
|
1607
1608
|
// On any entry: EntryHeader's `saving` is the open entry's alone.
|
|
@@ -1634,7 +1635,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1634
1635
|
// True exactly while the form below shows "Setting up…" or "Loading content…".
|
|
1635
1636
|
entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
|
|
1636
1637
|
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1637
|
-
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
|
|
1638
|
+
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
|
|
1638
1639
|
? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
|
|
1639
1640
|
: undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1640
1641
|
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|