canopycms 0.0.67 → 0.0.68-int.94
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.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/git-manager.d.ts
CHANGED
|
@@ -69,6 +69,17 @@ export declare class GitRemoteRefMissingError extends Error {
|
|
|
69
69
|
*/
|
|
70
70
|
gitError?: unknown | undefined);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* No git remote is configured and the one the strategy auto-detects does not
|
|
74
|
+
* exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
|
|
75
|
+
* its first boot, so until then every request that needs a workspace fails
|
|
76
|
+
* here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
|
|
77
|
+
* The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
|
|
78
|
+
*/
|
|
79
|
+
export declare class RemoteNotReadyError extends Error {
|
|
80
|
+
readonly expectedRemotePath: string;
|
|
81
|
+
constructor(expectedRemotePath: string);
|
|
82
|
+
}
|
|
72
83
|
export interface ResolveRemoteUrlOptions {
|
|
73
84
|
mode: OperatingMode;
|
|
74
85
|
remoteUrl?: string;
|
|
@@ -173,7 +184,7 @@ export declare class GitManager {
|
|
|
173
184
|
* deleteBranchHandler: a head left in `remote.git` forever makes the
|
|
174
185
|
* create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
|
|
175
186
|
* the reused branch's first publish non-fast-forward against the stale head
|
|
176
|
-
* (`GitManager.push()` pushes
|
|
187
|
+
* (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
|
|
177
188
|
* not an ancestor of the new branch), and a retried submit then skips the
|
|
178
189
|
* local push on a clean tree and enqueues the worker push of the STALE head,
|
|
179
190
|
* resurrecting the deleted branch's content on GitHub as an apparent success.
|
|
@@ -223,6 +234,8 @@ export declare class GitManager {
|
|
|
223
234
|
* @param source - Where `url` came from, for the thrown error message only.
|
|
224
235
|
*/
|
|
225
236
|
private static assertRemoteUrlAllowedInMode;
|
|
237
|
+
/** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
|
|
238
|
+
private static remoteNotReadyError;
|
|
226
239
|
/**
|
|
227
240
|
* Resolves the remote URL for git operations following the priority:
|
|
228
241
|
* 1. Explicit remoteUrl parameter
|
|
@@ -283,8 +296,15 @@ export declare class GitManager {
|
|
|
283
296
|
rebaseOntoBase(): Promise<void>;
|
|
284
297
|
private rebaseOntoBaseInner;
|
|
285
298
|
add(files: string | string[]): Promise<void>;
|
|
299
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
300
|
+
addAllExceptCanopyState(): Promise<void>;
|
|
286
301
|
commit(message: string): Promise<void>;
|
|
287
302
|
push(branch?: string): Promise<void>;
|
|
303
|
+
/**
|
|
304
|
+
* The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
|
|
305
|
+
* `heads/<name>` when a same-named tag exists, which is not a branch name.
|
|
306
|
+
*/
|
|
307
|
+
private currentBranchName;
|
|
288
308
|
/**
|
|
289
309
|
* Whether the local branch has commits the remote mirror doesn't -- i.e.
|
|
290
310
|
* whether push() would move the remote ref forward.
|
|
@@ -303,6 +323,13 @@ export declare class GitManager {
|
|
|
303
323
|
* ("couldn't find remote ref"); that counts as "ahead", not an error.
|
|
304
324
|
*/
|
|
305
325
|
hasUnpushedCommits(branch?: string): Promise<boolean>;
|
|
326
|
+
/**
|
|
327
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
328
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
329
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
330
|
+
* constraint.
|
|
331
|
+
*/
|
|
332
|
+
listChangedPathsSinceBase(): Promise<string[]>;
|
|
306
333
|
ensureAuthor(author: {
|
|
307
334
|
name: string;
|
|
308
335
|
email: string;
|
|
@@ -317,11 +344,58 @@ export declare class GitManager {
|
|
|
317
344
|
*/
|
|
318
345
|
ensureGitExclude(pattern: string): Promise<void>;
|
|
319
346
|
/**
|
|
320
|
-
*
|
|
321
|
-
* deployment-specific settings never pollute content history. It holds
|
|
322
|
-
* settings files committed by explicit path (permissions.json,
|
|
323
|
-
* the workspace root).
|
|
347
|
+
* Put the workspace on the settings branch: an orphan (no shared history),
|
|
348
|
+
* so deployment-specific settings never pollute content history. It holds
|
|
349
|
+
* only settings files committed by explicit path (permissions.json,
|
|
350
|
+
* groups.json at the workspace root).
|
|
351
|
+
*
|
|
352
|
+
* The remote is the settings branch's durable copy, and the workspace clone
|
|
353
|
+
* is `--single-branch` at the base branch, so the remote is asked first:
|
|
354
|
+
* - no local branch: check out the remote's, or create an empty orphan only
|
|
355
|
+
* when the remote has none. An unreadable remote throws
|
|
356
|
+
* {@link SettingsRemoteUnreadableError} rather than guess "none".
|
|
357
|
+
* - local branch present: a history unrelated to the remote's is repaired
|
|
358
|
+
* onto the remote's when the local branch is still its empty initial
|
|
359
|
+
* commit with a clean working tree, and otherwise throws
|
|
360
|
+
* {@link SettingsBranchDivergedError}; local commits are never discarded.
|
|
361
|
+
* An unreadable remote throws only for that empty branch, since one that
|
|
362
|
+
* holds settings can serve them and the next save surfaces the remote error.
|
|
363
|
+
* Either way a remote branch holding content is never adopted:
|
|
364
|
+
* {@link SettingsBranchHasContentHistoryError}.
|
|
324
365
|
*/
|
|
325
366
|
createOrphanSettingsBranch(branchName: string, initialFiles: Record<string, string>): Promise<void>;
|
|
326
367
|
private createOrphanSettingsBranchInner;
|
|
368
|
+
/**
|
|
369
|
+
* Compares the checked-out local settings branch with the remote's tip.
|
|
370
|
+
* Related histories are left for the next settings pull to reconcile; only
|
|
371
|
+
* an unrelated one is acted on here.
|
|
372
|
+
*/
|
|
373
|
+
private reconcileLocalSettingsBranch;
|
|
374
|
+
/**
|
|
375
|
+
* The remote's tip for `branch`, or `undefined` when the remote has no such
|
|
376
|
+
* branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
|
|
377
|
+
* output only for a reachable remote without the ref.
|
|
378
|
+
*/
|
|
379
|
+
private remoteBranchTip;
|
|
380
|
+
/**
|
|
381
|
+
* Fetch `branch` from the remote and return the fetched commit, pinned as
|
|
382
|
+
* pullBaseInner explains. The full ref: a bare name resolves to a
|
|
383
|
+
* same-named tag first.
|
|
384
|
+
*/
|
|
385
|
+
private fetchBranchTip;
|
|
386
|
+
/**
|
|
387
|
+
* Refuse to adopt a remote settings branch that holds content: its tip has a file other than
|
|
388
|
+
* the settings files, or its roots include the base branch's (a settings branch is an orphan).
|
|
389
|
+
* Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
|
|
390
|
+
* The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
|
|
391
|
+
*/
|
|
392
|
+
private assertNotContentHistory;
|
|
393
|
+
private baseBranchRev;
|
|
394
|
+
private rootCommits;
|
|
395
|
+
/**
|
|
396
|
+
* Whether `branch` is a single parentless commit with an empty tree, as
|
|
397
|
+
* orphan creation leaves it. Full refs throughout: a bare name resolves to a
|
|
398
|
+
* same-named tag first, and clones fetch tags.
|
|
399
|
+
*/
|
|
400
|
+
private isEmptyInitialBranch;
|
|
327
401
|
}
|
package/dist/git-manager.js
CHANGED
|
@@ -20,7 +20,7 @@ import { invalidateContentIndexesForRoot } from './content-index-registry.js';
|
|
|
20
20
|
import { invalidateBranchContentCaches } from './content-index-generation.js';
|
|
21
21
|
import { createDebugLogger } from './utils/debug.js';
|
|
22
22
|
import { getErrorMessage, isNotFoundError } from './utils/error.js';
|
|
23
|
-
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
|
|
23
|
+
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
|
|
24
24
|
import { acquireProvisioningLock } from './utils/provisioning-lock.js';
|
|
25
25
|
const log = createDebugLogger({ prefix: 'GitManager' });
|
|
26
26
|
/**
|
|
@@ -166,6 +166,78 @@ export class GitRemoteRefMissingError extends Error {
|
|
|
166
166
|
this.name = 'GitRemoteRefMissingError';
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* No git remote is configured and the one the strategy auto-detects does not
|
|
171
|
+
* exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
|
|
172
|
+
* its first boot, so until then every request that needs a workspace fails
|
|
173
|
+
* here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
|
|
174
|
+
* The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
|
|
175
|
+
*/
|
|
176
|
+
export class RemoteNotReadyError extends Error {
|
|
177
|
+
constructor(expectedRemotePath) {
|
|
178
|
+
super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
|
|
179
|
+
`not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
|
|
180
|
+
this.expectedRemotePath = expectedRemotePath;
|
|
181
|
+
this.name = 'RemoteNotReadyError';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Settings provisioning could not read the remote's settings branch, so it cannot tell a remote
|
|
186
|
+
* that has none from one it failed to reach. It stops instead of starting an empty settings
|
|
187
|
+
* branch: settings that silently vanish drop restrictive path rules, so content they hid
|
|
188
|
+
* becomes readable under `defaultPathAccess: { read: 'allow' }`.
|
|
189
|
+
*/
|
|
190
|
+
class SettingsRemoteUnreadableError extends Error {
|
|
191
|
+
constructor(branch, remote,
|
|
192
|
+
/** The underlying git failure; see {@link GitRemoteRefMissingError.gitError}. */
|
|
193
|
+
gitError) {
|
|
194
|
+
super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so ` +
|
|
195
|
+
`the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
|
|
196
|
+
this.branch = branch;
|
|
197
|
+
this.remote = remote;
|
|
198
|
+
this.gitError = gitError;
|
|
199
|
+
this.name = 'SettingsRemoteUnreadableError';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The workspace's settings branch shares no history with the remote's and holds work of its
|
|
204
|
+
* own, so neither copy can be chosen automatically, and a settings save could never pull.
|
|
205
|
+
*/
|
|
206
|
+
class SettingsBranchDivergedError extends Error {
|
|
207
|
+
constructor(branch, workspacePath,
|
|
208
|
+
/** What the workspace holds beyond an empty initial commit. */
|
|
209
|
+
localWork) {
|
|
210
|
+
super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the ` +
|
|
211
|
+
`remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy ` +
|
|
212
|
+
`is chosen. To keep the remote's settings: copy anything you need out of ` +
|
|
213
|
+
`${workspacePath}, move it aside, and restart; the next start checks out the remote's ` +
|
|
214
|
+
`'${branch}', and copied changes can then be re-applied in the editor.`);
|
|
215
|
+
this.branch = branch;
|
|
216
|
+
this.workspacePath = workspacePath;
|
|
217
|
+
this.localWork = localWork;
|
|
218
|
+
this.name = 'SettingsBranchDivergedError';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** The only files a settings branch ever commits (explicit paths at its root). */
|
|
222
|
+
const SETTINGS_BRANCH_FILES = new Set(['permissions.json', 'groups.json']);
|
|
223
|
+
/**
|
|
224
|
+
* The remote's settings branch holds content rather than settings. Adopting it would load no
|
|
225
|
+
* groups or path rules, and settings saves would then commit onto content history.
|
|
226
|
+
*/
|
|
227
|
+
class SettingsBranchHasContentHistoryError extends Error {
|
|
228
|
+
constructor(branch, remote,
|
|
229
|
+
/** What shows it is content. */
|
|
230
|
+
evidence) {
|
|
231
|
+
super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not ` +
|
|
232
|
+
`settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote ` +
|
|
233
|
+
`from a copy of the settings (it may hold only permissions.json and groups.json), ` +
|
|
234
|
+
`then restart.`);
|
|
235
|
+
this.branch = branch;
|
|
236
|
+
this.remote = remote;
|
|
237
|
+
this.evidence = evidence;
|
|
238
|
+
this.name = 'SettingsBranchHasContentHistoryError';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
169
241
|
export class GitManager {
|
|
170
242
|
constructor(options, gitOptions) {
|
|
171
243
|
this.repoPath = path.resolve(options.repoPath);
|
|
@@ -407,7 +479,7 @@ export class GitManager {
|
|
|
407
479
|
* deleteBranchHandler: a head left in `remote.git` forever makes the
|
|
408
480
|
* create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
|
|
409
481
|
* the reused branch's first publish non-fast-forward against the stale head
|
|
410
|
-
* (`GitManager.push()` pushes
|
|
482
|
+
* (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
|
|
411
483
|
* not an ancestor of the new branch), and a retried submit then skips the
|
|
412
484
|
* local push on a clean tree and enqueues the worker push of the STALE head,
|
|
413
485
|
* resurrecting the deleted branch's content on GitHub as an apparent success.
|
|
@@ -562,6 +634,12 @@ export class GitManager {
|
|
|
562
634
|
`intentionally runs git against a network remote (e.g. a single-VM deployment), set ` +
|
|
563
635
|
`config.allowNetworkRemoteInProd: true to acknowledge this.`);
|
|
564
636
|
}
|
|
637
|
+
/** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
|
|
638
|
+
static async remoteNotReadyError(mode) {
|
|
639
|
+
const { operatingStrategy } = await import('./operating-mode/index.js');
|
|
640
|
+
const { autoDetectRemotePath } = operatingStrategy(mode).getRemoteUrlConfig();
|
|
641
|
+
return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : undefined;
|
|
642
|
+
}
|
|
565
643
|
/**
|
|
566
644
|
* Resolves the remote URL for git operations following the priority:
|
|
567
645
|
* 1. Explicit remoteUrl parameter
|
|
@@ -686,6 +764,7 @@ export class GitManager {
|
|
|
686
764
|
}
|
|
687
765
|
}
|
|
688
766
|
let justCloned = false;
|
|
767
|
+
let resolvedRemoteUrl;
|
|
689
768
|
if (!repoExists) {
|
|
690
769
|
const remoteUrl = await GitManager.resolveRemoteUrl({
|
|
691
770
|
mode: options.mode,
|
|
@@ -696,6 +775,9 @@ export class GitManager {
|
|
|
696
775
|
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
|
|
697
776
|
});
|
|
698
777
|
if (!remoteUrl) {
|
|
778
|
+
const notReady = await GitManager.remoteNotReadyError(options.mode);
|
|
779
|
+
if (notReady)
|
|
780
|
+
throw notReady;
|
|
699
781
|
throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
|
|
700
782
|
}
|
|
701
783
|
try {
|
|
@@ -708,6 +790,7 @@ export class GitManager {
|
|
|
708
790
|
`from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
|
|
709
791
|
}
|
|
710
792
|
justCloned = true;
|
|
793
|
+
resolvedRemoteUrl = remoteUrl;
|
|
711
794
|
// Mark as managed immediately after clone so ensureRemote's guard works,
|
|
712
795
|
// and set a fallback author identity: GIT_CEILING_DIRECTORIES blocks
|
|
713
796
|
// global gitconfig, and internal commits (e.g. orphan branch init) need
|
|
@@ -748,9 +831,20 @@ export class GitManager {
|
|
|
748
831
|
if (remoteUrl) {
|
|
749
832
|
await git.ensureRemote(remoteUrl);
|
|
750
833
|
}
|
|
834
|
+
resolvedRemoteUrl = remoteUrl;
|
|
751
835
|
}
|
|
752
836
|
if (options.branchType === 'orphan') {
|
|
753
|
-
|
|
837
|
+
try {
|
|
838
|
+
await git.createOrphanSettingsBranch(options.branchName, {});
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
// A remote that is unreadable because the worker has not (re)created
|
|
842
|
+
// it yet is the transient not-ready case, not a broken workspace.
|
|
843
|
+
if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
|
|
844
|
+
throw (await GitManager.remoteNotReadyError(options.mode)) ?? err;
|
|
845
|
+
}
|
|
846
|
+
throw err;
|
|
847
|
+
}
|
|
754
848
|
// Settings mutations hold an OCC lockfile (<file>.lock, see
|
|
755
849
|
// authorization/settings-file-store.ts) inside this git-committed
|
|
756
850
|
// workspace. Commits here stage explicit paths, but a crash-orphaned lock
|
|
@@ -893,7 +987,8 @@ export class GitManager {
|
|
|
893
987
|
const branches = await this.git.branch();
|
|
894
988
|
const currentBranch = branches.current;
|
|
895
989
|
try {
|
|
896
|
-
|
|
990
|
+
// The full ref: a bare name resolves to a same-named tag first.
|
|
991
|
+
await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
|
|
897
992
|
}
|
|
898
993
|
catch (err) {
|
|
899
994
|
// The only benign failure here: the branch has never been pushed, so the
|
|
@@ -970,13 +1065,19 @@ export class GitManager {
|
|
|
970
1065
|
const fileArray = Array.isArray(files) ? files : [files];
|
|
971
1066
|
await this.git.add(fileArray);
|
|
972
1067
|
}
|
|
1068
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
1069
|
+
async addAllExceptCanopyState() {
|
|
1070
|
+
await stageAllExceptCanopyState(this.git);
|
|
1071
|
+
}
|
|
973
1072
|
async commit(message) {
|
|
974
1073
|
await this.git.commit(message);
|
|
975
1074
|
}
|
|
976
1075
|
async push(branch) {
|
|
977
|
-
const target = branch ?? (await this.
|
|
978
|
-
// Explicit refspec (local:remote) so push works for branches not
|
|
979
|
-
// remote (e.g. orphan settings branches)
|
|
1076
|
+
const target = branch ?? (await this.currentBranchName());
|
|
1077
|
+
// Explicit full-ref refspec (local:remote) so push works for branches not
|
|
1078
|
+
// yet in the remote (e.g. orphan settings branches), and a same-named tag
|
|
1079
|
+
// cannot redirect it: a short name for a new remote branch would create
|
|
1080
|
+
// `refs/heads/heads/<name>`. Built via raw() rather than the
|
|
980
1081
|
// push() wrapper so `--end-of-options` sits immediately before the
|
|
981
1082
|
// positional remote/refspec, guarding against a refspec starting with '-'
|
|
982
1083
|
// being parsed as a git option (e.g. --receive-pack=...). Real flags must
|
|
@@ -986,9 +1087,20 @@ export class GitManager {
|
|
|
986
1087
|
'--set-upstream',
|
|
987
1088
|
'--end-of-options',
|
|
988
1089
|
this.remote,
|
|
989
|
-
|
|
1090
|
+
`refs/heads/${target}:refs/heads/${target}`,
|
|
990
1091
|
]);
|
|
991
1092
|
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
|
|
1095
|
+
* `heads/<name>` when a same-named tag exists, which is not a branch name.
|
|
1096
|
+
*/
|
|
1097
|
+
async currentBranchName() {
|
|
1098
|
+
const branches = await this.git.branch();
|
|
1099
|
+
if (branches.detached || !branches.current) {
|
|
1100
|
+
throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
|
|
1101
|
+
}
|
|
1102
|
+
return branches.current;
|
|
1103
|
+
}
|
|
992
1104
|
/**
|
|
993
1105
|
* Whether the local branch has commits the remote mirror doesn't -- i.e.
|
|
994
1106
|
* whether push() would move the remote ref forward.
|
|
@@ -1010,12 +1122,14 @@ export class GitManager {
|
|
|
1010
1122
|
// `--end-of-options` before every caller-influenced ref name, as in push()
|
|
1011
1123
|
// above: names are sanitized upstream, but this file's rule is that a
|
|
1012
1124
|
// positional is guarded where it is passed, not where it was validated.
|
|
1013
|
-
|
|
1014
|
-
|
|
1125
|
+
// Each rev-parse needs `--verify`: without it, rev-parse echoes
|
|
1126
|
+
// `--end-of-options` as its own output line ahead of the result.
|
|
1127
|
+
const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
|
|
1128
|
+
const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
|
|
1015
1129
|
let fetchedTip;
|
|
1016
1130
|
try {
|
|
1017
1131
|
await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
|
|
1018
|
-
fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1132
|
+
fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1019
1133
|
}
|
|
1020
1134
|
catch {
|
|
1021
1135
|
// No ref on the remote yet -- the branch has never been pushed.
|
|
@@ -1028,6 +1142,27 @@ export class GitManager {
|
|
|
1028
1142
|
const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
|
|
1029
1143
|
return aheadCount !== '0';
|
|
1030
1144
|
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
1147
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
1148
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
1149
|
+
* constraint.
|
|
1150
|
+
*/
|
|
1151
|
+
async listChangedPathsSinceBase() {
|
|
1152
|
+
await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
|
|
1153
|
+
// `--verify`: without it rev-parse echoes `--end-of-options` back as output.
|
|
1154
|
+
const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1155
|
+
// -z: NUL-separated and unquoted, so no path needs unescaping.
|
|
1156
|
+
const output = await this.git.raw([
|
|
1157
|
+
'diff',
|
|
1158
|
+
'--name-only',
|
|
1159
|
+
'-z',
|
|
1160
|
+
'--no-renames',
|
|
1161
|
+
`${baseTip}...HEAD`,
|
|
1162
|
+
'--',
|
|
1163
|
+
]);
|
|
1164
|
+
return output.split('\0').filter((p) => p.length > 0);
|
|
1165
|
+
}
|
|
1031
1166
|
async ensureAuthor(author) {
|
|
1032
1167
|
const config = (await this.git.listConfig());
|
|
1033
1168
|
// Verify this is a CanopyCMS-managed workspace before setting author
|
|
@@ -1089,10 +1224,24 @@ export class GitManager {
|
|
|
1089
1224
|
await ensureGitExcludePattern(this.repoPath, pattern);
|
|
1090
1225
|
}
|
|
1091
1226
|
/**
|
|
1092
|
-
*
|
|
1093
|
-
* deployment-specific settings never pollute content history. It holds
|
|
1094
|
-
* settings files committed by explicit path (permissions.json,
|
|
1095
|
-
* the workspace root).
|
|
1227
|
+
* Put the workspace on the settings branch: an orphan (no shared history),
|
|
1228
|
+
* so deployment-specific settings never pollute content history. It holds
|
|
1229
|
+
* only settings files committed by explicit path (permissions.json,
|
|
1230
|
+
* groups.json at the workspace root).
|
|
1231
|
+
*
|
|
1232
|
+
* The remote is the settings branch's durable copy, and the workspace clone
|
|
1233
|
+
* is `--single-branch` at the base branch, so the remote is asked first:
|
|
1234
|
+
* - no local branch: check out the remote's, or create an empty orphan only
|
|
1235
|
+
* when the remote has none. An unreadable remote throws
|
|
1236
|
+
* {@link SettingsRemoteUnreadableError} rather than guess "none".
|
|
1237
|
+
* - local branch present: a history unrelated to the remote's is repaired
|
|
1238
|
+
* onto the remote's when the local branch is still its empty initial
|
|
1239
|
+
* commit with a clean working tree, and otherwise throws
|
|
1240
|
+
* {@link SettingsBranchDivergedError}; local commits are never discarded.
|
|
1241
|
+
* An unreadable remote throws only for that empty branch, since one that
|
|
1242
|
+
* holds settings can serve them and the next save surfaces the remote error.
|
|
1243
|
+
* Either way a remote branch holding content is never adopted:
|
|
1244
|
+
* {@link SettingsBranchHasContentHistoryError}.
|
|
1096
1245
|
*/
|
|
1097
1246
|
async createOrphanSettingsBranch(branchName, initialFiles) {
|
|
1098
1247
|
try {
|
|
@@ -1104,14 +1253,36 @@ export class GitManager {
|
|
|
1104
1253
|
}
|
|
1105
1254
|
}
|
|
1106
1255
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
1107
|
-
log.debug('git', '
|
|
1256
|
+
log.debug('git', 'Ensuring settings branch', { branchName });
|
|
1108
1257
|
const branches = await this.git.branch();
|
|
1109
|
-
|
|
1110
|
-
|
|
1258
|
+
const localExists = branches.all.includes(branchName);
|
|
1259
|
+
if (localExists) {
|
|
1111
1260
|
// No separator here — see checkoutBranch() above for why plain
|
|
1112
1261
|
// `git checkout <branch>` can't safely take one. branchName is always an
|
|
1113
1262
|
// internal/config-derived settings-branch name, never user input.
|
|
1114
1263
|
await this.git.checkout(branchName);
|
|
1264
|
+
}
|
|
1265
|
+
let remoteTip;
|
|
1266
|
+
try {
|
|
1267
|
+
remoteTip = await this.remoteBranchTip(branchName);
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
if (localExists && !(await this.isEmptyInitialBranch(branchName))) {
|
|
1271
|
+
log.debug('git', 'Remote unreadable; serving the local settings branch', { branchName });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
|
|
1275
|
+
}
|
|
1276
|
+
if (localExists) {
|
|
1277
|
+
await this.reconcileLocalSettingsBranch(branchName, remoteTip);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (remoteTip) {
|
|
1281
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
1282
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
1283
|
+
// `-b` consumes branchName as its literal value; see checkoutBranchInner.
|
|
1284
|
+
await this.git.raw(['checkout', '-b', branchName, fetchedTip]);
|
|
1285
|
+
log.debug('git', 'Checked out the remote settings branch', { branchName });
|
|
1115
1286
|
return;
|
|
1116
1287
|
}
|
|
1117
1288
|
// branchName is consumed as --orphan's literal argument value (like -b/-B
|
|
@@ -1134,4 +1305,123 @@ export class GitManager {
|
|
|
1134
1305
|
await this.git.commit('Initialize settings branch', ['--allow-empty']);
|
|
1135
1306
|
log.debug('git', 'Orphan settings branch created', { branchName });
|
|
1136
1307
|
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Compares the checked-out local settings branch with the remote's tip.
|
|
1310
|
+
* Related histories are left for the next settings pull to reconcile; only
|
|
1311
|
+
* an unrelated one is acted on here.
|
|
1312
|
+
*/
|
|
1313
|
+
async reconcileLocalSettingsBranch(branchName, remoteTip) {
|
|
1314
|
+
if (!remoteTip)
|
|
1315
|
+
return;
|
|
1316
|
+
const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
|
|
1317
|
+
if (localTip === remoteTip)
|
|
1318
|
+
return;
|
|
1319
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
1320
|
+
const [localRoots, remoteRoots] = await Promise.all([
|
|
1321
|
+
this.rootCommits(localTip),
|
|
1322
|
+
this.rootCommits(fetchedTip),
|
|
1323
|
+
]);
|
|
1324
|
+
if (localRoots.some((root) => remoteRoots.includes(root)))
|
|
1325
|
+
return;
|
|
1326
|
+
const localWork = [];
|
|
1327
|
+
if (!(await this.isEmptyInitialBranch(branchName))) {
|
|
1328
|
+
localWork.push('commits beyond its empty initial commit');
|
|
1329
|
+
}
|
|
1330
|
+
const uncommitted = await this.getUncommittedFiles();
|
|
1331
|
+
if (uncommitted.length > 0) {
|
|
1332
|
+
localWork.push(`uncommitted changes to ${uncommitted.join(', ')}`);
|
|
1333
|
+
}
|
|
1334
|
+
if (localWork.length > 0) {
|
|
1335
|
+
throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(' and '));
|
|
1336
|
+
}
|
|
1337
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
1338
|
+
// `checkout -B` resets the current branch onto the fetched tip, and
|
|
1339
|
+
// refuses rather than overwrite a file written since the check above.
|
|
1340
|
+
await this.git.raw(['checkout', '-B', branchName, fetchedTip]);
|
|
1341
|
+
log.debug('git', 'Replaced an empty settings branch with the remote one', { branchName });
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* The remote's tip for `branch`, or `undefined` when the remote has no such
|
|
1345
|
+
* branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
|
|
1346
|
+
* output only for a reachable remote without the ref.
|
|
1347
|
+
*/
|
|
1348
|
+
async remoteBranchTip(branch) {
|
|
1349
|
+
const ref = `refs/heads/${branch}`;
|
|
1350
|
+
const output = await this.git.raw(['ls-remote', '--heads', this.remote, ref]);
|
|
1351
|
+
for (const line of output.split('\n')) {
|
|
1352
|
+
const [sha, name] = line.trim().split('\t');
|
|
1353
|
+
if (sha && name === ref)
|
|
1354
|
+
return sha;
|
|
1355
|
+
}
|
|
1356
|
+
return undefined;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Fetch `branch` from the remote and return the fetched commit, pinned as
|
|
1360
|
+
* pullBaseInner explains. The full ref: a bare name resolves to a
|
|
1361
|
+
* same-named tag first.
|
|
1362
|
+
*/
|
|
1363
|
+
async fetchBranchTip(branch) {
|
|
1364
|
+
await this.git.fetch(this.remote, `refs/heads/${branch}`);
|
|
1365
|
+
return (await this.git.revparse(['FETCH_HEAD'])).trim();
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Refuse to adopt a remote settings branch that holds content: its tip has a file other than
|
|
1369
|
+
* the settings files, or its roots include the base branch's (a settings branch is an orphan).
|
|
1370
|
+
* Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
|
|
1371
|
+
* The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
|
|
1372
|
+
*/
|
|
1373
|
+
async assertNotContentHistory(branchName, tip) {
|
|
1374
|
+
const tree = await this.git.raw(['ls-tree', '-r', '--name-only', tip]);
|
|
1375
|
+
const strays = tree.split('\n').filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
|
|
1376
|
+
if (strays.length > 0) {
|
|
1377
|
+
const shown = strays.slice(0, 3).join(', ') + (strays.length > 3 ? ', …' : '');
|
|
1378
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
|
|
1379
|
+
}
|
|
1380
|
+
const [tipRoots, baseRoots] = await Promise.all([
|
|
1381
|
+
this.rootCommits(tip),
|
|
1382
|
+
this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev)),
|
|
1383
|
+
]);
|
|
1384
|
+
if (tipRoots.some((root) => baseRoots.includes(root))) {
|
|
1385
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async baseBranchRev(settingsBranch) {
|
|
1389
|
+
for (const ref of [
|
|
1390
|
+
`refs/heads/${this.baseBranch}`,
|
|
1391
|
+
`refs/remotes/${this.remote}/${this.baseBranch}`,
|
|
1392
|
+
]) {
|
|
1393
|
+
// `--quiet` makes a missing ref a silent non-zero exit, which simple-git resolves
|
|
1394
|
+
// with empty output rather than rejecting.
|
|
1395
|
+
const sha = await this.git
|
|
1396
|
+
.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
1397
|
+
.catch(() => '');
|
|
1398
|
+
if (sha.trim())
|
|
1399
|
+
return sha.trim();
|
|
1400
|
+
}
|
|
1401
|
+
try {
|
|
1402
|
+
return await this.fetchBranchTip(this.baseBranch);
|
|
1403
|
+
}
|
|
1404
|
+
catch (err) {
|
|
1405
|
+
throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote ` +
|
|
1406
|
+
`'${this.remote}', so settings branch '${settingsBranch}' was not checked out: ` +
|
|
1407
|
+
`${getErrorMessage(err)}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async rootCommits(rev) {
|
|
1411
|
+
const output = await this.git.raw(['rev-list', '--max-parents=0', rev]);
|
|
1412
|
+
return output.split('\n').filter(Boolean);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Whether `branch` is a single parentless commit with an empty tree, as
|
|
1416
|
+
* orphan creation leaves it. Full refs throughout: a bare name resolves to a
|
|
1417
|
+
* same-named tag first, and clones fetch tags.
|
|
1418
|
+
*/
|
|
1419
|
+
async isEmptyInitialBranch(branch) {
|
|
1420
|
+
const ref = `refs/heads/${branch}`;
|
|
1421
|
+
const commits = await this.git.raw(['rev-list', '--max-count=2', ref]);
|
|
1422
|
+
if (commits.split('\n').filter(Boolean).length !== 1)
|
|
1423
|
+
return false;
|
|
1424
|
+
const tree = await this.git.raw(['ls-tree', ref]);
|
|
1425
|
+
return tree.trim() === '';
|
|
1426
|
+
}
|
|
1137
1427
|
}
|
package/dist/github-service.d.ts
CHANGED
|
@@ -76,6 +76,7 @@ export interface PullRequestDetails {
|
|
|
76
76
|
state: 'open' | 'closed';
|
|
77
77
|
merged: boolean;
|
|
78
78
|
draft: boolean;
|
|
79
|
+
body: string;
|
|
79
80
|
}
|
|
80
81
|
export interface CreateOrUpdatePullRequestParams {
|
|
81
82
|
octokit: Octokit;
|
|
@@ -87,6 +88,11 @@ export interface CreateOrUpdatePullRequestParams {
|
|
|
87
88
|
body: string;
|
|
88
89
|
/** Convert a pre-existing draft PR to ready-for-review after updating it. */
|
|
89
90
|
markReadyIfDraft?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* `body` is the canopycms PR section: an existing PR keeps the text outside
|
|
93
|
+
* its section (see mergePrSection) instead of having its body replaced.
|
|
94
|
+
*/
|
|
95
|
+
mergeSectionIntoBody?: boolean;
|
|
90
96
|
/** Forwarded to all GitHub requests (worker task-timeout abort). */
|
|
91
97
|
signal?: AbortSignal;
|
|
92
98
|
}
|
|
@@ -128,6 +134,8 @@ export declare class GitHubService {
|
|
|
128
134
|
body: string;
|
|
129
135
|
/** Convert a pre-existing draft PR to ready-for-review after updating it. */
|
|
130
136
|
markReadyIfDraft?: boolean;
|
|
137
|
+
/** See {@link CreateOrUpdatePullRequestParams.mergeSectionIntoBody}. */
|
|
138
|
+
mergeSectionIntoBody?: boolean;
|
|
131
139
|
}): Promise<{
|
|
132
140
|
number: number;
|
|
133
141
|
url: string;
|
package/dist/github-service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getErrorMessage } from './utils/error.js';
|
|
|
7
7
|
// or CloudWatch folds it into the previous event. Under Lambda/dev the helper
|
|
8
8
|
// is plain console. See utils/logger.ts.
|
|
9
9
|
import { canopyLogWarn } from './utils/logger.js';
|
|
10
|
+
import { mergePrSection } from './submission-attribution.js';
|
|
10
11
|
const ThrottledOctokit = Octokit.plugin(throttling);
|
|
11
12
|
/**
|
|
12
13
|
* Retry primary rate limits at most twice and only for short waits; beyond
|
|
@@ -67,6 +68,7 @@ export function createCanopyOctokit(options) {
|
|
|
67
68
|
*/
|
|
68
69
|
export async function createOrUpdatePullRequest(params) {
|
|
69
70
|
const { octokit, owner, repo, head, base, title, body, markReadyIfDraft, signal } = params;
|
|
71
|
+
const mergeSection = params.mergeSectionIntoBody === true;
|
|
70
72
|
// CONDITIONAL spread: with no signal, the request objects below carry no
|
|
71
73
|
// `request` key at all (github-service.test.ts asserts on their exact shape).
|
|
72
74
|
const requestOption = signal ? { request: { signal } } : {};
|
|
@@ -92,7 +94,7 @@ export async function createOrUpdatePullRequest(params) {
|
|
|
92
94
|
repo,
|
|
93
95
|
pull_number: existing.number,
|
|
94
96
|
title,
|
|
95
|
-
body,
|
|
97
|
+
body: mergeSection ? mergePrSection(existing.body, body) : body,
|
|
96
98
|
...requestOption,
|
|
97
99
|
});
|
|
98
100
|
// Only a pre-existing PR can be a draft on this path — pulls.create
|
|
@@ -185,6 +187,7 @@ export class GitHubService {
|
|
|
185
187
|
title: options.title,
|
|
186
188
|
body: options.body,
|
|
187
189
|
markReadyIfDraft: options.markReadyIfDraft,
|
|
190
|
+
mergeSectionIntoBody: options.mergeSectionIntoBody,
|
|
188
191
|
});
|
|
189
192
|
return { number: result.number, url: result.url };
|
|
190
193
|
}
|
|
@@ -200,6 +203,7 @@ export class GitHubService {
|
|
|
200
203
|
state: response.data.state,
|
|
201
204
|
merged: response.data.merged ?? false,
|
|
202
205
|
draft: response.data.draft ?? false,
|
|
206
|
+
body: response.data.body ?? '',
|
|
203
207
|
};
|
|
204
208
|
}
|
|
205
209
|
async convertToDraft(prNumber) {
|