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.
@@ -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 "Queue eviction: approval withdrawn";
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
  }
@@ -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
- return [];
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
- return [];
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
  /**
@@ -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)
@@ -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>;
@@ -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,
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.33.1",
3
+ "version": "0.33.2",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {