canopycms 0.8.1 → 0.8.2

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 (79) hide show
  1. package/CHANGELOG.md +102 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/cli/cli.js +28 -8
  20. package/dist/cli/generate-ai-content.js +13 -3
  21. package/dist/cli/init.js +2 -2
  22. package/dist/editor/BranchManager.d.ts +8 -4
  23. package/dist/editor/BranchManager.js +43 -18
  24. package/dist/editor/Editor.js +3 -2
  25. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  26. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  27. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  28. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  29. package/dist/editor/admin/useSystemHealth.js +21 -0
  30. package/dist/editor/branch-lock.d.ts +2 -0
  31. package/dist/editor/branch-lock.js +5 -3
  32. package/dist/editor/branch-status.d.ts +25 -1
  33. package/dist/editor/branch-status.js +75 -0
  34. package/dist/editor/components/EditorHeader.d.ts +6 -2
  35. package/dist/editor/components/EditorHeader.js +51 -30
  36. package/dist/editor/components/EntryHeader.d.ts +3 -1
  37. package/dist/editor/components/EntryHeader.js +2 -1
  38. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  39. package/dist/editor/components/TooltipDetail.js +18 -0
  40. package/dist/editor/copy.d.ts +7 -0
  41. package/dist/editor/copy.js +9 -0
  42. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  43. package/dist/editor/hooks/useBranchManager.js +48 -5
  44. package/dist/pr-comments.d.ts +2 -0
  45. package/dist/pr-comments.js +20 -14
  46. package/dist/resource-generation.js +2 -3
  47. package/dist/submission-attribution.d.ts +1 -1
  48. package/dist/submission-attribution.js +20 -2
  49. package/dist/task-queue/task-queue.d.ts +5 -1
  50. package/dist/task-queue/task-queue.js +4 -2
  51. package/dist/types.d.ts +61 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/dist/worker/branch-merge.d.ts +68 -0
  55. package/dist/worker/branch-merge.js +228 -0
  56. package/dist/worker/canopy-state.d.ts +2 -0
  57. package/dist/worker/canopy-state.js +1 -1
  58. package/dist/worker/cms-worker.d.ts +1 -0
  59. package/dist/worker/cms-worker.js +5 -3
  60. package/dist/worker/git-sync.js +15 -8
  61. package/dist/worker/github-commits.d.ts +106 -0
  62. package/dist/worker/github-commits.js +485 -0
  63. package/dist/worker/github-gateway-http.js +3 -2
  64. package/dist/worker/github-gateway-wire.d.ts +22 -13
  65. package/dist/worker/github-gateway-wire.js +4 -1
  66. package/dist/worker/github-gateway.d.ts +16 -5
  67. package/dist/worker/github-gateway.js +22 -5
  68. package/dist/worker/github-mirror.d.ts +8 -0
  69. package/dist/worker/github-mirror.js +12 -1
  70. package/dist/worker/github-transfer.d.ts +3 -1
  71. package/dist/worker/github-transfer.js +3 -2
  72. package/dist/worker/history-rewrite.d.ts +13 -1
  73. package/dist/worker/history-rewrite.js +27 -3
  74. package/dist/worker/rebase.d.ts +13 -7
  75. package/dist/worker/rebase.js +351 -95
  76. package/dist/worker/task-runner.d.ts +1 -1
  77. package/dist/worker/task-runner.js +172 -34
  78. package/dist/worker/worker-context.d.ts +2 -0
  79. package/package.json +1 -1
@@ -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, {
@@ -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. */
package/dist/cli/cli.js CHANGED
@@ -562,7 +562,7 @@ async function failTask(taskDir, taskId, error, logger = nullLogger) {
562
562
  });
563
563
  logger.debug("Failed task", { id: taskId, error });
564
564
  }
