canopycms 0.8.1 → 0.8.3

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 (90) hide show
  1. package/CHANGELOG.md +120 -47
  2. package/dist/ai/resolve-branch.js +2 -1
  3. package/dist/api/admin-branch-health.d.ts +11 -0
  4. package/dist/api/admin-branch-health.js +36 -0
  5. package/dist/api/admin.d.ts +8 -1
  6. package/dist/api/branch-merge.js +1 -1
  7. package/dist/api/branch-review.js +20 -3
  8. package/dist/api/branch-status.js +30 -19
  9. package/dist/api/branch.d.ts +8 -3
  10. package/dist/api/branch.js +32 -3
  11. package/dist/api/client.d.ts +3 -1
  12. package/dist/api/client.js +4 -0
  13. package/dist/api/github-sync.d.ts +2 -0
  14. package/dist/api/github-sync.js +6 -1
  15. package/dist/api/pr-comment-sync.js +3 -1
  16. package/dist/authorization/branch.d.ts +10 -4
  17. package/dist/authorization/branch.js +24 -4
  18. package/dist/authorization/types.d.ts +1 -1
  19. package/dist/branch-metadata-file.d.ts +66 -0
  20. package/dist/branch-metadata-file.js +14 -0
  21. package/dist/branch-metadata.d.ts +4 -2
  22. package/dist/branch-metadata.js +10 -2
  23. package/dist/cli/cli.js +1913 -1606
  24. package/dist/cli/configured-asset-store.js +2 -8
  25. package/dist/cli/generate-ai-content.js +17 -4
  26. package/dist/cli/init.js +2 -2
  27. package/dist/cli/site-config.d.ts +9 -0
  28. package/dist/cli/site-config.js +17 -0
  29. package/dist/cli/sync.js +226 -74
  30. package/dist/editor/BranchManager.d.ts +8 -4
  31. package/dist/editor/BranchManager.js +43 -18
  32. package/dist/editor/Editor.js +3 -2
  33. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  34. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  35. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  36. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  37. package/dist/editor/admin/useSystemHealth.js +21 -0
  38. package/dist/editor/branch-lock.d.ts +2 -0
  39. package/dist/editor/branch-lock.js +5 -3
  40. package/dist/editor/branch-status.d.ts +25 -1
  41. package/dist/editor/branch-status.js +75 -0
  42. package/dist/editor/components/EditorHeader.d.ts +6 -2
  43. package/dist/editor/components/EditorHeader.js +51 -30
  44. package/dist/editor/components/EntryHeader.d.ts +3 -1
  45. package/dist/editor/components/EntryHeader.js +2 -1
  46. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  47. package/dist/editor/components/TooltipDetail.js +18 -0
  48. package/dist/editor/copy.d.ts +7 -0
  49. package/dist/editor/copy.js +9 -0
  50. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  51. package/dist/editor/hooks/useBranchManager.js +48 -5
  52. package/dist/pr-comments.d.ts +2 -0
  53. package/dist/pr-comments.js +20 -14
  54. package/dist/resource-generation.js +2 -3
  55. package/dist/services.d.ts +3 -2
  56. package/dist/submission-attribution.d.ts +1 -1
  57. package/dist/submission-attribution.js +20 -2
  58. package/dist/system-users.d.ts +2 -0
  59. package/dist/system-users.js +6 -1
  60. package/dist/task-queue/task-queue.d.ts +5 -1
  61. package/dist/task-queue/task-queue.js +4 -2
  62. package/dist/types.d.ts +61 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/worker/branch-merge.d.ts +68 -0
  66. package/dist/worker/branch-merge.js +228 -0
  67. package/dist/worker/canopy-state.d.ts +2 -0
  68. package/dist/worker/canopy-state.js +1 -1
  69. package/dist/worker/cms-worker.d.ts +1 -0
  70. package/dist/worker/cms-worker.js +5 -3
  71. package/dist/worker/git-sync.js +15 -8
  72. package/dist/worker/github-commits.d.ts +106 -0
  73. package/dist/worker/github-commits.js +485 -0
  74. package/dist/worker/github-gateway-http.js +3 -2
  75. package/dist/worker/github-gateway-wire.d.ts +22 -13
  76. package/dist/worker/github-gateway-wire.js +4 -1
  77. package/dist/worker/github-gateway.d.ts +16 -5
  78. package/dist/worker/github-gateway.js +22 -5
  79. package/dist/worker/github-mirror.d.ts +8 -0
  80. package/dist/worker/github-mirror.js +12 -1
  81. package/dist/worker/github-transfer.d.ts +3 -1
  82. package/dist/worker/github-transfer.js +3 -2
  83. package/dist/worker/history-rewrite.d.ts +13 -1
  84. package/dist/worker/history-rewrite.js +27 -3
  85. package/dist/worker/rebase.d.ts +13 -7
  86. package/dist/worker/rebase.js +351 -95
  87. package/dist/worker/task-runner.d.ts +1 -1
  88. package/dist/worker/task-runner.js +172 -34
  89. package/dist/worker/worker-context.d.ts +2 -0
  90. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { syncPrComments } from './pr-comment-sync.js';
