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.
Files changed (147) hide show
  1. package/CHANGELOG.md +201 -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-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +47 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Single resolution point for the branch namespace: the prefix every branch the GitHub gateway
3
+ * writes starts with, apart from this deployment's settings branch and the adopter's
4
+ * `legacyBranches` (worker/branch-policy.ts). It comes from a template in which `{deployment}`
5
+ * stands for the deployment name, so two deployments sharing a repository each get their own.
6
+ *
7
+ * Precedence, as for the deployment name and for the same reason: `CANOPYCMS_BRANCH_NAMESPACE`,
8
+ * which infrastructure stamps per stack, then `config.branchNamespace`, then
9
+ * {@link DEFAULT_BRANCH_NAMESPACE_TEMPLATE}. The gateway resolves its own from its own
10
+ * environment, never from anything the CMS Lambda can write.
11
+ */
12
+ import { RESERVED_SETTINGS_BRANCH_PREFIX, sanitizeBranchName } from '../paths/branch-name.js';
13
+ import { parseBranchRef } from '../paths/validation.js';
14
+ import { canopyLogWarn } from '../utils/logger.js';
15
+ import { resolveDeploymentName } from './deployment-name.js';
16
+ let warned = false;
17
+ function warnOnce(message) {
18
+ if (warned)
19
+ return;
20
+ warned = true;
21
+ canopyLogWarn(message);
22
+ }
23
+ /** The one placeholder a template may hold. */
24
+ const DEPLOYMENT_PLACEHOLDER = '{deployment}';
25
+ /** The template used when none is configured. */
26
+ export const DEFAULT_BRANCH_NAMESPACE_TEMPLATE = `canopycms/${DEPLOYMENT_PLACEHOLDER}/`;
27
+ /**
28
+ * The environment variable that carries the template, or the namespace it resolved to.
29
+ * @internal Exported for tests.
30
+ */
31
+ export const BRANCH_NAMESPACE_ENV = 'CANOPYCMS_BRANCH_NAMESPACE';
32
+ /**
33
+ * Why `namespace` cannot be a branch namespace, or null. It ends with `/`: a deployment name holds
34
+ * no `/`, so `canopycms/prod/` never starts `canopycms/prod-eu/`, as `canopycms-prod-` would start
35
+ * `canopycms-prod-eu-`. A branch in it must be a name git and the gateway take (checked with a
36
+ * branch appended), and none may be a settings branch, by name or by workspace directory.
37
+ */
38
+ export function branchNamespaceProblem(namespace) {
39
+ if (!namespace.endsWith('/'))
40
+ return 'it must end with "/"';
41
+ const sample = `${namespace}x`;
42
+ if (!/^[\x21-\x7e]+$/.test(namespace))
43
+ return 'it is not printable ASCII without spaces';
44
+ if (/["%{}]/.test(namespace))
45
+ return 'it contains ", %, { or }';
46
+ if (/^(?:refs|heads|tags|remotes)\//i.test(namespace)) {
47
+ return "it starts with 'refs/', 'heads/', 'tags/' or 'remotes/'";
48
+ }
49
+ const parsed = parseBranchRef(sample);
50
+ if (!parsed.ok)
51
+ return `a branch in it would be refused (${parsed.error})`;
52
+ if (sample.split('/').some((part) => part.startsWith('.') || part.endsWith('.lock'))) {
53
+ return "a part of it starts with '.' or ends with '.lock'";
54
+ }
55
+ // A branch's directory is the namespace's own sanitized form followed by the rest, so a
56
+ // namespace that sanitizes to a start of the settings prefix (`canopycms/`) holds settings names.
57
+ const directoryPrefix = sanitizeBranchName(namespace);
58
+ if (RESERVED_SETTINGS_BRANCH_PREFIX.startsWith(directoryPrefix) ||
59
+ sanitizeBranchName(sample).startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
60
+ return `some branches in it would be ${RESERVED_SETTINGS_BRANCH_PREFIX}* settings branches`;
61
+ }
62
+ return null;
63
+ }
64
+ /**
65
+ * The branch names git cannot hold beside any branch in `namespace`: each path it runs through
66
+ * (`canopycms` and `canopycms/prod` for `canopycms/prod/`), since no ref lives under a branch.
67
+ */
68
+ export function namespaceAncestors(namespace) {
69
+ const parts = namespace.split('/').filter(Boolean);
70
+ return parts.map((_, i) => parts.slice(0, i + 1).join('/'));
71
+ }
72
+ /**
73
+ * The namespace `template` names for `deploymentName`, or why it cannot be one. Throws, naming
74
+ * `source`, for a template with a placeholder other than `{deployment}` or a namespace
75
+ * {@link branchNamespaceProblem} refuses.
76
+ */
77
+ export function resolveBranchNamespaceTemplate(template, deploymentName, source) {
78
+ const unknown = template.replaceAll(DEPLOYMENT_PLACEHOLDER, '').match(/\{[^}]*\}?/);
79
+ if (unknown) {
80
+ throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(template)} (from ${source}): ` +
81
+ `${JSON.stringify(unknown[0])} is not a placeholder; the only one is ${DEPLOYMENT_PLACEHOLDER}`);
82
+ }
83
+ const namespace = template.replaceAll(DEPLOYMENT_PLACEHOLDER, deploymentName);
84
+ const problem = branchNamespaceProblem(namespace);
85
+ if (problem !== null) {
86
+ throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(namespace)} (from ${source}): ${problem}`);
87
+ }
88
+ return namespace;
89
+ }
90
+ /**
91
+ * The branch namespace a process resolves: from `env`'s `CANOPYCMS_BRANCH_NAMESPACE` (trimmed,
92
+ * empty ignored), then `config.branchNamespace`, then the default, with `{deployment}` filled by
93
+ * `resolveDeploymentName`. Throws for an invalid template or deployment name.
94
+ */
95
+ export function resolveBranchNamespace(config, modeDefault, env = process.env) {
96
+ const fromEnv = env[BRANCH_NAMESPACE_ENV]?.trim();
97
+ const [template, source] = fromEnv
98
+ ? [fromEnv, BRANCH_NAMESPACE_ENV]
99
+ : config.branchNamespace !== undefined
100
+ ? [config.branchNamespace, 'config.branchNamespace']
101
+ : [DEFAULT_BRANCH_NAMESPACE_TEMPLATE, 'the default'];
102
+ const deploymentName = resolveDeploymentName({ deploymentName: config.deploymentName }, modeDefault);
103
+ const namespace = resolveBranchNamespaceTemplate(template, deploymentName, source);
104
+ // Compared resolved: infrastructure stamps the namespace, config holds the template.
105
+ if (fromEnv && config.branchNamespace !== undefined) {
106
+ let fromConfig = null;
107
+ try {
108
+ fromConfig = resolveBranchNamespaceTemplate(config.branchNamespace, deploymentName, 'config.branchNamespace');
109
+ }
110
+ catch {
111
+ // An invalid config template is warned about too: the environment variable wins anyway.
112
+ }
113
+ if (fromConfig !== namespace) {
114
+ warnOnce(`CanopyCMS: ${BRANCH_NAMESPACE_ENV} (${JSON.stringify(fromEnv)}) and ` +
115
+ `config.branchNamespace (${JSON.stringify(config.branchNamespace)}) do not resolve to ` +
116
+ 'the same valid namespace; using the environment variable, which infrastructure stamps ' +
117
+ 'per stack.');
118
+ }
119
+ }
120
+ return namespace;
121
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import path from 'node:path';
6
6
  import { ProdClientSafeStrategy, DevClientSafeStrategy } from './client-safe-strategy.js';
7
+ import { resolveBranchNamespace } from './branch-namespace.js';
7
8
  import { resolveDeploymentName } from './deployment-name.js';
8
9
  import { DEFAULT_PROD_WORKSPACE } from '../config/index.js';
9
10
  class ProdStrategy extends ProdClientSafeStrategy {
@@ -46,6 +47,9 @@ class ProdStrategy extends ProdClientSafeStrategy {
46
47
  return config.settingsBranch;
47
48
  return `canopycms-settings-${resolveDeploymentName(config, 'prod')}`;
48
49
  }
50
+ getBranchNamespace(config) {
51
+ return resolveBranchNamespace(config, 'prod');
52
+ }
49
53
  getSettingsRoot(sourceRoot) {
50
54
  return path.join(this.getWorkspaceRoot(sourceRoot), 'settings');
51
55
  }
@@ -95,6 +99,9 @@ class DevStrategy extends DevClientSafeStrategy {
95
99
  return config.settingsBranch;
96
100
  return `canopycms-settings-${resolveDeploymentName(config, 'local')}`;
97
101
  }
102
+ getBranchNamespace(config) {
103
+ return resolveBranchNamespace(config, 'local');
104
+ }
98
105
  getSettingsRoot(sourceRoot) {
99
106
  return path.join(this.getWorkspaceRoot(sourceRoot), 'settings');
100
107
  }
@@ -82,6 +82,14 @@ export interface ClientUnsafeStrategy extends ClientSafeStrategy {
82
82
  deploymentName?: string;
83
83
  defaultBaseBranch?: string;
84
84
  }): string;
85
+ /**
86
+ * The prefix of every branch editors create, resolved from the namespace template with this
87
+ * mode's default deployment name (operating-mode/branch-namespace.ts).
88
+ */
89
+ getBranchNamespace(config: {
90
+ branchNamespace?: string;
91
+ deploymentName?: string;
92
+ }): string;
85
93
  /** `{workspaceRoot}/settings`, where settings storage lives. */
86
94
  getSettingsRoot(sourceRoot?: string): string;
87
95
  usesSeparateSettingsBranch(): boolean;
@@ -18,9 +18,9 @@ export declare function sanitizeBranchName(branchName: string): SanitizedBranchN
18
18
  * Exported from this dependency-free module -- not rebuilt ad hoc at each call
19
19
  * site -- so every consumer of the namespace agrees on the exact string:
20
20
  * worker/git-sync.ts's `pushSettingsBranches` never pushes a
21
- * `canopycms-settings-*` branch this deployment doesn't own, and api/branch.ts's
22
- * `createBranchHandler` rejects a user-requested branch whose SANITIZED name
23
- * falls in this namespace.
21
+ * `canopycms-settings-*` branch this deployment doesn't own, and
22
+ * operating-mode/branch-namespace.ts refuses a branch namespace whose branches
23
+ * could fall in it.
24
24
  *
25
25
  * The WHOLE prefix is reserved, not just this deployment's own settings branch:
26
26
  * two deployments can share one GitHub repo, and a content branch landing on
@@ -29,24 +29,16 @@ export declare function sanitizeBranchName(branchName: string): SanitizedBranchN
29
29
  * branch.
30
30
  */
31
31
  export declare const RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
32
+ /**
33
+ * Branch `name`'s short name: the part after `namespace` for a branch in it, the full name for
34
+ * any other, and for one whose remainder holds a `/`. So a short name holding a `/` is always a
35
+ * full name, which the editor's unlisted request segment (editor/branch-names.ts `unlistedSegment`)
36
+ * relies on. The server rebuilds the ref from it (branch-param.ts's `resolveBranchParam`); the
37
+ * editor is never told the namespace.
38
+ */
39
+ export declare function branchShortName(name: string, namespace: string | null): string;
32
40
  /**
33
41
  * Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
34
42
  * configured name (which need not carry it). Compared sanitized, the form git and disk use.
35
43
  */
36
44
  export declare function isSettingsBranchName(branchName: string, settingsBranchName?: string): boolean;
37
- /**
38
- * Branch names that collide with a static top-level API route namespace.
39
- *
40
- * http/router.ts's `compareSpecificity` ranks a literal pattern segment above a
41
- * `:param`, so a branch named e.g. `admin` has its `/:branch/...` routes
42
- * shadowed by the static `/admin/...` ones -- and only partially, which is what
43
- * makes it confusing: bare `GET /admin` still reaches the branch handler, so the
44
- * branch looks half-alive while every nested route 404s or 403s.
45
- *
46
- * Listed rather than derived at runtime because api/validators.ts is imported
47
- * *by* the route modules, so importing the router here would cycle.
48
- * http/router.test.ts derives the same set from the live route table and asserts
49
- * it equals this constant, so a new top-level namespace fails that test until
50
- * this list is updated.
51
- */
52
- export declare const RESERVED_ROUTE_BRANCH_NAMES: readonly string[];
@@ -22,9 +22,9 @@ export function sanitizeBranchName(branchName) {
22
22
  * Exported from this dependency-free module -- not rebuilt ad hoc at each call
23
23
  * site -- so every consumer of the namespace agrees on the exact string:
24
24
  * worker/git-sync.ts's `pushSettingsBranches` never pushes a
25
- * `canopycms-settings-*` branch this deployment doesn't own, and api/branch.ts's
26
- * `createBranchHandler` rejects a user-requested branch whose SANITIZED name
27
- * falls in this namespace.
25
+ * `canopycms-settings-*` branch this deployment doesn't own, and
26
+ * operating-mode/branch-namespace.ts refuses a branch namespace whose branches
27
+ * could fall in it.
28
28
  *
29
29
  * The WHOLE prefix is reserved, not just this deployment's own settings branch:
30
30
  * two deployments can share one GitHub repo, and a content branch landing on
@@ -33,6 +33,20 @@ export function sanitizeBranchName(branchName) {
33
33
  * branch.
34
34
  */
35
35
  export const RESERVED_SETTINGS_BRANCH_PREFIX = 'canopycms-settings-';
36
+ /**
37
+ * Branch `name`'s short name: the part after `namespace` for a branch in it, the full name for
38
+ * any other, and for one whose remainder holds a `/`. So a short name holding a `/` is always a
39
+ * full name, which the editor's unlisted request segment (editor/branch-names.ts `unlistedSegment`)
40
+ * relies on. The server rebuilds the ref from it (branch-param.ts's `resolveBranchParam`); the
41
+ * editor is never told the namespace.
42
+ */
43
+ export function branchShortName(name, namespace) {
44
+ if (namespace === null || !name.startsWith(namespace) || name.length === namespace.length) {
45
+ return name;
46
+ }
47
+ const rest = name.slice(namespace.length);
48
+ return rest.includes('/') ? name : rest;
49
+ }
36
50
  /**
37
51
  * Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
38
52
  * configured name (which need not carry it). Compared sanitized, the form git and disk use.
@@ -42,27 +56,3 @@ export function isSettingsBranchName(branchName, settingsBranchName) {
42
56
  return (sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) ||
43
57
  (settingsBranchName !== undefined && sanitized === sanitizeBranchName(settingsBranchName)));
44
58
  }
45
- /**
46
- * Branch names that collide with a static top-level API route namespace.
47
- *
48
- * http/router.ts's `compareSpecificity` ranks a literal pattern segment above a
49
- * `:param`, so a branch named e.g. `admin` has its `/:branch/...` routes
50
- * shadowed by the static `/admin/...` ones -- and only partially, which is what
51
- * makes it confusing: bare `GET /admin` still reaches the branch handler, so the
52
- * branch looks half-alive while every nested route 404s or 403s.
53
- *
54
- * Listed rather than derived at runtime because api/validators.ts is imported
55
- * *by* the route modules, so importing the router here would cycle.
56
- * http/router.test.ts derives the same set from the live route table and asserts
57
- * it equals this constant, so a new top-level namespace fails that test until
58
- * this list is updated.
59
- */
60
- export const RESERVED_ROUTE_BRANCH_NAMES = [
61
- 'admin',
62
- 'assets',
63
- 'branches',
64
- 'groups',
65
- 'permissions',
66
- 'users',
67
- 'whoami',
68
- ];
@@ -5,7 +5,7 @@
5
5
  export type { LogicalPath, PhysicalPath, SanitizedBranchName, BranchName, ContentId, Slug, PathValidationResult, } from './types.js';
6
6
  export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
7
7
  export { validateAndNormalizePath } from './normalize-server.js';
8
- export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
8
+ export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseBranchRef, parseCreatedBranchName, parseSlug, } from './validation.js';
9
9
  export { resolveLogicalPath } from './resolve.js';
10
10
  export { resolveBranchPath, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, type BranchPathOptions, type BranchPathResult, } from './branch.js';
11
- export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.js';
11
+ export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, branchShortName, isSettingsBranchName, } from './branch-name.js';
@@ -6,7 +6,7 @@
6
6
  export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
7
7
  // Normalization utilities (server-only, requires Node.js path module)
8
8
  export { validateAndNormalizePath } from './normalize-server.js';
9
- export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
9
+ export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseBranchRef, parseCreatedBranchName, parseSlug, } from './validation.js';
10
10
  export { resolveLogicalPath } from './resolve.js';