565
- async function retryTask(taskDir, taskId, error, logger = nullLogger) {
565
+ async function retryTask(taskDir, taskId, error, logger = nullLogger, options = {}) {
566
566
  const processingPath = path6.join(taskDir, "processing", `${taskId}.json`);
567
567
  const pendingDir = path6.join(taskDir, "pending");
568
568
  const pendingPath = path6.join(pendingDir, `${taskId}.json`);
@@ -583,7 +583,7 @@ async function retryTask(taskDir, taskId, error, logger = nullLogger) {
583
583
  throw err2;
584
584
  }
585
585
  const retryCount = (task.retryCount ?? 0) + 1;
586
- const backoffMs = Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
586
+ const backoffMs = options.delayMs ?? Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
587
587
  task.status = "pending";
588
588
  task.retryCount = retryCount;
589
589
  task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
@@ -5357,9 +5357,7 @@ async function bumpResourceGeneration(root, resource, options) {
5357
5357
  await atomicWriteFile2(resourceGenerationPath(root, resource), token);
5358
5358
  return token;
5359
5359
  } catch (err2) {
5360
- log4.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
5361
- error: getErrorMessage3(err2)
5362
- });
5360
+ canopyLogWarn2(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage3(err2));
5363
5361
  if (options?.mustSucceed)
5364
5362
  throw err2;
5365
5363
  return null;
@@ -10746,6 +10744,7 @@ var init_generate_ai_content = __esm({
10746
10744
  init_field_traversal();
10747
10745
  init_atomic_write2();
10748
10746
  init_error3();
10747
+ init_logger2();
10749
10748
  log4 = createDebugLogger2({ prefix: "ResourceGeneration" });
10750
10749
  META_DIR2 = ".canopy-meta";
10751
10750
  init_logger2();
@@ -12394,9 +12393,20 @@ var init_generate_ai_content = __esm({
12394
12393
  mergedAt: z72.string().optional(),
12395
12394
  rebaseFailure: z72.object({ message: z72.string(), firstAt: z72.string(), lastAt: z72.string() }).optional(),
12396
12395
  historyRewrittenFrom: z72.string().optional(),
12396
+ lastGitHubTip: z72.string().optional(),
12397
+ keptOverGitHub: z72.array(z72.string()).optional(),
12398
+ rewrittenOnGitHub: z72.string().optional(),
12399
+ submitUnfinished: z72.boolean().optional(),
12400
+ submitNotQueued: z72.boolean().optional(),
12401
+ unpublishedAtMerge: z72.boolean().optional(),
12397
12402
  syncFailureReason: z72.string().optional(),
12398
12403
  editors: z72.array(z72.string()).optional(),
12399
12404
  uncommittedEditors: z72.array(z72.string()).optional(),
12405
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
12406
+ pullRequestHold: z72.discriminatedUnion("kind", [
12407
+ z72.object({ kind: z72.literal("conflicts") }),
12408
+ z72.object({ kind: z72.literal("retargeted"), base: z72.string() })
12409
+ ]).optional().catch(void 0),
12400
12410
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
12401
12411
  review: z72.object({
12402
12412
  state: z72.enum(["approved", "changes-requested"]),
@@ -17848,9 +17858,7 @@ async function bumpResourceGeneration2(root, resource, options) {
17848
17858
  await atomicWriteFile3(resourceGenerationPath2(root, resource), token);
17849
17859
  return token;
17850
17860
  } catch (err2) {
17851
- log11.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
17852
- error: getErrorMessage(err2)
17853
- });
17861
+ canopyLogWarn3(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err2));
17854
17862
  if (options?.mustSucceed)
17855
17863
  throw err2;
17856
17864
  return null;
@@ -17879,6 +17887,7 @@ var init_resource_generation = __esm({
17879
17887
  init_atomic_write3();
17880
17888
  init_error();
17881
17889
  init_debug2();
17890
+ init_logger3();
17882
17891
  log11 = createDebugLogger3({ prefix: "ResourceGeneration" });
17883
17892
  META_DIR3 = ".canopy-meta";
17884
17893
  }
@@ -19206,9 +19215,20 @@ var init_branch_metadata_file = __esm({
19206
19215
  mergedAt: z18.string().optional(),
19207
19216
  rebaseFailure: z18.object({ message: z18.string(), firstAt: z18.string(), lastAt: z18.string() }).optional(),
19208
19217
  historyRewrittenFrom: z18.string().optional(),
19218
+ lastGitHubTip: z18.string().optional(),
19219
+ keptOverGitHub: z18.array(z18.string()).optional(),
19220
+ rewrittenOnGitHub: z18.string().optional(),
19221
+ submitUnfinished: z18.boolean().optional(),
19222
+ submitNotQueued: z18.boolean().optional(),
19223
+ unpublishedAtMerge: z18.boolean().optional(),
19209
19224
  syncFailureReason: z18.string().optional(),
19210
19225
  editors: z18.array(z18.string()).optional(),
19211
19226
  uncommittedEditors: z18.array(z18.string()).optional(),
19227
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
19228
+ pullRequestHold: z18.discriminatedUnion("kind", [
19229
+ z18.object({ kind: z18.literal("conflicts") }),
19230
+ z18.object({ kind: z18.literal("retargeted"), base: z18.string() })
19231
+ ]).optional().catch(void 0),
19212
19232
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
19213
19233
  review: z18.object({
19214
19234
  state: z18.enum(["approved", "changes-requested"]),
@@ -4404,6 +4404,7 @@ init_error();
4404
4404
  import fs5 from "node:fs/promises";
4405
4405
  import path7 from "node:path";
4406
4406
  import { randomUUID } from "node:crypto";
4407
+ init_logger();
4407
4408
  var log4 = createDebugLogger({ prefix: "ResourceGeneration" });
4408
4409
  var META_DIR2 = ".canopy-meta";
4409
4410
  function resourceGenerationPath(root, resource) {
@@ -4415,9 +4416,7 @@ async function bumpResourceGeneration(root, resource, options) {
4415
4416
  await atomicWriteFile(resourceGenerationPath(root, resource), token);
4416
4417
  return token;
4417
4418
  } catch (err) {
4418
- log4.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
4419
- error: getErrorMessage(err)
4420
- });
4419
+ canopyLogWarn(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err));
4421
4420
  if (options?.mustSucceed)
4422
4421
  throw err;
4423
4422
  return null;
@@ -7848,9 +7847,20 @@ var branchMetadataShape = {
7848
7847
  mergedAt: z7.string().optional(),
7849
7848
  rebaseFailure: z7.object({ message: z7.string(), firstAt: z7.string(), lastAt: z7.string() }).optional(),
7850
7849
  historyRewrittenFrom: z7.string().optional(),
7850
+ lastGitHubTip: z7.string().optional(),
7851
+ keptOverGitHub: z7.array(z7.string()).optional(),
7852
+ rewrittenOnGitHub: z7.string().optional(),
7853
+ submitUnfinished: z7.boolean().optional(),
7854
+ submitNotQueued: z7.boolean().optional(),
7855
+ unpublishedAtMerge: z7.boolean().optional(),
7851
7856
  syncFailureReason: z7.string().optional(),
7852
7857
  editors: z7.array(z7.string()).optional(),
7853
7858
  uncommittedEditors: z7.array(z7.string()).optional(),
7859
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
7860
+ pullRequestHold: z7.discriminatedUnion("kind", [
7861
+ z7.object({ kind: z7.literal("conflicts") }),
7862
+ z7.object({ kind: z7.literal("retargeted"), base: z7.string() })
7863
+ ]).optional().catch(void 0),
7854
7864
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
7855
7865
  review: z7.object({
7856
7866
  state: z7.enum(["approved", "changes-requested"]),
package/dist/cli/init.js CHANGED
@@ -1207,7 +1207,7 @@ async function failTask(taskDir, taskId, error, logger = nullLogger) {
1207
1207
  });
1208
1208
  logger.debug("Failed task", { id: taskId, error });
1209
1209
  }
1210
- async function retryTask(taskDir, taskId, error, logger = nullLogger) {
1210
+ async function retryTask(taskDir, taskId, error, logger = nullLogger, options = {}) {
1211
1211
  const processingPath = path6.join(taskDir, "processing", `${taskId}.json`);
1212
1212
  const pendingDir = path6.join(taskDir, "pending");
1213
1213
  const pendingPath = path6.join(pendingDir, `${taskId}.json`);
@@ -1228,7 +1228,7 @@ async function retryTask(taskDir, taskId, error, logger = nullLogger) {
1228
1228
  throw err;
1229
1229
  }
1230
1230
  const retryCount = (task.retryCount ?? 0) + 1;
1231
- const backoffMs = Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
1231
+ const backoffMs = options.delayMs ?? Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
1232
1232
  task.status = "pending";
1233
1233
  task.retryCount = retryCount;
1234
1234
  task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { OperatingMode } from '../operating-mode/index.js';
3
- import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
3
+ import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
4
4
  import type { CommentThread } from '../comment-store.js';
5
5
  import type { UserSearchResult } from '../auth/types.js';
6
6
  import type { CreateRefusal } from './hooks/create-branch-request.js';
@@ -32,17 +32,21 @@ export interface BranchSummary {
32
32
  conflictStatus?: ConflictStatus;
33
33
  /** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
34
34
  conflictFiles?: string[];
35
+ /** What is stuck and needs a person (api/branch.ts). */
36
+ hold?: BranchHold;
35
37
  commentCount?: number;
36
38
  isProtected?: boolean;
37
39
  readOnly?: boolean;
38
40
  /**
39
- * Server-computed compound submit rule (base-branch OR non-'editing'
40
- * status) -- see `useBranchManager.tsx`'s `BranchSummary.submitBlocked` and
41
- * `BranchWriteProtection.submitBlockedIncludingStatus` for the full
41
+ * Server-computed compound submit rule (base-branch, non-'editing'
42
+ * status, or rewritten on GitHub) -- see `useBranchManager.tsx`'s
43
+ * `BranchSummary.submitBlocked` and `BranchWriteProtection.submitBlockedIncludingStatus` for the full
42
44
  * rationale. `getBranchPermissions`'s `canSubmit` consumes this directly
43
45
  * instead of re-deriving `status === 'editing' && !isProtected`.
44
46
  */
45
47
  submitBlocked?: boolean;
48
+ /** Server-computed: the API refuses Approve, as the last submit didn't finish. */
49
+ approveBlocked?: boolean;
46
50
  }
47
51
  export interface UserContext {
48
52
  userId: string;