canopycms 0.5.0 → 0.7.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 (107) hide show
  1. package/CHANGELOG.md +185 -46
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +704 -131
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +15 -2
  31. package/dist/cli/template-files/cms-stack.ts.template +3 -0
  32. package/dist/cli/templates.js +5 -1
  33. package/dist/comment-store.d.ts +5 -0
  34. package/dist/comment-store.js +24 -0
  35. package/dist/config/helpers.js +1 -3
  36. package/dist/config/schemas/collection.d.ts +2 -2
  37. package/dist/config/schemas/config.d.ts +3 -0
  38. package/dist/config/schemas/config.js +2 -1
  39. package/dist/config/schemas/url.d.ts +6 -0
  40. package/dist/config/schemas/url.js +16 -0
  41. package/dist/config/types.d.ts +12 -1
  42. package/dist/content-id-index.d.ts +9 -0
  43. package/dist/content-id-index.js +31 -0
  44. package/dist/editor/BranchManager.d.ts +3 -0
  45. package/dist/editor/BranchManager.js +75 -44
  46. package/dist/editor/CanopyEditor.js +2 -2
  47. package/dist/editor/CanopyEditorPage.js +3 -4
  48. package/dist/editor/Editor.js +47 -8
  49. package/dist/editor/branch-lock.d.ts +7 -0
  50. package/dist/editor/branch-lock.js +9 -2
  51. package/dist/editor/client-reference-resolver.d.ts +5 -3
  52. package/dist/editor/client-reference-resolver.js +10 -5
  53. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  54. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  55. package/dist/editor/components/EditorHeader.d.ts +10 -4
  56. package/dist/editor/components/EditorHeader.js +108 -28
  57. package/dist/editor/components/EntryHeader.d.ts +5 -1
  58. package/dist/editor/components/EntryHeader.js +3 -1
  59. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  60. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  61. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  62. package/dist/editor/components/ReviewDialogs.js +41 -0
  63. package/dist/editor/copy.d.ts +4 -0
  64. package/dist/editor/copy.js +4 -0
  65. package/dist/editor/fields/MarkdownField.js +1 -1
  66. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  67. package/dist/editor/hooks/create-branch-request.js +7 -2
  68. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  69. package/dist/editor/hooks/useBranchManager.js +252 -37
  70. package/dist/editor-url.d.ts +20 -0
  71. package/dist/editor-url.js +71 -0
  72. package/dist/git-manager.js +10 -6
  73. package/dist/github-service.d.ts +2 -2
  74. package/dist/github-service.js +4 -2
  75. package/dist/pr-comments.d.ts +33 -0
  76. package/dist/pr-comments.js +41 -0
  77. package/dist/schema/schema-store.d.ts +8 -8
  78. package/dist/services.d.ts +2 -17
  79. package/dist/services.js +38 -1
  80. package/dist/settings-workspace.d.ts +21 -7
  81. package/dist/settings-workspace.js +91 -16
  82. package/dist/submission-attribution.d.ts +38 -1
  83. package/dist/submission-attribution.js +197 -18
  84. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  85. package/dist/types.d.ts +6 -0
  86. package/dist/utils/content-serialize.js +7 -6
  87. package/dist/utils/markdown-body-splice.d.ts +15 -10
  88. package/dist/utils/markdown-body-splice.js +71 -28
  89. package/dist/utils/markdown-export-options.d.ts +13 -0
  90. package/dist/utils/markdown-export-options.js +13 -0
  91. package/dist/utils/markdown-restyle.d.ts +58 -0
  92. package/dist/utils/markdown-restyle.js +482 -0
  93. package/dist/version.d.ts +1 -1
  94. package/dist/version.js +1 -1
  95. package/dist/worker/bundle-header.d.ts +13 -9
  96. package/dist/worker/bundle-header.js +17 -11
  97. package/dist/worker/git-process.d.ts +2 -2
  98. package/dist/worker/github-gateway-http.js +5 -4
  99. package/dist/worker/github-gateway-wire.d.ts +3 -0
  100. package/dist/worker/github-gateway-wire.js +1 -0
  101. package/dist/worker/github-gateway.d.ts +1 -1
  102. package/dist/worker/github-gateway.js +2 -2
  103. package/dist/worker/github-transfer.js +72 -2
  104. package/dist/worker/task-runner.js +53 -3
  105. package/package.json +2 -2
  106. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  107. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -107,6 +107,7 @@ export const answers = {
107
107
  merged_at: z.string().max(64).nullable(),
108
108
  state: z.enum(['open', 'closed']),
109
109
  node_id: z.string().max(256),
110
+ body: pullRequestText.body.nullable(),
110
111
  })
