merge-steward 0.33.1 → 0.33.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/dist/github/check-run-reporter.js +9 -1
- package/dist/github/pr-client.js +6 -4
- package/dist/github/shell-git.d.ts +1 -0
- package/dist/github/shell-git.js +7 -0
- package/dist/interfaces.d.ts +1 -0
- package/dist/open-pr-ancestry.d.ts +25 -0
- package/dist/open-pr-ancestry.js +33 -0
- package/dist/reconciler-evict.d.ts +6 -0
- package/dist/reconciler-evict.js +1 -0
- package/dist/reconciler-merge.js +33 -0
- package/dist/reconciler-prepare.js +17 -0
- package/dist/types.d.ts +7 -1
- package/package.json +1 -1
|
@@ -88,7 +88,9 @@ function formatTitle(incident) {
|
|
|
88
88
|
case "main_broken":
|
|
89
89
|
return "Queue eviction: main branch CI failing";
|
|
90
90
|
case "policy_blocked":
|
|
91
|
-
return
|
|
91
|
+
return incident.context.openPrAncestors?.length
|
|
92
|
+
? "Queue eviction: candidate shares unlanded history with another open PR"
|
|
93
|
+
: "Queue eviction: approval withdrawn";
|
|
92
94
|
default:
|
|
93
95
|
return "Queue eviction";
|
|
94
96
|
}
|
|
@@ -114,6 +116,12 @@ function formatSummary(entry, incident) {
|
|
|
114
116
|
lines.push(`- ${c.name} (${c.conclusion})`);
|
|
115
117
|
}
|
|
116
118
|
}
|
|
119
|
+
if (incident.context.openPrAncestors?.length) {
|
|
120
|
+
lines.push(``, `**Unlanded open PR ancestry:**`);
|
|
121
|
+
for (const blocker of incident.context.openPrAncestors) {
|
|
122
|
+
lines.push(`- #${blocker.prNumber} ${blocker.branch} (head ${blocker.headSha}; shared unlanded ancestor ${blocker.sharedAncestorSha})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
117
125
|
lines.push(``, `**Incident ID:** ${incident.id}`);
|
|
118
126
|
return lines.join("\n");
|
|
119
127
|
}
|
package/dist/github/pr-client.js
CHANGED
|
@@ -96,15 +96,17 @@ export class GitHubPRClient {
|
|
|
96
96
|
"--repo", this.repoFullName,
|
|
97
97
|
"--state", "open",
|
|
98
98
|
"--json", "number,headRefName,headRefOid",
|
|
99
|
+
"--limit", "1000",
|
|
99
100
|
], { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
100
|
-
if (result.exitCode !== 0)
|
|
101
|
-
|
|
101
|
+
if (result.exitCode !== 0) {
|
|
102
|
+
throw new Error(`Failed to list open PRs: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
103
|
+
}
|
|
102
104
|
try {
|
|
103
105
|
const data = JSON.parse(result.stdout);
|
|
104
106
|
return data.map((pr) => ({ number: pr.number, branch: pr.headRefName, headSha: pr.headRefOid }));
|
|
105
107
|
}
|
|
106
|
-
catch {
|
|
107
|
-
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new Error("GitHub returned malformed open PR data", { cause: error });
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
async deleteBranch(prNumber) {
|
|
@@ -23,6 +23,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
|
|
|
23
23
|
private gitIn;
|
|
24
24
|
fetch(remote?: string): Promise<void>;
|
|
25
25
|
headSha(branch: string): Promise<string>;
|
|
26
|
+
mergeBase(left: string, right: string): Promise<string>;
|
|
26
27
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
27
28
|
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
28
29
|
/**
|
package/dist/github/shell-git.js
CHANGED
|
@@ -74,6 +74,13 @@ export class ShellGitOperations {
|
|
|
74
74
|
const result = await this.git(["rev-parse", branch]);
|
|
75
75
|
return result.stdout.trim();
|
|
76
76
|
}
|
|
77
|
+
async mergeBase(left, right) {
|
|
78
|
+
const result = await this.git(["merge-base", left, right]);
|
|
79
|
+
const sha = result.stdout.trim();
|
|
80
|
+
if (!sha)
|
|
81
|
+
throw new Error(`git merge-base returned no commit for ${left} and ${right}`);
|
|
82
|
+
return sha;
|
|
83
|
+
}
|
|
77
84
|
async isAncestor(ancestor, descendant) {
|
|
78
85
|
const result = await this.git(["merge-base", "--is-ancestor", ancestor, descendant], { allowNonZero: true });
|
|
79
86
|
if (result.exitCode === 0)
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CIStatus, CheckResult, IncidentRecord, MergeResult, PRStatus, Queu
|
|
|
6
6
|
export interface GitOperations {
|
|
7
7
|
fetch(remote?: string): Promise<void>;
|
|
8
8
|
headSha(branch: string): Promise<string>;
|
|
9
|
+
mergeBase(left: string, right: string): Promise<string>;
|
|
9
10
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
10
11
|
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
11
12
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GitHubPRApi, GitOperations } from "./interfaces.ts";
|
|
2
|
+
export interface OpenPrAncestor {
|
|
3
|
+
prNumber: number;
|
|
4
|
+
branch: string;
|
|
5
|
+
headSha: string;
|
|
6
|
+
sharedAncestorSha: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Find ancestry shared with open PRs outside the current base.
|
|
10
|
+
*
|
|
11
|
+
* Testing only the other PR's current head is insufficient: a parent may have
|
|
12
|
+
* advanced after the child copied its earlier blocked head. The merge base
|
|
13
|
+
* captures that shared history. If it is absent from current main, landing the
|
|
14
|
+
* candidate can bypass the other PR's review gates. Explicit stacks become
|
|
15
|
+
* valid only after their shared parent history has landed in main.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findUnlandedOpenPrAncestors(params: {
|
|
18
|
+
github: GitHubPRApi;
|
|
19
|
+
git: GitOperations;
|
|
20
|
+
currentPrNumber: number;
|
|
21
|
+
prHeadSha: string;
|
|
22
|
+
candidateSha: string;
|
|
23
|
+
baseSha: string;
|
|
24
|
+
}): Promise<OpenPrAncestor[]>;
|
|
25
|
+
export declare function describeOpenPrAncestors(blockers: OpenPrAncestor[]): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find ancestry shared with open PRs outside the current base.
|
|
3
|
+
*
|
|
4
|
+
* Testing only the other PR's current head is insufficient: a parent may have
|
|
5
|
+
* advanced after the child copied its earlier blocked head. The merge base
|
|
6
|
+
* captures that shared history. If it is absent from current main, landing the
|
|
7
|
+
* candidate can bypass the other PR's review gates. Explicit stacks become
|
|
8
|
+
* valid only after their shared parent history has landed in main.
|
|
9
|
+
*/
|
|
10
|
+
export async function findUnlandedOpenPrAncestors(params) {
|
|
11
|
+
const openPrs = await params.github.listOpenPRs();
|
|
12
|
+
const blockers = [];
|
|
13
|
+
for (const openPr of openPrs) {
|
|
14
|
+
if (openPr.number === params.currentPrNumber)
|
|
15
|
+
continue;
|
|
16
|
+
if (openPr.headSha !== params.prHeadSha
|
|
17
|
+
&& await params.git.isAncestor(params.prHeadSha, openPr.headSha))
|
|
18
|
+
continue;
|
|
19
|
+
const sharedAncestorSha = await params.git.mergeBase(openPr.headSha, params.candidateSha);
|
|
20
|
+
if (await params.git.isAncestor(sharedAncestorSha, params.baseSha))
|
|
21
|
+
continue;
|
|
22
|
+
blockers.push({
|
|
23
|
+
prNumber: openPr.number,
|
|
24
|
+
branch: openPr.branch,
|
|
25
|
+
headSha: openPr.headSha,
|
|
26
|
+
sharedAncestorSha,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return blockers;
|
|
30
|
+
}
|
|
31
|
+
export function describeOpenPrAncestors(blockers) {
|
|
32
|
+
return `candidate shares unlanded history with open PR ${blockers.map((blocker) => `#${blocker.prNumber} (${blocker.sharedAncestorSha.slice(0, 12)})`).join(", ")}`;
|
|
33
|
+
}
|
|
@@ -9,4 +9,10 @@ export declare function evictEntry(ctx: ReconcileContext, entry: QueueEntry, fai
|
|
|
9
9
|
conclusion: string;
|
|
10
10
|
url?: string;
|
|
11
11
|
}>;
|
|
12
|
+
openPrAncestors?: Array<{
|
|
13
|
+
prNumber: number;
|
|
14
|
+
branch: string;
|
|
15
|
+
headSha: string;
|
|
16
|
+
sharedAncestorSha: string;
|
|
17
|
+
}>;
|
|
12
18
|
}): Promise<void>;
|
package/dist/reconciler-evict.js
CHANGED
|
@@ -59,6 +59,7 @@ export async function evictEntry(ctx, entry, failureClass, extra) {
|
|
|
59
59
|
queuePosition: entry.position,
|
|
60
60
|
conflictFiles: extra?.conflictFiles,
|
|
61
61
|
failedChecks: extra?.failedChecks,
|
|
62
|
+
openPrAncestors: extra?.openPrAncestors,
|
|
62
63
|
baseBranch: ctx.baseBranch,
|
|
63
64
|
branch: entry.branch,
|
|
64
65
|
issueKey: entry.issueKey,
|
package/dist/reconciler-merge.js
CHANGED
|
@@ -2,6 +2,7 @@ import { CLEAN_CANDIDATE_REF, CLEAN_CI, CLEAR_CANDIDATE, emit, isBudgetExhausted
|
|
|
2
2
|
import { cleanupCandidate, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
|
|
3
3
|
import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
|
|
4
4
|
import { evaluateCheckPolicy, formatRequiredCheck } from "./check-policy.js";
|
|
5
|
+
import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
|
|
5
6
|
const DEFAULT_PR_MERGED_POLL_ATTEMPTS = 6;
|
|
6
7
|
const DEFAULT_PR_MERGED_POLL_DELAY_MS = 2_000;
|
|
7
8
|
function delay(ms) {
|
|
@@ -276,6 +277,38 @@ export async function mergeHead(ctx, entry) {
|
|
|
276
277
|
candidatePolicyFingerprint: currentPolicyFingerprint,
|
|
277
278
|
}, `candidate revalidated under policy ${currentPolicyFingerprint.slice(0, 12)}`);
|
|
278
279
|
}
|
|
280
|
+
// Ancestry is the last remote truth read before the immutable push. Fetch
|
|
281
|
+
// main again because check and PR revalidation above can take long enough
|
|
282
|
+
// for either main or the open-PR set to change.
|
|
283
|
+
await ctx.git.fetch();
|
|
284
|
+
currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
|
|
285
|
+
if (!await ctx.git.isAncestor(currentBase, landingSha)) {
|
|
286
|
+
emit(ctx, entry, "branch_mismatch", {
|
|
287
|
+
detail: `candidate is not a fast-forward from final main (${currentBase.slice(0, 8)})`,
|
|
288
|
+
});
|
|
289
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
290
|
+
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAR_CANDIDATE }, "main changed during landing, re-prepare");
|
|
291
|
+
await invalidateDownstream(ctx, allActive, 0);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const ancestryBlockers = await findUnlandedOpenPrAncestors({
|
|
295
|
+
github: ctx.github,
|
|
296
|
+
git: ctx.git,
|
|
297
|
+
currentPrNumber: entry.prNumber,
|
|
298
|
+
prHeadSha: entry.headSha,
|
|
299
|
+
candidateSha: landingSha,
|
|
300
|
+
baseSha: currentBase,
|
|
301
|
+
});
|
|
302
|
+
if (ancestryBlockers.length > 0) {
|
|
303
|
+
const detail = describeOpenPrAncestors(ancestryBlockers);
|
|
304
|
+
emit(ctx, entry, "open_pr_ancestry_blocked", { baseSha: currentBase, detail });
|
|
305
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
306
|
+
const entryIndex = allActive.findIndex((candidate) => candidate.id === entry.id);
|
|
307
|
+
await evictEntry(ctx, entry, "policy_blocked", { openPrAncestors: ancestryBlockers });
|
|
308
|
+
if (entryIndex >= 0)
|
|
309
|
+
await invalidateDownstream(ctx, allActive, entryIndex);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
279
312
|
}
|
|
280
313
|
catch (error) {
|
|
281
314
|
const detail = `candidate revalidation unavailable: ${describeError(error)}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CLEAN_CI, CLEAR_CANDIDATE, emit, isRetryGated, ref, candidateRefName } from "./reconciler-core.js";
|
|
2
2
|
import { evictEntry } from "./reconciler-evict.js";
|
|
3
|
+
import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
|
|
3
4
|
export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
4
5
|
emit(ctx, entry, "fetch_started");
|
|
5
6
|
await ctx.git.fetch();
|
|
@@ -13,6 +14,22 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
13
14
|
ctx.store.updateHead(entry.id, currentRef);
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
17
|
+
if (isHead) {
|
|
18
|
+
const blockers = await findUnlandedOpenPrAncestors({
|
|
19
|
+
github: ctx.github,
|
|
20
|
+
git: ctx.git,
|
|
21
|
+
currentPrNumber: entry.prNumber,
|
|
22
|
+
prHeadSha: entry.headSha,
|
|
23
|
+
candidateSha: entry.headSha,
|
|
24
|
+
baseSha,
|
|
25
|
+
});
|
|
26
|
+
if (blockers.length > 0) {
|
|
27
|
+
const detail = describeOpenPrAncestors(blockers);
|
|
28
|
+
emit(ctx, entry, "open_pr_ancestry_blocked", { baseSha, detail });
|
|
29
|
+
await evictEntry(ctx, entry, "policy_blocked", { openPrAncestors: blockers });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
16
33
|
// Preparing/validating the head never waits on main's CI. The exact
|
|
17
34
|
// candidate includes current main and is gated solely by its own checks.
|
|
18
35
|
//
|
package/dist/types.d.ts
CHANGED
|
@@ -103,6 +103,12 @@ export interface EvictionContext {
|
|
|
103
103
|
conclusion: string;
|
|
104
104
|
url?: string;
|
|
105
105
|
}> | undefined;
|
|
106
|
+
openPrAncestors?: Array<{
|
|
107
|
+
prNumber: number;
|
|
108
|
+
branch: string;
|
|
109
|
+
headSha: string;
|
|
110
|
+
sharedAncestorSha: string;
|
|
111
|
+
}> | undefined;
|
|
106
112
|
baseSha: string;
|
|
107
113
|
prHeadSha: string;
|
|
108
114
|
queuePosition: number;
|
|
@@ -240,7 +246,7 @@ export interface QueueConfig {
|
|
|
240
246
|
flakyRetries: number;
|
|
241
247
|
pollIntervalMs: number;
|
|
242
248
|
}
|
|
243
|
-
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" | "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";
|
|
249
|
+
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";
|
|
244
250
|
export interface ReconcileEvent {
|
|
245
251
|
at: string;
|
|
246
252
|
entryId: string;
|