8
8
  import { CommentStore } from '../comment-store.js';
9
9
  import { getErrorMessage } from '../utils/error.js';
10
10
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
- import { getBranchProtection } from '../authorization/index.js';
11
+ import { getBranchProtection, isAdmin } from '../authorization/index.js';
12
12
  import { sanitizeDisplayName } from '../submission-attribution.js';
13
13
  /** Optional on the wire: a bodiless request means no note. */
14
14
  const requestChangesBodySchema = z
@@ -130,8 +130,11 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
130
130
  };
131
131
  }
132
132
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
133
- // As for request changes, the status on disk decides, in the step that writes.
133
+ // As for request changes, the status on disk decides, in the step that writes. So does an
134
+ // unfinished submit: what's approved may not be what the pull request holds.
134
135
  let found;
136
+ let unfinished = false;
137
+ let notQueued = false;
135
138
  const updated = await meta.saveIf({
136
139
  branch: {
137
140
  name: branchContext.branch.name,
@@ -140,8 +143,22 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
140
143
  },
141
144
  }, (existing) => {
142
145
  found = existing?.branch.status;
143
- return found === 'submitted';
146
+ unfinished = existing?.branch.submitUnfinished === true;
147
+ notQueued = existing?.branch.submitNotQueued === true;
148
+ return found === 'submitted' && !unfinished;
144
149
  });
150
+ if (unfinished && found === 'submitted') {
151
+ return {
152
+ ok: false,
153
+ status: 409,
154
+ error: "This branch's last submit didn't finish, so it can't be approved until it does. " +
155
+ (notQueued
156
+ ? 'Use "Request changes" to send it back to its editor.'
157
+ : isAdmin(req.user.groups)
158
+ ? 'Retry the submit from the "Tasks" tab in "System health".'
159
+ : 'Ask an admin to retry the submit.'),
160
+ };
161
+ }
145
162
  if (!updated)
146
163
  return approveConflict(found);
147
164
  // Against the branch the save returned, as for request changes. Best-effort: syncPrComments
@@ -6,7 +6,7 @@ import { withdrawBranch } from './branch-withdraw.js';
6
6
  import { requestChanges, approveBranch } from './branch-review.js';
7
7
  import { markAsMerged } from './branch-merge.js';
8
8
  import { defineEndpoint } from './route-builder.js';
9
- import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
9
+ import { canPerformWorkflowAction, getBranchProtection, isAdmin } from '../authorization/index.js';
10
10
  import { syncSubmitPr } from './github-sync.js';
11
11
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
12
12
  import { isNonFastForwardRejection } from '../utils/git.js';
@@ -85,6 +85,19 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
85
85
  : `Cannot submit branch with status '${status}'. Only 'editing' branches can be submitted.`,
86
86
  };
87
87
  }
88
+ // The worker pushes nothing for such a branch, so this would commit and report a failure later.
89
+ if (branchContext.branch.rewrittenOnGitHub !== undefined) {
90
+ return {
91
+ ok: false,
92
+ status: 409,
93
+ error: `"${shortNameOf(ctx.services.config, branchContext.branch.name)}" was changed on GitHub in ` +
94
+ "a way CanopyCMS can't combine with the work here, so it can't be submitted. Nothing is " +
95
+ 'lost. ' +
96
+ (isAdmin(req.user.groups)
97
+ ? 'See the "Branches" tab in "System health".'
98
+ : 'Ask an admin for help.'),
99
+ };
100
+ }
88
101
  // Commit and push changes
89
102
  const submitter = submissionEditorFromUser(req.user);
90
103
  const { authPlugin } = ctx;
@@ -122,28 +135,21 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
122
135
  // (e.g. x-access-token:TOKEN@github.com in git errors) are redacted even