111
112
  .strict(),
112
113
  createOrUpdatePullRequest: pullRequestRef.extend({ created: z.boolean() }).strict(),
@@ -62,7 +62,7 @@ export declare function describeMove(update: Extract<GitHubRefUpdate, {
62
62
  moved: true;
63
63
  }>): string;
64
64
  /** 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'>;
65
+ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['response']['data'], 'merged' | 'merged_at' | 'state' | 'node_id' | 'body'>;
66
66
  export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
67
67
  /**
68
68
  * Everything the worker does with GitHub, and the only place the credential and Octokit are used.
@@ -283,8 +283,8 @@ class LocalGitHubGateway {
283
283
  }
284
284
  /**
285
285
  * One fetch serves every caller: concurrent callers join it, and its result is answered again
286
- * for {@link AUTH_SNAPSHOT_REUSE_MS}. A fetch is a request per user, so a worker asking in a loop
287
- * would otherwise hold the provider's API at its rate limit. The fetch runs apart from every
286
+ * for {@link AUTH_SNAPSHOT_REUSE_MS}. A fetch pages the provider's whole user and membership lists,
287
+ * so a worker asking in a loop would otherwise hold the provider's API at its rate limit. The fetch runs apart from every
288
288
  * caller's signal, which ends only that caller's wait, so a retry after a timed-out wait joins it.
289
289
  */
