merge-steward 0.28.0 → 0.30.0

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/README.md CHANGED
@@ -14,7 +14,7 @@ PRs delivered through the queue are tested against `main` as it was at admission
14
14
  2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
15
15
  3. It builds a speculative branch — `main + PR` at the head, cumulative downstream (`main + A + B`, `main + A + B + C`).
16
16
  4. CI runs on that speculative SHA.
17
- 5. If the head's speculative SHA is still a fast-forward from current `main`, the steward pushes that SHA to `main`; otherwise it retries.
17
+ 5. Once the speculative SHA's checks are green and it is still a fast-forward from current `main`, the steward pushes that SHA to `main` immediately. It does **not** wait for `main`'s own CI to settle, and **never** pauses the queue because `main` is red — the green speculative SHA *is* the next `main`.
18
18
  6. On CI failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run.
19
19
  7. PatchRelay, the `ship-pr` skill, or any agent sees the check run failure and fixes the branch; when CI passes again the PR can be re-admitted.
20
20
 
@@ -79,6 +79,8 @@ The real gate is:
79
79
 
80
80
  `review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested speculative SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](../../docs/merge-steward.md) for the full App permission set.
81
81
 
82
+ **`main`'s own CI is information-only.** The speculative SHA the steward tests *is* the exact tree that becomes `main`, so re-testing `main` after the push adds no signal — it only catches flakiness or out-of-band changes (direct pushes, hotfixes). The queue therefore **ignores `main`'s CI entirely** for advancement: it does not gate landing on `main` being green, does not wait for `main` CI before the next landing, and is never "paused" by a red `main`. A red `main` with a green speculative SHA simply means the red was flaky or is fixed by landing — so the steward lands. Use `main`'s CI as a project-health canary, not a queue control.
83
+
82
84
  ## Interaction with PatchRelay
83
85
 
84
86
  Independent services, GitHub as the shared bus:
package/dist/config.d.ts CHANGED
@@ -30,6 +30,8 @@ export declare const stewardConfigSchema: z.ZodObject<{
30
30
  }, z.core.$strip>>;
31
31
  admissionLabel: z.ZodDefault<z.ZodString>;
32
32
  priorityQueueLabel: z.ZodDefault<z.ZodString>;
33
+ queueTestingLabel: z.ZodDefault<z.ZodString>;
34
+ queueMergingLabel: z.ZodDefault<z.ZodString>;
33
35
  mergeQueueCheckName: z.ZodDefault<z.ZodString>;
34
36
  evictionCheckName: z.ZodDefault<z.ZodString>;
35
37
  specReadyCheckName: z.ZodDefault<z.ZodString>;
package/dist/config.js CHANGED
@@ -31,6 +31,12 @@ export const stewardConfigSchema = z.object({
31
31
  }).default({ level: "info" }),
32
32
  admissionLabel: z.string().default("queue"),
33
33
  priorityQueueLabel: z.string().default("queue:priority"),
34
+ // Queue sub-state labels — applied/cleared by the reconciler so a PR's
35
+ // live position in the merge queue is visible on GitHub and readable by
36
+ // patchrelay for its Linear "In Merge Queue" status. `queue:testing` =
37
+ // spec CI validating; `queue:merging` = head, merge in progress.
38
+ queueTestingLabel: z.string().default("queue:testing"),
39
+ queueMergingLabel: z.string().default("queue:merging"),
34
40
  mergeQueueCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
35
41
  // Plan §2.4 — bus-contract artifact names exposed for cross-service
36
42
  // alignment. Defaults preserve current behavior. The steward writes
@@ -29,4 +29,8 @@ export declare class GitHubPRClient implements GitHubPRApi {
29
29
  deleteBranch(prNumber: number): Promise<void>;
30
30
  findPRByBranch(branch: string): Promise<number | null>;
31
31
  listLabels(prNumber: number): Promise<string[]>;
32
+ setLabels(prNumber: number, opts: {
33
+ add?: string[];
34
+ remove?: string[];
35
+ }): Promise<void>;
32
36
  }
@@ -146,6 +146,17 @@ export class GitHubPRClient {
146
146
  return [];
147
147
  }
148
148
  }
149
+ async setLabels(prNumber, opts) {
150
+ const args = ["pr", "edit", String(prNumber), "--repo", this.repoFullName];
151
+ for (const label of opts.add ?? [])
152
+ args.push("--add-label", label);
153
+ for (const label of opts.remove ?? [])
154
+ args.push("--remove-label", label);
155
+ // Nothing to change beyond the base args — skip the call.
156
+ if (args.length === 5)
157
+ return;
158
+ await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
159
+ }
149
160
  }
150
161
  /** Map GitHub REST API check-run status/conclusion to our union. */
151
162
  function mapRestConclusion(status, conclusion) {
@@ -50,6 +50,11 @@ export interface GitHubPRApi {
50
50
  listChecks(prNumber: number): Promise<CheckResult[]>;
51
51
  listChecksForRef(ref: string): Promise<CheckResult[]>;
52
52
  listLabels(prNumber: number): Promise<string[]>;
53
+ /** Add and/or remove labels on a PR in a single edit. */
54
+ setLabels(prNumber: number, opts: {
55
+ add?: string[];
56
+ remove?: string[];
57
+ }): Promise<void>;
53
58
  /** List open PRs for restart-time eligibility scans. */
54
59
  listOpenPRs(): Promise<Array<{
55
60
  number: number;
@@ -15,6 +15,13 @@ export interface ReconcileContext {
15
15
  speculativeDepth: number;
16
16
  flakyRetries: number;
17
17
  policy: GitHubPolicyCache;
18
+ /** Queue sub-state label names. When set, the reconciler keeps the
19
+ * `testing`/`merging` labels on each PR in sync with its phase so the
20
+ * queue position is visible on GitHub. Omit to disable label sync. */
21
+ queueStateLabels?: {
22
+ testing: string;
23
+ merging: string;
24
+ };
18
25
  onEvent: (event: ReconcileEvent) => void;
19
26
  }
20
27
  export declare const SPEC_BRANCH_PREFIX = "mq-spec-";
@@ -1,7 +1,6 @@
1
1
  import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
2
2
  import { cleanupSpec, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
3
3
  import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
4
- import { getEffectiveMainStatus } from "./reconciler-main-status.js";
5
4
  const DEFAULT_PR_MERGED_POLL_ATTEMPTS = 6;
6
5
  const DEFAULT_PR_MERGED_POLL_DELAY_MS = 2_000;
7
6
  function delay(ms) {
@@ -183,43 +182,12 @@ export async function mergeHead(ctx, entry) {
183
182
  catch {
184
183
  // Can't verify — proceed and let push fail if needed.
185
184
  }
186
- if (ctx.ci.getMainStatus && entry.priority <= 0) {
187
- const mainStatus = currentBase
188
- ? await getEffectiveMainStatus(ctx, currentBase)
189
- : { status: await ctx.ci.getMainStatus(ctx.baseBranch) };
190
- if (mainStatus.trustedMergedEntryId) {
191
- emit(ctx, entry, "main_pending_bypassed", {
192
- detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
193
- });
194
- }
195
- if (mainStatus.status === "pending") {
196
- // Main is still verifying itself (typically post-merge CI from the
197
- // previous entry). The spec was built on top of main-as-it-was
198
- // (isFF check above guarantees main hasn't diverged) and already
199
- // passed CI, so keep the spec + CI state and retry next tick.
200
- // Throwing the spec away here would force a rebuild + re-run of
201
- // CI that produces the same tree — wasting the speculation.
202
- emit(ctx, entry, "merge_waiting_main", { detail: "main checks still pending, holding merge" });
203
- ctx.store.transition(entry.id, "merging", { waitDetail: "main checks still pending, holding merge" }, "main checks still pending, holding merge");
204
- return;
205
- }
206
- if (mainStatus.status === "fail") {
207
- try {
208
- const refresh = await ctx.policy.refreshOnIssue("main_unhealthy_at_merge");
209
- if (refresh.attempted && refresh.changed) {
210
- emit(ctx, entry, "policy_changed", {
211
- detail: `GitHub required checks changed from [${refresh.previousRequiredChecks.join(", ") || "(none)"}] to [${refresh.requiredChecks.join(", ") || "(none)"}]`,
212
- });
213
- }
214
- }
215
- catch {
216
- // Keep using cached GitHub policy and pause the queue conservatively.
217
- }
218
- emit(ctx, entry, "main_broken", { detail: "main unhealthy at merge time, re-preparing" });
219
- ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main unhealthy at merge time");
220
- return;
221
- }
222
- }
185
+ // The queue gates only on its own spec CI. main's CI status is irrelevant to
186
+ // landing: the spec was built on current main (the fast-forward check above
187
+ // guarantees main hasn't diverged) and its checks passed, so pushing it advances
188
+ // main to a green SHA. We never wait for main's own CI to settle, and never pause
189
+ // the queue because main is red — a red main is either flaky or fixed by landing
190
+ // this green spec. main CI is information-only (out-of-band breakage canary).
223
191
  try {
224
192
  await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
225
193
  }
@@ -1,36 +1,5 @@
1
1
  import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, isRetryGated, ref, specBranchName } from "./reconciler-core.js";
2
2
  import { evictEntry } from "./reconciler-evict.js";
3
- import { getEffectiveMainStatus } from "./reconciler-main-status.js";
4
- function summarizeCheckNames(checks, limit = 3) {
5
- const names = [...new Set(checks.map((check) => check.name))];
6
- if (names.length <= limit) {
7
- return names.join(", ");
8
- }
9
- return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
10
- }
11
- function normalizeCheckName(name) {
12
- return name.trim().toLowerCase();
13
- }
14
- function getMissingRequiredChecks(requiredChecks, checks) {
15
- if (requiredChecks.length === 0) {
16
- return [];
17
- }
18
- const available = new Set(checks.map((check) => normalizeCheckName(check.name)).filter(Boolean));
19
- return requiredChecks.filter((check) => !available.has(normalizeCheckName(check)));
20
- }
21
- function describeMainBroken(failingChecks, pendingChecks, missingRequiredChecks) {
22
- const parts = [];
23
- if (missingRequiredChecks.length > 0) {
24
- parts.push(`missing required ${missingRequiredChecks.join(", ")}`);
25
- }
26
- if (failingChecks.length > 0) {
27
- parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
28
- }
29
- if (pendingChecks.length > 0) {
30
- parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
31
- }
32
- return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
33
- }
34
3
  export async function prepareEntry(ctx, entry, isHead, prevEntry) {
35
4
  emit(ctx, entry, "fetch_started");
36
5
  await ctx.git.fetch();
@@ -57,53 +26,8 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
57
26
  return;
58
27
  }
59
28
  if (isHead) {
60
- const bypassMainBlock = entry.priority > 0;
61
- if (ctx.ci.getMainStatus && !bypassMainBlock) {
62
- const mainStatus = await getEffectiveMainStatus(ctx, baseSha);
63
- if (mainStatus.trustedMergedEntryId) {
64
- emit(ctx, entry, "main_pending_bypassed", {
65
- baseSha,
66
- detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
67
- });
68
- }
69
- if (mainStatus.status !== "pass") {
70
- let mainChecks = [];
71
- try {
72
- mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
73
- }
74
- catch {
75
- mainChecks = [];
76
- }
77
- const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
78
- const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
79
- let missingRequiredChecks = getMissingRequiredChecks(ctx.policy.getRequiredChecks(), mainChecks);
80
- if (missingRequiredChecks.length > 0) {
81
- try {
82
- const refresh = await ctx.policy.refreshOnIssue("main_missing_required_checks");
83
- if (refresh.attempted && refresh.changed) {
84
- missingRequiredChecks = getMissingRequiredChecks(ctx.policy.getRequiredChecks(), mainChecks);
85
- emit(ctx, entry, "policy_changed", {
86
- detail: `GitHub required checks changed from [${refresh.previousRequiredChecks.join(", ") || "(none)"}] to [${refresh.requiredChecks.join(", ") || "(none)"}]`,
87
- });
88
- if (missingRequiredChecks.length === 0 && failingChecks.length === 0 && pendingChecks.length === 0) {
89
- return;
90
- }
91
- }
92
- }
93
- catch {
94
- // Keep using the last known GitHub policy and surface the current block.
95
- }
96
- }
97
- emit(ctx, entry, "main_broken", {
98
- baseSha,
99
- failingChecks,
100
- pendingChecks,
101
- missingRequiredChecks,
102
- detail: describeMainBroken(failingChecks, pendingChecks, missingRequiredChecks),
103
- });
104
- return;
105
- }
106
- }
29
+ // Preparing/validating the head never waits on main's CI: the spec is built on
30
+ // current main and gated solely on its own checks. main's health is information-only.
107
31
  if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
108
32
  emit(ctx, entry, "budget_exhausted", { baseSha });
109
33
  await evictEntry(ctx, entry, "integration_conflict");
@@ -0,0 +1,18 @@
1
+ import type { ReconcileContext } from "./reconciler-core.ts";
2
+ import type { QueueEntry } from "./types.ts";
3
+ /**
4
+ * Edge-triggered GitHub label sync for queue sub-state.
5
+ *
6
+ * The label is a pure function of the entry's phase, so a PR's live
7
+ * position in the merge queue is obvious from GitHub — and readable by
8
+ * patchrelay to drive its Linear "In Merge Queue" status:
9
+ *
10
+ * validating → testing label (spec CI is running / awaiting turn)
11
+ * merging → merging label (head of queue, merge in progress)
12
+ * everything else (queued, preparing_head, merged, evicted, …) → neither
13
+ *
14
+ * Idempotent: reads the PR's current labels and only edits the delta, so
15
+ * it is safe to call every tick and after a restart. Cosmetic-only — any
16
+ * GitHub error is swallowed so it can never break the reconcile loop.
17
+ */
18
+ export declare function syncQueueStateLabels(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { emit } from "./reconciler-core.js";
2
+ /**
3
+ * Edge-triggered GitHub label sync for queue sub-state.
4
+ *
5
+ * The label is a pure function of the entry's phase, so a PR's live
6
+ * position in the merge queue is obvious from GitHub — and readable by
7
+ * patchrelay to drive its Linear "In Merge Queue" status:
8
+ *
9
+ * validating → testing label (spec CI is running / awaiting turn)
10
+ * merging → merging label (head of queue, merge in progress)
11
+ * everything else (queued, preparing_head, merged, evicted, …) → neither
12
+ *
13
+ * Idempotent: reads the PR's current labels and only edits the delta, so
14
+ * it is safe to call every tick and after a restart. Cosmetic-only — any
15
+ * GitHub error is swallowed so it can never break the reconcile loop.
16
+ */
17
+ export async function syncQueueStateLabels(ctx, entry) {
18
+ const labels = ctx.queueStateLabels;
19
+ if (!labels)
20
+ return;
21
+ const managed = [labels.testing, labels.merging];
22
+ const desired = entry.status === "validating" ? labels.testing
23
+ : entry.status === "merging" ? labels.merging
24
+ : null;
25
+ let current;
26
+ try {
27
+ current = await ctx.github.listLabels(entry.prNumber);
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ const add = desired && !current.includes(desired) ? [desired] : [];
33
+ const remove = managed.filter((l) => l !== desired && current.includes(l));
34
+ if (add.length === 0 && remove.length === 0)
35
+ return;
36
+ try {
37
+ await ctx.github.setLabels(entry.prNumber, { add, remove });
38
+ const parts = [];
39
+ if (add.length)
40
+ parts.push(`+${add.join(",")}`);
41
+ if (remove.length)
42
+ parts.push(`-${remove.join(",")}`);
43
+ emit(ctx, entry, "queue_label_synced", { detail: `${entry.status}: ${parts.join(" ")}` });
44
+ }
45
+ catch {
46
+ // best-effort; the next tick retries
47
+ }
48
+ }
@@ -7,6 +7,7 @@ import { mergeHead } from "./reconciler-merge.js";
7
7
  import { cleanupSpec } from "./reconciler-evict.js";
8
8
  import { INVALIDATION_PATCH } from "./invalidation.js";
9
9
  import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
10
+ import { syncQueueStateLabels } from "./reconciler-queue-labels.js";
10
11
  // ─── Main reconcile loop ────────────────────────────────────────
11
12
  export async function reconcile(ctx) {
12
13
  const allActive = ctx.store.listActive(ctx.repoId);
@@ -21,8 +22,14 @@ export async function reconcile(ctx) {
21
22
  if (!entry || TERMINAL_STATUSES.includes(entry.status))
22
23
  continue;
23
24
  // Truth guard: verify entry against GitHub before processing.
24
- if (await sanitizeEntry(ctx, entry))
25
+ if (await sanitizeEntry(ctx, entry)) {
26
+ // Entry may have been terminalized (closed/duplicate) — clear any
27
+ // stale queue sub-state label it still carries.
28
+ const sanitized = ctx.store.getEntry(entry.id);
29
+ if (sanitized)
30
+ await syncQueueStateLabels(ctx, sanitized);
25
31
  continue;
32
+ }
26
33
  // Stale dependency guard: if this entry's spec was built on top of
27
34
  // another entry that was dequeued or evicted (without downstream
28
35
  // invalidation), the spec is contaminated with the removed entry's
@@ -74,6 +81,12 @@ export async function reconcile(ctx) {
74
81
  wrapped.stack = error.stack;
75
82
  throw wrapped;
76
83
  }
84
+ // Sync the GitHub queue sub-state label to the (possibly just
85
+ // transitioned) phase: queue:testing while validating, queue:merging
86
+ // while merging, cleared once merged/preparing/terminal.
87
+ const post = ctx.store.getEntry(entry.id);
88
+ if (post)
89
+ await syncQueueStateLabels(ctx, post);
77
90
  }
78
91
  await verifyMergedEntriesPostPush(ctx);
79
92
  }
@@ -24,8 +24,6 @@ export declare class MergeStewardRuntime {
24
24
  private lastTickError;
25
25
  private lastReconcileEvent;
26
26
  private currentQueueBlock;
27
- private readonly waitingMainInfoAt;
28
- private static readonly WAITING_MAIN_INFO_INTERVAL_MS;
29
27
  constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
30
28
  start(): Promise<void>;
31
29
  stop(): Promise<void>;
@@ -38,6 +36,4 @@ export declare class MergeStewardRuntime {
38
36
  private scheduleStaleTickWarning;
39
37
  private clearStaleTickTimer;
40
38
  private runTick;
41
- private describeMainBroken;
42
- private getMissingRequiredChecks;
43
39
  }
@@ -18,13 +18,8 @@ export class MergeStewardRuntime {
18
18
  lastTickOutcome = "idle";
19
19
  lastTickError = null;
20
20
  lastReconcileEvent = null;
21
+ // The queue never blocks on main CI, so this stays null; kept for status-shape stability.
21
22
  currentQueueBlock = null;
22
- // Per-entry timestamp of the last info-level `merge_waiting_main` log.
23
- // The event fires every tick while the gate waits, so subsequent ticks
24
- // log at debug to avoid spamming; a re-emission of info every ~5 min keeps
25
- // long waits visible in `service logs` without drowning it out.
26
- waitingMainInfoAt = new Map();
27
- static WAITING_MAIN_INFO_INTERVAL_MS = 5 * 60 * 1000;
28
23
  constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
29
24
  this.config = config;
30
25
  this.policy = policy;
@@ -130,7 +125,6 @@ export class MergeStewardRuntime {
130
125
  this.scheduleStaleTickWarning(this.lastTickStartedAt);
131
126
  try {
132
127
  await this.beforeTick?.();
133
- let tickQueueBlockEvent = null;
134
128
  await reconcile({
135
129
  store: this.store,
136
130
  repoId: this.config.repoId,
@@ -144,6 +138,10 @@ export class MergeStewardRuntime {
144
138
  eviction: this.eviction,
145
139
  flakyRetries: this.config.flakyRetries,
146
140
  policy: this.policy,
141
+ queueStateLabels: {
142
+ testing: this.config.queueTestingLabel,
143
+ merging: this.config.queueMergingLabel,
144
+ },
147
145
  onEvent: (event) => {
148
146
  this.lastReconcileEvent = summarizeReconcileEvent(event);
149
147
  const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
@@ -151,29 +149,12 @@ export class MergeStewardRuntime {
151
149
  || event.action === "merge_rejected" || event.action === "budget_exhausted";
152
150
  const isDebug = event.action === "ci_pending" || event.action === "retry_gated"
153
151
  || event.action === "fetch_started";
154
- let level = isWarn ? "warn" : isDebug ? "debug" : "info";
155
- if (event.action === "merge_waiting_main") {
156
- // The event fires every ~tick while the gate waits on main's own
157
- // CI. Log at info the first time (so an operator can see *why*
158
- // the head entry is parked in merging) and once every interval
159
- // after, debug on the intermediate ticks to avoid spamming.
160
- const last = this.waitingMainInfoAt.get(event.entryId);
161
- const now = Date.now();
162
- if (last === undefined || now - last >= MergeStewardRuntime.WAITING_MAIN_INFO_INTERVAL_MS) {
163
- level = "info";
164
- this.waitingMainInfoAt.set(event.entryId, now);
165
- }
166
- else {
167
- level = "debug";
168
- }
169
- }
152
+ const level = isWarn ? "warn" : isDebug ? "debug" : "info";
170
153
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
171
- if (event.action === "main_broken" && tickQueueBlockEvent === null) {
172
- tickQueueBlockEvent = event;
173
- }
174
154
  },
175
155
  });
176
- this.currentQueueBlock = tickQueueBlockEvent ? await this.describeMainBroken(tickQueueBlockEvent) : null;
156
+ // The queue never blocks on main CI — main health is information-only.
157
+ this.currentQueueBlock = null;
177
158
  this.lastTickOutcome = "succeeded";
178
159
  }
179
160
  catch (error) {
@@ -192,48 +173,6 @@ export class MergeStewardRuntime {
192
173
  }
193
174
  return true;
194
175
  }
195
- async describeMainBroken(event) {
196
- const baseRef = `origin/${this.config.baseBranch}`;
197
- let baseSha = null;
198
- try {
199
- baseSha = await this.git.headSha(baseRef);
200
- }
201
- catch {
202
- try {
203
- baseSha = await this.git.headSha(this.config.baseBranch);
204
- }
205
- catch {
206
- baseSha = event.baseSha ?? null;
207
- }
208
- }
209
- const checkRef = event.baseSha ?? baseSha ?? baseRef;
210
- let checks = [];
211
- try {
212
- checks = await this.github.listChecksForRef(checkRef);
213
- }
214
- catch {
215
- checks = [];
216
- }
217
- return {
218
- reason: "main_broken",
219
- entryId: event.entryId,
220
- headPrNumber: event.prNumber,
221
- baseBranch: this.config.baseBranch,
222
- baseSha,
223
- observedAt: event.at,
224
- failingChecks: event.failingChecks ?? checks.filter((check) => check.conclusion === "failure"),
225
- pendingChecks: event.pendingChecks ?? checks.filter((check) => check.conclusion === "pending"),
226
- missingRequiredChecks: event.missingRequiredChecks ?? this.getMissingRequiredChecks(checks),
227
- };
228
- }
229
- getMissingRequiredChecks(checks) {
230
- const requiredChecks = this.policy.getRequiredChecks();
231
- if (requiredChecks.length === 0) {
232
- return [];
233
- }
234
- const available = new Set(checks.map((check) => check.name.trim().toLowerCase()).filter(Boolean));
235
- return requiredChecks.filter((check) => !available.has(check.trim().toLowerCase()));
236
- }
237
176
  }
238
177
  function summarizeReconcileEvent(event) {
239
178
  return {
package/dist/types.d.ts CHANGED
@@ -242,7 +242,7 @@ export interface QueueConfig {
242
242
  flakyRetries: number;
243
243
  pollIntervalMs: number;
244
244
  }
245
- export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "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" | "branch_unreachable";
245
+ export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "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
246
  export interface ReconcileEvent {
247
247
  at: string;
248
248
  entryId: string;
@@ -112,9 +112,6 @@ export function buildExternalRepairObservations(detail, options) {
112
112
  if (entry.waitDetail?.toLowerCase().includes("approval") || entry.waitDetail?.toLowerCase().includes("review")) {
113
113
  observations.push({ tone: "warn", text: "CI passed. Merge is blocked until GitHub approval returns." });
114
114
  }
115
- else if (entry.waitDetail?.toLowerCase().includes("main")) {
116
- observations.push({ tone: "info", text: "CI passed. Waiting for main verification before landing." });
117
- }
118
115
  else {
119
116
  observations.push({ tone: "info", text: "CI passed. Landing on main." });
120
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
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": {
@@ -1,8 +0,0 @@
1
- import type { CIStatus } from "./types.ts";
2
- import type { ReconcileContext } from "./reconciler-core.ts";
3
- export interface EffectiveMainStatus {
4
- status: CIStatus;
5
- trustedMergedEntryId?: string | undefined;
6
- trustedMergedPrNumber?: number | undefined;
7
- }
8
- export declare function getEffectiveMainStatus(ctx: ReconcileContext, baseSha: string): Promise<EffectiveMainStatus>;
@@ -1,17 +0,0 @@
1
- export async function getEffectiveMainStatus(ctx, baseSha) {
2
- const status = await ctx.ci.getMainStatus?.(ctx.baseBranch) ?? "pass";
3
- if (status !== "pending") {
4
- return { status };
5
- }
6
- const trustedMergedEntry = ctx.store.listAll(ctx.repoId).find((entry) => entry.status === "merged"
7
- && entry.postMergeStatus === "pass"
8
- && entry.postMergeSha === baseSha);
9
- if (!trustedMergedEntry) {
10
- return { status };
11
- }
12
- return {
13
- status: "pass",
14
- trustedMergedEntryId: trustedMergedEntry.id,
15
- trustedMergedPrNumber: trustedMergedEntry.prNumber,
16
- };
17
- }