123
136
  // from server logs — a live installation token must never persist in logs.
124
137
  console.error(`CanopyCMS: Failed to push branch changes (${branchContext.branchRoot}):`, redactCredentials(message));
125
- // A non-fast-forward rejection means this branch and the deployment's
126
- // local repository have diverged. Retrying the identical push can never
127
- // succeed (see isNonFastForwardRejection), so surface 409 instead of the
128
- // generic 500 below. Everything else (network, auth) keeps the 500 path.
129
- //
130
- // This push targets the deployment's OWN local origin (remote.git), not
131
- // GitHub, so the message deliberately states only the observable fact and
132
- // names no cause: a foreign deployment cannot reach this repo, and the
133
- // realistic explanations are all internal (the worker's rebase loop
134
- // reconciling the branch, or a commit reaching remote.git that this
135
- // workspace never had). It also never advises renaming the branch --
136
- // a branch that reaches this point has usually been submitted before, so
137
- // it may well have an open PR that a rename would orphan.
138
+ // This push targets the deployment's OWN remote.git, so a non-fast-forward
139
+ // rejection means it holds a commit this clone lacks, which reached it from
140
+ // GitHub (worker/github-commits.ts). The commit made above stays: the tree is
141
+ // then clean, which is what the worker's merge needs, and the next submit
142
+ // pushes the result. A branch that reaches this point has usually been
143
+ // submitted before, so the message never advises renaming it: that would
144
+ // orphan its open PR.
138
145
  if (isNonFastForwardRejection(message)) {
139
146
  return {
140
147
  ok: false,
141
148
  status: 409,
142
- error: `Could not submit "${shortName}": it has diverged from the copy in ` +
143
- `this deployment's repository and needs to be reconciled before it can be submitted. ` +
144
- `The background worker reconciles branches when the base branch moves, so this often ` +
145
- `clears on its own within a few minutes -- try again shortly, and ask an administrator ` +
146
- `to check the worker if it persists.`,
149
+ error: `Could not submit "${shortName}": it has commits from GitHub that aren't in the editor ` +
150
+ `yet. Your saved changes are safe. CanopyCMS brings those commits in at its next sync, ` +
151
+ `usually within a few minutes, so submit again then. If this keeps happening, ` +
152
+ `${isAdmin(req.user.groups) ? 'check' : 'ask an admin to check'} "System health".`,
147
153
  };
148
154
  }
149
155
  return {
@@ -200,6 +206,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
200
206
  // warning through review and archive.
201
207
  rebaseFailure: undefined,
202
208
  review: undefined,
209
+ // Read by the card as soon as the status says review, and the worker's next read is minutes
210
+ // away, so what held the last submit must not show as holding this one.
211
+ pullRequestHold: undefined,
212
+ submitUnfinished: prResult.submitNotQueued,
213
+ submitNotQueued: prResult.submitNotQueued,
203
214
  },
204
215
  });