290
290
  async authSnapshot(signal) {
@@ -246,16 +246,46 @@ async function causeOf(proc, err, reason) {
246
246
  const own = await proc.exited.then(() => null, (gitErr) => gitErr);
247
247
  return own !== null && getErrorMessage(own) !== reason ? own : err;
248
248
  }
249
+ /**
250
+ * The most a fetch bundle's header may hold outside prerequisites' comments: a line per branch
251
+ * GitHub moved and one per boundary commit, so far more than a push's one head.
252
+ */
253
+ const FETCH_BUNDLE_HEADER_MAX_BYTES = 16 * 1024 * 1024;
249
254
  /**
250
255
  * Unbundle the gateway's bundle `bundleId` into the repository at `gitDir`, under the pins. It
251
256
  * adds objects and moves no ref.
257
+ *
258
+ * Not `git bundle unbundle`, which first runs a connectivity walk from every ref in `gitDir`:
259
+ * from git 2.56 one corrupt object in any branch's history is fatal to that walk, and so to every
260
+ * fetch until an operator repairs `remote.git`, where a reconcile skips that branch alone. Each
261
+ * prerequisite is checked to be a commit by its object header instead, so one torn after its
262
+ * header is taken like torn history anywhere else, and left to its branch's reconcile. The pack
263
+ * goes to the `index-pack --fix-thin --stdin` that `bundle unbundle` runs after its walk. A delta
264
+ * whose base is missing still fails, in index-pack.
252
265
  */
253
266
  async function unbundle(ctx, gitDir, bundleId, signal) {
254
267
  const stream = await ctx.github().openBundle(bundleId);
255
268
  try {
256
- await runGit(['bundle', 'unbundle', '--progress', '--end-of-options', '-'], {
269
+ const chunks = stream[Symbol.asyncIterator]();
270
+ const read = await readHeader(stream, chunks, ctx.taskTimeoutMs, signal);
271
+ const missing = await missingCommits(ctx, gitDir, read.header.prerequisites, signal);
272
+ if (missing.length > 0) {
273
+ throw new Error(`Refusing the bundle: its prerequisite ${missing[0]} is not a commit here`);
274
+ }
275
+ const { rest: first } = read;
276
+ const pack = Readable.from((async function* () {
277
+ if (first.length > 0)
278
+ yield first;
279
+ for (;;) {
280
+ const rest = await chunks.next();
281
+ if (rest.done)
282
+ return;
283
+ yield rest.value;
284
+ }
285
+ })());
286
+ await runGit(['index-pack', '-v', '--fix-thin', '--stdin'], {
257
287
  ...sharedRepoProcessOptions(gitDir, 'bare'),
258
- stdin: stream,
288
+ stdin: pack,
259
289
  inactivityTimeoutMs: ctx.taskTimeoutMs,
260
290
  signal,
261
291
  });
@@ -264,6 +294,46 @@ async function unbundle(ctx, gitDir, bundleId, signal) {
264
294
  stream.destroy();
265
295
  }
266
296
  }
297
+ /**
298
+ * Read the header of the fetch bundle `stream`, bounded as git is (worker/git-process.ts): the
299
+ * stream is destroyed when `signal` aborts or nothing arrives for `inactivityTimeoutMs`, since no
300
+ * git runs yet to be stopped.
301
+ */
302
+ async function readHeader(stream, chunks, inactivityTimeoutMs, signal) {
303
+ signal?.throwIfAborted();
304
+ let timer;
305
+ const onAbort = () => stream.destroy(new Error('Reading the bundle was aborted'));
306
+ signal?.addEventListener('abort', onAbort, { once: true });
307
+ try {
308
+ const reader = new BundleHeaderReader('skip', FETCH_BUNDLE_HEADER_MAX_BYTES);
309
+ for (;;) {
310
+ clearTimeout(timer);
311
+ timer = setTimeout(() => stream.destroy(new Error(`The bundle sent nothing for ${inactivityTimeoutMs}ms`)), inactivityTimeoutMs);
312
+ const next = await chunks.next();
313
+ if (next.done)
314
+ throw new Error('The bundle ended before its header did');
315
+ const read = reader.push(next.value);
316
+ if (read !== null)
317
+ return read;
318
+ }
319
+ }
320
+ finally {
321
+ clearTimeout(timer);
322
+ signal?.removeEventListener('abort', onAbort);
323
+ }
324
+ }
325
+ /** Of `ids`, those that are not a commit in the repository at `gitDir`. */
326
+ async function missingCommits(ctx, gitDir, ids, signal) {
327
+ if (ids.length === 0)
328
+ return [];
329
+ const lines = (await runGit(['cat-file', '--batch-check=%(objectname) %(objecttype)', '--end-of-options'], {
330
+ ...sharedRepoProcessOptions(gitDir, 'bare'),
331
+ stdin: ids.map((id) => `${id}\n`).join(''),
332
+ inactivityTimeoutMs: ctx.taskTimeoutMs,
333
+ signal,
334
+ })).split('\n');
335
+ return ids.filter((id, i) => lines[i] !== `${id} commit`);
336
+ }
267
337
  /**
268
338
  * Move the tracking namespace to exactly `refs`, every move stating the value it expects: all
269
339
  * deletions in one `update-ref` transaction, then every create and update in another. One
@@ -12,6 +12,7 @@ import { RefusedPushError, assertPlainBranchName } from './github-mirror.js';
12
12
  import { pushToGitHub } from './github-transfer.js';
13
13
  import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
14
14
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
15
+ import { SYNC_PR_COMMENTS_ACTION, refreshCommentsBlock, renderPrCommentsBody } from '../pr-comments.js';
15
16
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
16
17
  import { assertSharedRepoConfig } from './shared-repo-git.js';
17
18
  /**
@@ -331,16 +332,27 @@ export async function executeTask(ctx, task, signal) {
331
332
  return { pushed: true };
332
333
  }
333
334
  await ctx.pushBranchToGitHub(branch, signal);
335
+ const mergeSectionIntoBody = payload.mergeSectionIntoBody === true;
336
+ const queuedBody = optionalString(payload, 'body', '');
337
+ const body = mergeSectionIntoBody
338
+ ? await refreshCommentsBlock({
339
+ branchRoot: ctx.branchWorkspacePath(branch),
340
+ branch,
341
+ editorUrl: typeof payload.editorUrl === 'string' ? payload.editorUrl : undefined,
342
+ section: queuedBody,
343
+ warn: workerLogWarn,
344
+ })
345
+ : queuedBody;
334
346
  let result;
335
347
  try {
336
348
  result = await ctx.github().createOrUpdatePullRequest({
337
349
  head: branch,
338
350
  base,
339
351
  title: optionalString(payload, 'title', `Submit ${branch}`),
340
- body: optionalString(payload, 'body', ''),
352
+ body,
341
353
  // Content submits (api/github-sync.ts) set both.
342
354
  markReadyIfDraft: payload.markReadyIfDraft === true,
343
- mergeSectionIntoBody: payload.mergeSectionIntoBody === true,
355
+ mergeSectionIntoBody,
344
356
  }, signal);
345
357
  }
346
358
  catch (err) {
@@ -414,6 +426,44 @@ export async function executeTask(ctx, task, signal) {
414
426
  workerLog(`Deleted GitHub branch ${branch}`);
415
427
  return { deleted: true };
416
428
  }
429
+ case 'sync-pr-comments': {
430
+ const branch = requireString(payload, 'branch');
431
+ const prNumber = requireNumber(payload, 'pullRequestNumber');
432
+ const editorUrl = typeof payload.editorUrl === 'string' ? payload.editorUrl : undefined;
433
+ const branchPath = ctx.branchWorkspacePath(branch);
434
+ // The branch under this name now: a deleted branch, or a newer one with another PR, has
435
+ // no say in this PR's summary. Corrupt metadata skips too, as the next submit rewrites it.
436
+ let live = null;
437
+ try {
438
+ live = await BranchMetadataFileManager.loadOnly(branchPath);
439
+ }
440
+ catch (err) {
441
+ if (!(err instanceof BranchMetadataCorruptError))
442
+ throw err;
443
+ }
444
+ if (live?.branch.pullRequestNumber !== prNumber) {
445
+ return { updated: false, skipped: 'not-this-pr' };
446
+ }
447
+ const pr = await ctx.github().getPullRequest(prNumber, signal);
448
+ if (pr.state !== 'open')
449
+ return { updated: false, skipped: 'not-open' };
450
+ // A comments.json that does not parse stays so until a comment write rewrites it.
451
+ const body = await renderPrCommentsBody({
452
+ branchRoot: branchPath,
453
+ branch,
454
+ editorUrl,
455
+ body: pr.body,
456
+ }).catch((err) => {
457
+ if (err instanceof SyntaxError)
458
+ throw new PermanentTaskError(getErrorMessage(err));
459
+ throw err;
460
+ });
461
+ if (body === undefined)
462
+ return { updated: false, skipped: 'unchanged' };
463
+ await ctx.github().updatePullRequest(prNumber, { body }, signal);
464
+ workerLog(`Updated the editor comments on PR #${prNumber} for ${branch}`);
465
+ return { updated: true };
466
+ }
417
467
  default:
418
468
  throw new PermanentTaskError(`Unknown task action: ${action}`);
419
469
  }
@@ -424,7 +474,7 @@ export async function executeTask(ctx, task, signal) {
424
474
  * name is leftover or a newer branch's that reused the name.
425
475
  */
426
476
  function metadataBranchOf(task) {
427
- if (task.action === 'delete-remote-branch')
477
+ if (task.action === 'delete-remote-branch' || task.action === SYNC_PR_COMMENTS_ACTION)
428
478
  return null;
429
479
  return typeof task.payload.branch === 'string' ? task.payload.branch : null;
430
480
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.5.0",
4
+ "version": "0.7.0",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -161,7 +161,7 @@
161
161
  "react-dom": "^18.0.0 || ^19.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@playwright/test": "^1.57.0",
164
+ "@playwright/test": "^1.64.1",
165
165
  "@smithy/util-stream": "^3.0.0 || ^4.0.0",
166
166
  "@storybook/addon-essentials": "^8.1.11",
167
167
  "@storybook/react": "^8.1.11",
@@ -1,2 +0,0 @@
1
- import type { ToMarkdownOptions } from '@mdxeditor/editor';
2
- export declare const MARKDOWN_EXPORT_OPTIONS: ToMarkdownOptions;
@@ -1,8 +0,0 @@
1
- // Prettier's markers. The prop replaces MDXEditor's defaults, so `listItemIndent` is restated.
2
- export const MARKDOWN_EXPORT_OPTIONS = {
3
- listItemIndent: 'one',
4
- bullet: '-',
5
- emphasis: '_',
6
- strong: '*',
7
- rule: '-',
8
- };