canopycms 0.7.0 → 0.8.0
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 +201 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.js +23 -16
- package/dist/context.js +8 -7
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +47 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +40 -25
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryParts, } from '../content-listing.js';
|
|
5
|
+
import { ContentStore, ContentStoreError } from '../content-store.js';
|
|
6
|
+
import { resolveCollectionPath } from '../content-id-index.js';
|
|
7
|
+
import { BaseBranchMissingError } from '../git-manager.js';
|
|
8
|
+
import { withItemKeys } from '../item-keys.js';
|
|
9
|
+
import { getBranchProtection } from '../authorization/protected-branch.js';
|
|
10
|
+
import { baseBranchOf } from '../utils/base-branch.js';
|
|
11
|
+
import { findBodyFieldName } from '../utils/body-field.js';
|
|
12
|
+
import { isNodeError, isNotFoundError } from '../utils/error.js';
|
|
13
|
+
import { getFormatExtension } from '../utils/format.js';
|
|
14
|
+
import { entryLogicalPath } from '../paths/index.js';
|
|
15
|
+
import { defineEndpoint } from './route-builder.js';
|
|
16
|
+
import { branchNameSchema, contentIdSchema, logicalPathSchema } from './validators.js';
|
|
17
|
+
import { branchReadFailedResponse, readWhileSteady, unsteadyReadResponse, } from './branch-changes.js';
|
|
18
|
+
/** The most of a side's file read, so one huge file can't hold the API or the browser. */
|
|
19
|
+
const MAX_SIDE_BYTES = 1024 * 1024;
|
|
20
|
+
const NOT_COMPARABLE_ERROR = "This entry can't be compared.";
|
|
21
|
+
const UNREADABLE_ERROR = "This entry couldn't be read on one side, so it can't be compared.";
|
|
22
|
+
const TOO_LARGE_ERROR = 'This entry is too large to compare here.';
|
|
23
|
+
const FORMATS = ['mdx', 'md', 'json', 'yaml'];
|
|
24
|
+
const entryChangeParamsSchema = z.object({
|
|
25
|
+
branch: branchNameSchema,
|
|
26
|
+
entryPath: logicalPathSchema,
|
|
27
|
+
/** A moved entry's path at the base. */
|
|
28
|
+
from: logicalPathSchema.optional(),
|
|
29
|
+
/** The entry's content id: both sides must carry it, so a slug reused by another entry isn't it. */
|
|
30
|
+
id: contentIdSchema.optional(),
|
|
31
|
+
});
|
|
32
|
+
/** `logicalPath` under `contentRoot`, as content reads take it with or without the root. */
|
|
33
|
+
const underContentRoot = (logicalPath, contentRoot) => logicalPath === contentRoot || logicalPath.startsWith(`${contentRoot}/`)
|
|
34
|
+
? logicalPath
|
|
35
|
+
: `${contentRoot}/${logicalPath}`;
|
|
36
|
+
/**
|
|
37
|
+
* The path as the store resolves it (the slug lowercased, separators collapsed), so a path rule
|
|
38
|
+
* judges the entry that is read, as api/content.ts judges it. A collection the schema lacks has
|
|
39
|
+
* nothing to read in the working tree; its path is the requested segments rejoined, and only a
|
|
40
|
+
* base file at exactly that path matches it.
|
|
41
|
+
*/
|
|
42
|
+
const canonicalPath = (store, requested) => {
|
|
43
|
+
const segments = requested.split('/').filter(Boolean);
|
|
44
|
+
try {
|
|
45
|
+
const { schemaItem, slug } = store.resolvePath(segments);
|
|
46
|
+
return {
|
|
47
|
+
logicalPath: entryLogicalPath(schemaItem.logicalPath, slug),
|
|
48
|
+
stored: { collectionPath: schemaItem.logicalPath, slug },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err instanceof ContentStoreError)
|
|
53
|
+
return { logicalPath: segments.join('/') };
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** The entry type `entryType` names in `collectionPath`, when the schema still has both. */
|
|
58
|
+
const entryTypeIn = (flatSchema, collectionPath, entryType) => flatSchema
|
|
59
|
+
.find((item) => item.type === 'collection' && item.logicalPath === collectionPath)
|
|
60
|
+
?.entries?.find((e) => e.name === entryType);
|
|
61
|
+
/**
|
|
62
|
+
* The entry at `target` in the working tree, read as the editor's content read does. With `id`,
|
|
63
|
+
* a file at that path holding another id is not this entry.
|
|
64
|
+
*/
|
|
65
|
+
const readAfter = async (store, flatSchema, target, id) => {
|
|
66
|
+
if (!target.stored)
|
|
67
|
+
return { side: undefined };
|
|
68
|
+
const { collectionPath, slug } = target.stored;
|
|
69
|
+
let located;
|
|
70
|
+
try {
|
|
71
|
+
located = await store.resolveDocumentPath(collectionPath, slug);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof ContentStoreError)
|
|
75
|
+
return { side: undefined };
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
if (!located.existed || (id !== undefined && located.id !== id))
|
|
79
|
+
return { side: undefined };
|
|
80
|
+
const fields = entryTypeIn(flatSchema, collectionPath, located.entryTypeName)?.schema;
|
|
81
|
+
const found = { contentId: located.id, fields };
|
|
82
|
+
try {
|
|
83
|
+
const stat = await fs.stat(located.absolutePath);
|
|
84
|
+
if (stat.size > MAX_SIDE_BYTES)
|
|
85
|
+
return { ...found, side: 'too-large' };
|
|
86
|
+
const doc = await store.read(collectionPath, slug, { resolveReferences: false });
|
|
87
|
+
// The read resolves the slug again; another file there now is another entry than the one
|
|
88
|
+
// whose id and size were checked.
|
|
89
|
+
if (doc.absolutePath !== located.absolutePath)
|
|
90
|
+
return { side: 'moved' };
|
|
91
|
+
return {
|
|
92
|
+
...found,
|
|
93
|
+
side: {
|
|
94
|
+
format: doc.format,
|
|
95
|
+
entryType: located.entryTypeName,
|
|
96
|
+
data: doc.data,
|
|
97
|
+
...('body' in doc && { body: doc.body, bodyFieldName: doc.bodyFieldName }),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (isNotFoundError(err))
|
|
103
|
+
return { side: undefined };
|
|
104
|
+
// A filesystem failure (a `syscall` error) is the server's; anything else is this file not
|
|
105
|
+
// parsing, or its entry type's schema failing to load (SchemaUnavailableError). A parser's
|
|
106
|
+
// errors carry a `code` too, so `code` alone doesn't tell them apart.
|
|
107
|
+
if (isNodeError(err) && err.syscall !== undefined)
|
|
108
|
+
throw err;
|
|
109
|
+
return { ...found, side: 'unreadable' };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when there is one.
|
|
114
|
+
* Looked for under the collection's directory first, then the whole content root when that
|
|
115
|
+
* directory isn't in the base tree (a collection renamed or deleted on the branch). Files map
|
|
116
|
+
* to entries as GET /:branch/changes maps them, so the entry it lists resolves to the same
|
|
117
|
+
* file here. A base file whose collection or entry type the schema no longer has is keyed with
|
|
118
|
+
* `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
|
|
119
|
+
*/
|
|
120
|
+
const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
|
|
121
|
+
const { flatSchema, branchRoot } = branchContext;
|
|
122
|
+
const collectionPath = logicalPath.slice(0, logicalPath.lastIndexOf('/'));
|
|
123
|
+
const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
|
|
124
|
+
const dirs = [
|
|
125
|
+
...(collectionDir ? [path.relative(branchRoot, collectionDir).split(path.sep).join('/')] : []),
|
|
126
|
+
contentRoot,
|
|
127
|
+
];
|
|
128
|
+
for (const dir of dirs) {
|
|
129
|
+
const files = await git.listFilesWithSizeAt(mergeBase, dir);
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const placed = entryRefFromRepoPath(file.path, flatSchema, contentRoot);
|
|
132
|
+
const ref = placed.kind === 'other' ? entryRefFromRepoPathShape(file.path, contentRoot) : placed;
|
|
133
|
+
if (ref?.kind !== 'entry' || ref.logicalPath !== logicalPath)
|
|
134
|
+
continue;
|
|
135
|
+
if (match.contentId !== undefined && ref.contentId !== match.contentId)
|
|
136
|
+
continue;
|
|
137
|
+
if (file.size > MAX_SIDE_BYTES)
|
|
138
|
+
return 'too-large';
|
|
139
|
+
const entryType = entryTypeIn(flatSchema, ref.collectionPath, ref.entryType);
|
|
140
|
+
const fields = entryType?.schema ?? match.afterFields ?? [];
|
|
141
|
+
// An entry type the schema no longer has is parsed by its extension.
|
|
142
|
+
const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
|
|
143
|
+
if (!format)
|
|
144
|
+
return 'unreadable';
|
|
145
|
+
const raw = await git.showFile(mergeBase, file.path);
|
|
146
|
+
if (raw === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = parseEntryParts(raw, format);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return 'unreadable';
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
format,
|
|
157
|
+
entryType: ref.entryType,
|
|
158
|
+
// Keyed as ContentStore.read keys the working tree's side, so an item unchanged since the
|
|
159
|
+
// base carries the same key on both.
|
|
160
|
+
data: withItemKeys(fields, parsed.data, 'content'),
|
|
161
|
+
...(parsed.body !== undefined && {
|
|
162
|
+
body: parsed.body,
|
|
163
|
+
bodyFieldName: findBodyFieldName(fields),
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// The collection's directory is in the base tree, so an entry not in it is new.
|
|
168
|
+
if (files.length > 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
};
|
|
173
|
+
const entryChangeHandler = async (gc, ctx, req, params) => {
|
|
174
|
+
const { branchContext } = gc;
|
|
175
|
+
const { config } = ctx.services;
|
|
176
|
+
const base = branchContext.branch.baseBranch ?? baseBranchOf(config);
|
|
177
|
+
const contentRoot = config.contentRoot || 'content';
|
|
178
|
+
const notComparable = {
|
|
179
|
+
ok: false,
|
|
180
|
+
status: 404,
|
|
181
|
+
error: NOT_COMPARABLE_ERROR,
|
|
182
|
+
};
|
|
183
|
+
if (getBranchProtection(config, branchContext.branch.name, branchContext.branch.baseBranch)
|
|
184
|
+
.isProtected) {
|
|
185
|
+
return notComparable;
|
|
186
|
+
}
|
|
187
|
+
const store = new ContentStore(branchContext.branchRoot, branchContext.flatSchema, {
|
|
188
|
+
contentRootName: contentRoot,
|
|
189
|
+
});
|
|
190
|
+
const target = canonicalPath(store, underContentRoot(params.entryPath, contentRoot));
|
|
191
|
+
const from = params.from === undefined
|
|
192
|
+
? undefined
|
|
193
|
+
: canonicalPath(store, underContentRoot(params.from, contentRoot));
|
|
194
|
+
// A path this user can't read answers as one with nothing to compare, so the answer never
|
|
195
|
+
// says the entry exists.
|
|
196
|
+
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
197
|
+
if (!checkAccess(target.logicalPath, 'read').allowed ||
|
|
198
|
+
(from !== undefined && !checkAccess(from.logicalPath, 'read').allowed)) {
|
|
199
|
+
return notComparable;
|
|
200
|
+
}
|
|
201
|
+
const git = ctx.services.createGitManagerFor(branchContext.branchRoot, {
|
|
202
|
+
baseBranch: branchContext.branch.baseBranch,
|
|
203
|
+
});
|
|
204
|
+
let read;
|
|
205
|
+
try {
|
|
206
|
+
read = await readWhileSteady(branchContext.branchRoot, git, async () => {
|
|
207
|
+
const mergeBase = await git.mergeBaseWithBase();
|
|
208
|
+
if (!mergeBase)
|
|
209
|
+
return null;
|
|
210
|
+
const after = await readAfter(store, branchContext.flatSchema, target, params.id);
|
|
211
|
+
const before = await readBefore(git, mergeBase, (from ?? target).logicalPath, { contentId: params.id ?? after.contentId, afterFields: after.fields }, branchContext, contentRoot);
|
|
212
|
+
return { mergeBase, before, after: after.side };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
if (err instanceof BaseBranchMissingError)
|
|
217
|
+
return notComparable;
|
|
218
|
+
return branchReadFailedResponse(branchContext.branch.name, base, err);
|
|
219
|
+
}
|
|
220
|
+
if (!read.steady)
|
|
221
|
+
return unsteadyReadResponse(read.reason, base);
|
|
222
|
+
if (!read.value)
|
|
223
|
+
return notComparable;
|
|
224
|
+
const { mergeBase, before, after } = read.value;
|
|
225
|
+
if (before === 'moved' || after === 'moved')
|
|
226
|
+
return unsteadyReadResponse('moved', base);
|
|
227
|
+
if (before === 'too-large' || after === 'too-large') {
|
|
228
|
+
return { ok: false, status: 413, error: TOO_LARGE_ERROR };
|
|
229
|
+
}
|
|
230
|
+
if (before === 'unreadable' || after === 'unreadable') {
|
|
231
|
+
return { ok: false, status: 422, error: UNREADABLE_ERROR };
|
|
232
|
+
}
|
|
233
|
+
if (!before && !after)
|
|
234
|
+
return notComparable;
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
status: 200,
|
|
238
|
+
data: { before: before ?? null, after: after ?? null, mergeBaseSha: mergeBase },
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* One entry's saved state on the branch against the commit the branch forked from, for the
|
|
243
|
+
* editor's "Show changes". A moved entry takes `from`, its path at the base; `id` pins both
|
|
244
|
+
* sides to one content id.
|
|
245
|
+
* GET /:branch/changes/...entryPath
|
|
246
|
+
* @internal Exported for tests.
|
|
247
|
+
*/
|
|
248
|
+
export const readBranchEntryChange = defineEndpoint({
|
|
249
|
+
namespace: 'changes',
|
|
250
|
+
name: 'entry',
|
|
251
|
+
method: 'GET',
|
|
252
|
+
path: '/:branch/changes/...entryPath',
|
|
253
|
+
params: entryChangeParamsSchema,
|
|
254
|
+
responseType: 'BranchEntryChangeResponse',
|
|
255
|
+
response: {},
|
|
256
|
+
defaultMockData: { before: null, after: null, mergeBaseSha: '' },
|
|
257
|
+
guards: ['branchAccessWithSchema'],
|
|
258
|
+
handler: entryChangeHandler,
|
|
259
|
+
});
|
|
260
|
+
export const BRANCH_ENTRY_CHANGE_ROUTES = {
|
|
261
|
+
entry: readBranchEntryChange,
|
|
262
|
+
};
|
package/dist/api/branch-merge.js
CHANGED
|
@@ -45,7 +45,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
45
45
|
}
|
|
46
46
|
// Update branch status to 'archived'
|
|
47
47
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
48
|
-
await meta.save({ branch: buildMergedBranchUpdate(
|
|
48
|
+
await meta.save({ branch: buildMergedBranchUpdate() });
|
|
49
49
|
// Optionally delete remote branch (disabled by default for safety)
|
|
50
50
|
// if (ctx.services.githubService && req.body?.deleteRemoteBranch) {
|
|
51
51
|
// try {
|
|
@@ -61,7 +61,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
|
|
|
61
61
|
status: 200,
|
|
62
62
|
data: {
|
|
63
63
|
branch: {
|
|
64
|
-
name:
|
|
64
|
+
name: branchContext.branch.name,
|
|
65
65
|
status: 'archived',
|
|
66
66
|
},
|
|
67
67
|
},
|
|
@@ -9,6 +9,7 @@ import { CommentStore } from '../comment-store.js';
|
|
|
9
9
|
import { getErrorMessage } from '../utils/error.js';
|
|
10
10
|
import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
|
|
11
11
|
import { getBranchProtection } from '../authorization/index.js';
|
|
12
|
+
import { sanitizeDisplayName } from '../submission-attribution.js';
|
|
12
13
|
/** Optional on the wire: a bodiless request means no note. */
|
|
13
14
|
const requestChangesBodySchema = z
|
|
14
15
|
.object({ note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional() })
|
|
@@ -36,6 +37,18 @@ const approveConflict = (status) => ({
|
|
|
36
37
|
? 'This branch is already approved.'
|
|
37
38
|
: staleStatusError(status, "it can't be approved"),
|
|
38
39
|
});
|
|
40
|
+
/**
|
|
41
|
+
* The verdict `user` gives. The reviewer guard admits only authenticated users, and the record is
|
|
42
|
+
* always written, so a new verdict replaces whatever the last one left.
|
|
43
|
+
*/
|
|
44
|
+
const reviewBy = (user, state) => ({
|
|
45
|
+
state,
|
|
46
|
+
by: {
|
|
47
|
+
userId: user.userId,
|
|
48
|
+
name: user.type === 'authenticated' ? sanitizeDisplayName(user.name) : undefined,
|
|
49
|
+
},
|
|
50
|
+
at: new Date().toISOString(),
|
|
51
|
+
});
|
|
39
52
|
const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
40
53
|
const { branchContext } = gc;
|
|
41
54
|
if (!canRequestChangesFrom(branchContext.branch.status)) {
|
|
@@ -48,7 +61,13 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
|
48
61
|
// conversion above is left as it is: withdraw converts the pull request to a draft too, and a
|
|
49
62
|
// merged one cannot be converted.
|
|
50
63
|
let found;
|
|
51
|
-
const updated = await meta.saveIf({
|
|
64
|
+
const updated = await meta.saveIf({
|
|
65
|
+
branch: {
|
|
66
|
+
name: branchContext.branch.name,
|
|
67
|
+
status: 'editing',
|
|
68
|
+
review: reviewBy(req.user, 'changes-requested'),
|
|
69
|
+
},
|
|
70
|
+
}, (existing) => {
|
|
52
71
|
found = existing?.branch.status;
|
|
53
72
|
return canRequestChangesFrom(found);
|
|
54
73
|
});
|
|
@@ -70,9 +89,12 @@ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
|
70
89
|
console.error(`CanopyCMS: Failed to save the request-changes note for ${branchContext.branch.name}:`, getErrorMessage(err));
|
|
71
90
|
warning = NOTE_NOT_SAVED_WARNING;
|
|
72
91
|
}
|
|
73
|
-
if (!warning)
|
|
74
|
-
await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
|
|
75
92
|
}
|
|
93
|
+
// After the save and the note, which the PR body renders from, so one update carries both; the
|
|
94
|
+
// draft conversion above never writes the body. The branch is the one the status write returned:
|
|
95
|
+
// the guard read it earlier, and on the queued path the worker can record the PR's number in
|
|
96
|
+
// between. Best-effort: syncPrComments only logs failures.
|
|
97
|
+
await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
|
|
76
98
|
return {
|
|
77
99
|
ok: true,
|
|
78
100
|
status: 200,
|
|
@@ -110,12 +132,21 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
110
132
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
111
133
|
// As for request changes, the status on disk decides, in the step that writes.
|
|
112
134
|
let found;
|
|
113
|
-
const updated = await meta.saveIf({
|
|
135
|
+
const updated = await meta.saveIf({
|
|
136
|
+
branch: {
|
|
137
|
+
name: branchContext.branch.name,
|
|
138
|
+
status: 'approved',
|
|
139
|
+
review: reviewBy(req.user, 'approved'),
|
|
140
|
+
},
|
|
141
|
+
}, (existing) => {
|
|
114
142
|
found = existing?.branch.status;
|
|
115
143
|
return found === 'submitted';
|
|
116
144
|
});
|
|
117
145
|
if (!updated)
|
|
118
146
|
return approveConflict(found);
|
|
147
|
+
// Against the branch the save returned, as for request changes. Best-effort: syncPrComments
|
|
148
|
+
// only logs failures.
|
|
149
|
+
await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
|
|
119
150
|
return {
|
|
120
151
|
ok: true,
|
|
121
152
|
status: 200,
|
|
@@ -14,6 +14,7 @@ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
|
14
14
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
15
15
|
import { NothingToSubmitError } from '../services.js';
|
|
16
16
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
17
|
+
import { shortNameOf } from '../branch-param.js';
|
|
17
18
|
import { resolveEditorUrl } from '../editor-url.js';
|
|
18
19
|
const getBranchStatusHandler = async (gc, ctx, req, _params) => {
|
|
19
20
|
const { branchContext } = gc;
|
|
@@ -103,6 +104,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
103
104
|
if (err instanceof NothingToSubmitError) {
|
|
104
105
|
return { ok: false, status: 400, error: err.message };
|
|
105
106
|
}
|
|
107
|
+
const shortName = shortNameOf(ctx.services.config, branchContext.branch.name);
|
|
106
108
|
// Retriable either way: a resubmit commits nothing new and pushes only
|
|
107
109
|
// what has not reached the remote.
|
|
108
110
|
if (err instanceof ContentWriteLockBusyError) {
|
|
@@ -110,8 +112,8 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
110
112
|
ok: false,
|
|
111
113
|
status: 409,
|
|
112
114
|
error: err.outcome === 'not-run'
|
|
113
|
-
? `Could not submit "${
|
|
114
|
-
: `"${
|
|
115
|
+
? `Could not submit "${shortName}" right now: it is being synced with its base branch, or a save is in flight. Nothing was submitted; try again in a moment.`
|
|
116
|
+
: `"${shortName}" was being synced while it was submitted, so the submit may not have completed. Try submitting again.`,
|
|
115
117
|
};
|
|
116
118
|
}
|
|
117
119
|
const message = getErrorMessage(err);
|
|
@@ -137,7 +139,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
137
139
|
return {
|
|
138
140
|
ok: false,
|
|
139
141
|
status: 409,
|
|
140
|
-
error: `Could not submit "${
|
|
142
|
+
error: `Could not submit "${shortName}": it has diverged from the copy in ` +
|
|
141
143
|
`this deployment's repository and needs to be reconciled before it can be submitted. ` +
|
|
142
144
|
`The background worker reconciles branches when the base branch moves, so this often ` +
|
|
143
145
|
`clears on its own within a few minutes -- try again shortly, and ask an administrator ` +
|
|
@@ -150,6 +152,13 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
150
152
|
error: `Failed to push branch changes: ${sanitizeErrorMessage(message)}`,
|
|
151
153
|
};
|
|
152
154
|
}
|
|
155
|
+
// The verdict covers one submit. It is cleared on disk before the PR is written, because a
|
|
156
|
+
// comment sync between that write and the status save below renders the PR body from disk and
|
|
157
|
+
// would otherwise put the last round's verdict back on the new body.
|
|
158
|
+
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
159
|
+
if (branchContext.branch.review) {
|
|
160
|
+
await meta.save({ branch: { name: branchContext.branch.name, review: undefined } });
|
|
161
|
+
}
|
|
153
162
|
// Create or update PR (sync via githubService, or async via task queue)
|
|
154
163
|
const submittedAt = new Date().toISOString();
|
|
155
164
|
const editorUrl = submitEditorUrl(ctx, req, body?.editorUrl);
|
|
@@ -161,7 +170,6 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
161
170
|
editorUrl,
|
|
162
171
|
submittedAt,
|
|
163
172
|
});
|
|
164
|
-
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
165
173
|
const pushed = prResult.pushedToGitHub ? { pushedToGitHubAt: submittedAt } : {};
|
|
166
174
|
// GitHub found nothing between the pushed branch and its base, though the diff above found
|
|
167
175
|
// changes: GitHub's base can already hold them, or the diff could not be computed. The branch
|
|
@@ -172,7 +180,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
172
180
|
return {
|
|
173
181
|
ok: false,
|
|
174
182
|
status: 400,
|
|
175
|
-
error: new NothingToSubmitError(branchContext.branch.name, base).message,
|
|
183
|
+
error: new NothingToSubmitError(shortNameOf(ctx.services.config, branchContext.branch.name), base).message,
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
const updated = await meta.save({
|
|
@@ -191,6 +199,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
|
191
199
|
// pre-submit rebase-failure record would otherwise stick as a stale
|
|
192
200
|
// warning through review and archive.
|
|
193
201
|
rebaseFailure: undefined,
|
|
202
|
+
review: undefined,
|
|
194
203
|
},
|
|
195
204
|
});
|
|
196
205
|
return {
|
|
@@ -4,6 +4,7 @@ import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
|
4
4
|
import { defineEndpoint } from './route-builder.js';
|
|
5
5
|
import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
|
|
6
6
|
import { syncConvertToDraft } from './github-sync.js';
|
|
7
|
+
import { syncPrComments } from './pr-comment-sync.js';
|
|
7
8
|
const withdrawBranchHandler = async (gc, ctx, req, _params) => {
|
|
8
9
|
const { branchContext } = gc;
|
|
9
10
|
// Check if user can perform workflow actions (creator OR ACL access). On the
|
|
@@ -53,11 +54,17 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
53
54
|
branch: {
|
|
54
55
|
name: branchContext.branch.name,
|
|
55
56
|
status: 'editing',
|
|
57
|
+
// A verdict covers one submit; the next review starts blank.
|
|
58
|
+
review: undefined,
|
|
56
59
|
...(wasClosed
|
|
57
60
|
? { pullRequestState: undefined, pullRequestNumber: undefined, pullRequestUrl: undefined }
|
|
58
61
|
: {}),
|
|
59
62
|
},
|
|
60
63
|
});
|
|
64
|
+
// The PR body still says "Approved" until it is re-rendered from the cleared review.
|
|
65
|
+
// Against the branch the save returned, so a PR number recorded since the guard read is seen.
|
|
66
|
+
// Best-effort: syncPrComments logs its failures and never throws.
|
|
67
|
+
await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
|
|
61
68
|
return {
|
|
62
69
|
ok: true,
|
|
63
70
|
status: 200,
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -15,7 +15,13 @@ import type { UserProfile } from '../auth/types.js';
|
|
|
15
15
|
* rather than silently behaving as if nothing changed. That is by design:
|
|
16
16
|
* wire compatibility here means "doesn't break", not "behaves the same".
|
|
17
17
|
*/
|
|
18
|
-
export interface BranchListItem extends BranchMetadata {
|
|
18
|
+
export interface BranchListItem extends Omit<BranchMetadata, 'review'> {
|
|
19
|
+
/**
|
|
20
|
+
* What pull request links carry for this branch, which `?branch=` and the `/:branch` segment
|
|
21
|
+
* accept beside `name` (branch-param.ts's `shortNameOf`): `name` less the namespace for a branch
|
|
22
|
+
* in it, else the full `name`.
|
|
23
|
+
*/
|
|
24
|
+
shortName?: string;
|
|
19
25
|
isProtected?: boolean;
|
|
20
26
|
readOnly?: boolean;
|
|
21
27
|
/**
|
|
@@ -152,6 +158,12 @@ export interface CreateBranchBody {
|
|
|
152
158
|
* dev-mode refreshActiveBranch() updates are reflected.
|
|
153
159
|
*/
|
|
154
160
|
export declare const toBranchListItem: (config: ApiContext["services"]["config"], branch: BranchMetadata, viewer: CanopyUser) => BranchListItem;
|
|
161
|
+
/**
|
|
162
|
+
* The API's static top-level route segments, which the router ranks above `/:branch`, so a branch
|
|
163
|
+
* whose short name is one would be shadowed. `http/router.test.ts` holds this list to the router.
|
|
164
|
+
* @internal Exported for tests.
|
|
165
|
+
*/
|
|
166
|
+
export declare const TOP_LEVEL_ROUTE_SEGMENTS: readonly string[];
|
|
155
167
|
/** @internal Exported for tests. */
|
|
156
168
|
export declare const createBranchHandler: (ctx: ApiContext, req: ApiRequest, body: z.infer<typeof createBranchBodySchema>) => Promise<BranchListItemResponse>;
|
|
157
169
|
/** @internal Exported for tests. */
|