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
@@ -1,43 +1,121 @@
1
+ import { z } from 'zod';
1
2
  import { branchParamSchema } from './validators.js';
2
3
  import { toBranchListItem } from './branch.js';
3
4
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
4
5
  import { defineEndpoint } from './route-builder.js';
5
6
  import { syncConvertToDraft } from './github-sync.js';
6
- const requestChangesHandler = async (gc, ctx, req, _params) => {
7
+ import { syncPrComments } from './pr-comment-sync.js';
8
+ import { CommentStore } from '../comment-store.js';
9
+ import { getErrorMessage } from '../utils/error.js';
10
+ import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
+ import { getBranchProtection } from '../authorization/index.js';
12
+ /** Optional on the wire: a bodiless request means no note. */
13
+ const requestChangesBodySchema = z
14
+ .object({ note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional() })
15
+ .optional();
16
+ const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
17
+ /**
18
+ * The 409 for a branch that left review before this request arrived, because the reviewer's page
19
+ * was stale. `action` finishes "so ..." for any status that has no wording of its own.
20
+ */
21
+ const staleStatusError = (status, action) => status === 'editing'
22
+ ? "This branch is no longer in review: it's back with its editor."
23
+ : status === undefined
24
+ ? `This branch's status can't be read, so ${action}.`
25
+ : `This branch is ${status}, so ${action}.`;
26
+ const canRequestChangesFrom = (status) => status === 'submitted' || status === 'approved';
27
+ const requestChangesConflict = (status) => ({
28
+ ok: false,
29
+ status: 409,
30
+ error: staleStatusError(status, "changes can't be requested"),
31
+ });
32
+ const approveConflict = (status) => ({
33
+ ok: false,
34
+ status: 409,
35
+ error: status === 'approved'
36
+ ? 'This branch is already approved.'
37
+ : staleStatusError(status, "it can't be approved"),
38
+ });
39
+ const requestChangesHandler = async (gc, ctx, req, _params, body) => {
7
40
  const { branchContext } = gc;
8
- if (branchContext.branch.status !== 'submitted') {
9
- return {
10
- ok: false,
11
- status: 400,
12
- error: `Cannot request changes on branch with status '${branchContext.branch.status}'. Only 'submitted' branches can have changes requested.`,
13
- };
41
+ if (!canRequestChangesFrom(branchContext.branch.status)) {
42
+ return requestChangesConflict(branchContext.branch.status);
14
43
  }
15
44
  await syncConvertToDraft(ctx, branchContext);
16
45
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
17
- const updated = await meta.save({
18
- branch: { name: branchContext.branch.name, status: 'editing' },
46
+ // The status the guard read can be stale by now, so the save checks the status on disk, in the
47
+ // same step that writes. A branch that left review meanwhile is refused, and the draft
48
+ // conversion above is left as it is: withdraw converts the pull request to a draft too, and a
49
+ // merged one cannot be converted.
50
+ let found;
51
+ const updated = await meta.saveIf({ branch: { name: branchContext.branch.name, status: 'editing' } }, (existing) => {
52
+ found = existing?.branch.status;
53
+ return canRequestChangesFrom(found);
19
54
  });
20
- // TODO: Optionally record comment in .canopy-meta/comments.json when comment system is implemented
55
+ if (!updated)
56
+ return requestChangesConflict(found);
57
+ // The note is written after the status change so a failed write never blocks the branch from
58
+ // returning to editing; the caller is warned instead.
59
+ const note = body?.note;
60
+ let warning;
61
+ if (note) {
62
+ try {
63
+ await new CommentStore(branchContext.branchRoot).addComment({
64
+ userId: req.user.userId,
65
+ text: `Changes requested: ${note}`,
66
+ type: 'branch',
67
+ });
68
+ }
69
+ catch (err) {
70
+ console.error(`CanopyCMS: Failed to save the request-changes note for ${branchContext.branch.name}:`, getErrorMessage(err));
71
+ warning = NOTE_NOT_SAVED_WARNING;
72
+ }
73
+ if (!warning)
74
+ await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
75
+ }
21
76
  return {
22
77
  ok: true,
23
78
  status: 200,
24
- data: { branch: toBranchListItem(ctx.services.config, updated.branch, req.user) },
79
+ data: {
80
+ branch: toBranchListItem(ctx.services.config, updated.branch, req.user),
81
+ ...(warning && { warning }),
82
+ },
25
83
  };
26
84
  };
27
85
  const approveBranchHandler = async (gc, ctx, req, _params) => {
28
86
  const { branchContext } = gc;
29
- if (branchContext.branch.status !== 'submitted') {
87
+ // Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
88
+ // an author's approval review, so nothing is sent to GitHub.
89
+ if (branchContext.branch.createdBy === req.user.userId) {
90
+ return {
91
+ ok: false,
92
+ status: 403,
93
+ error: "You can't approve a branch you created. Ask another reviewer to approve it.",
94
+ };
95
+ }
96
+ // Request changes stays open on the base branch, as Withdraw does, to recover one stuck in review.
97
+ const { isProtected } = getBranchProtection(ctx.services.config, branchContext.branch.name, branchContext.branch.baseBranch);
98
+ if (isProtected) {
99
+ return { ok: false, status: 400, error: "The base branch can't be approved." };
100
+ }
101
+ if (branchContext.branch.status !== 'submitted')
102
+ return approveConflict(branchContext.branch.status);
103
+ if (branchContext.branch.pullRequestState === 'closed') {
30
104
  return {
31
105
  ok: false,
32
106
  status: 400,
33
- error: `Cannot approve branch with status '${branchContext.branch.status}'. Only 'submitted' branches can be approved.`,
107
+ error: 'The pull request was closed on GitHub, so this branch cannot be approved. Withdraw it from review, then submit it again to open a new one.',
34
108
  };
35
109
  }
36
110
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
37
- const updated = await meta.save({
38
- branch: { name: branchContext.branch.name, status: 'approved' },
111
+ // As for request changes, the status on disk decides, in the step that writes.
112
+ let found;
113
+ const updated = await meta.saveIf({ branch: { name: branchContext.branch.name, status: 'approved' } }, (existing) => {
114
+ found = existing?.branch.status;
115
+ return found === 'submitted';
39
116
  });
40
- // TODO: Optionally call githubService.approvePullRequest() when GitHub integration is needed
117
+ if (!updated)
118
+ return approveConflict(found);
41
119
  return {
42
120
  ok: true,
43
121
  status: 200,
@@ -50,8 +128,8 @@ export const requestChanges = defineEndpoint({
50
128
  method: 'POST',
51
129
  path: '/:branch/request-changes',
52
130
  params: branchParamSchema,
53
- // body/bodyType removed: comment field was declared but never stored.
54
- // Re-add when comment storage is implemented (see TODO in handler).
131
+ body: requestChangesBodySchema,
132
+ bodyType: 'RequestChangesBody',
55
133
  responseType: 'BranchListItemResponse',
56
134
  response: {},
57
135
  defaultMockData: {
@@ -3,6 +3,7 @@ import type { ApiResponse } from './types.js';
3
3
  import { type BranchListItem, type BranchListItemResponse } from './branch.js';
4
4
  import type { StampedEntry } from '../stamp-on-publish.js';
5
5
  export type { BranchMergeResponse } from './branch-merge.js';
6
+ export type { RequestChangesBody } from './branch-review.js';
6
7
  /**
7
8
  * A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
8
9
  * there were any, so the editor can carry its open forms and drafts onto their new versions.
@@ -11,6 +12,16 @@ export type SubmitBranchResponse = ApiResponse<{
11
12
  branch: BranchListItem;
12
13
  stamped?: StampedEntry[];
13
14
  }>;
15
+ declare const submitBranchBodySchema: z.ZodOptional<z.ZodObject<{
16
+ /** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
17
+ editorUrl: z.ZodCatch<z.ZodOptional<z.ZodString>>;
18
+ }, "strip", z.ZodTypeAny, {
19
+ editorUrl?: string | undefined;
20
+ }, {
21
+ editorUrl?: unknown;
22
+ }>>;
23
+ /** The submit request body. */
24
+ export type SubmitBranchBody = z.infer<typeof submitBranchBodySchema>;
14
25
  export declare const WORKFLOW_ROUTES: {
15
26
  readonly getStatus: import("./route-builder.js").RouteDefinition<z.ZodObject<{
16
27
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
@@ -33,7 +44,14 @@ export declare const WORKFLOW_ROUTES: {
33
44
  branch: string & {
34
45
  readonly __brand: "BranchName";
35
46
  };
36
- }>, undefined, SubmitBranchResponse>;
47
+ }>, z.ZodOptional<z.ZodObject<{
48
+ /** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
49
+ editorUrl: z.ZodCatch<z.ZodOptional<z.ZodString>>;
50
+ }, "strip", z.ZodTypeAny, {
51
+ editorUrl?: string | undefined;
52
+ }, {
53
+ editorUrl?: unknown;
54
+ }>>, SubmitBranchResponse>;
37
55
  readonly withdraw: import("./route-builder.js").RouteDefinition<z.ZodObject<{
38
56
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
39
57
  }, "strip", z.ZodTypeAny, {
@@ -55,7 +73,13 @@ export declare const WORKFLOW_ROUTES: {
55
73
  branch: string & {
56
74
  readonly __brand: "BranchName";
57
75
  };
58
- }>, undefined, BranchListItemResponse>;
76
+ }>, z.ZodOptional<z.ZodObject<{
77
+ note: z.ZodOptional<z.ZodString>;
78
+ }, "strip", z.ZodTypeAny, {
79
+ note?: string | undefined;
80
+ }, {
81
+ note?: string | undefined;
82
+ }>>, BranchListItemResponse>;
59
83
  readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
60
84
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
61
85
  }, "strip", z.ZodTypeAny, {
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import { branchParamSchema } from './validators.js';
2
3
  import { toBranchListItem } from './branch.js';
3
4
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
@@ -13,6 +14,7 @@ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
13
14
  import { submissionEditorFromUser } from '../submission-attribution.js';
14
15
  import { NothingToSubmitError } from '../services.js';
15
16
  import { baseBranchOf } from '../utils/base-branch.js';
17
+ import { resolveEditorUrl } from '../editor-url.js';
16
18
  const getBranchStatusHandler = async (gc, ctx, req, _params) => {
17
19
  const { branchContext } = gc;
18
20
  return {
@@ -21,7 +23,33 @@ const getBranchStatusHandler = async (gc, ctx, req, _params) => {
21
23
  data: { branch: toBranchListItem(ctx.services.config, branchContext.branch, req.user) },
22
24
  };
23
25
  };
24
- const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
26
+ const submitBranchBodySchema = z
27
+ .object({
28
+ /** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
29
+ // Any value but a usable URL costs only the links (editor-url.ts), never the submit.
30
+ editorUrl: z.string().optional().catch(undefined),
31
+ })
32
+ .optional();
33
+ /**
34
+ * The editor URL the PR body links to: `config.editorUrl`, else the URL the editor reported
35
+ * when the request was addressed to its host (editor-url.ts). A reported URL that fails that
36
+ * check is logged once per submit, since every link then goes missing.
37
+ */
38
+ function submitEditorUrl(ctx, req, claimed) {
39
+ const configured = ctx.services.config.editorUrl;
40
+ const resolved = resolveEditorUrl({
41
+ configured,
42
+ claimed,
43
+ header: req.rawRequest ? (name) => req.rawRequest?.header(name) ?? null : undefined,
44
+ });
45
+ if (!resolved && !configured && claimed) {
46
+ console.warn('CanopyCMS: The pull request body has no editor links: the editor URL the browser reported ' +
47
+ 'is not on the host the request was addressed to (X-Forwarded-Host, else Host). Set ' +
48
+ 'editorUrl in the CanopyCMS config to the editor page URL.');
49
+ }
50
+ return resolved;
51
+ }
52
+ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
25
53
  const { branchContext } = gc;
26
54
  // Check if user can perform workflow actions (creator OR ACL access). isProtectedBranch
27
55
  // is passed for defense-in-depth (disables the system-branch grant); the 'submittableBranch'
@@ -61,10 +89,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
61
89
  const { authPlugin } = ctx;
62
90
  let changedPaths;
63
91
  let editors;
92
+ let entryPaths;
64
93
  let stamped;
65
94
  try {
66
95
  ;
67
- ({ changedPaths, editors, stamped } = await ctx.services.submitBranch({
96
+ ({ changedPaths, editors, entryPaths, stamped } = await ctx.services.submitBranch({
68
97
  context: branchContext,
69
98
  submitter,
70
99
  lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
@@ -123,10 +152,13 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
123
152
  }
124
153
  // Create or update PR (sync via githubService, or async via task queue)
125
154
  const submittedAt = new Date().toISOString();
155
+ const editorUrl = submitEditorUrl(ctx, req, body?.editorUrl);
126
156
  const prResult = await syncSubmitPr(ctx, branchContext, {
127
157
  submitter,
128
158
  editors,
129
159
  changedPaths,
160
+ entryPaths,
161
+ editorUrl,
130
162
  submittedAt,
131
163
  });
132
164
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
@@ -149,6 +181,8 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
149
181
  status: 'submitted',
150
182
  submittedAt,
151
183
  ...pushed,
184
+ // Only a request-derived URL: config.editorUrl is read live by every later re-render.
185
+ ...(editorUrl && !ctx.services.config.editorUrl ? { editorUrl } : {}),
152
186
  syncFailureReason: prResult.syncFailureReason,
153
187
  pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
154
188
  pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
@@ -196,6 +230,8 @@ const submitBranchForMerge = defineEndpoint({
196
230
  method: 'POST',
197
231
  path: '/:branch/submit',
198
232
  params: branchParamSchema,
233
+ body: submitBranchBodySchema,
234
+ bodyType: 'SubmitBranchBody',
199
235
  responseType: 'SubmitBranchResponse',
200
236
  response: {},
201
237
  defaultMockData: {
@@ -22,13 +22,10 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
22
22
  error: 'You do not have permission to withdraw this branch from review.',
23
23
  };
24
24
  }
25
- // 'approved' is accepted alongside 'submitted' because withdraw is its only
26
- // non-destructive exit. Every non-'editing' status blocks content writes, and
27
- // request-changes requires 'submitted' -- so before this, an approved branch
28
- // could only be escaped by a raw workflow.submit call, which silently
29
- // discarded the approval and is now refused by the submit status gate
30
- // (api/branch-status.ts). Without this, closing that hole would seal the dead
31
- // end completely, leaving deletion as the only way out.
25
+ // 'approved' is accepted alongside 'submitted' because withdraw is the author's
26
+ // way back from either: the editor offers an author no Request changes. Every
27
+ // non-'editing' status blocks content writes, and a raw workflow.submit would
28
+ // discard the approval, which the submit status gate refuses (api/branch-status.ts).
32
29
  //
33
30
  // 'archived' stays refused: the PR is merged and there is nothing to return to.
34
31
  const status = branchContext.branch.status;
@@ -57,6 +57,8 @@ export interface BranchListItem extends BranchMetadata {
57
57
  */
58
58
  export type BranchListItemResponse = ApiResponse<{
59
59
  branch: BranchListItem;
60
+ /** Set when the request succeeded but a secondary effect failed; show it to the user. */
61
+ warning?: string;
60
62
  }>;
61
63
  /** Response type for listing branches */
62
64
  export type BranchListResponse = ApiResponse<{
@@ -9,7 +9,7 @@ import { type RequestOptions } from './request-deadline.js';
9
9
  export { isApiTimeoutError, WRITE_TIMED_OUT_MESSAGE, type RequestOptions } from './request-deadline.js';
10
10
  import type { OperatingMode } from '../operating-mode/types.js';
11
11
  import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
12
- import type { BranchMergeResponse, SubmitBranchResponse } from './branch-status.js';
12
+ import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
13
13
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
14
14
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
15
15
  import type { ReferenceOptionsResponse } from './reference-options.js';
@@ -82,7 +82,7 @@ export declare class CanopyApiClient {
82
82
  /** POST /:branch/withdraw */
83
83
  withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
84
84
  /** POST /:branch/request-changes */
85
- requestChanges: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
85
+ requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
86
86
  /** POST /:branch/approve */
87
87
  approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
88
88
  /** POST /:branch/mark-merged */
@@ -90,7 +90,7 @@ export declare class CanopyApiClient {
90
90
  /** GET /:branch/status */
91
91
  getStatus: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
92
92
  /** POST /:branch/submit */
93
- submit: (params: Record<string, string>, options?: RequestOptions) => Promise<SubmitBranchResponse>;
93
+ submit: (params: Record<string, string>, body: SubmitBranchBody, options?: RequestOptions) => Promise<SubmitBranchResponse>;
94
94
  };
95
95
  readonly comments: {
96
96
  /** GET /:branch/comments */
@@ -47,8 +47,8 @@ export class CanopyApiClient {
47
47
  return this.request('POST', this.buildPath('/:branch/withdraw', params), undefined, 'standard', options);
48
48
  },
49
49
  /** POST /:branch/request-changes */
50
- requestChanges: (params, options) => {
51
- return this.request('POST', this.buildPath('/:branch/request-changes', params), undefined, 'standard', options);
50
+ requestChanges: (params, body, options) => {
51
+ return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
52
52
  },
53
53
  /** POST /:branch/approve */
54
54
  approve: (params, options) => {
@@ -63,8 +63,8 @@ export class CanopyApiClient {
63
63
  return this.request('GET', this.buildPath('/:branch/status', params), undefined, 'standard', options);
64
64
  },
65
65
  /** POST /:branch/submit */
66
- submit: (params, options) => {
67
- return this.request('POST', this.buildPath('/:branch/submit', params), undefined, 'long', options);
66
+ submit: (params, body, options) => {
67
+ return this.request('POST', this.buildPath('/:branch/submit', params), body, 'long', options);
68
68
  },
69
69
  };
70
70
  this.comments = {
@@ -60,16 +60,16 @@ export declare const COMMENT_ROUTES: {
60
60
  }, "strip", z.ZodTypeAny, {
61
61
  type: "branch" | "entry" | "field";
62
62
  text: string;
63
- entryPath?: LogicalPath | undefined;
64
63
  threadId?: string | undefined;
65
64
  itemKeys?: string[] | undefined;
65
+ entryPath?: LogicalPath | undefined;
66
66
  canopyPath?: string | undefined;
67
67
  }, {
68
68
  type: "branch" | "entry" | "field";
69
69
  text: string;
70
- entryPath?: LogicalPath | undefined;
71
70
  threadId?: string | undefined;
72
71
  itemKeys?: string[] | undefined;
72
+ entryPath?: LogicalPath | undefined;
73
73
  canopyPath?: string | undefined;
74
74
  }>, AddCommentResponse>;
75
75
  readonly resolve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -6,6 +6,7 @@ import { isReviewer } from '../authorization/index.js';
6
6
  import { parseLogicalPath } from '../paths/validation.js';
7
7
  import { normalizeFilesystemPath } from '../paths/normalize.js';
8
8
  import { defineEndpoint } from './route-builder.js';
9
+ import { syncPrComments } from './pr-comment-sync.js';
9
10
  import { isItemKey } from '../item-keys.js';
10
11
  import { parseCanopyPath } from '../editor/canopy-path.js';
11
12
  import { branchParamSchema, branchNameSchema, logicalPathSchema } from './validators.js';
@@ -141,6 +142,7 @@ const addCommentHandler = async (gc, ctx, req, _params, body) => {
141
142
  if (!reply) {
142
143
  return { ok: false, status: 404, error: 'Thread not found' };
143
144
  }
145
+ await syncPrComments(ctx, branchContext);
144
146
  return { ok: true, status: 201, data: reply };
145
147
  }
146
148
  if (!canRead(body.entryPath)) {
@@ -154,6 +156,7 @@ const addCommentHandler = async (gc, ctx, req, _params, body) => {
154
156
  canopyPath: body.canopyPath,
155
157
  itemKeys: body.itemKeys,
156
158
  });
159
+ await syncPrComments(ctx, branchContext);
157
160
  return { ok: true, status: 201, data: result };
158
161
  };
159
162
  const resolveCommentHandler = async (gc, ctx, req, params) => {
@@ -181,6 +184,7 @@ const resolveCommentHandler = async (gc, ctx, req, params) => {
181
184
  if (!resolved) {
182
185
  return { ok: false, status: 404, error: 'Thread not found' };
183
186
  }
187
+ await syncPrComments(ctx, branchContext);
184
188
  return { ok: true, status: 200, data: { resolved: true } };
185
189
  };
186
190
  // Deliberately no 'writableBranch' guard on any endpoint below: comments live
@@ -103,12 +103,12 @@ export declare const CONTENT_ROUTES: {
103
103
  body: z.ZodOptional<z.ZodString>;
104
104
  expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
105
105
  }, "strip", z.ZodTypeAny, {
106
- format: "mdx" | "md" | "json" | "yaml";
106
+ format: "md" | "mdx" | "json" | "yaml";
107
107
  data?: Record<string, unknown> | undefined;
108
108
  expectedVersion?: number | null | undefined;
109
109
  body?: string | undefined;
110
110
  }, {
111
- format: "mdx" | "md" | "json" | "yaml";
111
+ format: "md" | "mdx" | "json" | "yaml";
112
112
  data?: Record<string, unknown> | undefined;
113
113
  expectedVersion?: number | null | undefined;
114
114
  body?: string | undefined;
@@ -21,6 +21,10 @@ export interface SubmissionRecord {
21
21
  /** Every recorded editor of the branch; the PR body lists those other than the submitter. */
22
22
  editors?: readonly SubmissionEditor[];
23
23
  changedPaths: readonly string[];
24
+ /** The entry logical path of each changed path that is an entry, for its editor link. */
25
+ entryPaths?: ReadonlyMap<string, string>;
26
+ /** The editor page URL the PR body links to (editor-url.ts resolveEditorUrl). */
27
+ editorUrl?: string;
24
28
  /** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
25
29
  submittedAt?: string;
26
30
  }
@@ -6,6 +6,19 @@ import { sanitizeBranchName } from '../paths/branch-name.js';
6
6
  import { buildPrSection, mergePrSection } from '../submission-attribution.js';
7
7
  import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
8
8
  import { baseBranchOf } from '../utils/base-branch.js';
9
+ import { CommentStore } from '../comment-store.js';
10
+ import { prBodyLockKey, refreshCommentsBlock } from '../pr-comments.js';
11
+ import { withLock } from '../utils/async-mutex.js';
12
+ /** The branch's comment threads for the PR body, or null (said so in the body) if unreadable. */
13
+ async function readThreadsOrWarn(context) {
14
+ try {
15
+ return await new CommentStore(context.branchRoot).listThreadsStrict();
16
+ }
17
+ catch (err) {
18
+ console.warn(`CanopyCMS: Could not read the editor comments of ${context.branch.name} for its PR:`, getErrorMessage(err));
19
+ return null;
20
+ }
21
+ }
9
22
  /**
10
23
  * Uses githubService directly if available, otherwise queues a task for the worker.
11
24
  *
@@ -16,11 +29,16 @@ export async function syncSubmitPr(ctx, context, submission) {
16
29
  const { githubService } = ctx.services;
17
30
  const mode = ctx.services.config.mode;
18
31
  const prTitle = context.branch.title || `Submit ${context.branch.name}`;
32
+ const threads = await readThreadsOrWarn(context);
19
33
  const prSection = buildPrSection({
20
34
  description: context.branch.description,
21
35
  submitter: submission.submitter,
22
36
  editors: submission.editors,
23
37
  changedPaths: submission.changedPaths,
38
+ branch: context.branch.name,
39
+ editorUrl: submission.editorUrl,
40
+ entryPaths: submission.entryPaths,
41
+ threads,
24
42
  });
25
43
  // Target the fork point recorded at branch creation when available.
26
44
  const baseBranch = context.branch.baseBranch ?? baseBranchOf(ctx.services.config);
@@ -40,12 +58,24 @@ export async function syncSubmitPr(ctx, context, submission) {
40
58
  if (githubService) {
41
59
  try {
42
60
  if (context.branch.pullRequestNumber) {
61
+ const prNumber = context.branch.pullRequestNumber;
43
62
  // Read before writing: the update keeps the human text around the
44
63
  // canopycms section, and the same read says whether the PR is a draft.
45
- const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
46
- await githubService.updatePullRequest(context.branch.pullRequestNumber, {
47
- title: prTitle,
48
- body: mergePrSection(pr.body, prSection),
64
+ const pr = await withLock(prBodyLockKey(context.branchRoot), async () => {
65
+ const current = await githubService.getPullRequest(prNumber);
66
+ // Re-read under the lock: a comment synced since the read above has the newer block.
67
+ const section = await refreshCommentsBlock({
68
+ branchRoot: context.branchRoot,
69
+ branch: context.branch.name,
70
+ editorUrl: submission.editorUrl,
71
+ section: prSection,
72
+ warn: (message) => console.warn(`CanopyCMS: ${message}`),
73
+ });
74
+ await githubService.updatePullRequest(prNumber, {
75
+ title: prTitle,
76
+ body: mergePrSection(current.body, section),
77
+ });
78
+ return current;
49
79
  });
50
80
  // Best-effort draft->ready conversion. This branch updates a known
51
81
  // PR number directly (not through createOrUpdatePR), so it doesn't
@@ -83,14 +113,14 @@ export async function syncSubmitPr(ctx, context, submission) {
83
113
  // shared createOrUpdatePullRequest helper (github-service.ts), which
84
114
  // treats conversion as best-effort so a permissions-limited token
85
115
  // can't fail this submit.
86
- const result = await githubService.createOrUpdatePR({
116
+ const result = await withLock(prBodyLockKey(context.branchRoot), () => githubService.createOrUpdatePR({
87
117
  head: context.branch.name,
88
118
  base: baseBranch,
89
119
  title: prTitle,
90
120
  body: prSection,
91
121
  markReadyIfDraft: true,
92
122
  mergeSectionIntoBody: true,
93
- });
123
+ }));
94
124
  return {
95
125
  prUrl: result.url,
96
126
  prNumber: result.number,
@@ -127,6 +157,8 @@ export async function syncSubmitPr(ctx, context, submission) {
127
157
  title: prTitle,
128
158
  body: prSection,
129
159
  mergeSectionIntoBody: true,
160
+ // The worker re-renders the comment block from comments.json when the task runs.
161
+ ...(submission.editorUrl ? { editorUrl: submission.editorUrl } : {}),
130
162
  baseBranch,
131
163
  pullRequestNumber: context.branch.pullRequestNumber,
132
164
  // A content submit is an explicit "ready for review" action: convert a
@@ -0,0 +1,18 @@
1
+ import type { ApiContext } from './types.js';
2
+ import type { BranchContext } from '../types.js';
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.
6
+ *
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.
9
+ * GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
10
+ * orders this process's writers (docs/concurrency.md).
11
+ *
12
+ * 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.
17
+ */
18
+ export declare function syncPrComments(ctx: ApiContext, context: BranchContext): Promise<void>;
@@ -0,0 +1,77 @@
1
+ import { clientOperatingStrategy } from '../operating-mode/index.js';
2
+ import { SYNC_PR_COMMENTS_ACTION, prBodyLockKey, renderPrCommentsBody } from '../pr-comments.js';
3
+ import { isApiTimeoutError, runWithDeadline } from './request-deadline.js';
4
+ import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
5
+ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
6
+ import { withLock } from '../utils/async-mutex.js';
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. */
9
+ function openPullRequest(context) {
10
+ const { pullRequestNumber, pullRequestState, status } = context.branch;
11
+ if (!pullRequestNumber || status === 'archived')
12
+ return undefined;
13
+ return pullRequestState === 'merged' || pullRequestState === 'closed'
14
+ ? undefined
15
+ : pullRequestNumber;
16
+ }
17
+ /** How long a comment request waits on GitHub for its summary update, lock wait included. */
18
+ const DIRECT_SYNC_TIMEOUT_MS = 10_000;
19
+ /**
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.
22
+ *
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
+ * GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
26
+ * orders this process's writers (docs/concurrency.md).
27
+ *
28
+ * 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.
33
+ */
34
+ export async function syncPrComments(ctx, context) {
35
+ const prNumber = openPullRequest(context);
36
+ if (prNumber === undefined)
37
+ return;
38
+ if (!clientOperatingStrategy(ctx.services.config.mode).supportsPullRequests())
39
+ return;
40
+ const branch = context.branch.name;
41
+ const editorUrl = ctx.services.config.editorUrl ?? context.branch.editorUrl;
42
+ try {
43
+ const { githubService } = ctx.services;
44
+ if (githubService) {
45
+ await runWithDeadline((signal) => withLock(prBodyLockKey(context.branchRoot), async () => {
46
+ const pr = await githubService.getPullRequest(prNumber, signal);
47
+ if (pr.state !== 'open')
48
+ return;
49
+ const body = await renderPrCommentsBody({
50
+ branchRoot: context.branchRoot,
51
+ branch,
52
+ editorUrl,
53
+ body: pr.body,
54
+ });
55
+ if (body !== undefined)
56
+ await githubService.updatePullRequest(prNumber, { body }, signal);
57
+ }), { method: 'PATCH', path: `pull request #${prNumber}`, timeoutMs: DIRECT_SYNC_TIMEOUT_MS });
58
+ return;
59
+ }
60
+ const taskDir = getTaskQueueDir(ctx.services.config);
61
+ const pending = await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER);
62
+ const queued = pending.some((t) => t.action === SYNC_PR_COMMENTS_ACTION &&
63
+ t.payload.branch === branch &&
64
+ t.payload.pullRequestNumber === prNumber);
65
+ if (queued)
66
+ return;
67
+ await enqueueTask(taskDir, {
68
+ action: SYNC_PR_COMMENTS_ACTION,
69
+ payload: { branch, pullRequestNumber: prNumber, ...(editorUrl ? { editorUrl } : {}) },
70
+ });
71
+ }
72
+ catch (err) {
73
+ console.warn(`CanopyCMS: Could not update the editor comments on PR #${prNumber} for ${branch}:`, isApiTimeoutError(err)
74
+ ? `GitHub did not answer within ${DIRECT_SYNC_TIMEOUT_MS / 1000} s`
75
+ : getErrorMessage(err));
76
+ }
77
+ }