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.
- package/CHANGELOG.md +120 -47
- package/dist/ai/resolve-branch.js +2 -1
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/authorization/branch.d.ts +10 -4
- package/dist/authorization/branch.js +24 -4
- package/dist/authorization/types.d.ts +1 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +1913 -1606
- package/dist/cli/configured-asset-store.js +2 -8
- package/dist/cli/generate-ai-content.js +17 -4
- package/dist/cli/init.js +2 -2
- package/dist/cli/site-config.d.ts +9 -0
- package/dist/cli/site-config.js +17 -0
- package/dist/cli/sync.js +226 -74
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/services.d.ts +3 -2
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/system-users.d.ts +2 -0
- package/dist/system-users.js +6 -1
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- 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
|
-
|
|
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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
|
143
|
-
`
|
|
144
|
-
`
|
|
145
|
-
|
|
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 {
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -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
|
package/dist/api/branch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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 */
|
package/dist/api/client.js
CHANGED
|
@@ -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 {
|
package/dist/api/github-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/branch-metadata.js
CHANGED
|
@@ -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
|
|
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. */
|