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
@@ -58,29 +58,29 @@ export declare const GROUP_ROUTES: {
58
58
  description: z.ZodOptional<z.ZodString>;
59
59
  members: z.ZodArray<z.ZodString, "many">;
60
60
  }, "strip", z.ZodTypeAny, {
61
- name: string;
62
61
  id: string;
62
+ name: string;
63
63
  members: string[];
64
64
  description?: string | undefined;
65
65
  }, {
66
- name: string;
67
66
  id: string;
67
+ name: string;
68
68
  members: string[];
69
69
  description?: string | undefined;
70
70
  }>, "many">;
71
71
  expectedContentVersion: z.ZodOptional<z.ZodNumber>;
72
72
  }, "strip", z.ZodTypeAny, {
73
73
  groups: {
74
- name: string;
75
74
  id: string;
75
+ name: string;
76
76
  members: string[];
77
77
  description?: string | undefined;
78
78
  }[];
79
79
  expectedContentVersion?: number | undefined;
80
80
  }, {
81
81
  groups: {
82
- name: string;
83
82
  id: string;
83
+ name: string;
84
84
  members: string[];
85
85
  description?: string | undefined;
86
86
  }[];
@@ -1,18 +1,19 @@
1
1
  import type { ApiContext } from './types.js';
2
2
  import type { BranchContext } from '../types.js';
3
3
  /**
4
- * Brings the PR's editor-comment summary up to date after a comment is added, replied to or
5
- * resolved. Best-effort: a failure is logged and never fails the comment request.
4
+ * Brings both of the PR body's blocks, the editor-comment summary and the review block, up to
5
+ * date after a comment is added, replied to or resolved, or a review is given or withdrawn.
6
+ * Best-effort: a failure is logged and never fails the request.
6
7
  *
7
- * Direct path: read the PR body, replace the block, write it back, within
8
- * DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the comment request.
8
+ * Direct path: read the PR body, replace the blocks, write it back, within
9
+ * DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the request.
9
10
  * GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
10
11
  * orders this process's writers (docs/concurrency.md).
11
12
  *
12
13
  * Queued path: one pending task per branch and PR, rendered by the worker from comments.json
13
- * when it runs, so a burst of comments costs one update. The comment was written before this
14
- * call and a pending task reads comments.json only after it leaves pending, so its run carries
15
- * the change this call skipped; a task already running doesn't count, so a later change gets
16
- * a run of its own.
14
+ * and branch.json when it runs, so a burst of changes costs one update. The change was written
15
+ * before this call and a pending task reads those files only after it leaves pending, so its
16
+ * run carries the change this call skipped; a task already running doesn't count, so a later
17
+ * change gets a run of its own.
17
18
  */
18
19
  export declare function syncPrComments(ctx: ApiContext, context: BranchContext): Promise<void>;
@@ -5,7 +5,8 @@ import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
5
5
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
6
6
  import { withLock } from '../utils/async-mutex.js';
7
7
  import { getErrorMessage } from '../utils/error.js';
8
- /** A PR the comment summary is still worth updating on: one exists and is not merged or closed. */
8
+ import { shortNameOf } from '../branch-param.js';
9
+ /** A PR whose body blocks are still worth updating: one exists and is not merged or closed. */
9
10
  function openPullRequest(context) {
10
11
  const { pullRequestNumber, pullRequestState, status } = context.branch;
11
12
  if (!pullRequestNumber || status === 'archived')
@@ -14,22 +15,23 @@ function openPullRequest(context) {
14
15
  ? undefined
15
16
  : pullRequestNumber;
16
17
  }
17
- /** How long a comment request waits on GitHub for its summary update, lock wait included. */
18
+ /** How long a request waits on GitHub for its PR body update, lock wait included. */
18
19
  const DIRECT_SYNC_TIMEOUT_MS = 10_000;
19
20
  /**
20
- * Brings the PR's editor-comment summary up to date after a comment is added, replied to or
21
- * resolved. Best-effort: a failure is logged and never fails the comment request.
21
+ * Brings both of the PR body's blocks, the editor-comment summary and the review block, up to
22
+ * date after a comment is added, replied to or resolved, or a review is given or withdrawn.
23
+ * Best-effort: a failure is logged and never fails the request.
22
24
  *
23
- * Direct path: read the PR body, replace the block, write it back, within
24
- * DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the comment request.
25
+ * Direct path: read the PR body, replace the blocks, write it back, within
26
+ * DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the request.
25
27
  * GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
26
28
  * orders this process's writers (docs/concurrency.md).
27
29
  *
28
30
  * Queued path: one pending task per branch and PR, rendered by the worker from comments.json
29
- * when it runs, so a burst of comments costs one update. The comment was written before this
30
- * call and a pending task reads comments.json only after it leaves pending, so its run carries
31
- * the change this call skipped; a task already running doesn't count, so a later change gets
32
- * a run of its own.
31
+ * and branch.json when it runs, so a burst of changes costs one update. The change was written
32
+ * before this call and a pending task reads those files only after it leaves pending, so its
33
+ * run carries the change this call skipped; a task already running doesn't count, so a later
34
+ * change gets a run of its own.
33
35
  */
34
36
  export async function syncPrComments(ctx, context) {
35
37
  const prNumber = openPullRequest(context);
@@ -38,6 +40,8 @@ export async function syncPrComments(ctx, context) {
38
40
  if (!clientOperatingStrategy(ctx.services.config.mode).supportsPullRequests())
39
41
  return;
40
42
  const branch = context.branch.name;
43
+ // The short name, which `?branch=` resolves, for the body's links.
44
+ const shortName = shortNameOf(ctx.services.config, branch);
41
45
  const editorUrl = ctx.services.config.editorUrl ?? context.branch.editorUrl;
42
46
  try {
43
47
  const { githubService } = ctx.services;
@@ -48,9 +52,10 @@ export async function syncPrComments(ctx, context) {
48
52
  return;
49
53
  const body = await renderPrCommentsBody({
50
54
  branchRoot: context.branchRoot,
51
- branch,
55
+ branch: shortName,
52
56
  editorUrl,
53
57
  body: pr.body,
58
+ warn: (message) => console.warn(`CanopyCMS: ${message}`),
54
59
  });
55
60
  if (body !== undefined)
56
61
  await githubService.updatePullRequest(prNumber, { body }, signal);
@@ -66,11 +71,16 @@ export async function syncPrComments(ctx, context) {
66
71
  return;
67
72
  await enqueueTask(taskDir, {
68
73
  action: SYNC_PR_COMMENTS_ACTION,
69
- payload: { branch, pullRequestNumber: prNumber, ...(editorUrl ? { editorUrl } : {}) },
74
+ payload: {
75
+ branch,
76
+ pullRequestNumber: prNumber,
77
+ ...(editorUrl ? { editorUrl } : {}),
78
+ editorBranch: shortName,
79
+ },
70
80
  });
71
81
  }
72
82
  catch (err) {
73
- console.warn(`CanopyCMS: Could not update the editor comments on PR #${prNumber} for ${branch}:`, isApiTimeoutError(err)
83
+ console.warn(`CanopyCMS: Could not update the editor comments or review state on PR #${prNumber} for ${branch}:`, isApiTimeoutError(err)
74
84
  ? `GitHub did not answer within ${DIRECT_SYNC_TIMEOUT_MS / 1000} s`
75
85
  : getErrorMessage(err));
76
86
  }
@@ -5,6 +5,8 @@
5
5
  * `http-reaches-api-only-via-routes` dependency-cruiser rule holds this one.
6
6
  */
7
7
  import { BRANCH_ROUTES } from './branch.js';
8
+ import { BRANCH_CHANGES_ROUTES } from './branch-changes.js';
9
+ import { BRANCH_ENTRY_CHANGE_ROUTES } from './branch-entry-changes.js';
8
10
  import { WORKFLOW_ROUTES } from './branch-status.js';
9
11
  import { COMMENT_ROUTES } from './comments.js';
10
12
  import { CONTENT_ROUTES } from './content.js';
@@ -24,6 +26,8 @@ import { ADMIN_ROUTES } from './admin.js';
24
26
  export function buildCanopyRoutes() {
25
27
  return [
26
28
  ...Object.values(BRANCH_ROUTES),
29
+ ...Object.values(BRANCH_CHANGES_ROUTES),
30
+ ...Object.values(BRANCH_ENTRY_CHANGE_ROUTES),
27
31
  ...Object.values(WORKFLOW_ROUTES),
28
32
  ...Object.values(COMMENT_ROUTES),
29
33
  ...Object.values(CONTENT_ROUTES),
@@ -65,5 +65,7 @@ export interface ApiResponse<TData = unknown> {
65
65
  * 409, see `SettingsVersionConflictError`); nothing was saved, and a reload shows the other edit.
66
66
  * The other settings 409, a busy file lock, carries no code. `BRANCH_SYNCING`: an entry save was
67
67
  * refused because the branch is mid-rebase (a 409); nothing was saved, and a retry soon can pass.
68
+ * `BRANCH_NAME_REFUSED`: a branch create was refused for the name it gave (a 400 or 409), so only
69
+ * another name can pass; no other create failure carries it.
68
70
  */
69
- export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN' | 'ENTRY_GONE' | 'EDITOR_MODE_MISMATCH' | 'SETTINGS_CHANGED' | 'BRANCH_SYNCING';
71
+ export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN' | 'ENTRY_GONE' | 'EDITOR_MODE_MISMATCH' | 'SETTINGS_CHANGED' | 'BRANCH_SYNCING' | 'BRANCH_NAME_REFUSED';
@@ -14,21 +14,21 @@ export declare const GroupsFileSchema: z.ZodObject<{
14
14
  description: z.ZodOptional<z.ZodString>;
15
15
  members: z.ZodArray<z.ZodType<string, z.ZodTypeDef, string>, "many">;
16
16
  }, "strip", z.ZodTypeAny, {
17
- name: string;
18
17
  id: string;
18
+ name: string;
19
19
  members: string[];
20
20
  description?: string | undefined;
21
21
  }, {
22
- name: string;
23
22
  id: string;
23
+ name: string;
24
24
  members: string[];
25
25
  description?: string | undefined;
26
26
  }>, "many">;
27
27
  }, "strip", z.ZodTypeAny, {
28
28
  updatedAt: string;
29
29
  groups: {
30
- name: string;
31
30
  id: string;
31
+ name: string;
32
32
  members: string[];
33
33
  description?: string | undefined;
34
34
  }[];
@@ -38,8 +38,8 @@ export declare const GroupsFileSchema: z.ZodObject<{
38
38
  }, {
39
39
  updatedAt: string;
40
40
  groups: {
41
- name: string;
42
41
  id: string;
42
+ name: string;
43
43
  members: string[];
44
44
  description?: string | undefined;
45
45
  }[];
@@ -52,9 +52,9 @@ export interface BranchWriteProtection extends BranchProtection {
52
52
  /**
53
53
  * Whether `branchName` is the protected base branch for `config`.
54
54
  *
55
- * The comparison is sanitization-aware: branch metadata names are sanitized but
56
- * `config.defaultBaseBranch` holds the raw git name, so both sides go through
57
- * `sanitizeBranchName` first.
55
+ * The comparison is sanitization-aware: both sides go through `sanitizeBranchName`
56
+ * first, the form a workspace directory takes, so a base workspace whose recorded
57
+ * name is sanitized still matches the raw git name `config.defaultBaseBranch` holds.
58
58
  *
59
59
  * `recordedBaseBranch` (a branch's own recorded fork point) is a second, purely
60
60
  * additive clause: a branch whose fork point equals its own name IS a base
@@ -15,9 +15,9 @@ import { baseBranchOf } from '../utils/base-branch.js';
15
15
  /**
16
16
  * Whether `branchName` is the protected base branch for `config`.
17
17
  *
18
- * The comparison is sanitization-aware: branch metadata names are sanitized but
19
- * `config.defaultBaseBranch` holds the raw git name, so both sides go through
20
- * `sanitizeBranchName` first.
18
+ * The comparison is sanitization-aware: both sides go through `sanitizeBranchName`
19
+ * first, the form a workspace directory takes, so a base workspace whose recorded
20
+ * name is sanitized still matches the raw git name `config.defaultBaseBranch` holds.
21
21
  *
22
22
  * `recordedBaseBranch` (a branch's own recorded fork point) is a second, purely
23
23
  * additive clause: a branch whose fork point equals its own name IS a base
@@ -85,6 +85,34 @@ export declare const branchMetadataSchema: z.ZodObject<{
85
85
  syncFailureReason: z.ZodOptional<z.ZodString>;
86
86
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
87
87
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
88
+ review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
89
+ state: z.ZodEnum<["approved", "changes-requested"]>;
90
+ by: z.ZodObject<{
91
+ userId: z.ZodString;
92
+ name: z.ZodOptional<z.ZodString>;
93
+ }, "strip", z.ZodTypeAny, {
94
+ userId: string;
95
+ name?: string | undefined;
96
+ }, {
97
+ userId: string;
98
+ name?: string | undefined;
99
+ }>;
100
+ at: z.ZodString;
101
+ }, "strip", z.ZodTypeAny, {
102
+ at: string;
103
+ state: "approved" | "changes-requested";
104
+ by: {
105
+ userId: string;
106
+ name?: string | undefined;
107
+ };
108
+ }, {
109
+ at: string;
110
+ state: "approved" | "changes-requested";
111
+ by: {
112
+ userId: string;
113
+ name?: string | undefined;
114
+ };
115
+ }>>>;
88
116
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
89
117
  name: z.ZodString;
90
118
  title: z.ZodOptional<z.ZodString>;
@@ -150,6 +178,34 @@ export declare const branchMetadataSchema: z.ZodObject<{
150
178
  syncFailureReason: z.ZodOptional<z.ZodString>;
151
179
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
152
180
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
181
+ review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
182
+ state: z.ZodEnum<["approved", "changes-requested"]>;
183
+ by: z.ZodObject<{
184
+ userId: z.ZodString;
185
+ name: z.ZodOptional<z.ZodString>;
186
+ }, "strip", z.ZodTypeAny, {
187
+ userId: string;
188
+ name?: string | undefined;
189
+ }, {
190
+ userId: string;
191
+ name?: string | undefined;
192
+ }>;
193
+ at: z.ZodString;
194
+ }, "strip", z.ZodTypeAny, {
195
+ at: string;
196
+ state: "approved" | "changes-requested";
197
+ by: {
198
+ userId: string;
199
+ name?: string | undefined;
200
+ };
201
+ }, {
202
+ at: string;
203
+ state: "approved" | "changes-requested";
204
+ by: {
205
+ userId: string;
206
+ name?: string | undefined;
207
+ };
208
+ }>>>;
153
209
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
154
210
  name: z.ZodString;
155
211
  title: z.ZodOptional<z.ZodString>;
@@ -215,6 +271,34 @@ export declare const branchMetadataSchema: z.ZodObject<{
215
271
  syncFailureReason: z.ZodOptional<z.ZodString>;
216
272
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
217
273
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
274
+ review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
275
+ state: z.ZodEnum<["approved", "changes-requested"]>;
276
+ by: z.ZodObject<{
277
+ userId: z.ZodString;
278
+ name: z.ZodOptional<z.ZodString>;
279
+ }, "strip", z.ZodTypeAny, {
280
+ userId: string;
281
+ name?: string | undefined;
282
+ }, {
283
+ userId: string;
284
+ name?: string | undefined;
285
+ }>;
286
+ at: z.ZodString;
287
+ }, "strip", z.ZodTypeAny, {
288
+ at: string;
289
+ state: "approved" | "changes-requested";
290
+ by: {
291
+ userId: string;
292
+ name?: string | undefined;
293
+ };
294
+ }, {
295
+ at: string;
296
+ state: "approved" | "changes-requested";
297
+ by: {
298
+ userId: string;
299
+ name?: string | undefined;
300
+ };
301
+ }>>>;
218
302
  }, z.ZodTypeAny, "passthrough">>;
219
303
  /**
220
304
  * Read, parse and validate `branch.json`, with no locking, no OCC and no side
@@ -84,6 +84,15 @@ const branchMetadataShape = {
84
84
  syncFailureReason: z.string().optional(),
85
85
  editors: z.array(z.string()).optional(),
86
86
  uncommittedEditors: z.array(z.string()).optional(),
87
+ // Dropped when malformed: it only decorates the PR body, so it never fails a read.
88
+ review: z
89
+ .object({
90
+ state: z.enum(['approved', 'changes-requested']),
91
+ by: z.object({ userId: z.string(), name: z.string().optional() }),
92
+ at: z.string(),
93
+ })
94
+ .optional()
95
+ .catch(undefined),
87
96
  };
88
97
  /** The `branch` object, also embedded in the registry snapshot (branch-registry.ts). */
89
98
  export const branchMetadataSchema = z.object(branchMetadataShape).passthrough();
@@ -124,11 +124,11 @@ export interface BranchMetadataUpdate {
124
124
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
125
125
  * API (api/branch-merge.ts), so both produce identical metadata.
126
126
  *
127
- * Deliberately omits pullRequestNumber/pullRequestUrl: save() keeps existing
128
- * values for fields the incoming update omits, so the recorded PR number and
129
- * URL survive untouched.
127
+ * Deliberately omits `name` and pullRequestNumber/pullRequestUrl: save() keeps
128
+ * existing values for fields the incoming update omits, so the branch's ref and
129
+ * its recorded PR number and URL survive untouched.
130
130
  */
131
- export declare function buildMergedBranchUpdate(branchName: string, now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
131
+ export declare function buildMergedBranchUpdate(now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
132
132
  /** {@link BranchMetadataFileManager.get} as a function. */
133
133
  export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string, options?: {
134
134
  settleMs?: number;
@@ -272,13 +272,12 @@ export function buildInitialBranchMetadata(incoming, now) {
272
272
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
273
273
  * API (api/branch-merge.ts), so both produce identical metadata.
274
274
  *
275
- * Deliberately omits pullRequestNumber/pullRequestUrl: save() keeps existing
276
- * values for fields the incoming update omits, so the recorded PR number and
277
- * URL survive untouched.
275
+ * Deliberately omits `name` and pullRequestNumber/pullRequestUrl: save() keeps
276
+ * existing values for fields the incoming update omits, so the branch's ref and
277
+ * its recorded PR number and URL survive untouched.
278
278
  */
279
- export function buildMergedBranchUpdate(branchName, now = new Date()) {
279
+ export function buildMergedBranchUpdate(now = new Date()) {
280
280
  return {
281
- name: branchName,
282
281
  status: 'archived',
283
282
  pullRequestState: 'merged',
284
283
  mergedAt: now.toISOString(),
@@ -0,0 +1,24 @@
1
+ import type { CanopyConfig } from './config/index.js';
2
+ import type { BranchContext } from './types.js';
3
+ export type BranchParamConfig = Pick<CanopyConfig, 'mode' | 'defaultBaseBranch' | 'defaultActiveBranch' | 'branchNamespace' | 'deploymentName'>;
4
+ /**
5
+ * The name pull request links and messages give the branch whose ref is `name`: its short name
6
+ * under this CMS's namespace, or the full ref when the short name is the base or active
7
+ * branch's, which the resolver would answer instead.
8
+ */
9
+ export declare function shortNameOf(config: BranchParamConfig, name: string): string;
10
+ /**
11
+ * The workspace `param` names, or null. A `param` in the exact form (`exactBranchSegment`,
12
+ * `~canopycms~prod~spring`) names only the branch whose branch.json `name` it decodes to, and a
13
+ * malformed one names none. Any other `param`, in order:
14
+ * 1. the base or configured active branch, exactly, so no namespace branch ever shadows it;
15
+ * 2. the namespace branch whose short name is `param` (which holds no `/`), when its branch.json
16
+ * `name` is that ref;
17
+ * 3. a branch outside the namespace by its full name, when its `name` is `param`. A namespace
18
+ * branch's full ref resolves here too. A branch outside the namespace that step 2 shadows
19
+ * opens only by the exact form.
20
+ * Throws what loading throws for a name that can name no workspace (`namesNoWorkspace`).
21
+ */
22
+ export declare function resolveBranchParam(param: string, config: BranchParamConfig, options?: {
23
+ basePathOverride?: string;
24
+ }): Promise<BranchContext | null>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The one way a branch named in a request, by a `/:branch` segment or `?branch=`, becomes its
3
+ * workspace. A branch has three names: its ref (`canopycms/prod/spring`), which git and
4
+ * branch.json's `name` hold; its key (`sanitizeBranchName(ref)`), the workspace directory and
5
+ * registry key; and its short name (`spring`, `branchShortName`), which pull request links carry
6
+ * and requests may carry in place of the ref.
7
+ */
8
+ import { loadBranchContext } from './branch-metadata.js';
9
+ import { operatingStrategy } from './operating-mode/index.js';
10
+ import { namesNoWorkspace } from './paths/branch.js';
11
+ import { branchShortName } from './paths/branch-name.js';
12
+ import { exactBranchName, isExactBranchForm } from './paths/validation.js';
13
+ import { BaseBranchUnresolvedError, baseBranchOf } from './utils/base-branch.js';
14
+ /**
15
+ * The namespace this CMS creates branches in, or null when its template is invalid: creation
16
+ * then refuses, and System health names why.
17
+ */
18
+ function cmsBranchNamespace(config) {
19
+ try {
20
+ return operatingStrategy(config.mode).getBranchNamespace(config);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ /** Whether `param` is the base or configured active branch, which resolve before any other. */
27
+ function namesBaseOrActive(param, config) {
28
+ let base;
29
+ try {
30
+ base = baseBranchOf(config);
31
+ }
32
+ catch (err) {
33
+ if (err instanceof BaseBranchUnresolvedError)
34
+ return false;
35
+ throw err;
36
+ }
37
+ return param === base || param === (config.defaultActiveBranch ?? base);
38
+ }
39
+ /**
40
+ * The name pull request links and messages give the branch whose ref is `name`: its short name
41
+ * under this CMS's namespace, or the full ref when the short name is the base or active
42
+ * branch's, which the resolver would answer instead.
43
+ */
44
+ export function shortNameOf(config, name) {
45
+ const short = branchShortName(name, cmsBranchNamespace(config));
46
+ return short !== name && namesBaseOrActive(short, config) ? name : short;
47
+ }
48
+ /**
49
+ * The workspace `param` names, or null. A `param` in the exact form (`exactBranchSegment`,
50
+ * `~canopycms~prod~spring`) names only the branch whose branch.json `name` it decodes to, and a
51
+ * malformed one names none. Any other `param`, in order:
52
+ * 1. the base or configured active branch, exactly, so no namespace branch ever shadows it;
53
+ * 2. the namespace branch whose short name is `param` (which holds no `/`), when its branch.json
54
+ * `name` is that ref;
55
+ * 3. a branch outside the namespace by its full name, when its `name` is `param`. A namespace
56
+ * branch's full ref resolves here too. A branch outside the namespace that step 2 shadows
57
+ * opens only by the exact form.
58
+ * Throws what loading throws for a name that can name no workspace (`namesNoWorkspace`).
59
+ */
60
+ export async function resolveBranchParam(param, config, options = {}) {
61
+ const load = (branchName) => loadBranchContext({ branchName, mode: config.mode, basePathOverride: options.basePathOverride });
62
+ if (isExactBranchForm(param)) {
63
+ const exact = exactBranchName(param);
64
+ if (exact === null)
65
+ return null;
66
+ const named = await load(exact);
67
+ return named?.branch.name === exact ? named : null;
68
+ }
69
+ if (namesBaseOrActive(param, config))
70
+ return load(param);
71
+ const namespace = cmsBranchNamespace(config);
72
+ // A short name holding a `/` is a full name (`branchShortName`), so a `param` with one names no
73
+ // namespace branch by the part after the namespace.
74
+ if (namespace !== null && !param.includes('/')) {
75
+ const ref = `${namespace}${param}`;
76
+ // A ref past a filename's length can still leave `param` naming a branch outside it.
77
+ const inNamespace = await load(ref).catch((err) => {
78
+ if (namesNoWorkspace(err))
79
+ return null;
80
+ throw err;
81
+ });
82
+ if (inNamespace?.branch.name === ref)
83
+ return inNamespace;
84
+ }
85
+ const outside = await load(param);
86
+ return outside?.branch.name === param ? outside : null;
87
+ }
@@ -5,11 +5,12 @@ import type { ProvisionLog } from './utils/provision-log.js';
5
5
  export declare const CREATE_RESIDUE_MIN_QUIET_MS = 60000;
6
6
  /**
7
7
  * Retriable: another process is setting this branch up, or what is at its path is too recently
8
- * touched (or under an admin repair) to judge. Callers answer 503.
8
+ * touched (or under an admin repair) to judge. Callers answer 503. The message names the branch
9
+ * by `shortName`, which an editor knows it by, not by its workspace directory.
9
10
  */
10
11
  export declare class BranchProvisioningBusyError extends Error {
11
12
  readonly dirName: string;
12
- constructor(dirName: string, message?: string);
13
+ constructor(dirName: string, shortName?: string, message?: string);
13
14
  }
14
15
  /** The branch's path holds a git repository this deployment did not create (prod only). */
15
16
  export declare class BranchDirOccupiedError extends Error {
@@ -130,12 +131,14 @@ export declare function stageBranchWorkspace(options: StageBranchOptions): Promi
130
131
  * The branch's provisioning lock with a bounded wait; contention is
131
132
  * {@link BranchProvisioningBusyError}.
132
133
  */
133
- export declare function lockBranchName(baseRoot: string, dirName: string): Promise<() => Promise<void>>;
134
+ export declare function lockBranchName(baseRoot: string, dirName: string, shortName?: string): Promise<() => Promise<void>>;
134
135
  /** What blocks a refused publish rename, as {@link publishStaging} sees it. */
135
136
  export type BlockingKind = 'live' | 'protected' | 'residue';
136
137
  export interface PublishOptions {
137
138
  baseRoot: string;
138
139
  dirName: string;
140
+ /** What a busy answer calls the branch; defaults to `dirName`. */
141
+ shortName?: string;
139
142
  stagingPath: string;
140
143
  /** branch.json's `writeId` in the staged workspace: proof the rename was ours. */
141
144
  writeId: string | undefined;
@@ -45,10 +45,11 @@ const DELETING_MAX_AGE_MS = 10 * 60_000;
45
45
  const SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
46
46
  /**
47
47
  * Retriable: another process is setting this branch up, or what is at its path is too recently
48
- * touched (or under an admin repair) to judge. Callers answer 503.
48
+ * touched (or under an admin repair) to judge. Callers answer 503. The message names the branch
49
+ * by `shortName`, which an editor knows it by, not by its workspace directory.
49
50
  */
50
51
  export class BranchProvisioningBusyError extends Error {
51
- constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
52
+ constructor(dirName, shortName = dirName, message = `Branch '${shortName}' is still being set up. Try again in a minute.`) {
52
53
  super(message);
53
54
  this.dirName = dirName;
54
55
  this.name = 'BranchProvisioningBusyError';
@@ -334,13 +335,14 @@ export async function stageBranchWorkspace(options) {
334
335
  * The branch's provisioning lock with a bounded wait; contention is
335
336
  * {@link BranchProvisioningBusyError}.
336
337
  */
337
- export async function lockBranchName(baseRoot, dirName) {
338
+ export async function lockBranchName(baseRoot, dirName, shortName = dirName) {
338
339
  try {
339
340
  return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
340
341
  }
341
342
  catch (err) {
342
- if (isNodeError(err) && err.code === 'ELOCKED')
343
- throw new BranchProvisioningBusyError(dirName);
343
+ if (isNodeError(err) && err.code === 'ELOCKED') {
344
+ throw new BranchProvisioningBusyError(dirName, shortName);
345
+ }
344
346
  throw err;
345
347
  }
346
348
  }
@@ -363,9 +365,9 @@ const RENAME_BLOCKED = new Set(['EEXIST', 'ENOTEMPTY', 'ENOTDIR']);
363
365
  * `writeId`. The caller removes the staging directory on any outcome but `published`.
364
366
  */
365
367
  export async function publishStaging(options) {
366
- const { baseRoot, dirName, stagingPath } = options;
368
+ const { baseRoot, dirName, stagingPath, shortName = dirName } = options;
367
369
  const finalPath = path.join(baseRoot, dirName);
368
- const release = await lockBranchName(baseRoot, dirName);
370
+ const release = await lockBranchName(baseRoot, dirName, shortName);
369
371
  try {
370
372
  for (let attempt = 0; attempt < 2; attempt++) {
371
373
  try {
@@ -388,7 +390,7 @@ export async function publishStaging(options) {
388
390
  if (blocking === 'live')
389
391
  return 'exists';
390
392
  if (blocking === 'protected')
391
- throw new BranchProvisioningBusyError(dirName);
393
+ throw new BranchProvisioningBusyError(dirName, shortName);
392
394
  const result = await quarantineResidueAt(baseRoot, dirName, {
393
395
  minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
394
396
  expectedRemoteUrl: options.expectedRemoteUrl,
@@ -403,9 +405,9 @@ export async function publishStaging(options) {
403
405
  }
404
406
  if (result.reason === 'foreign')
405
407
  throw new BranchDirOccupiedError(dirName);
406
- throw new BranchProvisioningBusyError(dirName);
408
+ throw new BranchProvisioningBusyError(dirName, shortName);
407
409
  }
408
- throw new BranchProvisioningBusyError(dirName);
410
+ throw new BranchProvisioningBusyError(dirName, shortName);
409
411
  }
410
412
  finally {
411
413
  await release().catch((err) => {
@@ -56,6 +56,12 @@ export declare class BranchRegistry {
56
56
  * genuinely-absent branch costs at most one extra scan per window.
57
57
  */
58
58
  get(name: string): Promise<BranchContext | undefined>;
59
+ /**
60
+ * Returns the branch whose workspace directory is `key` (`sanitizeBranchName` of its ref),
61
+ * whatever its name, with {@link get}'s backstop.
62
+ */
63
+ getByKey(key: string): Promise<BranchContext | undefined>;
64
+ private find;
59
65
  private shouldForceRefresh;
60
66
  /**
61
67
  * Marks the cache stale for every process sharing this root by bumping the
@@ -136,14 +136,23 @@ export class BranchRegistry {
136
136
  * genuinely-absent branch costs at most one extra scan per window.
137
137
  */
138
138
  async get(name) {
139
- const branches = await this.list();
140
- const found = branches.find((b) => b.branch.name === name);
139
+ return this.find((b) => b.branch.name === name);
140
+ }
141
+ /**
142
+ * Returns the branch whose workspace directory is `key` (`sanitizeBranchName` of its ref),
143
+ * whatever its name, with {@link get}'s backstop.
144
+ */
145
+ async getByKey(key) {
146
+ return this.find((b) => path.basename(b.branchRoot) === key);
147
+ }
148
+ async find(matches) {
149
+ const found = (await this.list()).find(matches);
141
150
  if (found)
142
151
  return found;
143
152
  if (!this.shouldForceRefresh())
144
153
  return undefined;
145
154
  const refreshed = await this.regenerate();
146
- return refreshed.find((b) => b.branch.name === name);
155
+ return refreshed.find(matches);
147
156
  }
148
157
  shouldForceRefresh() {
149
158
  const now = Date.now();