canopycms 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -131,34 +131,49 @@ export declare const answers: {
131
131
  fatalError: z.ZodNullable<z.ZodString>;
132
132
  preflight: z.ZodEnum<["not-an-app", "verified", "unverified", "pending"]>;
133
133
  clerkConfigured: z.ZodBoolean;
134
- protectedBranches: z.ZodObject<{
134
+ branchPolicy: z.ZodObject<{
135
135
  configured: z.ZodArray<z.ZodString, "many">;
136
136
  githubDefault: z.ZodNullable<z.ZodString>;
137
+ namespace: z.ZodString;
138
+ legacy: z.ZodArray<z.ZodString, "many">;
139
+ settingsBranch: z.ZodNullable<z.ZodString>;
137
140
  }, "strict", z.ZodTypeAny, {
141
+ settingsBranch: string | null;
142
+ namespace: string;
143
+ legacy: string[];
138
144
  configured: string[];
139
145
  githubDefault: string | null;
140
146
  }, {
147
+ settingsBranch: string | null;
148
+ namespace: string;
149
+ legacy: string[];
141
150
  configured: string[];
142
151
  githubDefault: string | null;
143
152
  }>;
144
153
  }, "strict", z.ZodTypeAny, {
145
154
  ready: boolean;
146
- protectedBranches: {
147
- configured: string[];
148
- githubDefault: string | null;
149
- };
150
155
  preflight: "pending" | "not-an-app" | "verified" | "unverified";
151
156
  fatalError: string | null;
152
157
  clerkConfigured: boolean;
153
- }, {
154
- ready: boolean;
155
- protectedBranches: {
158
+ branchPolicy: {
159
+ settingsBranch: string | null;
160
+ namespace: string;
161
+ legacy: string[];
156
162
  configured: string[];
157
163
  githubDefault: string | null;
158
164
  };
165
+ }, {
166
+ ready: boolean;
159
167
  preflight: "pending" | "not-an-app" | "verified" | "unverified";
160
168
  fatalError: string | null;
161
169
  clerkConfigured: boolean;
170
+ branchPolicy: {
171
+ settingsBranch: string | null;
172
+ namespace: string;
173
+ legacy: string[];
174
+ configured: string[];
175
+ githubDefault: string | null;
176
+ };
162
177
  }>;
163
178
  readonly defaultBranch: z.ZodObject<{
164
179
  branch: z.ZodString;
@@ -227,13 +242,13 @@ export declare const answers: {
227
242
  email: z.ZodString;
228
243
  avatarUrl: z.ZodOptional<z.ZodString>;
229
244
  }, "strict", z.ZodTypeAny, {
230
- name: string;
231
245
  id: string;
246
+ name: string;
232
247
  email: string;
233
248
  avatarUrl?: string | undefined;
234
249
  }, {
235
- name: string;
236
250
  id: string;
251
+ name: string;
237
252
  email: string;
238
253
  avatarUrl?: string | undefined;
239
254
  }>, "many">;
@@ -243,41 +258,41 @@ export declare const answers: {
243
258
  description: z.ZodOptional<z.ZodString>;
244
259
  memberCount: z.ZodOptional<z.ZodNumber>;
245
260
  }, "strict", z.ZodTypeAny, {
246
- name: string;
247
261
  id: string;
262
+ name: string;
248
263
  description?: string | undefined;
249
264
  memberCount?: number | undefined;
250
265
  }, {
251
- name: string;
252
266
  id: string;
267
+ name: string;
253
268
  description?: string | undefined;
254
269
  memberCount?: number | undefined;
255
270
  }>, "many">;
256
271
  memberships: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString, "many">>;
257
272
  }, "strict", z.ZodTypeAny, {
258
273
  groups: {
259
- name: string;
260
274
  id: string;
275
+ name: string;
261
276
  description?: string | undefined;
262
277
  memberCount?: number | undefined;
263
278
  }[];
264
279
  users: {
265
- name: string;
266
280
  id: string;
281
+ name: string;
267
282
  email: string;
268
283
  avatarUrl?: string | undefined;
269
284
  }[];
270
285
  memberships: Record<string, string[]>;
271
286
  }, {
272
287
  groups: {
273
- name: string;
274
288
  id: string;
289
+ name: string;
275
290
  description?: string | undefined;
276
291
  memberCount?: number | undefined;
277
292
  }[];
278
293
  users: {
279
- name: string;
280
294
  id: string;
295
+ name: string;
281
296
  email: string;
282
297
  avatarUrl?: string | undefined;
283
298
  }[];
@@ -325,11 +340,11 @@ export declare const fetchPoll: z.ZodDiscriminatedUnion<"state", [z.ZodObject<{
325
340
  state: z.ZodLiteral<"running">;
326
341
  progress: z.ZodNumber;
327
342
  }, "strict", z.ZodTypeAny, {
328
- progress: number;
329
343
  state: "running";
330
- }, {
331
344
  progress: number;
345
+ }, {
332
346
  state: "running";
347
+ progress: number;
333
348
  }>, z.ZodObject<{
334
349
  state: z.ZodLiteral<"done">;
335
350
  progress: z.ZodNumber;
@@ -337,13 +352,13 @@ export declare const fetchPoll: z.ZodDiscriminatedUnion<"state", [z.ZodObject<{
337
352
  refs: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodString], null>, "many">;
338
353
  bundleId: z.ZodNullable<z.ZodString>;
339
354
  }, "strict", z.ZodTypeAny, {
340
- progress: number;
341
355
  state: "done";
356
+ progress: number;
342
357
  refs: [string, string][];
343
358
  bundleId: string | null;
344
359
  }, {
345
- progress: number;
346
360
  state: "done";
361
+ progress: number;
347
362
  refs: [string, string][];
348
363
  bundleId: string | null;
349
364
  }>]>;
@@ -401,22 +416,22 @@ declare const errorEnvelope: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
401
416
  push: z.ZodBoolean;
402
417
  operation: z.ZodEnum<["push", "delete", "pull-request"]>;
403
418
  branch: z.ZodString;
404
- reason: z.ZodEnum<["invalid", "protected", "settings", "not-a-commit", "too-long"]>;
419
+ reason: z.ZodEnum<["invalid", "protected", "settings", "other-settings", "outside-namespace", "not-a-commit", "too-long"]>;
405
420
  message: z.ZodString;
406
421
  }, "strict", z.ZodTypeAny, {
422
+ branch: string;
407
423
  push: boolean;
408
424
  message: string;
409
- branch: string;
410
- reason: "settings" | "protected" | "invalid" | "not-a-commit" | "too-long";
425
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
411
426
  kind: "refusal";
412
- operation: "push" | "pull-request" | "delete";
427
+ operation: "push" | "delete" | "pull-request";
413
428
  }, {
429
+ branch: string;
414
430
  push: boolean;
415
431
  message: string;
416
- branch: string;
417
- reason: "settings" | "protected" | "invalid" | "not-a-commit" | "too-long";
432
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
418
433
  kind: "refusal";
419
- operation: "push" | "pull-request" | "delete";
434
+ operation: "push" | "delete" | "pull-request";
420
435
  }>, z.ZodObject<{
421
436
  kind: z.ZodLiteral<"push">;
422
437
  pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease"]>;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { GatewayRefusalError, MAX_PROTECTED_BRANCHES } from './branch-policy.js';
2
+ import { GatewayRefusalError, MAX_LEGACY_BRANCHES, MAX_PROTECTED_BRANCHES } from './branch-policy.js';
3
3
  import { GitHubPushError, MAX_FETCH_HAVE } from './github-gateway.js';
4
4
  import { GitHubTransientError, RefusedPushError } from './github-mirror.js';
5
5
  import { gatewayLogWarn, redactEgress } from './gateway-egress.js';
@@ -89,10 +89,13 @@ export const answers = {
89
89
  fatalError: message.nullable(),
90
90
  preflight: z.enum(['not-an-app', 'verified', 'unverified', 'pending']),
91
91
  clerkConfigured: z.boolean(),
92
- protectedBranches: z
92
+ branchPolicy: z
93
93
  .object({
94
94
  configured: z.array(name).max(MAX_PROTECTED_BRANCHES + 1),
95
95
  githubDefault: name.nullable(),
96
+ namespace: name,
97
+ legacy: z.array(name).max(MAX_LEGACY_BRANCHES),
98
+ settingsBranch: name.nullable(),
96
99
  })
97
100
  .strict(),
98
101
  })
@@ -232,7 +235,15 @@ const errorEnvelope = z.discriminatedUnion('kind', [
232
235
  push: z.boolean(),
233
236
  operation: z.enum(['push', 'delete', 'pull-request']),
234
237
  branch: name,
235
- reason: z.enum(['invalid', 'protected', 'settings', 'not-a-commit', 'too-long']),
238
+ reason: z.enum([
239
+ 'invalid',
240
+ 'protected',
241
+ 'settings',
242
+ 'other-settings',
243
+ 'outside-namespace',
244
+ 'not-a-commit',
245
+ 'too-long',
246
+ ]),
236
247
  message,
237
248
  })
238
249
  .strict(),
@@ -4,6 +4,7 @@ import type { AuthCacheSnapshot } from '../auth/cache.js';
4
4
  import { type CreateOrUpdatePullRequestParams } from '../github-service.js';
5
5
  import { type ResolvedGitHubAuth } from './github-auth.js';
6
6
  import { type GitHubRefUpdate } from './github-mirror.js';
7
+ import { type BranchPolicyDescription } from './branch-policy.js';
7
8
  import { type GatewayInstanceOptions } from './gateway-instance.js';
8
9
  /** What a {@link GitHubGateway.fetch} brought back. */
9
10
  export interface GitHubFetchResult {
@@ -62,7 +63,14 @@ export declare function describeMove(update: Extract<GitHubRefUpdate, {
62
63
  moved: true;
63
64
  }>): string;
64
65
  /** The fields of a pull request the worker reads. */
65
- export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['response']['data'], 'merged' | 'merged_at' | 'state' | 'node_id' | 'body'>;
66
+ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['response']['data'], 'merged' | 'merged_at' | 'state' | 'node_id'> & {
67
+ /**
68
+ * The pull request's body, `''` when it has none. `null` when the gateway withholds it, as it
69
+ * does for every pull request it would not update (`LocalGitHubGateway.getPullRequest`). A pull
70
+ * request a person opened from a branch in the namespace has its body answered.
71
+ */
72
+ body: string | null;
73
+ };
66
74
  export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
67
75
  /**
68
76
  * Everything the worker does with GitHub, and the only place the credential and Octokit are used.
@@ -178,11 +186,8 @@ export interface GitHubGatewayHealth {
178
186
  preflight: 'not-an-app' | 'verified' | 'unverified' | 'pending';
179
187
  /** Whether the gateway holds a Clerk secret key. */
180
188
  clerkConfigured: boolean;
181
- /** The protected set (worker/branch-policy.ts): configured names and globs, and GitHub's default as last read. */
182
- protectedBranches: {
183
- configured: string[];
184
- githubDefault: string | null;
185
- };
189
+ /** The branch policy as it stands (`BranchPolicy.describe`). */
190
+ branchPolicy: BranchPolicyDescription;
186
191
  }
187
192
  /** `GatewayInstanceOptions` carries the termination watch and the lifecycle completion. */
188
193
  export interface LocalGitHubGatewayOptions extends GatewayInstanceOptions {
@@ -211,6 +216,12 @@ export interface LocalGitHubGatewayOptions extends GatewayInstanceOptions {
211
216
  baseBranch?: string;
212
217
  /** Further branches it protects alike: names and `*` globs (worker/branch-policy.ts). */
213
218
  protectedBranches?: readonly string[];
219
+ /** This deployment's settings branch, the only settings branch the gateway pushes. */
220
+ settingsBranch?: string;
221
+ /** The branch namespace it writes, resolved (operating-mode/branch-namespace.ts). */
222
+ branchNamespace?: string;
223
+ /** Branches outside the namespace it may still write: exact names (worker/branch-policy.ts). */
224
+ legacyBranches?: readonly string[];
214
225
  /**
215
226
  * Fetches the auth provider's snapshot with the provider's key; its presence is
216
227
  * `health().clerkConfigured`. Without it, `authSnapshot()` throws.
@@ -5,7 +5,7 @@ import { getErrorMessage } from '../utils/error.js';
5
5
  import { isStaleLeaseRejection, workflowPushRefusalFile } from '../utils/git.js';
6
6
  import { isTransientAuthFailure } from './github-auth.js';
7
7
  import { GitHubMirror, GitHubTransientError, RefusedPushError, assertPlainBranchName, isObjectId, } from './github-mirror.js';
8
- import { BranchPolicy, GatewayRefusalError, assertPullRequestText } from './branch-policy.js';
8
+ import { BranchPolicy, GatewayRefusalError, assertPullRequestText, } from './branch-policy.js';
9
9
  import { gatewayLog, gatewayLogError, gatewayLogWarn, redactEgress, redactError, redactValue, registerLiveSecret, } from './gateway-egress.js';
10
10
  import { GatewayInstance, untilAborted } from './gateway-instance.js';
11
11
  /** The most `have` IDs one fetch takes. */
@@ -78,6 +78,9 @@ class LocalGitHubGateway {
78
78
  this.policy = new BranchPolicy({
79
79
  baseBranch: options.baseBranch,
80
80
  protectedBranches: options.protectedBranches,
81
+ settingsBranch: options.settingsBranch,
82
+ legacyBranches: options.legacyBranches,
83
+ namespace: options.branchNamespace,
81
84
  });
82
85
  }
83
86
  get repo() {
@@ -341,7 +344,7 @@ class LocalGitHubGateway {
341
344
  fatalError,
342
345
  preflight: this.preflight,
343
346
  clerkConfigured: this.options.authSnapshot !== undefined,
344
- protectedBranches: this.policy.describe(),
347
+ branchPolicy: this.policy.describe(),
345
348
  };
346
349
  }
347
350
  /**
@@ -618,7 +621,9 @@ class LocalGitHubGateway {
618
621
  }
619
622
  updatePullRequest(number, update, signal) {
620
623
  return this.credentialed(signal, async (reach) => {
621
- assertPullRequestText(`Refusing to update pull request #${number}`, `#${number}`, update);
624
+ const refusing = `Refusing to ${update.state === 'closed' ? 'close' : 'update'} pull request #${number}`;
625
+ assertPullRequestText(refusing, `#${number}`, update);
626
+ await this.assertOwnPullRequest(reach, number, refusing, signal);
622
627
  await this.api(reach, () => this.octokit.pulls.update({
623
628
  ...this.repo,
624
629
  pull_number: number,
@@ -627,11 +632,62 @@ class LocalGitHubGateway {
627
632
  }));
628
633
  });
629
634
  }
635
+ /**
636
+ * Pull request `number`'s merge state, for any number, and its body only when `updatePullRequest`
637
+ * would take its head: a branch of this repository that `BranchPolicy.takesPullRequestFrom`
638
+ * admits (the namespace or a `legacyBranches` entry) and that is not protected, whoever opened
639
+ * the pull request. Withholding more would protect nothing a write cannot change. When GitHub's
640
+ * default branch cannot be read, the configured protected set and the default as last read
641
+ * decide. The number comes from the worker, which a compromised CMS Lambda can steer; the head is
642
+ * GitHub's record.
643
+ */
630
644
  getPullRequest(number, signal) {
631
- return this.credentialed(signal, (reach) => this.pullRequest(reach, number, signal));
645
+ return this.credentialed(signal, async (reach) => {
646
+ const { data } = await this.api(reach, () => this.octokit.pulls.get({ ...this.repo, pull_number: number, request: { signal } }));
647
+ const head = data.head.ref;
648
+ // By id, as `assertOwnPullRequest` compares; a deleted fork has no repo. A protected head is
649
+ // one the gateway refuses to update the pull request of, so its body is withheld too.
650
+ let ours = data.head.repo?.id === data.base.repo.id && this.policy.takesPullRequestFrom(head);
651
+ if (ours) {
652
+ try {
653
+ ours = !(await this.policy.isProtected(head, () => this.readDefaultBranch(reach, signal)));
654
+ }
655
+ catch (err) {
656
+ // `isProtected` reads GitHub only for a head the configured set (the base branch and
657
+ // each `protectedBranches` entry) does not cover. The merge poll needs the merge state,
658
+ // so an unread default branch leaves the body to that set and to the default as last
659
+ // read, however old: `updatePullRequest` judges the head again before writing it.
660
+ if (signal?.aborted)
661
+ throw err;
662
+ if (this.policy.matchesLastReadDefault(head))
663
+ ours = false;
664
+ gatewayLogWarn(`Could not read GitHub's default branch for pull request #${number} ` +
665
+ `(${redactEgress(getErrorMessage(err))}); judging its head by the configured protected set and the default as last read`);
666
+ }
667
+ }
668
+ return {
669
+ merged: data.merged,
670
+ merged_at: data.merged_at,
671
+ state: data.state,
672
+ node_id: data.node_id,
673
+ body: ours ? (data.body ?? '') : null,
674
+ };
675
+ });
632
676
  }
633
- async pullRequest(reach, number, signal) {
677
+ /**
678
+ * Read pull request `number` and refuse it unless its head is a branch of this repository that
679
+ * a pull request may come from (`BranchPolicy.assertPullRequestHeadAllowed`). The number comes
680
+ * from the worker; the head is GitHub's record, which the worker cannot choose.
681
+ */
682
+ async assertOwnPullRequest(reach, number, refusing, signal) {
634
683
  const { data } = await this.api(reach, () => this.octokit.pulls.get({ ...this.repo, pull_number: number, request: { signal } }));
684
+ const head = data.head.ref;
685
+ // By id, not name: the configured name keeps working after a rename or transfer, and the base
686
+ // repository is the one asked. A deleted fork has no repo.
687
+ if (data.head.repo?.id !== data.base.repo.id) {
688
+ throw new GatewayRefusalError('pull-request', head, 'outside-namespace', `${refusing}: its head is not a branch of this repository`);
689
+ }
690
+ await this.policy.assertPullRequestHeadAllowed(`${refusing}, whose head is ${JSON.stringify(head)}`, head, () => this.readDefaultBranch(reach, signal));
635
691
  return data;
636
692
  }
637
693
  createOrUpdatePullRequest(request, signal) {
@@ -649,7 +705,7 @@ class LocalGitHubGateway {
649
705
  convertPullRequestToDraft(number, signal) {
650
706
  return this.credentialed(signal, async (reach) => {
651
707
  // The REST API cannot convert a PR to a draft; GraphQL can.
652
- const pr = await this.pullRequest(reach, number, signal);
708
+ const pr = await this.assertOwnPullRequest(reach, number, `Refusing to convert pull request #${number} to a draft`, signal);
653
709
  await this.api(reach, () => this.octokit.graphql(`mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: pr.node_id, request: { signal } }));
654
710
  });
655
711
  }
@@ -1,7 +1,7 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import { type SimpleGit } from 'simple-git';
3
3
  import { type BundleHeader } from './bundle-header.js';
4
- import { BranchPolicy, GatewayRefusalError } from './branch-policy.js';
4
+ import { BranchPolicy, GatewayRefusalError, type PushPolicyRefusal } from './branch-policy.js';
5
5
  /**
6
6
  * The worker's private bare mirror of the GitHub repository, in a state directory the CMS Lambda
7
7
  * cannot reach. It and the quarantines beside it are the only repositories in which git ever runs
@@ -222,11 +222,14 @@ export interface GitHubCredential {
222
222
  */
223
223
  export declare function isOwnLockFailure(message: string): boolean;
224
224
  /**
225
- * A push the worker never makes: to a name that is not a plain branch name, to a protected branch
226
- * (`BranchPolicy`), to a settings branch under a lease, or of an object that is not a commit.
225
+ * A push the worker never makes: to a name that is not a plain branch name, one the policy refuses
226
+ * (`BranchPolicy.assertPushAllowed`, which owns those messages), or of an object that is not a
227
+ * commit.
227
228
  */
228
229
  export declare class RefusedPushError extends GatewayRefusalError {
229
- constructor(branch: string, reason: 'invalid' | 'protected' | 'settings' | 'not-a-commit', sha?: string);
230
+ constructor(branch: string, reason: 'invalid' | PushPolicyRefusal | 'not-a-commit', sha?: string,
231
+ /** The policy's own message, which names the namespace (`pushRefusalMessage`). */
232
+ message?: string);
230
233
  }
231
234
  /**
232
235
  * Throw {@link RefusedPushError} (or, for another `operation`, {@link GatewayRefusalError}) unless
@@ -7,7 +7,7 @@ import { getErrorMessage } from '../utils/error.js';
7
7
  import { BundleHeaderError, BundleHeaderReader, hasOnlyHead, } from './bundle-header.js';
8
8
  import { runGit } from './git-process.js';
9
9
  import { MaintenanceMutex, findCorruption, maintainMirror, objectStoreKiB, rebuildPaths, recoverRebuild, sweepLocksAndTemporaries, } from './github-mirror-upkeep.js';
10
- import { GatewayRefusalError, MAX_BRANCH_NAME_LENGTH, branchNameProblem, } from './branch-policy.js';
10
+ import { GatewayRefusalError, MAX_BRANCH_NAME_LENGTH, branchNameProblem, namespaceBlockedMessage, branchInsideMessage, } from './branch-policy.js';
11
11
  import { gatewayLog, gatewayLogError, gatewayLogWarn, redactEgress } from './gateway-egress.js';
12
12
  import { mirrorGitOptions, mirrorPins, quarantinePins } from './shared-repo-git.js';
13
13
  /**
@@ -553,23 +553,22 @@ async function assertOwnDirectory(dir) {
553
553
  }
554
554
  }
555
555
  /**
556
- * A push the worker never makes: to a name that is not a plain branch name, to a protected branch
557
- * (`BranchPolicy`), to a settings branch under a lease, or of an object that is not a commit.
556
+ * A push the worker never makes: to a name that is not a plain branch name, one the policy refuses
557
+ * (`BranchPolicy.assertPushAllowed`, which owns those messages), or of an object that is not a
558
+ * commit.
558
559
  */
559
560
  export class RefusedPushError extends GatewayRefusalError {
560
- constructor(branch, reason, sha) {
561
- super('push', branch, reason, reason === 'invalid'
562
- ? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.`
563
- : reason === 'protected'
564
- ? `Refusing to push "${branch}" to GitHub: it is protected (the base branch, GitHub's ` +
565
- `default branch, or a name protectedBranches lists), which the CanopyCMS worker never ` +
566
- `pushes. Nothing in CanopyCMS queues such a push; find out what wrote this task.`
567
- : reason === 'settings'
568
- ? `Refusing to push "${branch}" to GitHub under a lease: a settings branch only ever ` +
569
- `fast-forwards. Find out what set a history-rewrite marker on it.`
570
- : `Refusing to push "${branch}" to GitHub: ${sha ?? 'the object asked for'} is not a ` +
561
+ constructor(branch, reason, sha,
562
+ /** The policy's own message, which names the namespace (`pushRefusalMessage`). */
563
+ message) {
564
+ super('push', branch, reason, message ??
565
+ (reason === 'invalid'
566
+ ? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.`
567
+ : reason === 'not-a-commit'
568
+ ? `Refusing to push "${branch}" to GitHub: ${sha ?? 'the object asked for'} is not a ` +
571
569
  `commit, and a branch can only point at one. Find out what put it at ` +
572
- `refs/heads/${branch} in remote.git.`);
570
+ `refs/heads/${branch} in remote.git.`
571
+ : `Refusing to push ${JSON.stringify(branch)} to GitHub (${reason}).`));
573
572
  this.name = 'RefusedPushError';
574
573
  }
575
574
  }
@@ -890,7 +889,19 @@ export class MirrorSession {
890
889
  () => this.githubDefaultBranch(credential, options.signal).catch((err) => {
891
890
  throw new GitHubTransientError('default-branch-unread', `Could not read GitHub's default branch before pushing ${branch}: ` +
892
891
  redactEgress(getErrorMessage(err)).trim());
893
- }), (reason) => new RefusedPushError(branch, reason));
892
+ }), (reason, message) => new RefusedPushError(branch, reason, undefined, message));
893
+ // The mirror's heads are GitHub's as of this session's fetch.
894
+ if (branch.startsWith(options.policy.namespace)) {
895
+ const heads = await this.heads();
896
+ const blocker = options.policy.namespaceBlocker(heads);
897
+ if (blocker !== null) {
898
+ throw new RefusedPushError(branch, 'invalid', undefined, namespaceBlockedMessage(branch, blocker));
899
+ }
900
+ const inside = [...heads.keys()].find((name) => name.startsWith(`${branch}/`));
901
+ if (inside !== undefined) {
902
+ throw new RefusedPushError(branch, 'invalid', undefined, branchInsideMessage(branch, inside));
903
+ }
904
+ }
894
905
  options.onPushing?.();
895
906
  const status = await this.githubBound(credential, [
896
907
  'push',
@@ -1,3 +1,4 @@
1
+ import { type BranchPolicyDescription } from './branch-policy.js';
1
2
  import type { WorkerContext } from './worker-context.js';
2
3
  /**
3
4
  * [SYNC-H1] The history-rewrite kernel: what happens when the rebase loop
@@ -29,19 +30,25 @@ export type HistoryRewriteContext = Pick<WorkerContext, 'remoteGitPath' | 'conte
29
30
  */
30
31
  export declare function readPublishedSha(ctx: Pick<HistoryRewriteContext, 'remoteGitPath'>, branchRef: string): Promise<string | null>;
31
32
  /**
32
- * Record that this worker rewrote `expectedSha` out of a branch's already
33
- * published history (see BranchMetadata.historyRewrittenFrom).
33
+ * Record that this worker rewrote `expectedSha` out of `branchRef`'s already
34
+ * published history (see BranchMetadata.historyRewrittenFrom), on a branch.json
35
+ * whose `name` is `branchRef` only: it never creates one, nor marks another
36
+ * branch's that shares the key.
34
37
  *
35
38
  * Set-once: an existing marker is LEFT ALONE. Across two rebases before any
36
39
  * GitHub push lands, GitHub still holds the commit the FIRST rebase replaced,
37
40
  * so advancing the marker would aim the lease at a commit GitHub never had and
38
41
  * permanently wedge the branch.
39
42
  *
40
- * Re-reads metadata rather than trusting the caller's snapshot: the task loop
41
- * runs concurrently with syncGit() and may have cleared the marker while this
42
- * branch was rebasing.
43
+ * Decided against the metadata on disk inside the write's lock, not the
44
+ * caller's snapshot: the task loop runs concurrently with syncGit() and may
45
+ * have cleared the marker while this branch was rebasing.
46
+ *
47
+ * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
48
+ * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
49
+ * remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
43
50
  */
44
- export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchDir: string, expectedSha: string): Promise<void>;
51
+ export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
45
52
  /**
46
53
  * Clear the marker once GitHub is confirmed to hold the rewritten history.
47
54
  *
@@ -56,7 +63,7 @@ export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'c
56
63
  * Tracked, with the concurrent-clear race that can drop a marker mid-arming,
57
64
  * in safeinsights/canopycms#902.
58
65
  */
59
- export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchDir: string): Promise<void>;
66
+ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string): Promise<void>;
60
67
  /**
61
68
  * Publish a branch clone's rebased history into `remote.git`, replacing
62
69
  * EXACTLY `expectedSha` and nothing else.
@@ -79,10 +86,15 @@ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContex
79
86
  *
80
87
  * Deliberately NOT skipped when a marker is already set: inferring "a task must
81
88
  * already be queued" from the marker starves this hop whenever a task was lost,
82
- * failed permanently, or was never written. Duplicate push tasks are bounded by
83
- * base-branch advances and are idempotent.
89
+ * failed permanently, or was never written. Skipped instead when `gatewayPolicy`,
90
+ * the gateway's own description, says it would refuse the branch: the marker
91
+ * clears only on a push that lands, so such a task would fail permanently on
92
+ * every cycle, and System health's unpublishable alert already names the branch.
93
+ * Without a description every hop is queued. So while a marker stands, at most
94
+ * one push task is in flight and one is added per sync cycle, only for a failure
95
+ * the policy does not predict; the tasks are idempotent.
84
96
  */
85
- export declare function enqueueGitHubPush(ctx: Pick<HistoryRewriteContext, 'taskDir'>, branchRef: string): Promise<void>;
97
+ export declare function enqueueGitHubPush(ctx: Pick<HistoryRewriteContext, 'taskDir'>, branchRef: string, gatewayPolicy: BranchPolicyDescription | undefined): Promise<void>;
86
98
  /**
87
99
  * Complete a rewrite this worker started but did not finish: get the rebased
88
100
  * history into `remote.git` and queued for GitHub.
@@ -100,6 +112,7 @@ export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, opti
100
112
  branchRef: string;
101
113
  headSha: string;
102
114
  marker: string;
115
+ gatewayPolicy: BranchPolicyDescription | undefined;
103
116
  }): Promise<void>;
104
117
  /**
105
118
  * Whether this branch carries a pending history rewrite -- the rebase loop