merge-steward 0.35.1 → 0.35.3

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/config.js CHANGED
@@ -14,7 +14,7 @@ export const stewardConfigSchema = z.object({
14
14
  maxRetries: z.number().int().min(0).default(2),
15
15
  flakyRetries: z.number().int().min(0).default(1),
16
16
  /** Max speculative branches to maintain in parallel. 1 = serial mode. */
17
- speculativeDepth: z.number().int().min(1).default(10),
17
+ speculativeDepth: z.number().int().min(1).default(3),
18
18
  pollIntervalMs: z.number().int().min(1000).default(30_000),
19
19
  reconcileStaleAfterMs: z.number().int().min(1000).default(5 * 60_000),
20
20
  server: z.object({
@@ -31,6 +31,7 @@ export declare class GitHubPRClient implements GitHubPRApi {
31
31
  number: number;
32
32
  branch: string;
33
33
  headSha: string;
34
+ baseBranch: string;
34
35
  }>>;
35
36
  deleteBranch(prNumber: number): Promise<void>;
36
37
  findPRByBranch(branch: string): Promise<number | null>;
@@ -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,7 +106,12 @@ 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 });
package/dist/install.js CHANGED
@@ -163,7 +163,7 @@ export async function upsertRepoConfig(options) {
163
163
  gitBin: existing?.gitBin ?? "git",
164
164
  maxRetries: existing?.maxRetries ?? 2,
165
165
  flakyRetries: existing?.flakyRetries ?? 1,
166
- speculativeDepth: existing?.speculativeDepth ?? 10,
166
+ speculativeDepth: existing?.speculativeDepth ?? 3,
167
167
  pollIntervalMs: existing?.pollIntervalMs ?? 30_000,
168
168
  server: {
169
169
  bind: existing?.server.bind ?? (homeConfig.server?.bind ?? "127.0.0.1"),
@@ -55,6 +55,7 @@ export interface GitHubPRApi {
55
55
  number: number;
56
56
  branch: string;
57
57
  headSha: string;
58
+ baseBranch: string;
58
59
  }>>;
59
60
  /** Find the open PR number for a branch, or null if none exists. */
60
61
  findPRByBranch(branch: string): Promise<number | null>;
@@ -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,
@@ -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,
@@ -13,7 +13,9 @@ async function holdForIntegrationRepair(ctx, entry, allActive, index, checks) {
13
13
  .map((check) => check.name)
14
14
  .join(", ");
15
15
  ctx.store.transition(entry.id, "validating", {
16
- candidateKind: "integration",
16
+ // A failed repaired candidate is still a repaired candidate. Preserve the
17
+ // provenance so a same-SHA rerun remains gated by integration review.
18
+ candidateKind: entry.candidateKind === "integration_repair" ? "integration_repair" : "integration",
17
19
  ciRunId: entry.ciRunId ?? `head:${entry.candidateSha ?? entry.headSha}`,
18
20
  candidateRef,
19
21
  candidateSha: entry.candidateSha ?? entry.headSha,
@@ -142,9 +144,21 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
142
144
  if (entry.candidateRef && !await ctx.git.isAncestor(entry.headSha, entry.candidateSha)) {
143
145
  return;
144
146
  }
145
- if (entry.candidateRef && entry.candidateKind === "integration" && entry.lastFailedBaseSha && entry.ciRunId) {
146
- // The same failed SHA is intentionally quiescent until PatchRelay moves
147
- // the workspace ref. Do not manufacture another CI run on every wakeup.
147
+ if (entry.candidateRef && entry.lastFailedBaseSha && entry.ciRunId) {
148
+ // Do not manufacture another CI run on every wakeup, but do re-read the
149
+ // exact SHA: an operator or GitHub may have rerun the failed workflow in
150
+ // place. A green rerun must resume the queue without a no-op commit.
151
+ const checks = (await ctx.github.listChecksForRef(entry.candidateSha))
152
+ .filter((check) => check.name.toLowerCase() !== INTEGRATION_REVIEW_CHECK);
153
+ const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
154
+ if (evaluation.status === "pass") {
155
+ await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, entry.ciRunId);
156
+ }
157
+ else if (evaluation.status === "pending") {
158
+ ctx.store.transition(entry.id, "validating", {
159
+ waitDetail: "waiting for exact-candidate rerun",
160
+ }, "exact-candidate rerun pending");
161
+ }
148
162
  return;
149
163
  }
150
164
  if (entry.candidateKind === "head") {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
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": {