11
11
  export { resolveBranchPath, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, } from './branch.js';
12
- export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.js';
12
+ export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, branchShortName, isSettingsBranchName, } from './branch-name.js';
@@ -11,6 +11,5 @@ import type { LogicalPath } from './types.js';
11
11
  * @example
12
12
  * resolveLogicalPath("content/docs.ABC/api.DEF", schemaItems)
13
13
  * // Returns: "content/docs/api"
14
- * @internal Exported for tests.
15
14
  */
16
15
  export declare function resolveLogicalPath(physicalPath: string, schemaItems: Iterable<FlatSchemaItem>): LogicalPath | string;
@@ -9,7 +9,6 @@
9
9
  * @example
10
10
  * resolveLogicalPath("content/docs.ABC/api.DEF", schemaItems)
11
11
  * // Returns: "content/docs/api"
12
- * @internal Exported for tests.
13
12
  */
14
13
  export function resolveLogicalPath(physicalPath, schemaItems) {
15
14
  const pathSegments = physicalPath.split('/');
@@ -64,7 +64,20 @@ export declare function parseContentId(id: string): {
64
64
  ok: false;
65
65
  error: string;
66
66
  };
67
- /** Validate a branch name against git's ref rules and cast it to BranchName. */
67
+ /** Whether `param` is in the exact form, well formed or not: no branch name starts with `~`. */
68
+ export declare function isExactBranchForm(param: string): boolean;
69
+ /** The exact form of the branch whose full name is `fullName`. */
70
+ export declare function exactBranchSegment(fullName: string): string;
71
+ /**
72
+ * The full name the exact form `param` names, or null when `param` is no well-formed exact form:
73
+ * it starts with `~`, holds no `/`, and with each later `~` read as `/` names a branch
74
+ * `parseBranchRef` takes, which refuses an empty name or part (`~`, `~~a`, `~a~`).
75
+ */
76
+ export declare function exactBranchName(param: string): string | null;
77
+ /**
78
+ * Validate a branch named in a request, by a `/:branch` segment or `?branch=`, and cast it to
79
+ * BranchName: a name `parseBranchRef` takes, or a well-formed exact form of one.
80
+ */
68
81
  export declare function parseBranchName(name: string): {
69
82
  ok: true;
70
83
  name: BranchName;
@@ -72,6 +85,32 @@ export declare function parseBranchName(name: string): {
72
85
  ok: false;
73
86
  error: string;
74
87
  };
88
+ /**
89
+ * Validate a branch's own name, the ref git and branch.json hold, against git's ref rules, except
90
+ * that an inner part may start with `.` or end `.lock` (`a/.b`, `a.lock/b`), and cast it to
91
+ * BranchName. It refuses every `~`, so the exact form is never one.
92
+ */
93
+ export declare function parseBranchRef(name: string): {
94
+ ok: true;
95
+ name: BranchName;
96
+ } | {
97
+ ok: false;
98
+ error: string;
99
+ };
100
+ /**
101
+ * The branch an editor's typed name creates in `namespace`, or why it cannot: the typed name
102
+ * sanitized, so it holds no `/`, is the short name, and the ref is the namespace followed by it.
103
+ * `createBranchHandler` passes its resolved namespace; the editor, which is never told one, passes
104
+ * none and checks the typed name alone, so its length limit is the looser one.
105
+ */
106
+ export declare function parseCreatedBranchName(typed: string, namespace?: string): {
107
+ ok: true;
108
+ name: BranchName;
109
+ shortName: string;
110
+ } | {
111
+ ok: false;
112
+ error: string;
113
+ };
75
114
  /** Validate a collection or entry slug and cast it to Slug. */
76
115
  export declare function parseSlug(slug: string): {
77
116
  ok: true;
@@ -1,4 +1,5 @@
1
1
  /** Security-focused validation for content paths, IDs, branch names and slugs. */
2
+ import { sanitizeBranchName } from './branch-name.js';
2
3
  import { normalizeFilesystemPath, hasTraversalSequence } from './normalize.js';
3
4
  /** Base58 alphabet for content IDs: excludes the ambiguous 0, O, I and l. */
4
5
  const BASE58_PATTERN = '[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]';
@@ -133,14 +134,60 @@ export function parseContentId(id) {
133
134
  }
134
135
  return { ok: true, id: id };
135
136
  }
136
- /** Validate a branch name against git's ref rules and cast it to BranchName. */
137
+ /** The longest branch name taken, in characters: names become directory names. */
138
+ const MAX_BRANCH_NAME_LENGTH = 250;
139
+ /**
140
+ * What starts a branch's exact form: `~`, then its full name with each `/` written `~`, so
141
+ * `canopycms/prod/spring` is `~canopycms~prod~spring` and a branch outside the namespace named
142
+ * `spring` is `~spring`. That branch and the namespace branch `canopycms/prod/spring` both answer
143
+ * to `spring`, which opens the namespace one; `~spring` opens the other. Git refuses `~` in a
144
+ * branch name, so no other name starts with it and the mapping reverses exactly, and
145
+ * `encodeURIComponent` leaves `~` alone, so a request path carries the form without `%2F`.
146
+ */
147
+ const EXACT_BRANCH_MARK = '~';
148
+ /** Whether `param` is in the exact form, well formed or not: no branch name starts with `~`. */
149
+ export function isExactBranchForm(param) {
150
+ return param.startsWith(EXACT_BRANCH_MARK);
151
+ }
152
+ /** The exact form of the branch whose full name is `fullName`. */
153
+ export function exactBranchSegment(fullName) {
154
+ return `${EXACT_BRANCH_MARK}${fullName.replaceAll('/', EXACT_BRANCH_MARK)}`;
155
+ }
156
+ /**
157
+ * The full name the exact form `param` names, or null when `param` is no well-formed exact form:
158
+ * it starts with `~`, holds no `/`, and with each later `~` read as `/` names a branch
159
+ * `parseBranchRef` takes, which refuses an empty name or part (`~`, `~~a`, `~a~`).
160
+ */
161
+ export function exactBranchName(param) {
162
+ if (!isExactBranchForm(param) || param.includes('/'))
163
+ return null;
164
+ const named = parseBranchRef(param.slice(EXACT_BRANCH_MARK.length).replaceAll(EXACT_BRANCH_MARK, '/'));
165
+ return named.ok ? named.name : null;
166
+ }
167
+ /**
168
+ * Validate a branch named in a request, by a `/:branch` segment or `?branch=`, and cast it to
169
+ * BranchName: a name `parseBranchRef` takes, or a well-formed exact form of one.
170
+ */
137
171
  export function parseBranchName(name) {
172
+ if (typeof name === 'string' && isExactBranchForm(name)) {
173
+ return exactBranchName(name) === null
174
+ ? { ok: false, error: 'Branch name starts with "~" but names no branch' }
175
+ : { ok: true, name: name };
176
+ }
177
+ return parseBranchRef(name);
178
+ }
179
+ /**
180
+ * Validate a branch's own name, the ref git and branch.json hold, against git's ref rules, except
181
+ * that an inner part may start with `.` or end `.lock` (`a/.b`, `a.lock/b`), and cast it to
182
+ * BranchName. It refuses every `~`, so the exact form is never one.
183
+ */
184
+ export function parseBranchRef(name) {
138
185
  if (!name || typeof name !== 'string') {
139
186
  return { ok: false, error: 'Branch name is required' };
140
187
  }
141
- // Length limit (branch names become directory names)
142
- if (name.length > 250) {
143
- return { ok: false, error: 'Branch name too long (max 250 characters)' };
188
+ // Length limit (branch names become directory names).
189
+ if (name.length > MAX_BRANCH_NAME_LENGTH) {
190
+ return { ok: false, error: `Branch name too long (max ${MAX_BRANCH_NAME_LENGTH} characters)` };
144
191
  }
145
192
  if (name.includes('..')) {
146
193
  return { ok: false, error: 'Branch name cannot contain ".."' };
@@ -178,6 +225,38 @@ export function parseBranchName(name) {
178
225
  }
179
226
  return { ok: true, name: name };
180
227
  }
228
+ /**
229
+ * The branch an editor's typed name creates in `namespace`, or why it cannot: the typed name
230
+ * sanitized, so it holds no `/`, is the short name, and the ref is the namespace followed by it.
231
+ * `createBranchHandler` passes its resolved namespace; the editor, which is never told one, passes
232
+ * none and checks the typed name alone, so its length limit is the looser one.
233
+ */
234
+ export function parseCreatedBranchName(typed, namespace = '') {
235
+ const valid = parseBranchRef(typed);
236
+ if (!valid.ok)
237
+ return valid;
238
+ if (namespace !== '' && typed.startsWith(namespace)) {
239
+ return {
240
+ ok: false,
241
+ error: `Type only the branch's own name, without "${namespace}" in front: CanopyCMS adds it`,
242
+ };
243
+ }
244
+ // Without a leading '-' or '.', which a branch name may not start with (`parseBranchRef`).
245
+ const shortName = sanitizeBranchName(typed).replace(/^[-.]+/, '');
246
+ // "_" alone would name a branch whose short name is no word at all.
247
+ if (!/[a-zA-Z0-9]/.test(shortName)) {
248
+ return { ok: false, error: 'Branch name needs at least one letter (a–z) or digit' };
249
+ }
250
+ const ref = `${namespace}${shortName}`;
251
+ if (ref.length > MAX_BRANCH_NAME_LENGTH) {
252
+ return {
253
+ ok: false,
254
+ error: `Branch name too long (max ${MAX_BRANCH_NAME_LENGTH - namespace.length} characters)`,
255
+ };
256
+ }
257
+ const parsed = parseBranchRef(ref);
258
+ return parsed.ok ? { ok: true, name: parsed.name, shortName } : parsed;
259
+ }
181
260
  /** Validate a collection or entry slug and cast it to Slug. */
182
261
  export function parseSlug(slug) {
183
262
  if (!slug) {
@@ -1,17 +1,23 @@
1
1
  /**
2
- * The worker action that re-renders a PR's editor-comment summary: enqueued by
3
- * api/pr-comment-sync.ts, run by worker/task-runner.ts.
2
+ * The worker action that re-renders both blocks of a PR body that change after a submit, the
3
+ * editor-comment summary and the review block: enqueued by api/pr-comment-sync.ts, run by
4
+ * worker/task-runner.ts. The action string is the queue's contract.
4
5
  */
5
6
  export declare const SYNC_PR_COMMENTS_ACTION = "sync-pr-comments";
6
7
  /**
7
- * `body` with its comment summary re-rendered from `branchRoot`'s comments.json, or undefined
8
- * when there is nothing to write: no summary block to replace, or no change.
8
+ * `body` with its comment summary re-rendered from `branchRoot`'s comments.json and its review
9
+ * block from the review in branch.json as stored, or undefined when there is nothing to write.
10
+ * The two blocks are replaced independently: a body without one still gets the other, and a
11
+ * file that is corrupt leaves its block as it is, with a warning, without stopping the other.
12
+ * A missing file is no failure. Any other read error, an IO fault that may pass, is thrown so
13
+ * that the caller retries or fails visibly.
9
14
  */
10
15
  export declare function renderPrCommentsBody(options: {
11
16
  branchRoot: string;
12
17
  branch: string;
13
18
  editorUrl?: string;
14
- body: string | null | undefined;
19
+ body: string;
20
+ warn: (message: string) => void;
15
21
  }): Promise<string | undefined>;
16
22
  /**
17
23
  * The in-process lock every direct-path write of a branch's PR body holds (submit and comment
@@ -19,10 +25,12 @@ export declare function renderPrCommentsBody(options: {
19
25
  */
20
26
  export declare function prBodyLockKey(branchRoot: string): string;
21
27
  /**
22
- * A submit's section with its comment block re-rendered from comments.json, for a queued submit
23
- * whose block was rendered when it was queued: a comment change since then, or a sync task that
24
- * ran while this one waited out a retry, would otherwise be overwritten. If comments.json cannot
25
- * be read, the section keeps the block it was queued with.
28
+ * A submit's section with its comment and review blocks re-rendered from disk, for a queued
29
+ * submit whose blocks were rendered when it was queued: a comment or review since
30
+ * then, or a sync task that ran while this one waited out a retry, would otherwise be
31
+ * overwritten. The review is the one stored, as in renderPrCommentsBody: a submit clears the
32
+ * last verdict before it writes the PR. A file that cannot be read leaves its block as it was
33
+ * queued.
26
34
  */
27
35
  export declare function refreshCommentsBlock(options: {
28
36
  branchRoot: string;
@@ -1,19 +1,46 @@
1
- import { CommentStore } from './comment-store.js';
1
+ import { CommentStore, CommentsFileMalformedError } from './comment-store.js';
2
2
  import { getErrorMessage } from './utils/error.js';
3
- import { buildCommentsBlock, mergeCommentsBlock } from './submission-attribution.js';
3
+ import { BranchMetadataCorruptError, readBranchMetadataFile } from './branch-metadata-file.js';
4
+ import { buildCommentsBlock, buildReviewBlock, mergeCommentsBlock, mergeReviewBlock, } from './submission-attribution.js';
4
5
  /**
5
- * The worker action that re-renders a PR's editor-comment summary: enqueued by
6
- * api/pr-comment-sync.ts, run by worker/task-runner.ts.
6
+ * The worker action that re-renders both blocks of a PR body that change after a submit, the
7
+ * editor-comment summary and the review block: enqueued by api/pr-comment-sync.ts, run by
8
+ * worker/task-runner.ts. The action string is the queue's contract.
7
9
  */
8
10
  export const SYNC_PR_COMMENTS_ACTION = 'sync-pr-comments';
9
11
  /**
10
- * `body` with its comment summary re-rendered from `branchRoot`'s comments.json, or undefined
11
- * when there is nothing to write: no summary block to replace, or no change.
12
+ * `body` with its comment summary re-rendered from `branchRoot`'s comments.json and its review
13
+ * block from the review in branch.json as stored, or undefined when there is nothing to write.
14
+ * The two blocks are replaced independently: a body without one still gets the other, and a
15
+ * file that is corrupt leaves its block as it is, with a warning, without stopping the other.
16
+ * A missing file is no failure. Any other read error, an IO fault that may pass, is thrown so
17
+ * that the caller retries or fails visibly.
12
18
  */
13
19
  export async function renderPrCommentsBody(options) {
14
- const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
15
- const next = mergeCommentsBlock(options.body, buildCommentsBlock(threads, options.branch, options.editorUrl));
16
- return next === undefined || next === options.body ? undefined : next;
20
+ const original = options.body;
21
+ let next = original;
22
+ try {
23
+ const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
24
+ next =
25
+ mergeCommentsBlock(next, buildCommentsBlock(threads, options.branch, options.editorUrl)) ??
26
+ next;
27
+ }
28
+ catch (err) {
29
+ if (!(err instanceof SyntaxError || err instanceof CommentsFileMalformedError))
30
+ throw err;
31
+ options.warn(`Could not read the editor comments of ${options.branch}; the PR keeps its summary: ${getErrorMessage(err)}`);
32
+ }
33
+ try {
34
+ const file = await readBranchMetadataFile(options.branchRoot);
35
+ if (file)
36
+ next = mergeReviewBlock(next, buildReviewBlock(file.branch.review)) ?? next;
37
+ }
38
+ catch (err) {
39
+ if (!(err instanceof BranchMetadataCorruptError))
40
+ throw err;
41
+ options.warn(`Could not read the review state of ${options.branch}; the PR keeps its review block: ${getErrorMessage(err)}`);
42
+ }
43
+ return next === original ? undefined : next;
17
44
  }
18
45
  /**
19
46
  * The in-process lock every direct-path write of a branch's PR body holds (submit and comment
@@ -23,19 +50,30 @@ export function prBodyLockKey(branchRoot) {
23
50
  return `pr-body:${branchRoot}`;
24
51
  }
25
52
  /**
26
- * A submit's section with its comment block re-rendered from comments.json, for a queued submit
27
- * whose block was rendered when it was queued: a comment change since then, or a sync task that
28
- * ran while this one waited out a retry, would otherwise be overwritten. If comments.json cannot
29
- * be read, the section keeps the block it was queued with.
53
+ * A submit's section with its comment and review blocks re-rendered from disk, for a queued
54
+ * submit whose blocks were rendered when it was queued: a comment or review since
55
+ * then, or a sync task that ran while this one waited out a retry, would otherwise be
56
+ * overwritten. The review is the one stored, as in renderPrCommentsBody: a submit clears the
57
+ * last verdict before it writes the PR. A file that cannot be read leaves its block as it was
58
+ * queued.
30
59
  */
31
60
  export async function refreshCommentsBlock(options) {
61
+ let section = options.section;
32
62
  try {
33
63
  const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
34
64
  const block = buildCommentsBlock(threads, options.branch, options.editorUrl);
35
- return mergeCommentsBlock(options.section, block) ?? options.section;
65
+ section = mergeCommentsBlock(section, block) ?? section;
36
66
  }
37
67
  catch (err) {
38
68
  options.warn(`Could not re-read the editor comments of ${options.branch}; the PR gets the summary from submit time: ${getErrorMessage(err)}`);
39
- return options.section;
40
69
  }
70
+ try {
71
+ const file = await readBranchMetadataFile(options.branchRoot);
72
+ if (file)
73
+ section = mergeReviewBlock(section, buildReviewBlock(file.branch.review)) ?? section;
74
+ }
75
+ catch (err) {
76
+ options.warn(`Could not re-read the review state of ${options.branch}; the PR gets it from submit time: ${getErrorMessage(err)}`);
77
+ }
78
+ return section;
41
79
  }
package/dist/services.js CHANGED
@@ -29,6 +29,7 @@ import { BranchSchemaCache } from './branch-schema-cache.js';
29
29
  import { enqueueTask } from './task-queue/cms-task-queue.js';
30
30
  import { getTaskQueueDir } from './task-queue/task-queue-config.js';
31
31
  import { baseBranchOf } from './utils/base-branch.js';
32
+ import { shortNameOf } from './branch-param.js';
32
33
  import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
33
34
  import { readsFromCheckout } from './build-mode.js';
34
35
  import { timeRequestPhase } from './utils/request-timing.js';
@@ -330,7 +331,7 @@ async function _createCanopyServicesInternal(config, options) {
330
331
  if (changedPaths?.length === 0) {
331
332
  if (committed)
332
333
  await git.resetKeepingChanges(preCommitSha);
333
- throw new NothingToSubmitError(options.context.branch.name, effectiveBase);
334
+ throw new NothingToSubmitError(shortNameOf(config, options.context.branch.name), effectiveBase);
334
335
  }
335
336
  // A failure here leaves them uncommitted, so the next commit names them again.
336
337
  if (committed && uncommitted.length > 0) {