merge-steward 0.35.2 → 0.35.4
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/dist/github/pr-client.d.ts +8 -0
- package/dist/github/pr-client.js +36 -2
- package/dist/interfaces.d.ts +10 -0
- package/dist/open-pr-ancestry.d.ts +10 -0
- package/dist/open-pr-ancestry.js +14 -0
- package/dist/reconciler-merge.js +24 -3
- package/dist/reconciler-prepare.js +1 -0
- package/dist/service-queue.d.ts +15 -2
- package/dist/service-queue.js +37 -6
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -31,7 +31,15 @@ export declare class GitHubPRClient implements GitHubPRApi {
|
|
|
31
31
|
number: number;
|
|
32
32
|
branch: string;
|
|
33
33
|
headSha: string;
|
|
34
|
+
baseBranch: string;
|
|
34
35
|
}>>;
|
|
36
|
+
listOpenPRsByBase(baseBranch: string): Promise<Array<{
|
|
37
|
+
number: number;
|
|
38
|
+
branch: string;
|
|
39
|
+
headSha: string;
|
|
40
|
+
baseBranch: string;
|
|
41
|
+
}>>;
|
|
42
|
+
setBaseBranch(prNumber: number, baseBranch: string): Promise<void>;
|
|
35
43
|
deleteBranch(prNumber: number): Promise<void>;
|
|
36
44
|
findPRByBranch(branch: string): Promise<number | null>;
|
|
37
45
|
listLabels(prNumber: number): Promise<string[]>;
|
package/dist/github/pr-client.js
CHANGED
|
@@ -98,7 +98,7 @@ export class GitHubPRClient {
|
|
|
98
98
|
"pr", "list",
|
|
99
99
|
"--repo", this.repoFullName,
|
|
100
100
|
"--state", "open",
|
|
101
|
-
"--json", "number,headRefName,headRefOid",
|
|
101
|
+
"--json", "number,headRefName,headRefOid,baseRefName",
|
|
102
102
|
"--limit", "1000",
|
|
103
103
|
], { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
104
104
|
if (result.exitCode !== 0) {
|
|
@@ -106,12 +106,46 @@ export class GitHubPRClient {
|
|
|
106
106
|
}
|
|
107
107
|
try {
|
|
108
108
|
const data = JSON.parse(result.stdout);
|
|
109
|
-
return data.map((pr) => ({
|
|
109
|
+
return data.map((pr) => ({
|
|
110
|
+
number: pr.number,
|
|
111
|
+
branch: pr.headRefName,
|
|
112
|
+
headSha: pr.headRefOid,
|
|
113
|
+
baseBranch: pr.baseRefName,
|
|
114
|
+
}));
|
|
110
115
|
}
|
|
111
116
|
catch (error) {
|
|
112
117
|
throw new Error("GitHub returned malformed open PR data", { cause: error });
|
|
113
118
|
}
|
|
114
119
|
}
|
|
120
|
+
async listOpenPRsByBase(baseBranch) {
|
|
121
|
+
const result = await exec("gh", [
|
|
122
|
+
"api", "--method", "GET",
|
|
123
|
+
`repos/${this.repoFullName}/pulls`,
|
|
124
|
+
"-f", "state=open",
|
|
125
|
+
"-f", `base=${baseBranch}`,
|
|
126
|
+
"-f", "per_page=100",
|
|
127
|
+
"--paginate", "--slurp",
|
|
128
|
+
], { githubRepoFullName: this.repoFullName });
|
|
129
|
+
try {
|
|
130
|
+
const pages = JSON.parse(result.stdout);
|
|
131
|
+
return pages.flat().map((pr) => ({
|
|
132
|
+
number: pr.number,
|
|
133
|
+
branch: pr.head.ref,
|
|
134
|
+
headSha: pr.head.sha,
|
|
135
|
+
baseBranch: pr.base.ref,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
throw new Error("GitHub returned malformed stack child PR data", { cause: error });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async setBaseBranch(prNumber, baseBranch) {
|
|
143
|
+
await exec("gh", [
|
|
144
|
+
"api", "--method", "PATCH",
|
|
145
|
+
`repos/${this.repoFullName}/pulls/${prNumber}`,
|
|
146
|
+
"-f", `base=${baseBranch}`,
|
|
147
|
+
], { githubRepoFullName: this.repoFullName });
|
|
148
|
+
}
|
|
115
149
|
async deleteBranch(prNumber) {
|
|
116
150
|
const status = await this.getStatus(prNumber);
|
|
117
151
|
await exec("gh", [
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -55,9 +55,19 @@ export interface GitHubPRApi {
|
|
|
55
55
|
number: number;
|
|
56
56
|
branch: string;
|
|
57
57
|
headSha: string;
|
|
58
|
+
baseBranch: string;
|
|
59
|
+
}>>;
|
|
60
|
+
/** List every open PR whose current base is the given branch. */
|
|
61
|
+
listOpenPRsByBase(baseBranch: string): Promise<Array<{
|
|
62
|
+
number: number;
|
|
63
|
+
branch: string;
|
|
64
|
+
headSha: string;
|
|
65
|
+
baseBranch: string;
|
|
58
66
|
}>>;
|
|
59
67
|
/** Find the open PR number for a branch, or null if none exists. */
|
|
60
68
|
findPRByBranch(branch: string): Promise<number | null>;
|
|
69
|
+
/** Retarget a PR before deleting a branch used as its current base. */
|
|
70
|
+
setBaseBranch(prNumber: number, baseBranch: string): Promise<void>;
|
|
61
71
|
/** Delete the PR's head branch from the remote (best-effort cleanup). */
|
|
62
72
|
deleteBranch(prNumber: number): Promise<void>;
|
|
63
73
|
/** List open PRs with a specific label (for startup scan). */
|
|
@@ -13,11 +13,21 @@ export interface OpenPrAncestor {
|
|
|
13
13
|
* captures that shared history. If it is absent from current main, landing the
|
|
14
14
|
* candidate can bypass the other PR's review gates. Explicit stacks become
|
|
15
15
|
* valid only after their shared parent history has landed in main.
|
|
16
|
+
*
|
|
17
|
+
* The danger is one-directional. A stack child's candidate carries its
|
|
18
|
+
* parent's commits, which GitHub reviewed against the parent's branch rather
|
|
19
|
+
* than against main, so the child must wait. A parent's candidate carries only
|
|
20
|
+
* its own commits: the history it shares with its child is the parent's own,
|
|
21
|
+
* it is in the parent's diff, and it was reviewed there. So a child never
|
|
22
|
+
* blocks its parent, and saying so takes the declared base — after the parent
|
|
23
|
+
* advances past the commit the child sits on, ancestry alone can no longer
|
|
24
|
+
* tell a stack child from an unrelated PR holding the same unlanded history.
|
|
16
25
|
*/
|
|
17
26
|
export declare function findUnlandedOpenPrAncestors(params: {
|
|
18
27
|
github: GitHubPRApi;
|
|
19
28
|
git: GitOperations;
|
|
20
29
|
currentPrNumber: number;
|
|
30
|
+
currentBranch: string;
|
|
21
31
|
prHeadSha: string;
|
|
22
32
|
candidateSha: string;
|
|
23
33
|
baseSha: string;
|
package/dist/open-pr-ancestry.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* captures that shared history. If it is absent from current main, landing the
|
|
7
7
|
* candidate can bypass the other PR's review gates. Explicit stacks become
|
|
8
8
|
* valid only after their shared parent history has landed in main.
|
|
9
|
+
*
|
|
10
|
+
* The danger is one-directional. A stack child's candidate carries its
|
|
11
|
+
* parent's commits, which GitHub reviewed against the parent's branch rather
|
|
12
|
+
* than against main, so the child must wait. A parent's candidate carries only
|
|
13
|
+
* its own commits: the history it shares with its child is the parent's own,
|
|
14
|
+
* it is in the parent's diff, and it was reviewed there. So a child never
|
|
15
|
+
* blocks its parent, and saying so takes the declared base — after the parent
|
|
16
|
+
* advances past the commit the child sits on, ancestry alone can no longer
|
|
17
|
+
* tell a stack child from an unrelated PR holding the same unlanded history.
|
|
9
18
|
*/
|
|
10
19
|
export async function findUnlandedOpenPrAncestors(params) {
|
|
11
20
|
const openPrs = await params.github.listOpenPRs();
|
|
@@ -13,6 +22,11 @@ export async function findUnlandedOpenPrAncestors(params) {
|
|
|
13
22
|
for (const openPr of openPrs) {
|
|
14
23
|
if (openPr.number === params.currentPrNumber)
|
|
15
24
|
continue;
|
|
25
|
+
// Declared onto this PR's branch: a child, whatever its head has done since.
|
|
26
|
+
// Both names must be present: a missing base must never read as a match,
|
|
27
|
+
// or an API that stopped returning one would disable this check in silence.
|
|
28
|
+
if (params.currentBranch !== "" && openPr.baseBranch === params.currentBranch)
|
|
29
|
+
continue;
|
|
16
30
|
if (openPr.headSha !== params.prHeadSha
|
|
17
31
|
&& await params.git.isAncestor(params.prHeadSha, openPr.headSha))
|
|
18
32
|
continue;
|
package/dist/reconciler-merge.js
CHANGED
|
@@ -285,6 +285,7 @@ export async function mergeHead(ctx, entry) {
|
|
|
285
285
|
github: ctx.github,
|
|
286
286
|
git: ctx.git,
|
|
287
287
|
currentPrNumber: entry.prNumber,
|
|
288
|
+
currentBranch: entry.branch,
|
|
288
289
|
prHeadSha: entry.headSha,
|
|
289
290
|
candidateSha: landingSha,
|
|
290
291
|
baseSha: currentBase,
|
|
@@ -417,15 +418,35 @@ export async function deletePrBranchAfterGitHubMarksMerged(ctx, entry, options =
|
|
|
417
418
|
const attempts = options.attempts ?? DEFAULT_PR_MERGED_POLL_ATTEMPTS;
|
|
418
419
|
const delayMs = options.delayMs ?? DEFAULT_PR_MERGED_POLL_DELAY_MS;
|
|
419
420
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
420
|
-
let
|
|
421
|
+
let mergedStatus = null;
|
|
421
422
|
try {
|
|
422
|
-
|
|
423
|
+
const status = await ctx.github.getStatus(entry.prNumber);
|
|
424
|
+
if (status.merged)
|
|
425
|
+
mergedStatus = status;
|
|
423
426
|
}
|
|
424
427
|
catch {
|
|
425
428
|
// Keep polling briefly. If GitHub is unavailable, branch cleanup is
|
|
426
429
|
// cosmetic; preserving correct PR merge classification matters more.
|
|
427
430
|
}
|
|
428
|
-
if (
|
|
431
|
+
if (mergedStatus) {
|
|
432
|
+
let children;
|
|
433
|
+
try {
|
|
434
|
+
children = await ctx.github.listOpenPRsByBase(mergedStatus.branch);
|
|
435
|
+
for (const child of children) {
|
|
436
|
+
await ctx.github.setBaseBranch(child.number, ctx.baseBranch);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
emit(ctx, entry, "pr_branch_cleanup_deferred", {
|
|
441
|
+
detail: "preserving the merged PR branch because stacked children could not be safely retargeted",
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (children.length > 0) {
|
|
446
|
+
emit(ctx, entry, "stack_children_retargeted", {
|
|
447
|
+
detail: `retargeted child PR ${children.map((child) => `#${child.number}`).join(", ")} to ${ctx.baseBranch} before deleting ${mergedStatus.branch}`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
429
450
|
try {
|
|
430
451
|
await ctx.github.deleteBranch(entry.prNumber);
|
|
431
452
|
}
|
package/dist/service-queue.d.ts
CHANGED
|
@@ -23,12 +23,25 @@ export declare class MergeStewardQueueCommands {
|
|
|
23
23
|
baseRefName?: string;
|
|
24
24
|
}): QueueEntry | undefined;
|
|
25
25
|
scanStartupAdmissions(): Promise<void>;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A full re-read of what is admissible.
|
|
28
|
+
*
|
|
29
|
+
* This runs both periodically and on startup. Only the startup caller asks
|
|
30
|
+
* to re-read environmental evictions, because that is when the admission
|
|
31
|
+
* policy can have changed under an entry that a previous version evicted.
|
|
32
|
+
*/
|
|
33
|
+
scanEligibleOpenPrs(options?: {
|
|
34
|
+
rereadEnvironmentalEvictions?: boolean;
|
|
35
|
+
}): Promise<{
|
|
27
36
|
scanned: number;
|
|
28
37
|
admitted: number;
|
|
29
38
|
}>;
|
|
30
39
|
updatePriorityByPR(prNumber: number, priority: number): boolean;
|
|
31
|
-
|
|
40
|
+
/** Whether this entry's eviction was about the world rather than the head. */
|
|
41
|
+
private evictedOnEnvironment;
|
|
42
|
+
tryAdmit(prNumber: number, branch: string, headSha: string, options?: {
|
|
43
|
+
rereadEnvironmentalEvictions?: boolean;
|
|
44
|
+
}): Promise<boolean>;
|
|
32
45
|
dequeueByPR(prNumber: number): void;
|
|
33
46
|
updateHeadByPR(prNumber: number, headSha: string): void;
|
|
34
47
|
acknowledgeExternalMerge(prNumber: number): Promise<void>;
|
package/dist/service-queue.js
CHANGED
|
@@ -5,6 +5,18 @@ function matchGlob(pattern, value) {
|
|
|
5
5
|
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
6
6
|
return regex.test(value);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Evictions the same head can recover from, because what blocked it was the
|
|
10
|
+
* state around the PR rather than anything about the PR.
|
|
11
|
+
*
|
|
12
|
+
* Every other class is a fact about this head — it conflicts, its checks fail,
|
|
13
|
+
* its branch is broken — and re-admitting it unchanged would only reproduce
|
|
14
|
+
* the eviction. `policy_blocked` is the exception: it says another open PR's
|
|
15
|
+
* unlanded history is in the way, which the head cannot fix and which stops
|
|
16
|
+
* being true when that PR lands, closes, retargets, or when the admission
|
|
17
|
+
* policy itself changes. Those are re-read on the next startup scan.
|
|
18
|
+
*/
|
|
19
|
+
const ENVIRONMENTAL_FAILURES = new Set(["policy_blocked"]);
|
|
8
20
|
function getLatestEvictedEntry(entries, prNumber) {
|
|
9
21
|
const evicted = entries.filter((entry) => entry.prNumber === prNumber && entry.status === "evicted");
|
|
10
22
|
evicted.sort((left, right) => {
|
|
@@ -89,7 +101,7 @@ export class MergeStewardQueueCommands {
|
|
|
89
101
|
async scanStartupAdmissions() {
|
|
90
102
|
this.logger.info({ repoId: this.config.repoId }, "Scanning startup admissions");
|
|
91
103
|
try {
|
|
92
|
-
const { scanned, admitted } = await this.scanEligibleOpenPrs();
|
|
104
|
+
const { scanned, admitted } = await this.scanEligibleOpenPrs({ rereadEnvironmentalEvictions: true });
|
|
93
105
|
if (scanned > 0) {
|
|
94
106
|
this.logger.info({ scanned, admitted }, "Startup scan for eligible open PRs complete");
|
|
95
107
|
}
|
|
@@ -98,12 +110,20 @@ export class MergeStewardQueueCommands {
|
|
|
98
110
|
this.logger.warn({ err: error }, "Startup scan for eligible open PRs failed");
|
|
99
111
|
}
|
|
100
112
|
}
|
|
101
|
-
|
|
113
|
+
/**
|
|
114
|
+
* A full re-read of what is admissible.
|
|
115
|
+
*
|
|
116
|
+
* This runs both periodically and on startup. Only the startup caller asks
|
|
117
|
+
* to re-read environmental evictions, because that is when the admission
|
|
118
|
+
* policy can have changed under an entry that a previous version evicted.
|
|
119
|
+
*/
|
|
120
|
+
async scanEligibleOpenPrs(options) {
|
|
102
121
|
const open = await this.github.listOpenPRs();
|
|
103
122
|
let admitted = 0;
|
|
104
123
|
for (const pr of open) {
|
|
105
|
-
if (await this.tryAdmit(pr.number, pr.branch, pr.headSha))
|
|
124
|
+
if (await this.tryAdmit(pr.number, pr.branch, pr.headSha, options)) {
|
|
106
125
|
admitted += 1;
|
|
126
|
+
}
|
|
107
127
|
}
|
|
108
128
|
return { scanned: open.length, admitted };
|
|
109
129
|
}
|
|
@@ -123,7 +143,13 @@ export class MergeStewardQueueCommands {
|
|
|
123
143
|
this.logger.info({ prNumber, entryId: entry.id, priority }, "Updated queued PR priority");
|
|
124
144
|
return true;
|
|
125
145
|
}
|
|
126
|
-
|
|
146
|
+
/** Whether this entry's eviction was about the world rather than the head. */
|
|
147
|
+
evictedOnEnvironment(entry) {
|
|
148
|
+
const incidents = this.store.listIncidents(entry.id);
|
|
149
|
+
const latest = incidents[incidents.length - 1];
|
|
150
|
+
return latest !== undefined && ENVIRONMENTAL_FAILURES.has(latest.failureClass);
|
|
151
|
+
}
|
|
152
|
+
async tryAdmit(prNumber, branch, headSha, options) {
|
|
127
153
|
if (this.config.excludeBranches.some((pattern) => matchGlob(pattern, branch))) {
|
|
128
154
|
this.logger.debug({ prNumber, branch }, "Branch excluded from admission");
|
|
129
155
|
return false;
|
|
@@ -135,8 +161,13 @@ export class MergeStewardQueueCommands {
|
|
|
135
161
|
}
|
|
136
162
|
const latestEvicted = getLatestEvictedEntry(this.store.listAll(this.config.repoId), prNumber);
|
|
137
163
|
if (latestEvicted?.headSha === headSha) {
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
const rereadable = options?.rereadEnvironmentalEvictions === true
|
|
165
|
+
&& this.evictedOnEnvironment(latestEvicted);
|
|
166
|
+
if (!rereadable) {
|
|
167
|
+
this.logger.debug({ prNumber, headSha, evictedEntryId: latestEvicted.id }, "PR head matches latest evicted entry, skipping admission until a new push");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
this.logger.info({ prNumber, headSha, evictedEntryId: latestEvicted.id }, "Re-admitting a head evicted on environment; the blocking condition is re-read from scratch");
|
|
140
171
|
}
|
|
141
172
|
try {
|
|
142
173
|
const status = await this.github.getStatus(prNumber);
|
package/dist/types.d.ts
CHANGED
|
@@ -243,7 +243,7 @@ export interface QueueConfig {
|
|
|
243
243
|
flakyRetries: number;
|
|
244
244
|
pollIntervalMs: number;
|
|
245
245
|
}
|
|
246
|
-
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "candidate_selected" | "integration_build_started" | "integration_build_succeeded" | "integration_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "head_candidate_landed" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "open_pr_ancestry_blocked" | "stack_dependency_waiting" | "merge_waiting_recognition" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "queue_label_synced" | "branch_unreachable";
|
|
246
|
+
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "candidate_selected" | "integration_build_started" | "integration_build_succeeded" | "integration_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "head_candidate_landed" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "open_pr_ancestry_blocked" | "stack_dependency_waiting" | "merge_waiting_recognition" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "stack_children_retargeted" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "queue_label_synced" | "branch_unreachable";
|
|
247
247
|
export interface ReconcileEvent {
|
|
248
248
|
at: string;
|
|
249
249
|
entryId: string;
|