merge-steward 0.29.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/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-";
@@ -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
  }
@@ -138,6 +138,10 @@ export class MergeStewardRuntime {
138
138
  eviction: this.eviction,
139
139
  flakyRetries: this.config.flakyRetries,
140
140
  policy: this.policy,
141
+ queueStateLabels: {
142
+ testing: this.config.queueTestingLabel,
143
+ merging: this.config.queueMergingLabel,
144
+ },
141
145
  onEvent: (event) => {
142
146
  this.lastReconcileEvent = summarizeReconcileEvent(event);
143
147
  const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.29.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": {