canopycms 0.7.1 → 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 +187 -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 +42 -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { UserSearchResult } from './auth/types.js';
|
|
2
2
|
import type { CanopyUser } from './user.js';
|
|
3
3
|
import type { CommentThread } from './comment-store.js';
|
|
4
|
+
import type { BranchReview } from './types.js';
|
|
4
5
|
/**
|
|
5
6
|
* Who submitted (and edited) a branch, recorded in the submit commit's trailers
|
|
6
7
|
* and the pull request body. The bot stays the commit author; these records are
|
|
@@ -33,6 +34,14 @@ export declare const PR_SECTION_END = "<!-- canopycms:submission:end -->";
|
|
|
33
34
|
export declare const PR_COMMENTS_START = "<!-- canopycms:comments:start -->";
|
|
34
35
|
/** @internal Exported for tests. */
|
|
35
36
|
export declare const PR_COMMENTS_END = "<!-- canopycms:comments:end -->";
|
|
37
|
+
/**
|
|
38
|
+
* The reviewer's verdict, nested in the section like the comment block so a review action can
|
|
39
|
+
* replace it alone (mergeReviewBlock).
|
|
40
|
+
* @internal Exported for tests.
|
|
41
|
+
*/
|
|
42
|
+
export declare const PR_REVIEW_START = "<!-- canopycms:review:start -->";
|
|
43
|
+
/** @internal Exported for tests. */
|
|
44
|
+
export declare const PR_REVIEW_END = "<!-- canopycms:review:end -->";
|
|
36
45
|
/**
|
|
37
46
|
* A display name safe for a single line of git trailer or Markdown, or undefined.
|
|
38
47
|
* @internal Exported for tests.
|
|
@@ -87,6 +96,12 @@ export interface PrSectionInput {
|
|
|
87
96
|
/** The branch's editor comment threads (comments.json), or null when they could not be read. */
|
|
88
97
|
threads?: readonly CommentThread[] | null;
|
|
89
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The reviewer's verdict between its markers. The name is a cleaned code span, so it cannot
|
|
101
|
+
* mention, reference an issue, carry HTML or spell a marker; the id is never written, and a
|
|
102
|
+
* name that is only the id counts as none.
|
|
103
|
+
*/
|
|
104
|
+
export declare function buildReviewBlock(review: BranchReview | undefined): string;
|
|
90
105
|
/**
|
|
91
106
|
* The editor-comment summary between its markers: the unresolved threads, oldest first, or
|
|
92
107
|
* that every thread is resolved, or that there are none, or that they could not be read.
|
|
@@ -116,3 +131,10 @@ export declare function mergePrSection(existing: string | null | undefined, sect
|
|
|
116
131
|
* count, and the description in it cannot spell one (neutralizeComments).
|
|
117
132
|
*/
|
|
118
133
|
export declare function mergeCommentsBlock(existing: string | null | undefined, block: string): string | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* `existing` with the review block inside its canopycms section replaced by `block`, or
|
|
136
|
+
* undefined when the section or its block is missing (a PR body written before the block
|
|
137
|
+
* existed, or edited by hand): the next submit writes it. Markers are found as in
|
|
138
|
+
* mergeCommentsBlock.
|
|
139
|
+
*/
|
|
140
|
+
export declare function mergeReviewBlock(existing: string | null | undefined, block: string): string | undefined;
|
|
@@ -29,6 +29,14 @@ export const PR_SECTION_END = '<!-- canopycms:submission:end -->';
|
|
|
29
29
|
export const PR_COMMENTS_START = '<!-- canopycms:comments:start -->';
|
|
30
30
|
/** @internal Exported for tests. */
|
|
31
31
|
export const PR_COMMENTS_END = '<!-- canopycms:comments:end -->';
|
|
32
|
+
/**
|
|
33
|
+
* The reviewer's verdict, nested in the section like the comment block so a review action can
|
|
34
|
+
* replace it alone (mergeReviewBlock).
|
|
35
|
+
* @internal Exported for tests.
|
|
36
|
+
*/
|
|
37
|
+
export const PR_REVIEW_START = '<!-- canopycms:review:start -->';
|
|
38
|
+
/** @internal Exported for tests. */
|
|
39
|
+
export const PR_REVIEW_END = '<!-- canopycms:review:end -->';
|
|
32
40
|
// Cc (C0/C1 controls incl. newlines), Cf (zero-width and bidi overrides), Zl/Zp
|
|
33
41
|
// (Unicode line/paragraph separators) can forge a trailer line or hide text; Cs
|
|
34
42
|
// (a lone surrogate) reaches git as U+FFFD, since Node encodes it so.
|
|
@@ -222,6 +230,34 @@ function editorLabel(editor) {
|
|
|
222
230
|
function editorLinker(branch, editorUrl) {
|
|
223
231
|
return (entryPath) => branch && editorUrl ? markdownHref(editorDeepLink(editorUrl, branch, entryPath)) : undefined;
|
|
224
232
|
}
|
|
233
|
+
const REVIEW_NOT_A_GITHUB_REVIEW = 'Approval in CanopyCMS is not a GitHub review.';
|
|
234
|
+
/** The UTC minute as `date time UTC`, or undefined for a timestamp that is not a date. */
|
|
235
|
+
function reviewTime(at) {
|
|
236
|
+
const time = typeof at === 'string' ? new Date(at) : undefined;
|
|
237
|
+
return time && !Number.isNaN(time.getTime())
|
|
238
|
+
? `${time.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The reviewer's verdict between its markers. The name is a cleaned code span, so it cannot
|
|
243
|
+
* mention, reference an issue, carry HTML or spell a marker; the id is never written, and a
|
|
244
|
+
* name that is only the id counts as none.
|
|
245
|
+
*/
|
|
246
|
+
export function buildReviewBlock(review) {
|
|
247
|
+
const verdict = review?.state === 'approved'
|
|
248
|
+
? 'Approved'
|
|
249
|
+
: review?.state === 'changes-requested'
|
|
250
|
+
? 'Changes requested'
|
|
251
|
+
: undefined;
|
|
252
|
+
let status = 'Not yet reviewed in CanopyCMS.';
|
|
253
|
+
if (review && verdict) {
|
|
254
|
+
// Cleaned first, so a name that becomes the id once cleaned still counts as none.
|
|
255
|
+
const name = personName(review.by.userId, sanitizeDisplayName(review.by.name));
|
|
256
|
+
const when = reviewTime(review.at);
|
|
257
|
+
status = `${verdict} in CanopyCMS by ${name ? codeSpan(name) : 'a reviewer'}${when ? ` on ${when}` : ''}.`;
|
|
258
|
+
}
|
|
259
|
+
return `${PR_REVIEW_START}\n${status}\n${REVIEW_NOT_A_GITHUB_REVIEW}\n${PR_REVIEW_END}`;
|
|
260
|
+
}
|
|
225
261
|
const MAX_LISTED_THREADS = 50;
|
|
226
262
|
const MAX_EXCERPT_LENGTH = 80;
|
|
227
263
|
const MAX_COMMENTS_LENGTH = 15_000;
|
|
@@ -296,7 +332,7 @@ export function buildCommentsBlock(threads, branch, editorUrl) {
|
|
|
296
332
|
}
|
|
297
333
|
return `${PR_COMMENTS_START}\n${body}\n${PR_COMMENTS_END}`;
|
|
298
334
|
}
|
|
299
|
-
function renderPrSection(input, layout, commentsBlock) {
|
|
335
|
+
function renderPrSection(input, layout, commentsBlock, reviewBlock) {
|
|
300
336
|
const parts = [];
|
|
301
337
|
const [submitter] = input.submitter ? cleanEditors([input.submitter]) : [];
|
|
302
338
|
parts.push(submitter
|
|
@@ -306,6 +342,7 @@ function renderPrSection(input, layout, commentsBlock) {
|
|
|
306
342
|
if (others.length > 0) {
|
|
307
343
|
parts.push(`Also edited by: ${others.map(editorLabel).join(', ')}`);
|
|
308
344
|
}
|
|
345
|
+
parts.push(reviewBlock);
|
|
309
346
|
const link = editorLinker(input.branch, input.editorUrl);
|
|
310
347
|
const branchLink = link();
|
|
311
348
|
if (branchLink)
|
|
@@ -359,23 +396,25 @@ export function buildPrSection(input) {
|
|
|
359
396
|
const raw = input.description?.trim();
|
|
360
397
|
const description = raw ? neutralizeComments(truncate(raw, MAX_DESCRIPTION_LENGTH)) : '';
|
|
361
398
|
const block = buildCommentsBlock(input.threads === undefined ? [] : input.threads, input.branch, input.editorUrl);
|
|
362
|
-
|
|
399
|
+
// A submit clears the stored review, so a new section starts unreviewed.
|
|
400
|
+
const reviewBlock = buildReviewBlock(undefined);
|
|
401
|
+
const size = (l) => renderPrSection(input, l, block, reviewBlock).length - block.length;
|
|
363
402
|
const layout = { entryLinks: true, listed: MAX_LISTED_ENTRIES, description };
|
|
364
403
|
const fits = (l) => size(l) <= MAX_SECTION_LENGTH;
|
|
365
404
|
if (fits(layout))
|
|
366
|
-
return renderPrSection(input, layout, block);
|
|
405
|
+
return renderPrSection(input, layout, block, reviewBlock);
|
|
367
406
|
layout.entryLinks = false;
|
|
368
407
|
if (fits(layout))
|
|
369
|
-
return renderPrSection(input, layout, block);
|
|
408
|
+
return renderPrSection(input, layout, block, reviewBlock);
|
|
370
409
|
// The description's separator is two newlines.
|
|
371
410
|
const room = MAX_SECTION_LENGTH - size({ ...layout, description: '' }) - 2;
|
|
372
411
|
if (room > 0) {
|
|
373
|
-
return renderPrSection(input, { ...layout, description: truncateUnits(description, room) }, block);
|
|
412
|
+
return renderPrSection(input, { ...layout, description: truncateUnits(description, room) }, block, reviewBlock);
|
|
374
413
|
}
|
|
375
414
|
layout.description = '';
|
|
376
415
|
while (layout.listed > 0 && !fits(layout))
|
|
377
416
|
layout.listed = Math.max(0, layout.listed - 10);
|
|
378
|
-
return renderPrSection(input, layout, block);
|
|
417
|
+
return renderPrSection(input, layout, block, reviewBlock);
|
|
379
418
|
}
|
|
380
419
|
/**
|
|
381
420
|
* `existing` with its canopycms section replaced by `section`, keeping every
|
|
@@ -420,3 +459,20 @@ export function mergeCommentsBlock(existing, block) {
|
|
|
420
459
|
return undefined;
|
|
421
460
|
return body.slice(0, start) + block + body.slice(end + PR_COMMENTS_END.length);
|
|
422
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* `existing` with the review block inside its canopycms section replaced by `block`, or
|
|
464
|
+
* undefined when the section or its block is missing (a PR body written before the block
|
|
465
|
+
* existed, or edited by hand): the next submit writes it. Markers are found as in
|
|
466
|
+
* mergeCommentsBlock.
|
|
467
|
+
*/
|
|
468
|
+
export function mergeReviewBlock(existing, block) {
|
|
469
|
+
const body = existing ?? '';
|
|
470
|
+
const section = findPrSection(body);
|
|
471
|
+
if (!section)
|
|
472
|
+
return undefined;
|
|
473
|
+
const start = body.indexOf(PR_REVIEW_START, section.start);
|
|
474
|
+
const end = start < 0 ? -1 : body.indexOf(PR_REVIEW_END, start);
|
|
475
|
+
if (start < 0 || end < 0 || end > section.end)
|
|
476
|
+
return undefined;
|
|
477
|
+
return body.slice(0, start) + block + body.slice(end + PR_REVIEW_END.length);
|
|
478
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* worker-status.json: the CmsWorker daemon's self-reported liveness/health
|
|
3
3
|
* snapshot, written under the task queue directory
|
|
4
4
|
* (`{taskDir}/worker-status.json`) and read tolerantly by the admin API
|
|
5
|
-
* (
|
|
5
|
+
* (`readWorkerStatus`). Wire shape: `WorkerStatusReport` in
|
|
6
6
|
* ../types.
|
|
7
7
|
*
|
|
8
8
|
* Single-writer by intent (only the lock-holding CmsWorker -- see
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { WorkerStatusReport } from '../types.js';
|
|
23
23
|
export declare const WORKER_STATUS_FILE = "worker-status.json";
|
|
24
|
+
/** The status file as last written, null when there is none, and why it could not be read. */
|
|
25
|
+
export declare function readWorkerStatus(taskDir: string): Promise<{
|
|
26
|
+
workerStatus: WorkerStatusReport | null;
|
|
27
|
+
statusReadError?: string;
|
|
28
|
+
}>;
|
|
24
29
|
/**
|
|
25
30
|
* What a new worker carries from the previous status file into its first snapshot:
|
|
26
31
|
* `lastFatalError`, so a crash loop keeps its alert, and how the last worker that ran stopped.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* worker-status.json: the CmsWorker daemon's self-reported liveness/health
|
|
3
3
|
* snapshot, written under the task queue directory
|
|
4
4
|
* (`{taskDir}/worker-status.json`) and read tolerantly by the admin API
|
|
5
|
-
* (
|
|
5
|
+
* (`readWorkerStatus`). Wire shape: `WorkerStatusReport` in
|
|
6
6
|
* ../types.
|
|
7
7
|
*
|
|
8
8
|
* Single-writer by intent (only the lock-holding CmsWorker -- see
|
|
@@ -22,8 +22,29 @@
|
|
|
22
22
|
import fs from 'node:fs/promises';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
25
|
-
import { isNotFoundError } from '../utils/error.js';
|
|
25
|
+
import { getErrorMessage, isNotFoundError } from '../utils/error.js';
|
|
26
26
|
export const WORKER_STATUS_FILE = 'worker-status.json';
|
|
27
|
+
/** The status file as last written, null when there is none, and why it could not be read. */
|
|
28
|
+
export async function readWorkerStatus(taskDir) {
|
|
29
|
+
let content;
|
|
30
|
+
try {
|
|
31
|
+
content = await fs.readFile(path.join(taskDir, WORKER_STATUS_FILE), 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (isNotFoundError(err))
|
|
35
|
+
return { workerStatus: null };
|
|
36
|
+
return { workerStatus: null, statusReadError: getErrorMessage(err) };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
// Cast, not validated. The CMS Lambda can write this file too, since it mounts the same EFS,
|
|
40
|
+
// but every caller of this reader runs in the Lambda, so a forged file misleads only the
|
|
41
|
+
// Lambda itself and the System health it serves.
|
|
42
|
+
return { workerStatus: JSON.parse(content) };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { workerStatus: null, statusReadError: getErrorMessage(err) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
27
48
|
/**
|
|
28
49
|
* What a new worker carries from the previous status file into its first snapshot:
|
|
29
50
|
* `lastFatalError`, so a crash loop keeps its alert, and how the last worker that ran stopped.
|
package/dist/types.d.ts
CHANGED
|
@@ -20,6 +20,20 @@ export interface BranchAccessControl {
|
|
|
20
20
|
allowedGroups?: CanopyGroupId[];
|
|
21
21
|
managerOrAdminAllowed?: boolean;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* A reviewer's verdict on the branch as last submitted, shown in the pull request body
|
|
25
|
+
* (submission-attribution.ts). Set by approve and request-changes, cleared by submit and
|
|
26
|
+
* withdraw, and never sent to clients: `by` names the reviewer.
|
|
27
|
+
*/
|
|
28
|
+
export interface BranchReview {
|
|
29
|
+
state: 'approved' | 'changes-requested';
|
|
30
|
+
by: {
|
|
31
|
+
userId: CanopyUserId;
|
|
32
|
+
name?: string;
|
|
33
|
+
};
|
|
34
|
+
/** ISO timestamp of the verdict. */
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
23
37
|
export interface BranchMetadata {
|
|
24
38
|
name: string;
|
|
25
39
|
title?: string;
|
|
@@ -111,6 +125,8 @@ export interface BranchMetadata {
|
|
|
111
125
|
editors?: CanopyUserId[];
|
|
112
126
|
/** The `editors` who saved since the last submit commit; the next one's trailers name them. */
|
|
113
127
|
uncommittedEditors?: CanopyUserId[];
|
|
128
|
+
/** The reviewer's verdict on the last submit; absent until a reviewer acts. */
|
|
129
|
+
review?: BranchReview;
|
|
114
130
|
}
|
|
115
131
|
export interface BranchPaths {
|
|
116
132
|
/** Root where all branches live (e.g., /mnt/efs/site, ~/.canopycms/branches) */
|
|
@@ -175,6 +191,33 @@ export interface BaseSchemaHold {
|
|
|
175
191
|
/** Set on the cycle the hold outlived its bound and the worker advanced anyway. */
|
|
176
192
|
expired?: true;
|
|
177
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Why listing an unpublishable branch in `legacyBranches` would not publish it.
|
|
196
|
+
* `blocks-namespace`: it is named after part of the namespace, so while it exists neither
|
|
197
|
+
* remote.git nor GitHub holds a branch in the namespace. `unlistable`: `legacyBranches` refuses
|
|
198
|
+
* its name. The report lists branch workspaces only, read from their branch.json: a blocker that
|
|
199
|
+
* exists only on GitHub is never listed, even after `reconcileTrackedBranches` brings it into
|
|
200
|
+
* remote.git.
|
|
201
|
+
*/
|
|
202
|
+
export type UnpublishableBranchReason = 'blocks-namespace' | 'unlistable';
|
|
203
|
+
/** A branch the GitHub gateway will not publish, by ref. */
|
|
204
|
+
export interface UnpublishableBranch {
|
|
205
|
+
name: string;
|
|
206
|
+
/**
|
|
207
|
+
* Absent when neither applies: a `legacyBranches` entry would then publish it unless it is
|
|
208
|
+
* protected, by `protectedBranches`, as the base branch, or as GitHub's default branch.
|
|
209
|
+
*/
|
|
210
|
+
reason?: UnpublishableBranchReason;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Branches the GitHub gateway will not publish: at most 50 `branches`, of `count` in all, as of
|
|
214
|
+
* `at` (ISO).
|
|
215
|
+
*/
|
|
216
|
+
export interface UnpublishableBranchesReport {
|
|
217
|
+
branches: UnpublishableBranch[];
|
|
218
|
+
count: number;
|
|
219
|
+
at: string;
|
|
220
|
+
}
|
|
178
221
|
/**
|
|
179
222
|
* Wire shape of the worker's self-reported status file (worker-status.json, under
|
|
180
223
|
* the task queue dir), written by the CmsWorker daemon. Read-only here: GET
|
|
@@ -248,6 +291,22 @@ export interface WorkerStatusReport {
|
|
|
248
291
|
*/
|
|
249
292
|
workerStartedAt?: string;
|
|
250
293
|
};
|
|
294
|
+
/**
|
|
295
|
+
* The branch namespace the GitHub gateway writes, as of the last git sync that could ask it.
|
|
296
|
+
* System health compares it with the namespace the CMS creates branches in.
|
|
297
|
+
*/
|
|
298
|
+
gatewayBranchNamespace?: string;
|
|
299
|
+
/**
|
|
300
|
+
* The exact names the GitHub gateway protects, its base branch among them, as of the same sync.
|
|
301
|
+
* Branch creation refuses a short name equal to one.
|
|
302
|
+
*/
|
|
303
|
+
gatewayProtectedBranches?: string[];
|
|
304
|
+
/**
|
|
305
|
+
* Editor branches the GitHub gateway will not publish, as of the last git sync that could ask
|
|
306
|
+
* it; absent when there are none (worker/unpublishable-branches.ts). Carried into a restarted
|
|
307
|
+
* worker's first snapshot.
|
|
308
|
+
*/
|
|
309
|
+
unpublishableBranches?: UnpublishableBranchesReport;
|
|
251
310
|
/**
|
|
252
311
|
* Present while the schema gate holds the base branch, and on the cycle a hold expires. Carried
|
|
253
312
|
* into a restarted worker's first snapshot, so the bound survives restarts.
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.8.0";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.8.0';
|
|
@@ -16,7 +16,9 @@ export declare const MAX_PULL_REQUEST_TITLE_LENGTH = 1024;
|
|
|
16
16
|
*/
|
|
17
17
|
export declare const MAX_PULL_REQUEST_BODY_LENGTH = 65536;
|
|
18
18
|
export type GatewayOperation = 'push' | 'delete' | 'pull-request';
|
|
19
|
-
export type RefusalReason = 'invalid' | 'protected' | 'settings' | 'not-a-commit' | 'too-long';
|
|
19
|
+
export type RefusalReason = 'invalid' | 'protected' | 'settings' | 'other-settings' | 'outside-namespace' | 'not-a-commit' | 'too-long';
|
|
20
|
+
/** The refusals {@link BranchPolicy.assertPushAllowed} makes. */
|
|
21
|
+
export type PushPolicyRefusal = 'protected' | 'settings' | 'other-settings' | 'outside-namespace';
|
|
20
22
|
/**
|
|
21
23
|
* Something the gateway refuses to do for any caller. No CanopyCMS flow asks for it, so no retry
|
|
22
24
|
* can change the answer: the task runner fails such a task permanently, and a refusal never arms
|
|
@@ -28,6 +30,15 @@ export declare class GatewayRefusalError extends Error {
|
|
|
28
30
|
readonly reason: RefusalReason;
|
|
29
31
|
constructor(operation: GatewayOperation, branch: string, reason: RefusalReason, message: string);
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Why the policy refuses to push `branch`, for `reason`, in `namespace`.
|
|
35
|
+
* @internal Exported for tests.
|
|
36
|
+
*/
|
|
37
|
+
export declare function pushRefusalMessage(branch: string, reason: PushPolicyRefusal, namespace: string): string;
|
|
38
|
+
/** Why the gateway refuses to push `branch`, in the namespace, while GitHub has `blocker`. */
|
|
39
|
+
export declare function namespaceBlockedMessage(branch: string, blocker: string): string;
|
|
40
|
+
/** Why a push of `branch` is refused while GitHub has `inside`, a branch inside it. */
|
|
41
|
+
export declare function branchInsideMessage(branch: string, inside: string): string;
|
|
31
42
|
/**
|
|
32
43
|
* Why `branch` is not a name the gateway takes, before git sees it, or null. Printable ASCII only,
|
|
33
44
|
* so no two names differ invisibly; no `"`, `%`, `{` or `}`, which reach URLs and log lines; no
|
|
@@ -44,6 +55,15 @@ export declare function branchNameProblem(branch: string): string | null;
|
|
|
44
55
|
* @internal Exported for tests.
|
|
45
56
|
*/
|
|
46
57
|
export declare function protectedPatternProblem(pattern: string): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Why `entry` cannot be a `legacyBranches` entry, whatever the namespace, or null. An entry is an
|
|
60
|
+
* exact branch name git can push: a `*` would admit names no one listed, and one alone would
|
|
61
|
+
* switch the namespace off. A comma separates entries in `CANOPYCMS_LEGACY_BRANCHES`. The CDK's
|
|
62
|
+
* `assertValidLegacyBranches` holds a copy of these rules and the constructor's, both held to
|
|
63
|
+
* worker/legacy-branch-fixtures.ts.
|
|
64
|
+
* @internal Exported for tests.
|
|
65
|
+
*/
|
|
66
|
+
export declare function legacyBranchProblem(entry: string): string | null;
|
|
47
67
|
/**
|
|
48
68
|
* Whether `name` matches `pattern`, where `*` matches any run of characters. Never a regex.
|
|
49
69
|
* @internal Exported for tests.
|
|
@@ -54,18 +74,51 @@ export declare function matchesBranchPattern(pattern: string, name: string): boo
|
|
|
54
74
|
* branch (`describe()`), so its bound is one above this.
|
|
55
75
|
*/
|
|
56
76
|
export declare const MAX_PROTECTED_BRANCHES = 1024;
|
|
77
|
+
/** The most `legacyBranches` entries a policy takes. */
|
|
78
|
+
export declare const MAX_LEGACY_BRANCHES = 1024;
|
|
79
|
+
/**
|
|
80
|
+
* The namespace a policy enforces when it is given none: the default template's, for the default
|
|
81
|
+
* deployment name.
|
|
82
|
+
* @internal Exported for tests.
|
|
83
|
+
*/
|
|
84
|
+
export declare const DEFAULT_BRANCH_NAMESPACE: string;
|
|
57
85
|
export interface BranchPolicyOptions {
|
|
86
|
+
/**
|
|
87
|
+
* The namespace this gateway writes (`CANOPYCMS_BRANCH_NAMESPACE` resolved,
|
|
88
|
+
* operating-mode/branch-namespace.ts). Default {@link DEFAULT_BRANCH_NAMESPACE}.
|
|
89
|
+
*/
|
|
90
|
+
namespace?: string;
|
|
58
91
|
/** The configured base branch (`CANOPYCMS_BASE_BRANCH`); never one read from the workspace. */
|
|
59
92
|
baseBranch?: string;
|
|
60
93
|
/** Further protected names and `*` globs (`CANOPYCMS_PROTECTED_BRANCHES`). */
|
|
61
94
|
protectedBranches?: readonly string[];
|
|
95
|
+
/**
|
|
96
|
+
* This deployment's settings branch, the only one pushed. Without it no settings branch is.
|
|
97
|
+
*/
|
|
98
|
+
settingsBranch?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Branches outside the namespace that may still be written: exact names, from before the
|
|
101
|
+
* namespace (`CANOPYCMS_LEGACY_BRANCHES`).
|
|
102
|
+
*/
|
|
103
|
+
legacyBranches?: readonly string[];
|
|
62
104
|
/** Test seam: the clock {@link DEFAULT_BRANCH_TTL_MS} is measured on. */
|
|
63
105
|
now?: () => number;
|
|
64
106
|
}
|
|
107
|
+
/** What {@link BranchPolicy.describe} answers, which the gateway's health carries. */
|
|
108
|
+
export interface BranchPolicyDescription {
|
|
109
|
+
/** The protected set's configured names and globs, the base branch included. */
|
|
110
|
+
configured: string[];
|
|
111
|
+
/** GitHub's default branch as last read; null before a read or for a repository with none. */
|
|
112
|
+
githubDefault: string | null;
|
|
113
|
+
/** The namespace the gateway writes. */
|
|
114
|
+
namespace: string;
|
|
115
|
+
legacy: string[];
|
|
116
|
+
settingsBranch: string | null;
|
|
117
|
+
}
|
|
65
118
|
/** Reads GitHub's default branch: null for a repository with none. */
|
|
66
119
|
export type DefaultBranchReader = () => Promise<string | null>;
|
|
67
120
|
/**
|
|
68
|
-
* The protected set and the settings rules.
|
|
121
|
+
* The protected set, the namespace and the settings rules.
|
|
69
122
|
*
|
|
70
123
|
* A branch is protected when it is the configured base branch, GitHub's default branch or a
|
|
71
124
|
* `protectedBranches` entry, compared case-sensitively as GitHub compares refs. An exact entry
|
|
@@ -76,38 +129,74 @@ export type DefaultBranchReader = () => Promise<string | null>;
|
|
|
76
129
|
* Reads are not shared between concurrent callers, because a push reads inside a mirror session
|
|
77
130
|
* and an operation that joined it from outside one would wait on that session.
|
|
78
131
|
*
|
|
132
|
+
* Nothing outside the namespace is written: a push, deletion or pull request head must start with
|
|
133
|
+
* the namespace, be this deployment's settings branch, or be a `legacyBranches` entry, compared
|
|
134
|
+
* exactly. Sanitized equivalence, right for the protected set where matching more refuses more,
|
|
135
|
+
* would let an allow-list admit more. The protected set wins inside the namespace too. The
|
|
136
|
+
* namespace comes from the gateway's own configuration, never a request. The base and settings
|
|
137
|
+
* branches must lie outside it, by name and by workspace directory, and not be named after part of
|
|
138
|
+
* it, since git then holds no branch in it; a legacy entry inside it is refused as pointless, and
|
|
139
|
+
* one named after part of it as blocking it.
|
|
140
|
+
*
|
|
79
141
|
* Settings branches are pushed plainly or not at all: no lease, no deletion, no pull request.
|
|
80
|
-
* One is a name with the reserved prefix
|
|
81
|
-
*
|
|
82
|
-
* branch's.
|
|
142
|
+
* One is a name with the reserved prefix or this deployment's settings branch. Only this
|
|
143
|
+
* deployment's is pushed: another deployment sharing the repository owns its own.
|
|
83
144
|
*/
|
|
84
145
|
export declare class BranchPolicy {
|
|
85
146
|
private readonly exact;
|
|
86
147
|
private readonly globs;
|
|
148
|
+
/** The namespace the gateway writes. */
|
|
149
|
+
readonly namespace: string;
|
|
150
|
+
private readonly settingsBranch;
|
|
151
|
+
private readonly legacy;
|
|
87
152
|
private readonly now;
|
|
88
153
|
private githubDefault;
|
|
89
154
|
constructor(options?: BranchPolicyOptions);
|
|
90
|
-
/** The
|
|
91
|
-
describe():
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
155
|
+
/** The policy as it stands. */
|
|
156
|
+
describe(): BranchPolicyDescription;
|
|
157
|
+
/** Whether the gateway may write `branch` at all: the namespace, the settings branch, the list. */
|
|
158
|
+
inNamespace(branch: string): boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Whether the gateway opens and updates a pull request from `head`, protection aside: a branch it
|
|
161
|
+
* writes that is not a settings branch. `isProtected`, which may read GitHub, judges the rest.
|
|
162
|
+
*/
|
|
163
|
+
takesPullRequestFrom(head: string): boolean;
|
|
164
|
+
/**
|
|
165
|
+
* The branch among `branches` named after part of the namespace, or null. While one exists git
|
|
166
|
+
* holds no branch in the namespace, so the gateway refuses a push into it rather than send it.
|
|
167
|
+
*/
|
|
168
|
+
namespaceBlocker(branches: {
|
|
169
|
+
has(name: string): boolean;
|
|
170
|
+
}): string | null;
|
|
95
171
|
/** Whether `branch` is protected; GitHub's default is read only when nothing else matches. */
|
|
96
172
|
isProtected(branch: string, readDefault: DefaultBranchReader): Promise<boolean>;
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Whether `branch` is GitHub's default branch as last read, even once that read has expired:
|
|
175
|
+
* what protection is judged by when a fresh read fails.
|
|
176
|
+
*/
|
|
177
|
+
matchesLastReadDefault(branch: string): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Refuse a push to a protected branch, a leased push to a settings branch, a push to another
|
|
180
|
+
* deployment's settings branch, and a push outside the namespace.
|
|
181
|
+
*/
|
|
182
|
+
assertPushAllowed(branch: string, lease: string | undefined, readDefault: DefaultBranchReader, refuse: (reason: PushPolicyRefusal, message: string) => Error): Promise<void>;
|
|
183
|
+
/** Refuse to delete a protected branch, a settings branch, or one outside the namespace. */
|
|
100
184
|
assertDeleteAllowed(branch: string, readDefault: DefaultBranchReader): Promise<void>;
|
|
101
185
|
/**
|
|
102
|
-
* Refuse a pull request whose head is protected
|
|
103
|
-
* cap. The base is not restricted: a branch keeps the base it was created
|
|
104
|
-
* adopter moves theirs, and a pull request merges nothing by itself.
|
|
186
|
+
* Refuse a pull request whose head is protected, a settings branch or outside the namespace, or
|
|
187
|
+
* whose text is over its cap. The base is not restricted: a branch keeps the base it was created
|
|
188
|
+
* from after the adopter moves theirs, and a pull request merges nothing by itself.
|
|
105
189
|
*/
|
|
106
190
|
assertPullRequestAllowed(request: {
|
|
107
191
|
head: string;
|
|
108
192
|
title?: string;
|
|
109
193
|
body?: string;
|
|
110
194
|
}, readDefault: DefaultBranchReader): Promise<void>;
|
|
195
|
+
/**
|
|
196
|
+
* Refuse a pull request head the gateway may not open one from, or act on one with. `refusing`
|
|
197
|
+
* opens the message and names `head`, which the rest calls "it".
|
|
198
|
+
*/
|
|
199
|
+
assertPullRequestHeadAllowed(refusing: string, head: string, readDefault: DefaultBranchReader): Promise<void>;
|
|
111
200
|
private isSettings;
|
|
112
201
|
private matchesConfigured;
|
|
113
202
|
private githubDefaultBranch;
|
|
@@ -117,6 +206,12 @@ export declare class BranchPolicy {
|
|
|
117
206
|
*/
|
|
118
207
|
recordGitHubDefault(readDefault: DefaultBranchReader): Promise<string | null>;
|
|
119
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether the gateway that described its policy as `policy` would take a plain push of `branch`:
|
|
211
|
+
* `BranchPolicy.assertPushAllowed`'s rules, for a reader that has only the description, with
|
|
212
|
+
* GitHub's default as last read. A test holds the two equal.
|
|
213
|
+
*/
|
|
214
|
+
export declare function publishesBranch(policy: BranchPolicyDescription, branch: string): boolean;
|
|
120
215
|
/** Refuse pull request text over its cap; `refusing` opens the message, `branch` names its head. */
|
|
121
216
|
export declare function assertPullRequestText(refusing: string, branch: string, text: {
|
|
122
217
|
title?: string;
|