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.
@@ -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[]>;
@@ -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) => ({ number: pr.number, branch: pr.headRefName, headSha: pr.headRefOid }));
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", [
@@ -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;
@@ -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;
@@ -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 merged = false;
421
+ let mergedStatus = null;
421
422
  try {
422
- merged = (await ctx.github.getStatus(entry.prNumber)).merged;
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 (merged) {
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
  }
@@ -31,6 +31,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
31
31
  github: ctx.github,
32
32
  git: ctx.git,
33
33
  currentPrNumber: entry.prNumber,
34
+ currentBranch: entry.branch,
34
35
  prHeadSha: entry.headSha,
35
36
  candidateSha: entry.headSha,
36
37
  baseSha,
@@ -23,12 +23,25 @@ export declare class MergeStewardQueueCommands {
23
23
  baseRefName?: string;
24
24
  }): QueueEntry | undefined;
25
25
  scanStartupAdmissions(): Promise<void>;
26
- scanEligibleOpenPrs(): Promise<{
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
- tryAdmit(prNumber: number, branch: string, headSha: string): Promise<boolean>;
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>;
@@ -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
- async scanEligibleOpenPrs() {
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
- async tryAdmit(prNumber, branch, headSha) {
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
- this.logger.debug({ prNumber, headSha, evictedEntryId: latestEvicted.id }, "PR head matches latest evicted entry, skipping admission until a new push");
139
- return false;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.35.2",
3
+ "version": "0.35.4",
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": {