merge-steward 0.33.0 → 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)
package/dist/install.js CHANGED
@@ -3,23 +3,12 @@ import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { DEFAULT_MERGE_QUEUE_CHECK_NAME, parseConfig } from "./config.js";
6
- import { getBuiltCliEntryPath, getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getMergeStewardPathLayout, getRepoConfigPath, getSystemdUnitPath, readBundledAsset } from "./runtime-paths.js";
6
+ import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getMergeStewardPathLayout, getRepoConfigPath, getSystemdUnitPath, readBundledAsset } from "./runtime-paths.js";
7
7
  import { parseHomeConfigObject, stringifyJson } from "./steward-home.js";
8
8
  function renderTemplate(template, replacements) {
9
9
  const home = homedir();
10
10
  const user = basename(home);
11
11
  const layout = getMergeStewardPathLayout();
12
- // When running from source (e.g. during tests or `npm run dev`) the dist/
13
- // entrypoint isn't built yet. Fall back to the PATH-resolved binary so the
14
- // systemd template still renders — the ExecStart will need `merge-steward`
15
- // on PATH at service start, which is true for any normal install.
16
- let cliEntry;
17
- try {
18
- cliEntry = getBuiltCliEntryPath();
19
- }
20
- catch {
21
- cliEntry = "/usr/bin/env merge-steward";
22
- }
23
12
  let rendered = template
24
13
  .replaceAll("/home/your-user", home)
25
14
  .replaceAll("your-user", user)
@@ -29,8 +18,7 @@ function renderTemplate(template, replacements) {
29
18
  .replaceAll("/home/your-user/.config/merge-steward/merge-steward.json", layout.configPath)
30
19
  .replaceAll("/home/your-user/.config/merge-steward/repos", layout.repoConfigDir)
31
20
  .replaceAll("/home/your-user/.local/state/merge-steward", layout.stateDir)
32
- .replaceAll("/home/your-user/.local/share/merge-steward", layout.dataDir)
33
- .replaceAll("/usr/bin/env merge-steward", cliEntry);
21
+ .replaceAll("/home/your-user/.local/share/merge-steward", layout.dataDir);
34
22
  if (replacements?.publicBaseUrl) {
35
23
  rendered = rendered.replaceAll("https://queue.example.com", replacements.publicBaseUrl);
36
24
  }
@@ -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
  //
@@ -34,6 +51,23 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
34
51
  }
35
52
  return;
36
53
  }
54
+ // A remote merge can advance main before GitHub's PR API reports `merged`.
55
+ // The PR head is then already contained in main, so building and testing an
56
+ // "integration" candidate would only reproduce the current main commit.
57
+ // Hold the entry until sanitizeEntry observes GitHub's terminal truth.
58
+ if (baseSha !== entry.headSha && await ctx.git.isAncestor(entry.headSha, baseSha)) {
59
+ const detail = `PR head ${entry.headSha.slice(0, 12)} is already contained in main ${baseSha.slice(0, 12)}; waiting for GitHub merge recognition`;
60
+ if (entry.waitDetail !== detail) {
61
+ emit(ctx, entry, "merge_waiting_recognition", { baseSha, detail });
62
+ ctx.store.transition(entry.id, "preparing_head", {
63
+ baseSha,
64
+ ...CLEAN_CI,
65
+ ...CLEAR_CANDIDATE,
66
+ waitDetail: detail,
67
+ }, detail);
68
+ }
69
+ return;
70
+ }
37
71
  // Candidate selection is structural, not a separate fast-forward workflow.
38
72
  // If the prospective base is already an ancestor of the PR head, that exact
39
73
  // immutable head is the future main candidate and its existing SHA-bound
@@ -48,11 +48,12 @@ export async function reconcile(ctx) {
48
48
  // Truth guard: verify entry against GitHub before processing.
49
49
  if (await sanitizeEntry(ctx, entry)) {
50
50
  // Entry may have been terminalized (closed/duplicate) — clear any
51
- // stale queue sub-state label it still carries.
51
+ // stale queue sub-state label it still carries. Stop this snapshot:
52
+ // later entries were ordered against topology that has now changed.
52
53
  const sanitized = ctx.store.getEntry(entry.id);
53
54
  if (sanitized)
54
55
  await syncQueueStateLabels(ctx, sanitized);
55
- continue;
56
+ break;
56
57
  }
57
58
  // Stale dependency guard: if this entry's spec was built on top of
58
59
  // another entry that was dequeued or evicted (without downstream
@@ -112,6 +113,12 @@ export async function reconcile(ctx) {
112
113
  const post = ctx.store.getEntry(entry.id);
113
114
  if (post)
114
115
  await syncQueueStateLabels(ctx, post);
116
+ if (post && (post.status === "evicted" || post.status === "dequeued")) {
117
+ // Dependency readiness was computed before this terminal transition.
118
+ // Re-enter on the next tick so an independent root cannot reuse or
119
+ // rebuild through a child that just became blocked by its parent.
120
+ break;
121
+ }
115
122
  }
116
123
  await verifyMergedEntriesPostPush(ctx);
117
124
  }
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_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.0",
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": {