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.
- package/CHANGELOG.md +102 -47
- 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/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 +28 -8
- package/dist/cli/generate-ai-content.js +13 -3
- package/dist/cli/init.js +2 -2
- 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/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- 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
|
@@ -219,9 +219,9 @@ class GatewayRequestHandler {
|
|
|
219
219
|
return {};
|
|
220
220
|
},
|
|
221
221
|
getPullRequest: async ({ number }, signal) => {
|
|
222
|
-
const { merged, merged_at, state, node_id, body } = await gateway.getPullRequest(number, signal);
|
|
222
|
+
const { merged, merged_at, state, node_id, body, mergeable_state, baseRef } = await gateway.getPullRequest(number, signal);
|
|
223
223
|
// A null body is the gateway withholding it (`GitHubPullRequest.body`), and crosses as one.
|
|
224
|
-
return { merged, merged_at, state, node_id, body };
|
|
224
|
+
return { merged, merged_at, state, node_id, body, mergeable_state, baseRef };
|
|
225
225
|
},
|
|
226
226
|
createOrUpdatePullRequest: async (input, signal) => {
|
|
227
227
|
const { number, url, created } = await gateway.createOrUpdatePullRequest(input, signal);
|
|
@@ -777,6 +777,7 @@ class GitHubGatewayClient {
|
|
|
777
777
|
branch: request.branch,
|
|
778
778
|
sha: request.sha,
|
|
779
779
|
...(request.lease === undefined ? {} : { lease: request.lease }),
|
|
780
|
+
...(request.seen === undefined ? {} : { seen: request.seen }),
|
|
780
781
|
prerequisites: [...request.prerequisites],
|
|
781
782
|
bundle: bundle !== undefined,
|
|
782
783
|
});
|
|
@@ -18,6 +18,7 @@ export declare const pushRequest: z.ZodObject<{
|
|
|
18
18
|
branch: z.ZodString;
|
|
19
19
|
sha: z.ZodString;
|
|
20
20
|
lease: z.ZodOptional<z.ZodString>;
|
|
21
|
+
seen: z.ZodOptional<z.ZodString>;
|
|
21
22
|
prerequisites: z.ZodArray<z.ZodString, "many">;
|
|
22
23
|
/** Whether the request body is a bundle; without one it is empty. */
|
|
23
24
|
bundle: z.ZodBoolean;
|
|
@@ -26,12 +27,14 @@ export declare const pushRequest: z.ZodObject<{
|
|
|
26
27
|
sha: string;
|
|
27
28
|
prerequisites: string[];
|
|
28
29
|
bundle: boolean;
|
|
30
|
+
seen?: string | undefined;
|
|
29
31
|
lease?: string | undefined;
|
|
30
32
|
}, {
|
|
31
33
|
branch: string;
|
|
32
34
|
sha: string;
|
|
33
35
|
prerequisites: string[];
|
|
34
36
|
bundle: boolean;
|
|
37
|
+
seen?: string | undefined;
|
|
35
38
|
lease?: string | undefined;
|
|
36
39
|
}>;
|
|
37
40
|
export declare const requests: {
|
|
@@ -52,13 +55,13 @@ export declare const requests: {
|
|
|
52
55
|
base: z.ZodString;
|
|
53
56
|
}, "strict", z.ZodTypeAny, {
|
|
54
57
|
title: string;
|
|
55
|
-
head: string;
|
|
56
58
|
base: string;
|
|
59
|
+
head: string;
|
|
57
60
|
body: string;
|
|
58
61
|
}, {
|
|
59
62
|
title: string;
|
|
60
|
-
head: string;
|
|
61
63
|
base: string;
|
|
64
|
+
head: string;
|
|
62
65
|
body: string;
|
|
63
66
|
}>;
|
|
64
67
|
readonly updatePullRequest: z.ZodObject<{
|
|
@@ -93,15 +96,15 @@ export declare const requests: {
|
|
|
93
96
|
base: z.ZodString;
|
|
94
97
|
}, "strict", z.ZodTypeAny, {
|
|
95
98
|
title: string;
|
|
96
|
-
head: string;
|
|
97
99
|
base: string;
|
|
100
|
+
head: string;
|
|
98
101
|
body: string;
|
|
99
102
|
markReadyIfDraft?: boolean | undefined;
|
|
100
103
|
mergeSectionIntoBody?: boolean | undefined;
|
|
101
104
|
}, {
|
|
102
105
|
title: string;
|
|
103
|
-
head: string;
|
|
104
106
|
base: string;
|
|
107
|
+
head: string;
|
|
105
108
|
body: string;
|
|
106
109
|
markReadyIfDraft?: boolean | undefined;
|
|
107
110
|
mergeSectionIntoBody?: boolean | undefined;
|
|
@@ -206,18 +209,24 @@ export declare const answers: {
|
|
|
206
209
|
state: z.ZodEnum<["open", "closed"]>;
|
|
207
210
|
node_id: z.ZodString;
|
|
208
211
|
body: z.ZodNullable<z.ZodString>;
|
|
212
|
+
mergeable_state: z.ZodOptional<z.ZodString>;
|
|
213
|
+
baseRef: z.ZodOptional<z.ZodString>;
|
|
209
214
|
}, "strict", z.ZodTypeAny, {
|
|
210
215
|
merged: boolean;
|
|
211
216
|
state: "open" | "closed";
|
|
212
217
|
body: string | null;
|
|
213
218
|
merged_at: string | null;
|
|
214
219
|
node_id: string;
|
|
220
|
+
mergeable_state?: string | undefined;
|
|
221
|
+
baseRef?: string | undefined;
|
|
215
222
|
}, {
|
|
216
223
|
merged: boolean;
|
|
217
224
|
state: "open" | "closed";
|
|
218
225
|
body: string | null;
|
|
219
226
|
merged_at: string | null;
|
|
220
227
|
node_id: string;
|
|
228
|
+
mergeable_state?: string | undefined;
|
|
229
|
+
baseRef?: string | undefined;
|
|
221
230
|
}>;
|
|
222
231
|
readonly createOrUpdatePullRequest: z.ZodObject<{
|
|
223
232
|
number: z.ZodNumber;
|
|
@@ -303,21 +312,21 @@ export declare const answers: {
|
|
|
303
312
|
kind: z.ZodEnum<["auto-scaling", "spot"]>;
|
|
304
313
|
reason: z.ZodString;
|
|
305
314
|
}, "strict", z.ZodTypeAny, {
|
|
306
|
-
reason: string;
|
|
307
315
|
kind: "auto-scaling" | "spot";
|
|
308
|
-
}, {
|
|
309
316
|
reason: string;
|
|
317
|
+
}, {
|
|
310
318
|
kind: "auto-scaling" | "spot";
|
|
319
|
+
reason: string;
|
|
311
320
|
}>>;
|
|
312
321
|
}, "strict", z.ZodTypeAny, {
|
|
313
322
|
notice: {
|
|
314
|
-
reason: string;
|
|
315
323
|
kind: "auto-scaling" | "spot";
|
|
324
|
+
reason: string;
|
|
316
325
|
} | null;
|
|
317
326
|
}, {
|
|
318
327
|
notice: {
|
|
319
|
-
reason: string;
|
|
320
328
|
kind: "auto-scaling" | "spot";
|
|
329
|
+
reason: string;
|
|
321
330
|
} | null;
|
|
322
331
|
}>;
|
|
323
332
|
readonly completeLifecycle: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
|
|
@@ -422,28 +431,28 @@ declare const errorEnvelope: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
|
|
|
422
431
|
branch: string;
|
|
423
432
|
push: boolean;
|
|
424
433
|
message: string;
|
|
425
|
-
reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
|
|
426
434
|
kind: "refusal";
|
|
435
|
+
reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
|
|
427
436
|
operation: "push" | "delete" | "pull-request";
|
|
428
437
|
}, {
|
|
429
438
|
branch: string;
|
|
430
439
|
push: boolean;
|
|
431
440
|
message: string;
|
|
432
|
-
reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
|
|
433
441
|
kind: "refusal";
|
|
442
|
+
reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
|
|
434
443
|
operation: "push" | "delete" | "pull-request";
|
|
435
444
|
}>, z.ZodObject<{
|
|
436
445
|
kind: z.ZodLiteral<"push">;
|
|
437
|
-
pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease"]>;
|
|
446
|
+
pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease", "seen-commit-gone"]>;
|
|
438
447
|
message: z.ZodString;
|
|
439
448
|
}, "strict", z.ZodTypeAny, {
|
|
440
449
|
message: string;
|
|
441
450
|
kind: "push";
|
|
442
|
-
pushKind: "rejected" | "rejected-after-stale-lease";
|
|
451
|
+
pushKind: "rejected" | "rejected-after-stale-lease" | "seen-commit-gone";
|
|
443
452
|
}, {
|
|
444
453
|
message: string;
|
|
445
454
|
kind: "push";
|
|
446
|
-
pushKind: "rejected" | "rejected-after-stale-lease";
|
|
455
|
+
pushKind: "rejected" | "rejected-after-stale-lease" | "seen-commit-gone";
|
|
447
456
|
}>, z.ZodObject<{
|
|
448
457
|
kind: z.ZodLiteral<"transient">;
|
|
449
458
|
code: z.ZodEnum<["github-fetch-failed", "bundle-rejected", "prerequisite-gone", "bundle-gone", "mirror-corrupt", "objects-still-needed", "ref-kept-moving", "default-branch-unread"]>;
|
|
@@ -43,6 +43,7 @@ export const pushRequest = z
|
|
|
43
43
|
branch: name,
|
|
44
44
|
sha: objectId,
|
|
45
45
|
lease: objectId.optional(),
|
|
46
|
+
seen: objectId.optional(),
|
|
46
47
|
prerequisites: z.array(objectId).max(3),
|
|
47
48
|
/** Whether the request body is a bundle; without one it is empty. */
|
|
48
49
|
bundle: z.boolean(),
|
|
@@ -111,6 +112,8 @@ export const answers = {
|
|
|
111
112
|
state: z.enum(['open', 'closed']),
|
|
112
113
|
node_id: z.string().max(256),
|
|
113
114
|
body: pullRequestText.body.nullable(),
|
|
115
|
+
mergeable_state: z.string().max(64).optional(),
|
|
116
|
+
baseRef: z.string().max(256).optional(),
|
|
114
117
|
})
|
|
115
118
|
.strict(),
|
|
116
119
|
createOrUpdatePullRequest: pullRequestRef.extend({ created: z.boolean() }).strict(),
|
|
@@ -250,7 +253,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
|
|
|
250
253
|
z
|
|
251
254
|
.object({
|
|
252
255
|
kind: z.literal('push'),
|
|
253
|
-
pushKind: z.enum(['rejected', 'rejected-after-stale-lease']),
|
|
256
|
+
pushKind: z.enum(['rejected', 'rejected-after-stale-lease', 'seen-commit-gone']),
|
|
254
257
|
message,
|
|
255
258
|
})
|
|
256
259
|
.strict(),
|
|
@@ -24,6 +24,12 @@ export interface GitHubPushRequest {
|
|
|
24
24
|
sha: string;
|
|
25
25
|
/** The commit GitHub must still be at for the push to replace it (`--force-with-lease`). */
|
|
26
26
|
lease?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The last commit the caller saw on GitHub's branch. A reset on GitHub leaves a tip the push
|
|
29
|
+
* would fast-forward, restoring what it removed, so a push is refused while GitHub's branch
|
|
30
|
+
* doesn't contain this commit.
|
|
31
|
+
*/
|
|
32
|
+
seen?: string;
|
|
27
33
|
/**
|
|
28
34
|
* At most {@link MAX_PUSH_PREREQUISITES} commits the caller would build a bundle of the branch
|
|
29
35
|
* on, so an answer of `need-objects` can say which of them the gateway can use.
|
|
@@ -50,13 +56,14 @@ export type GitHubPushAnswer = ({
|
|
|
50
56
|
};
|
|
51
57
|
/**
|
|
52
58
|
* A push to GitHub failed: `rejected` on the first attempt, `rejected-after-stale-lease` on the
|
|
53
|
-
* plain retry after GitHub refused a stale lease
|
|
54
|
-
*
|
|
55
|
-
*
|
|
59
|
+
* plain retry after GitHub refused a stale lease, `seen-commit-gone` before any attempt, when
|
|
60
|
+
* GitHub's branch no longer holds {@link GitHubPushRequest.seen}. The message is the failure's
|
|
61
|
+
* own: git's output, for a refusal, which the task runner classifies. A {@link RefusedPushError}
|
|
62
|
+
* from the first attempt is thrown as itself.
|
|
56
63
|
*/
|
|
57
64
|
export declare class GitHubPushError extends Error {
|
|
58
|
-
readonly kind: 'rejected' | 'rejected-after-stale-lease';
|
|
59
|
-
constructor(kind: 'rejected' | 'rejected-after-stale-lease', message: string);
|
|
65
|
+
readonly kind: 'rejected' | 'rejected-after-stale-lease' | 'seen-commit-gone';
|
|
66
|
+
constructor(kind: 'rejected' | 'rejected-after-stale-lease' | 'seen-commit-gone', message: string);
|
|
60
67
|
}
|
|
61
68
|
/** A moved ref for a log line: `<old7>..<new7>`, or `new branch at <new7>`. */
|
|
62
69
|
export declare function describeMove(update: Extract<GitHubRefUpdate, {
|
|
@@ -70,6 +77,10 @@ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['re
|
|
|
70
77
|
* request a person opened from a branch in the namespace has its body answered.
|
|
71
78
|
*/
|
|
72
79
|
body: string | null;
|
|
80
|
+
/** GitHub's own word on what stands between the pull request and a merge; `behind` needs the base. */
|
|
81
|
+
mergeable_state?: string;
|
|
82
|
+
/** The branch the pull request merges into on GitHub, which a person can change. */
|
|
83
|
+
baseRef?: string;
|
|
73
84
|
};
|
|
74
85
|
export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
|
|
75
86
|
/**
|
|
@@ -30,9 +30,10 @@ const MAX_PUSH_PREREQUISITES = 3;
|
|
|
30
30
|
const DEFAULT_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024;
|
|
31
31
|
/**
|
|
32
32
|
* A push to GitHub failed: `rejected` on the first attempt, `rejected-after-stale-lease` on the
|
|
33
|
-
* plain retry after GitHub refused a stale lease
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* plain retry after GitHub refused a stale lease, `seen-commit-gone` before any attempt, when
|
|
34
|
+
* GitHub's branch no longer holds {@link GitHubPushRequest.seen}. The message is the failure's
|
|
35
|
+
* own: git's output, for a refusal, which the task runner classifies. A {@link RefusedPushError}
|
|
36
|
+
* from the first attempt is thrown as itself.
|
|
36
37
|
*/
|
|
37
38
|
export class GitHubPushError extends Error {
|
|
38
39
|
constructor(kind, message) {
|
|
@@ -498,6 +499,8 @@ class LocalGitHubGateway {
|
|
|
498
499
|
assertObjectId(request.sha, 'a commit ID');
|
|
499
500
|
if (request.lease !== undefined)
|
|
500
501
|
assertObjectId(request.lease, 'a commit ID');
|
|
502
|
+
if (request.seen !== undefined)
|
|
503
|
+
assertObjectId(request.seen, 'a commit ID');
|
|
501
504
|
if (request.prerequisites.length > MAX_PUSH_PREREQUISITES) {
|
|
502
505
|
throw new Error(`A push names at most ${MAX_PUSH_PREREQUISITES} prerequisites`);
|
|
503
506
|
}
|
|
@@ -513,6 +516,10 @@ class LocalGitHubGateway {
|
|
|
513
516
|
// and sessions are not re-entrant. So does resolving the credential, so the session never
|
|
514
517
|
// waits on GitHub's API. A body is read first, so its writer is not left waiting.
|
|
515
518
|
const session = (received) => this.afterGitHubFetch(async () => {
|
|
519
|
+
// A reused fetch can predate a reset, whose heads would still show the commit `seen`
|
|
520
|
+
// names. Nothing moved at GitHub, so this ends the reuse and leaves other fetches good.
|
|
521
|
+
if (request.seen !== undefined)
|
|
522
|
+
this.githubFetch = null;
|
|
516
523
|
const snapshot = await this.joinGitHubFetchForPush(credential, request.branch, signal);
|
|
517
524
|
await credential();
|
|
518
525
|
return snapshot;
|
|
@@ -573,8 +580,15 @@ class LocalGitHubGateway {
|
|
|
573
580
|
else {
|
|
574
581
|
repo = await mirror.quarantine(received, request, heads, signal);
|
|
575
582
|
}
|
|
576
|
-
const
|
|
583
|
+
const tip = heads.get(branch);
|
|
584
|
+
if (request.seen !== undefined &&
|
|
585
|
+
tip !== undefined &&
|
|
586
|
+
!(await mirror.historyHolds(tip, request.seen))) {
|
|
587
|
+
throw new GitHubPushError('seen-commit-gone', `GitHub's ${branch} no longer contains ${request.seen}, the last commit seen there`);
|
|
588
|
+
}
|
|
589
|
+
const attempt = (lease, pin) => mirror.pushToGitHub(credential, branch, sha, {
|
|
577
590
|
lease,
|
|
591
|
+
pin,
|
|
578
592
|
signal,
|
|
579
593
|
repo,
|
|
580
594
|
policy: this.policy,
|
|
@@ -582,7 +596,8 @@ class LocalGitHubGateway {
|
|
|
582
596
|
onPushing: () => this.invalidateGitHubFetch(),
|
|
583
597
|
});
|
|
584
598
|
try {
|
|
585
|
-
|
|
599
|
+
const pin = request.seen === undefined || request.lease !== undefined ? undefined : tip;
|
|
600
|
+
return { kind: 'pushed', ...(await attempt(request.lease, pin)), pastStaleLease: false };
|
|
586
601
|
}
|
|
587
602
|
catch (err) {
|
|
588
603
|
if (err instanceof RefusedPushError || err instanceof GitHubTransientError)
|
|
@@ -671,6 +686,8 @@ class LocalGitHubGateway {
|
|
|
671
686
|
state: data.state,
|
|
672
687
|
node_id: data.node_id,
|
|
673
688
|
body: ours ? (data.body ?? '') : null,
|
|
689
|
+
mergeable_state: data.mergeable_state,
|
|
690
|
+
baseRef: data.base.ref,
|
|
674
691
|
};
|
|
675
692
|
});
|
|
676
693
|
}
|
|
@@ -294,6 +294,8 @@ export declare class MirrorSession {
|
|
|
294
294
|
reachableFromHeads(sha: string): Promise<boolean>;
|
|
295
295
|
/** Of `prerequisites`, the commits a GitHub branch contained at the last fetch. */
|
|
296
296
|
usablePrerequisites(prerequisites: readonly string[]): Promise<string[]>;
|
|
297
|
+
/** Whether `tip`'s history holds `commit`. One the mirror doesn't hold, GitHub's heads don't. */
|
|
298
|
+
historyHolds(tip: string, commit: string): Promise<boolean>;
|
|
297
299
|
/**
|
|
298
300
|
* Whether every one of `commits` is reachable from one of `tips`, in ONE walk: rev-list fed the
|
|
299
301
|
* commits and a `^<tip>` per tip lists what the tips do not reach, so empty output means all
|
|
@@ -337,6 +339,12 @@ export declare class MirrorSession {
|
|
|
337
339
|
*/
|
|
338
340
|
pushToGitHub(credential: GitHubCredential, branch: string, sha: string, options: {
|
|
339
341
|
lease?: string;
|
|
342
|
+
/**
|
|
343
|
+
* The tip a caller's check saw. A reset to an ancestor of `sha` after that check would
|
|
344
|
+
* otherwise take a plain push, so a push that fast-forwards this tip carries it as a lease,
|
|
345
|
+
* which forces nothing there and is refused if the ref moved.
|
|
346
|
+
*/
|
|
347
|
+
pin?: string;
|
|
340
348
|
signal?: AbortSignal;
|
|
341
349
|
repo?: string;
|
|
342
350
|
policy: BranchPolicy;
|
|
@@ -775,6 +775,12 @@ export class MirrorSession {
|
|
|
775
775
|
}
|
|
776
776
|
return usable;
|
|
777
777
|
}
|
|
778
|
+
/** Whether `tip`'s history holds `commit`. One the mirror doesn't hold, GitHub's heads don't. */
|
|
779
|
+
async historyHolds(tip, commit) {
|
|
780
|
+
if ((await this.objectTypes([commit])).get(commit) !== 'commit')
|
|
781
|
+
return false;
|
|
782
|
+
return this.allReachable([commit], [tip]);
|
|
783
|
+
}
|
|
778
784
|
/**
|
|
779
785
|
* Whether every one of `commits` is reachable from one of `tips`, in ONE walk: rev-list fed the
|
|
780
786
|
* commits and a `^<tip>` per tip lists what the tips do not reach, so empty output means all
|
|
@@ -902,12 +908,17 @@ export class MirrorSession {
|
|
|
902
908
|
throw new RefusedPushError(branch, 'invalid', undefined, branchInsideMessage(branch, inside));
|
|
903
909
|
}
|
|
904
910
|
}
|
|
911
|
+
const pinned = options.pin !== undefined &&
|
|
912
|
+
(await this.run(['rev-list', '--max-count=1', '--end-of-options', options.pin, `^${sha}`], {
|
|
913
|
+
repo: options.repo,
|
|
914
|
+
})) === '';
|
|
915
|
+
const lease = options.lease ?? (pinned ? options.pin : undefined);
|
|
905
916
|
options.onPushing?.();
|
|
906
917
|
const status = await this.githubBound(credential, [
|
|
907
918
|
'push',
|
|
908
919
|
'--porcelain',
|
|
909
920
|
'--progress',
|
|
910
|
-
...(
|
|
921
|
+
...(lease ? [`--force-with-lease=refs/heads/${branch}:${lease}`] : []),
|
|
911
922
|
'--end-of-options',
|
|
912
923
|
credential.url,
|
|
913
924
|
`${sha}:refs/heads/${branch}`,
|
|
@@ -32,7 +32,9 @@ export declare function seedFromGitHub(ctx: TransferContext, gitDir: string, bas
|
|
|
32
32
|
*/
|
|
33
33
|
export declare function unreachableFrom(ctx: TransferContext, gitDir: string, ids: readonly string[], tips: readonly string[]): Promise<Set<string>>;
|
|
34
34
|
/** A push to GitHub of the commit `readSha` finds, as {@link pushToGitHub} takes it. */
|
|
35
|
-
export interface TransferPushRequest extends Omit<GitHubPushRequest, 'sha' | 'prerequisites'> {
|
|
35
|
+
export interface TransferPushRequest extends Omit<GitHubPushRequest, 'sha' | 'prerequisites' | 'seen'> {
|
|
36
|
+
/** {@link GitHubPushRequest.seen}, read after each attempt's commit, as the two go together. */
|
|
37
|
+
readSeen?: () => Promise<string | undefined>;
|
|
36
38
|
/**
|
|
37
39
|
* Branches whose GitHub tips, as `remote.git`'s tracking namespace holds them, a bundle of
|
|
38
40
|
* `branch` is built on: the branch itself, and the base for a content branch. Missing ones are
|
|
@@ -109,11 +109,12 @@ export async function unreachableFrom(ctx, gitDir, ids, tips) {
|
|
|
109
109
|
* {@link MAX_PUSH_RESTARTS} times, so a ref moved on every pass cannot hold a task for ever.
|
|
110
110
|
*/
|
|
111
111
|
export async function pushToGitHub(ctx, request, readSha, signal) {
|
|
112
|
-
const { prerequisiteBranches, ...rest } = request;
|
|
112
|
+
const { prerequisiteBranches, readSeen, ...rest } = request;
|
|
113
113
|
for (let restarts = 0;; restarts++) {
|
|
114
114
|
const sha = await readSha();
|
|
115
|
+
const seen = await readSeen?.();
|
|
115
116
|
const prerequisites = await trackingTips(ctx.remoteGitPath, prerequisiteBranches);
|
|
116
|
-
const ask = { ...rest, sha, prerequisites };
|
|
117
|
+
const ask = { ...rest, sha, ...(seen ? { seen } : {}), prerequisites };
|
|
117
118
|
const first = await ctx.github().push(ask, undefined, signal);
|
|
118
119
|
if (first.kind === 'pushed')
|
|
119
120
|
return { sha, outcome: outcomeOf(first) };
|
|
@@ -44,11 +44,15 @@ export declare function readPublishedSha(ctx: Pick<HistoryRewriteContext, 'remot
|
|
|
44
44
|
* caller's snapshot: the task loop runs concurrently with syncGit() and may
|
|
45
45
|
* have cleared the marker while this branch was rebasing.
|
|
46
46
|
*
|
|
47
|
+
* `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
|
|
48
|
+
* and dies before its stamp is never read as someone rewriting the branch on
|
|
49
|
+
* GitHub (github-commits.ts).
|
|
50
|
+
*
|
|
47
51
|
* Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
|
|
48
52
|
* When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
|
|
49
53
|
* remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
|
|
50
54
|
*/
|
|
51
|
-
export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
55
|
+
export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string, expectedSha: string, rewrittenTip: string): Promise<boolean>;
|
|
52
56
|
/**
|
|
53
57
|
* Clear the marker once GitHub is confirmed to hold the rewritten history.
|
|
54
58
|
*
|
|
@@ -126,3 +130,11 @@ export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, opti
|
|
|
126
130
|
* louder `diverged` bucket.
|
|
127
131
|
*/
|
|
128
132
|
export declare function hasPendingHistoryRewrite(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchName: string): Promise<boolean>;
|
|
133
|
+
/**
|
|
134
|
+
* Records that GitHub's `branchName` holds `sha`, in `lastGitHubTip`. It runs before `remote.git`
|
|
135
|
+
* takes the commit, so `remote.git` never holds a GitHub commit the record doesn't name: a clone
|
|
136
|
+
* can lag `remote.git` for as long as it stays busy, and once GitHub drops that commit it could not
|
|
137
|
+
* be told from the branch's own work (github-commits.ts). A branch name with no workspace has no
|
|
138
|
+
* record, and a directory whose branch.json names another ref is that ref's.
|
|
139
|
+
*/
|
|
140
|
+
export declare function recordSeenOnGitHub(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchName: string, sha: string): Promise<void>;
|
|
@@ -47,18 +47,28 @@ export async function readPublishedSha(ctx, branchRef) {
|
|
|
47
47
|
* caller's snapshot: the task loop runs concurrently with syncGit() and may
|
|
48
48
|
* have cleared the marker while this branch was rebasing.
|
|
49
49
|
*
|
|
50
|
+
* `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
|
|
51
|
+
* and dies before its stamp is never read as someone rewriting the branch on
|
|
52
|
+
* GitHub (github-commits.ts).
|
|
53
|
+
*
|
|
50
54
|
* Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
|
|
51
55
|
* When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
|
|
52
56
|
* remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
|
|
53
57
|
*/
|
|
54
|
-
export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha) {
|
|
58
|
+
export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha, rewrittenTip) {
|
|
55
59
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
56
60
|
let marked = false;
|
|
57
61
|
await meta.update((current) => {
|
|
58
62
|
marked = current?.branch.name === branchRef;
|
|
59
|
-
if (!marked
|
|
63
|
+
if (!marked)
|
|
60
64
|
return null;
|
|
61
|
-
|
|
65
|
+
const marker = current?.branch.historyRewrittenFrom;
|
|
66
|
+
return {
|
|
67
|
+
branch: {
|
|
68
|
+
historyRewrittenFrom: typeof marker === 'string' ? marker : expectedSha,
|
|
69
|
+
lastGitHubTip: rewrittenTip,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
62
72
|
});
|
|
63
73
|
return marked;
|
|
64
74
|
}
|
|
@@ -226,3 +236,17 @@ export async function hasPendingHistoryRewrite(ctx, branchName) {
|
|
|
226
236
|
return false;
|
|
227
237
|
}
|
|
228
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Records that GitHub's `branchName` holds `sha`, in `lastGitHubTip`. It runs before `remote.git`
|
|
241
|
+
* takes the commit, so `remote.git` never holds a GitHub commit the record doesn't name: a clone
|
|
242
|
+
* can lag `remote.git` for as long as it stays busy, and once GitHub drops that commit it could not
|
|
243
|
+
* be told from the branch's own work (github-commits.ts). A branch name with no workspace has no
|
|
244
|
+
* record, and a directory whose branch.json names another ref is that ref's.
|
|
245
|
+
*/
|
|
246
|
+
export async function recordSeenOnGitHub(ctx, branchName, sha) {
|
|
247
|
+
const branchPath = path.join(ctx.contentBranchesPath, sanitizeBranchName(branchName));
|
|
248
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
249
|
+
if (metaFile?.branch.name !== branchName)
|
|
250
|
+
return;
|
|
251
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => (current?.branch.name === branchName ? { branch: { lastGitHubTip: sha } } : null), { keepUpdatedAt: true });
|
|
252
|
+
}
|
package/dist/worker/rebase.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type BranchMetadataFile } from '../branch-metadata.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type BranchPolicyDescription } from './branch-policy.js';
|
|
3
|
+
import type { GitHubPullRequest } from './github-gateway.js';
|
|
4
|
+
import { type GitHubSnapshot } from './github-commits.js';
|
|
3
5
|
import type { WorkerContext } from './worker-context.js';
|
|
4
6
|
/**
|
|
5
7
|
* The rebase loop: the deepest leaf of the git-sync cluster, and the most
|
|
@@ -16,12 +18,16 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
16
18
|
* - it carries a rewritten history forward into remote.git and GitHub under a
|
|
17
19
|
* lease keyed to the exact commit it replaced ([SYNC-H1], history-rewrite.ts).
|
|
18
20
|
*
|
|
21
|
+
* Ahead of the rebase it brings commits made on GitHub into the clone, for every
|
|
22
|
+
* status (github-commits.ts): the rebase would otherwise rewrite the clone away
|
|
23
|
+
* from a tip it never had.
|
|
24
|
+
*
|
|
19
25
|
* `pollMergeState` lives here rather than in git-sync.ts because the rebase
|
|
20
26
|
* loop is its only caller: submitted/approved branches are skipped for rebasing
|
|
21
27
|
* but still need their PR's resolution polled, and this loop is the only thing
|
|
22
28
|
* that walks every branch workspace.
|
|
23
29
|
*/
|
|
24
|
-
export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining'>;
|
|
30
|
+
export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'ensureStatusReport' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining'>;
|
|
25
31
|
/**
|
|
26
32
|
* Per-cycle outcome of `rebaseActiveBranches()`, folded by `syncGit()` into the
|
|
27
33
|
* worker's self-reported status (`WorkerStatusReport.lastGitSync`, see
|
|
@@ -29,8 +35,8 @@ export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBra
|
|
|
29
35
|
*/
|
|
30
36
|
export interface RebaseSummary {
|
|
31
37
|
/**
|
|
32
|
-
* Branches that were behind and
|
|
33
|
-
* whether or not conflicts were resolved
|
|
38
|
+
* Branches that were behind and caught up with the base branch, by a rebase or
|
|
39
|
+
* by merging it, whether or not conflicts were resolved. Branches already up to
|
|
34
40
|
* date are NOT listed here.
|
|
35
41
|
*/
|
|
36
42
|
rebased: string[];
|
|
@@ -64,9 +70,9 @@ export interface RebaseSummary {
|
|
|
64
70
|
* markAsMerged API so both paths produce identical archived-branch metadata.
|
|
65
71
|
* closed-without-merge -> record pullRequestState only; an admin decides the
|
|
66
72
|
* workflow transition. Best-effort: a failure is logged, swallowed and retried
|
|
67
|
-
* next sync cycle.
|
|
73
|
+
* next sync cycle. Resolves GitHub's answer for a pull request not merged.
|
|
68
74
|
*/
|
|
69
|
-
export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<
|
|
75
|
+
export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<GitHubPullRequest | null>;
|
|
70
76
|
/**
|
|
71
77
|
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
72
78
|
*
|
|
@@ -79,4 +85,4 @@ export declare function pollMergeState(ctx: RebaseContext, branchDir: string, br
|
|
|
79
85
|
* that branch's cross-host content-write lock and runs a series of git
|
|
80
86
|
* subprocesses against a shared filesystem.
|
|
81
87
|
*/
|
|
82
|
-
export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription): Promise<RebaseSummary>;
|
|
88
|
+
export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription, gitHub?: GitHubSnapshot): Promise<RebaseSummary>;
|