205
216
  return {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { BranchContext, BranchMetadata } from '../types.js';
2
+ import type { BranchContext, BranchHold, BranchMetadata } from '../types.js';
3
3
  import type { ApiContext, ApiRequest, ApiResponse } from './types.js';
4
4
  import { branchParamSchema } from './validators.js';
5
5
  import type { UserProfile } from '../auth/types.js';
@@ -15,7 +15,11 @@ import type { UserProfile } from '../auth/types.js';
15
15
  * rather than silently behaving as if nothing changed. That is by design:
16
16
  * wire compatibility here means "doesn't break", not "behaves the same".
17
17
  */
18
- export interface BranchListItem extends Omit<BranchMetadata, 'review'> {
18
+ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOverGitHub' | 'lastGitHubTip' | 'rewrittenOnGitHub' | 'submitUnfinished' | 'unpublishedAtMerge' | 'submitNotQueued' | 'rebaseFailure' | 'pullRequestHold'> {
19
+ /** What is stuck and needs a person, for the branch card. Names no file. */
20
+ hold?: BranchHold;
21
+ /** The last submit didn't finish, which the server refuses Approve for. */
22
+ approveBlocked?: boolean;
19
23
  /**
20
24
  * What pull request links carry for this branch, which `?branch=` and the `/:branch` segment
21
25
  * accept beside `name` (branch-param.ts's `shortNameOf`): `name` less the namespace for a branch
@@ -32,7 +36,8 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review'> {
32
36
  */
33
37
  writeBlocked?: boolean;
34
38
  /**
35
- * Populated from {@link BranchWriteProtection.submitBlockedIncludingStatus}
39
+ * Populated from {@link BranchWriteProtection.submitBlockedIncludingStatus},
40
+ * or true for a branch rewritten on GitHub
36
41
  * -- READ THAT DOC COMMENT before touching this field. On the wire this
37
42
  * name means the COMPOUND answer (base-branch OR non-'editing' status),
38
43
  * mirroring how `writeBlocked` above is the compound of `readOnly` + status
@@ -126,16 +126,36 @@ const accessForViewer = (access, viewer) => {
126
126
  */
127
127
  export const toBranchListItem = (config, branch, viewer) => {
128
128
  const protection = getBranchWriteProtection(config, branch.name, branch.baseBranch, branch.status);
129
- // `review` names the reviewer, whom the editor has no use for.
130
- const { review: _review, ...listed } = branch;
129
+ // `review` names the reviewer, whom the editor has no use for. `keptOverGitHub` and
130
+ // `rebaseFailure` can name files, which a viewer's path rules can hide; System Health reads
131
+ // failures from the admin endpoint. The others are the worker's.
132
+ const { review: _review, keptOverGitHub: _kept, lastGitHubTip: _tip, rewrittenOnGitHub: _rewritten, submitUnfinished: _unfinished, submitNotQueued, unpublishedAtMerge: _unpublished, rebaseFailure: _failure, pullRequestHold, ...listed } = branch;
133
+ const inReview = branch.status === 'submitted' || branch.status === 'approved';
134
+ const rewritten = branch.rewrittenOnGitHub !== undefined && branch.status !== 'archived';
135
+ const hold = rewritten
136
+ ? { kind: 'rewritten' }
137
+ : !inReview
138
+ ? branch.unpublishedAtMerge
139
+ ? { kind: 'unpublished-at-merge' }
140
+ : undefined
141
+ : branch.submitUnfinished
142
+ ? {
143
+ kind: 'submit-unfinished',
144
+ opened: branch.pullRequestNumber !== undefined,
145
+ notQueued: submitNotQueued,
146
+ }
147
+ : pullRequestHold;
131
148
  return {
132
149
  ...listed,
150
+ hold,
151
+ approveBlocked: (inReview && branch.submitUnfinished) || undefined,
133
152
  shortName: shortNameOf(config, branch.name),
134
153
  ...accessForViewer(branch.access ?? {}, viewer),
135
154
  isProtected: protection.isProtected,
136
155
  readOnly: protection.readOnly,
137
156
  writeBlocked: protection.writeBlocked,
138
- submitBlocked: protection.submitBlockedIncludingStatus,
157
+ // The worker refuses to push such a branch, so a submit could only fail later.
158
+ submitBlocked: protection.submitBlockedIncludingStatus || rewritten,
139
159
  };
140
160
  };
141
161
  /**
@@ -572,6 +592,15 @@ export const deleteBranchHandler = async (ctx, req, params) => {
572
592
  error: 'Cannot delete branch with open pull request',
573
593
  };
574
594
  }
595
+ // It may hold the only copy of commits the merge left out, and deleting it would drop the alert
596
+ // that says so.
597
+ if (branchContext.branch.unpublishedAtMerge && !isAdmin(req.user.groups)) {
598
+ return {
599
+ ok: false,
600
+ status: 409,
601
+ error: 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.',
602
+ };
603
+ }
575
604
  // The branch directory leaves its name by one rename, under the same
576
605
  // server-enforced lockfile branch-metadata saves hold (see
577
606
  // utils/occ-json-write.ts), so a save() that reaches that lock after the
@@ -23,7 +23,7 @@ import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGrou
23
23
  import type { UserInfoResponse } from './user.js';
24
24
  import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
25
25
  import type { CreateCollectionInput, CreateEntryTypeInput, UpdateCollectionInput, UpdateEntryTypeInput } from './../schema/schema-store-types.js';
26
- import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
26
+ import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
27
27
  /** Options for {@link createApiClient}. */
28
28
  export interface ApiClientOptions {
29
29
  /** Base URL for API requests. Defaults to '/api/canopycms'. */
@@ -206,6 +206,8 @@ export declare class CanopyApiClient {
206
206
  purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
207
207
  /** POST /admin/branch-dirs/:dirName/repair-metadata */
208
208
  repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
209
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
210
+ dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
209
211
  /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
210
212
  repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
211
213
  /** GET /admin/status */
@@ -259,6 +259,10 @@ export class CanopyApiClient {
259
259
  repairBranchDir: (params, options) => {
260
260
  return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
261
261
  },
262
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
263
+ dismissUnpublished: (params, options) => {
264
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
265
+ },
262
266
  /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
263
267
  repairContentDuplicates: (params, options) => {
264
268
  return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
@@ -14,6 +14,8 @@ export interface GitHubSyncResult {
14
14
  nothingToSubmit?: boolean;
15
15
  /** The direct path, whose submit pushes to GitHub itself: the branch is there whatever the PR call did. */
16
16
  pushedToGitHub?: boolean;
17
+ /** No task runs this submit, so only submitting again finishes it. */
18
+ submitNotQueued?: boolean;
17
19
  }
18
20
  /** What the PR body records about a submit. */
19
21
  export interface SubmissionRecord {
@@ -10,6 +10,8 @@ import { shortNameOf } from '../branch-param.js';
10
10
  import { CommentStore } from '../comment-store.js';
11
11
  import { prBodyLockKey, refreshCommentsBlock } from '../pr-comments.js';
12
12
  import { withLock } from '../utils/async-mutex.js';
13
+ /** A submit's commits reached this deployment's repository, and nothing will take them further. */
14
+ const SUBMIT_NOT_QUEUED_REASON = "This submit couldn't be queued, so nothing reached GitHub. Nothing is lost.";
13
15
  /** The branch's comment threads for the PR body, or null (said so in the body) if unreadable. */
14
16
  async function readThreadsOrWarn(context) {
15
17
  try {
@@ -153,7 +155,7 @@ export async function syncSubmitPr(ctx, context, submission) {
153
155
  // recorded the number, the next submit would otherwise re-enqueue
154
156
  // 'push-and-create-pr' and 422 on GitHub's duplicate-PR check, wedging the
155
157
  // branch in 'sync-failed' with no way to recover.
156
- return enqueueGitHubTask(ctx, context, {
158
+ const queued = await enqueueGitHubTask(ctx, context, {
157
159
  action: 'push-and-create-or-update-pr',
158
160
  payload: {
159
161
  branch: context.branch.name,
@@ -171,6 +173,9 @@ export async function syncSubmitPr(ctx, context, submission) {
171
173
  submittedAt: submission.submittedAt,
172
174
  },
173
175
  });
176
+ return queued.syncStatus === 'sync-failed'
177
+ ? { ...queued, syncFailureReason: SUBMIT_NOT_QUEUED_REASON, submitNotQueued: true }
178
+ : queued;
174
179
  }
175
180
  /**
176
181
  * Used by withdraw and request-changes. Uses githubService directly if
@@ -66,7 +66,9 @@ export async function syncPrComments(ctx, context) {
66
66
  const pending = await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER);
67
67
  const queued = pending.some((t) => t.action === SYNC_PR_COMMENTS_ACTION &&
68
68
  t.payload.branch === branch &&
69
- t.payload.pullRequestNumber === prNumber);
69
+ t.payload.pullRequestNumber === prNumber &&
70
+ // The worker's own task leaves the comment summary as it is, so it carries no comment.
71
+ t.payload.reviewBlockOnly !== true);
70
72
  if (queued)
71
73
  return;
72
74
  await enqueueTask(taskDir, {
@@ -25,6 +25,12 @@ interface BranchAccessOptions {
25
25
  * private.
26
26
  */
27
27
  isProtectedBranch?: boolean;
28
+ /**
29
+ * Whether this is dev's auto-provisioned active branch ({@link isDevActiveBranch}). It passes
30
+ * this layer where the base branch does, so it reads (and in dev saves) like the base;
31
+ * {@link canPerformWorkflowAction} never passes it, so no workflow rights follow.
32
+ */
33
+ isDevActiveBranch?: boolean;
28
34
  }
29
35
  /**
30
36
  * Check if user has access to a branch with explicit default behavior.
@@ -35,17 +41,17 @@ interface BranchAccessOptions {
35
41
  * 3. An explicit user/group ACL -- an allowlist omitting the creator still
36
42
  * denies them, so an admin can lock down a branch someone else created
37
43
  * 4. With no ACL: the branch creator, then `defaultAccess`, and only where that
38
- * would deny, the protected base branch grant
39
- * (see {@link BranchAccessOptions.isProtectedBranch})
44
+ * would deny, the base branch or dev active branch grant ({@link BranchAccessOptions})
40
45
  * @internal Exported for tests.
41
46
  */
42
47
  export declare function checkBranchAccessWithDefault(context: BranchContext, user: CanopyUser, defaultAccess?: DefaultBranchAccess, options?: BranchAccessOptions): BranchAccessResult;
43
48
  /**
44
49
  * Bind a default access level. `config` is needed only to resolve the
45
50
  * protected-base-branch question, which routes through `getBranchProtection`,
46
- * its single source of truth, rather than repeating the comparison here.
51
+ * its single source of truth, rather than repeating the comparison here, and
52
+ * the dev-active-branch question ({@link isDevActiveBranch}).
47
53
  */
48
- export declare function createCheckBranchAccess(defaultAccess?: DefaultBranchAccess, config?: Pick<CanopyConfig, 'mode' | 'defaultBaseBranch'>): (context: BranchContext, user: CanopyUser) => BranchAccessResult;
54
+ export declare function createCheckBranchAccess(defaultAccess?: DefaultBranchAccess, config?: Pick<CanopyConfig, 'mode' | 'defaultBaseBranch' | 'defaultActiveBranch'>): (context: BranchContext, user: CanopyUser) => BranchAccessResult;
49
55
  /** Options tightening {@link canPerformWorkflowAction} beyond the default hybrid model. */
50
56
  export interface WorkflowActionOptions {
51
57
  /**
@@ -1,6 +1,22 @@
1
1
  import { isAdmin, isLockedOutOfBranch, isReviewer } from './helpers.js';
2
2
  import { getBranchProtection } from './protected-branch.js';
3
- import { SYSTEM_USER_ID } from '../system-users.js';
3
+ import { isSystemUserId, SYSTEM_USER_ID } from '../system-users.js';
4
+ import { sanitizeBranchName } from '../paths/branch-name.js';
5
+ import { baseBranchOf } from '../utils/base-branch.js';
6
+ /**
7
+ * Whether `context` is the active branch a dev server provisioned for itself: owned by a CanopyCMS
8
+ * system user, so nobody is its creator, and named like the active branch now, which follows git
9
+ * HEAD unless configured and is not the base when HEAD has left a configured `defaultBaseBranch`.
10
+ * A person's branch never is.
11
+ * `mode` is the adopter's configured operating mode; a prod deployment's is 'prod', so prod
12
+ * returns before the name comparison and never consults its possibly unresolved base branch.
13
+ */
14
+ function isDevActiveBranch(config, context) {
15
+ if (config.mode !== 'dev' || !isSystemUserId(context.branch.createdBy))
16
+ return false;
17
+ const active = config.defaultActiveBranch ?? baseBranchOf(config);
18
+ return sanitizeBranchName(context.branch.name) === sanitizeBranchName(active);
19
+ }
4
20
  /**
5
21
  * Check if user has access to a branch with explicit default behavior.
6
22
  *
@@ -10,8 +26,7 @@ import { SYSTEM_USER_ID } from '../system-users.js';
10
26
  * 3. An explicit user/group ACL -- an allowlist omitting the creator still
11
27
  * denies them, so an admin can lock down a branch someone else created
12
28
  * 4. With no ACL: the branch creator, then `defaultAccess`, and only where that
13
- * would deny, the protected base branch grant
14
- * (see {@link BranchAccessOptions.isProtectedBranch})
29
+ * would deny, the base branch or dev active branch grant ({@link BranchAccessOptions})
15
30
  * @internal Exported for tests.
16
31
  */
17
32
  export function checkBranchAccessWithDefault(context, user, defaultAccess = 'deny', options) {
@@ -43,6 +58,9 @@ export function checkBranchAccessWithDefault(context, user, defaultAccess = 'den
43
58
  if (options?.isProtectedBranch) {
44
59
  return { allowed: true, reason: 'base_branch' };
45
60
  }
61
+ if (options?.isDevActiveBranch) {
62
+ return { allowed: true, reason: 'dev_active_branch' };
63
+ }
46
64
  return { allowed: false, reason: 'no_acl' };
47
65
  }
48
66
  const userAllowed = hasUserConstraint && access.allowedUsers?.includes(user.userId);
@@ -53,13 +71,15 @@ export function checkBranchAccessWithDefault(context, user, defaultAccess = 'den
53
71
  /**
54
72
  * Bind a default access level. `config` is needed only to resolve the
55
73
  * protected-base-branch question, which routes through `getBranchProtection`,
56
- * its single source of truth, rather than repeating the comparison here.
74
+ * its single source of truth, rather than repeating the comparison here, and
75
+ * the dev-active-branch question ({@link isDevActiveBranch}).
57
76
  */
58
77
  export function createCheckBranchAccess(defaultAccess = 'deny', config) {
59
78
  return (context, user) => checkBranchAccessWithDefault(context, user, defaultAccess, {
60
79
  isProtectedBranch: config
61
80
  ? getBranchProtection(config, context.branch.name, context.branch.baseBranch).isProtected
62
81
  : false,
82
+ isDevActiveBranch: config ? isDevActiveBranch(config, context) : false,
63
83
  });
64
84
  }
65
85
  /**
@@ -7,7 +7,7 @@ export type PermissionPath = string & {
7
7
  };
8
8
  export interface BranchAccessResult {
9
9
  allowed: boolean;
10
- reason: 'privileged' | 'base_branch' | 'creator' | 'allowed_by_acl' | 'denied_by_acl' | 'no_acl';
10
+ reason: 'privileged' | 'base_branch' | 'dev_active_branch' | 'creator' | 'allowed_by_acl' | 'denied_by_acl' | 'no_acl';
11
11
  }
12
12
  export interface PathPermissionResult {
13
13
  allowed: boolean;
@@ -82,9 +82,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
82
82
  lastAt: string;
83
83
  }>>;
84
84
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
85
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
86
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
87
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
88
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
89
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
90
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
85
91
  syncFailureReason: z.ZodOptional<z.ZodString>;
86
92
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
87
93
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
94
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
95
+ kind: z.ZodLiteral<"conflicts">;
96
+ }, "strip", z.ZodTypeAny, {
97
+ kind: "conflicts";
98
+ }, {
99
+ kind: "conflicts";
100
+ }>, z.ZodObject<{
101
+ kind: z.ZodLiteral<"retargeted">;
102
+ base: z.ZodString;
103
+ }, "strip", z.ZodTypeAny, {
104
+ kind: "retargeted";
105
+ base: string;
106
+ }, {
107
+ kind: "retargeted";
108
+ base: string;
109
+ }>]>>>;
88
110
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
89
111
  state: z.ZodEnum<["approved", "changes-requested"]>;
90
112
  by: z.ZodObject<{
@@ -175,9 +197,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
175
197
  lastAt: string;
176
198
  }>>;
177
199
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
200
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
201
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
202
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
203
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
204
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
205
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
178
206
  syncFailureReason: z.ZodOptional<z.ZodString>;
179
207
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
180
208
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
209
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
210
+ kind: z.ZodLiteral<"conflicts">;
211
+ }, "strip", z.ZodTypeAny, {
212
+ kind: "conflicts";
213
+ }, {
214
+ kind: "conflicts";
215
+ }>, z.ZodObject<{
216
+ kind: z.ZodLiteral<"retargeted">;
217
+ base: z.ZodString;
218
+ }, "strip", z.ZodTypeAny, {
219
+ kind: "retargeted";
220
+ base: string;
221
+ }, {
222
+ kind: "retargeted";
223
+ base: string;
224
+ }>]>>>;
181
225
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
182
226
  state: z.ZodEnum<["approved", "changes-requested"]>;
183
227
  by: z.ZodObject<{
@@ -268,9 +312,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
268
312
  lastAt: string;
269
313
  }>>;
270
314
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
315
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
316
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
317
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
318
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
319
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
320
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
271
321
  syncFailureReason: z.ZodOptional<z.ZodString>;
272
322
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
273
323
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
324
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
325
+ kind: z.ZodLiteral<"conflicts">;
326
+ }, "strip", z.ZodTypeAny, {
327
+ kind: "conflicts";
328
+ }, {
329
+ kind: "conflicts";
330
+ }>, z.ZodObject<{
331
+ kind: z.ZodLiteral<"retargeted">;
332
+ base: z.ZodString;
333
+ }, "strip", z.ZodTypeAny, {
334
+ kind: "retargeted";
335
+ base: string;
336
+ }, {
337
+ kind: "retargeted";
338
+ base: string;
339
+ }>]>>>;
274
340
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
275
341
  state: z.ZodEnum<["approved", "changes-requested"]>;
276
342
  by: z.ZodObject<{
@@ -81,9 +81,23 @@ const branchMetadataShape = {
81
81
  .object({ message: z.string(), firstAt: z.string(), lastAt: z.string() })
82
82
  .optional(),
83
83
  historyRewrittenFrom: z.string().optional(),
84
+ lastGitHubTip: z.string().optional(),
85
+ keptOverGitHub: z.array(z.string()).optional(),
86
+ rewrittenOnGitHub: z.string().optional(),
87
+ submitUnfinished: z.boolean().optional(),
88
+ submitNotQueued: z.boolean().optional(),
89
+ unpublishedAtMerge: z.boolean().optional(),
84
90
  syncFailureReason: z.string().optional(),
85
91
  editors: z.array(z.string()).optional(),
86
92
  uncommittedEditors: z.array(z.string()).optional(),
93
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
94
+ pullRequestHold: z
95
+ .discriminatedUnion('kind', [
96
+ z.object({ kind: z.literal('conflicts') }),
97
+ z.object({ kind: z.literal('retargeted'), base: z.string() }),
98
+ ])
99
+ .optional()
100
+ .catch(undefined),
87
101
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
88
102
  review: z
89
103
  .object({
@@ -119,8 +119,10 @@ export declare function buildInitialBranchMetadata(incoming: BranchMetadataUpdat
119
119
  export interface BranchMetadataUpdate {
120
120
  branch?: Partial<Omit<BranchMetadata, 'createdAt' | 'updatedAt'>>;
121
121
  }
122
+ /** A failed submit whose pull request step hasn't run; no other task's success clears its failure. */
123
+ export declare const awaitsSubmitRetry: ({ submitUnfinished, status }: BranchMetadata) => boolean;
122
124
  /**
123
- * The metadata update for archiving a branch whose PR merged. Shared by the
125
+ * The metadata update for archiving `branch`, whose PR merged. Shared by the
124
126
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
125
127
  * API (api/branch-merge.ts), so both produce identical metadata.
126
128
  *
@@ -128,7 +130,7 @@ export interface BranchMetadataUpdate {
128
130
  * existing values for fields the incoming update omits, so the branch's ref and
129
131
  * its recorded PR number and URL survive untouched.
130
132
  */
131
- export declare function buildMergedBranchUpdate(now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
133
+ export declare function buildMergedBranchUpdate(branch: BranchMetadata, now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
132
134
  /** {@link BranchMetadataFileManager.get} as a function. */
133
135
  export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string, options?: {
134
136
  settleMs?: number;
@@ -267,8 +267,14 @@ function mergeBranchMetadata(existing, version, incoming, now = new Date().toISO
267
267
  export function buildInitialBranchMetadata(incoming, now) {
268
268
  return mergeBranchMetadata(null, null, incoming, now);
269
269
  }
270
+ /** A failed submit whose pull request step hasn't run; no other task's success clears its failure. */
271
+ export const awaitsSubmitRetry = ({ submitUnfinished, status }) => submitUnfinished === true && (status === 'submitted' || status === 'approved');
272
+ /** Only a submit writes `pending-sync` to branch.json, so that too is a submit not yet finished. */
273
+ const owesSubmit = (branch) => awaitsSubmitRetry(branch) ||
274
+ (branch.syncStatus === 'pending-sync' &&
275
+ (branch.status === 'submitted' || branch.status === 'approved'));
270
276
  /**
271
- * The metadata update for archiving a branch whose PR merged. Shared by the
277
+ * The metadata update for archiving `branch`, whose PR merged. Shared by the
272
278
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
273
279
  * API (api/branch-merge.ts), so both produce identical metadata.
274
280
  *
@@ -276,7 +282,7 @@ export function buildInitialBranchMetadata(incoming, now) {
276
282
  * existing values for fields the incoming update omits, so the branch's ref and
277
283
  * its recorded PR number and URL survive untouched.
278
284
  */
279
- export function buildMergedBranchUpdate(now = new Date()) {
285
+ export function buildMergedBranchUpdate(branch, now = new Date()) {
280
286
  return {
281
287
  status: 'archived',
282
288
  pullRequestState: 'merged',
@@ -284,6 +290,8 @@ export function buildMergedBranchUpdate(now = new Date()) {
284
290
  // The merge settled every conflict the record named.
285
291
  conflictStatus: 'clean',
286
292
  conflictFiles: [],
293
+ submitUnfinished: undefined,
294
+ ...(owesSubmit(branch) ? { unpublishedAtMerge: true } : {}),
287
295
  };
288
296
  }
289
297
  /** {@link BranchMetadataFileManager.get} as a function. */