canopycms 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. 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
+ };
@@ -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(branchName) });
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: branchName,
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({ branch: { name: branchContext.branch.name, status: 'editing' } }, (existing) => {
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({ branch: { name: branchContext.branch.name, status: 'approved' } }, (existing) => {
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 "${branchContext.branch.name}" right now: it is being synced with its base branch, or a save is in flight. Nothing was submitted; try again in a moment.`
114
- : `"${branchContext.branch.name}" was being synced while it was submitted, so the submit may not have completed. Try submitting again.`,
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 "${branchContext.branch.name}": it has diverged from the copy in ` +
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,
@@ -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. */