merge-steward 0.21.0 → 0.22.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.
@@ -15,6 +15,7 @@ export declare class SqliteStore implements QueueStore {
15
15
  transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
16
16
  dequeue(entryId: string): void;
17
17
  updateHead(entryId: string, newHeadSha: string): void;
18
+ updatePriority(entryId: string, priority: number, detail?: string): void;
18
19
  insertIncident(incident: IncidentRecord): void;
19
20
  listIncidents(entryId: string): IncidentRecord[];
20
21
  getIncident(incidentId: string): IncidentRecord | undefined;
@@ -227,6 +227,21 @@ export class SqliteStore {
227
227
  this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
228
228
  })();
229
229
  }
230
+ updatePriority(entryId, priority, detail) {
231
+ this.conn.transaction(() => {
232
+ const current = this.conn.prepare("SELECT status, priority FROM queue_entries WHERE id = ?").get(entryId);
233
+ if (!current)
234
+ return;
235
+ const from = String(current.status);
236
+ if (Number(current.priority) === priority)
237
+ return;
238
+ this.conn.prepare(`UPDATE queue_entries SET
239
+ priority = ?,
240
+ updated_at = ?
241
+ WHERE id = ?`).run(priority, isoNow(), entryId);
242
+ this.writeEvent(entryId, from, from, detail ?? `priority updated to ${priority}`);
243
+ })();
244
+ }
230
245
  insertIncident(incident) {
231
246
  this.conn.prepare(`INSERT INTO queue_incidents (id, entry_id, at, failure_class, context_json, outcome)
232
247
  VALUES (?, ?, ?, ?, ?, ?)`).run(incident.id, incident.entryId, incident.at, incident.failureClass, JSON.stringify(incident.context), incident.outcome);
@@ -0,0 +1,8 @@
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>;
@@ -0,0 +1,17 @@
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
+ }
@@ -1,6 +1,7 @@
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";
4
5
  export async function mergeHead(ctx, entry) {
5
6
  emit(ctx, entry, "merge_revalidating");
6
7
  const prStatus = await ctx.github.getStatus(entry.prNumber);
@@ -31,9 +32,10 @@ export async function mergeHead(ctx, entry) {
31
32
  ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "no spec branch, re-prepare");
32
33
  return;
33
34
  }
35
+ let currentBase = null;
34
36
  try {
35
37
  await ctx.git.fetch();
36
- const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
38
+ currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
37
39
  const isFF = await ctx.git.isAncestor(currentBase, entry.specSha);
38
40
  if (!isFF) {
39
41
  emit(ctx, entry, "branch_mismatch", { detail: `spec is not a fast-forward from main (${currentBase.slice(0, 8)})` });
@@ -47,8 +49,15 @@ export async function mergeHead(ctx, entry) {
47
49
  // Can't verify — proceed and let push fail if needed.
48
50
  }
49
51
  if (ctx.ci.getMainStatus && entry.priority <= 0) {
50
- const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
51
- if (mainStatus === "pending") {
52
+ const mainStatus = currentBase
53
+ ? await getEffectiveMainStatus(ctx, currentBase)
54
+ : { status: await ctx.ci.getMainStatus(ctx.baseBranch) };
55
+ if (mainStatus.trustedMergedEntryId) {
56
+ emit(ctx, entry, "main_pending_bypassed", {
57
+ detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
58
+ });
59
+ }
60
+ if (mainStatus.status === "pending") {
52
61
  // Main is still verifying itself (typically post-merge CI from the
53
62
  // previous entry). The spec was built on top of main-as-it-was
54
63
  // (isFF check above guarantees main hasn't diverged) and already
@@ -59,7 +68,7 @@ export async function mergeHead(ctx, entry) {
59
68
  ctx.store.transition(entry.id, "merging", { waitDetail: "main checks still pending, holding merge" }, "main checks still pending, holding merge");
60
69
  return;
61
70
  }
62
- if (mainStatus === "fail") {
71
+ if (mainStatus.status === "fail") {
63
72
  try {
64
73
  const refresh = await ctx.policy.refreshOnIssue("main_unhealthy_at_merge");
65
74
  if (refresh.attempted && refresh.changed) {
@@ -1,5 +1,6 @@
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";
3
4
  function summarizeCheckNames(checks, limit = 3) {
4
5
  const names = [...new Set(checks.map((check) => check.name))];
5
6
  if (names.length <= limit) {
@@ -46,8 +47,14 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
46
47
  if (isHead) {
47
48
  const bypassMainBlock = entry.priority > 0;
48
49
  if (ctx.ci.getMainStatus && !bypassMainBlock) {
49
- const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
50
- if (mainStatus !== "pass") {
50
+ const mainStatus = await getEffectiveMainStatus(ctx, baseSha);
51
+ if (mainStatus.trustedMergedEntryId) {
52
+ emit(ctx, entry, "main_pending_bypassed", {
53
+ baseSha,
54
+ detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
55
+ });
56
+ }
57
+ if (mainStatus.status !== "pass") {
51
58
  let mainChecks = [];
52
59
  try {
53
60
  mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
@@ -25,10 +25,13 @@ export declare class MergeStewardQueueCommands {
25
25
  scanned: number;
26
26
  admitted: number;
27
27
  }>;
28
+ updatePriorityByPR(prNumber: number, priority: number): boolean;
28
29
  tryAdmit(prNumber: number, branch: string, headSha: string): Promise<boolean>;
29
30
  dequeueByPR(prNumber: number): void;
30
31
  updateHeadByPR(prNumber: number, headSha: string): void;
31
32
  acknowledgeExternalMerge(prNumber: number): void;
32
33
  private invalidateDownstreamOf;
34
+ private findAffectedEntriesAfterPriorityChange;
35
+ private requeueAffectedEntries;
33
36
  private nextPosition;
34
37
  }
@@ -105,6 +105,22 @@ export class MergeStewardQueueCommands {
105
105
  }
106
106
  return { scanned: open.length, admitted };
107
107
  }
108
+ updatePriorityByPR(prNumber, priority) {
109
+ const entry = this.store.getEntryByPR(this.config.repoId, prNumber);
110
+ if (!entry) {
111
+ return false;
112
+ }
113
+ if (entry.priority === priority) {
114
+ return true;
115
+ }
116
+ const before = this.store.listActive(this.config.repoId);
117
+ this.store.updatePriority(entry.id, priority, `priority lane ${priority > 0 ? "enabled" : "disabled"}`);
118
+ const after = this.store.listActive(this.config.repoId);
119
+ const affected = this.findAffectedEntriesAfterPriorityChange(before, after);
120
+ this.requeueAffectedEntries(affected, `priority changed for entry ${entry.id.slice(0, 8)}`);
121
+ this.logger.info({ prNumber, entryId: entry.id, priority }, "Updated queued PR priority");
122
+ return true;
123
+ }
108
124
  async tryAdmit(prNumber, branch, headSha) {
109
125
  if (this.config.excludeBranches.some((pattern) => matchGlob(pattern, branch))) {
110
126
  this.logger.debug({ prNumber, branch }, "Branch excluded from admission");
@@ -226,6 +242,31 @@ export class MergeStewardQueueCommands {
226
242
  this.logger.info({ removedEntryId: removedEntry.id, invalidated: targets.length }, "Invalidated downstream entries after dequeue");
227
243
  }
228
244
  }
245
+ findAffectedEntriesAfterPriorityChange(before, after) {
246
+ const maxLength = Math.max(before.length, after.length);
247
+ let firstChangedIndex = -1;
248
+ for (let index = 0; index < maxLength; index += 1) {
249
+ if (before[index]?.id !== after[index]?.id) {
250
+ firstChangedIndex = index;
251
+ break;
252
+ }
253
+ }
254
+ if (firstChangedIndex < 0) {
255
+ return [];
256
+ }
257
+ return after.slice(firstChangedIndex);
258
+ }
259
+ requeueAffectedEntries(entries, reason) {
260
+ for (const affected of entries) {
261
+ if (affected.specBranch) {
262
+ this.specBuilder.deleteSpeculative(affected.specBranch).catch(() => { });
263
+ }
264
+ this.store.transition(affected.id, "queued", INVALIDATION_PATCH, reason);
265
+ }
266
+ if (entries.length > 0) {
267
+ this.logger.info({ affectedEntries: entries.length, reason }, "Requeued affected entries after priority change");
268
+ }
269
+ }
229
270
  nextPosition() {
230
271
  const existing = this.store.listAll(this.config.repoId);
231
272
  let next = 1;
package/dist/service.d.ts CHANGED
@@ -39,6 +39,7 @@ export declare class MergeStewardService {
39
39
  updateEntryHead(entryId: string, headSha: string): boolean;
40
40
  dequeueByPR(prNumber: number): void;
41
41
  updateHeadByPR(prNumber: number, headSha: string): void;
42
+ updatePriorityByPR(prNumber: number, priority: number): boolean;
42
43
  acknowledgeExternalMerge(prNumber: number): void;
43
44
  getStatus(): QueueEntry[];
44
45
  getIncident(incidentId: string): IncidentRecord | undefined;
package/dist/service.js CHANGED
@@ -73,6 +73,9 @@ export class MergeStewardService {
73
73
  updateHeadByPR(prNumber, headSha) {
74
74
  this.queueCommands.updateHeadByPR(prNumber, headSha);
75
75
  }
76
+ updatePriorityByPR(prNumber, priority) {
77
+ return this.queueCommands.updatePriorityByPR(prNumber, priority);
78
+ }
76
79
  acknowledgeExternalMerge(prNumber) {
77
80
  this.queueCommands.acknowledgeExternalMerge(prNumber);
78
81
  }
package/dist/store.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface QueueStore {
14
14
  transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
15
15
  dequeue(entryId: string): void;
16
16
  updateHead(entryId: string, newHeadSha: string): void;
17
+ updatePriority(entryId: string, priority: number, detail?: string): void;
17
18
  insertIncident(incident: IncidentRecord): void;
18
19
  listIncidents(entryId: string): IncidentRecord[];
19
20
  getIncident(incidentId: string): IncidentRecord | undefined;
package/dist/types.d.ts CHANGED
@@ -196,7 +196,7 @@ export interface QueueConfig {
196
196
  flakyRetries: number;
197
197
  pollIntervalMs: number;
198
198
  }
199
- 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" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "branch_unreachable";
199
+ 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" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "branch_unreachable";
200
200
  export interface ReconcileEvent {
201
201
  at: string;
202
202
  entryId: string;
@@ -3,7 +3,7 @@ import { useMemo } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
4
  import { TERMINAL_STATUSES } from "../types.js";
5
5
  import { buildChainEntries } from "./display-filter.js";
6
- import { ciStatusIcon, formatEventSummary, humanStatus, isPendingMainVerification, nextStepLabel, postMergeStatusLine, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
6
+ import { ciStatusIcon, formatEventSummary, humanStatus, isPendingMainVerification, nextStepLabel, postMergeStatusLine, queueBlockMatchesEntry, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
7
7
  const CHROME_ROWS = 13;
8
8
  function QueueRow({ entry, selected, isHead, queueBlock, }) {
9
9
  const isTerminal = TERMINAL_STATUSES.includes(entry.status);
@@ -15,7 +15,7 @@ function QueueRow({ entry, selected, isHead, queueBlock, }) {
15
15
  : "";
16
16
  return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { dimColor: true, children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: iconColor, children: `${icon} ${humanStatus(entry.status)}${statusSuffix}` })] }));
17
17
  }
18
- const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
18
+ const blockedOnMain = isHead && queueBlockMatchesEntry(queueBlock, entry);
19
19
  const pendingMainVerification = blockedOnMain && isPendingMainVerification(queueBlock);
20
20
  const status = blockedOnMain
21
21
  ? pendingMainVerification ? "verifying main" : "waiting for main"
@@ -36,13 +36,14 @@ export function QueueListView({ entries, allEntries, selectedEntryId, recentEven
36
36
  // All entries are 1 row now.
37
37
  const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
38
38
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
39
- const queueBlockLabel = summarizeQueueBlock(queueBlock);
39
+ const effectiveQueueBlock = queueBlock && queueBlock.entryId === headEntryId ? queueBlock : null;
40
+ const queueBlockLabel = summarizeQueueBlock(effectiveQueueBlock);
40
41
  // Chain header always shows the live queue, regardless of display filter.
41
42
  const chainEntries = useMemo(() => buildChainEntries(allEntries), [allEntries]);
42
43
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [chainEntries.length > 0 && (_jsxs(Box, { marginBottom: 1, gap: 0, children: [_jsx(Text, { dimColor: true, children: "main" }), chainEntries.map((entry) => {
43
44
  const ci = ciStatusIcon(entry);
44
45
  return (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " \u2500 " }), _jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), _jsx(Text, { color: ci.color, children: ci.icon })] }, entry.id));
45
- })] })), queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "waiting for main checks", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsx(Text, { dimColor: true, children: queueBlock.missingRequiredChecks.length > 0
46
- ? `Head PR #${queueBlock.headPrNumber ?? "?"} is waiting for an operator fix on ${queueBlock.baseBranch}.`
47
- : `Head PR #${queueBlock.headPrNumber ?? "?"} will resume automatically once main is healthy.` })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, isHead: entry.id === headEntryId, queueBlock: queueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
46
+ })] })), effectiveQueueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "waiting for main checks", effectiveQueueBlock.baseSha ? ` at ${truncate(effectiveQueueBlock.baseSha, 10)}` : "", "."] }), _jsx(Text, { dimColor: true, children: effectiveQueueBlock.missingRequiredChecks.length > 0
47
+ ? `Head PR #${effectiveQueueBlock.headPrNumber ?? "?"} is waiting for an operator fix on ${effectiveQueueBlock.baseBranch}.`
48
+ : `Head PR #${effectiveQueueBlock.headPrNumber ?? "?"} will resume automatically once main is healthy.` })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, isHead: entry.id === headEntryId, queueBlock: effectiveQueueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
48
49
  }
@@ -1,5 +1,5 @@
1
1
  import { TERMINAL_STATUSES } from "../types.js";
2
- import { mergeWaitState } from "./format.js";
2
+ import { mergeWaitState, queueBlockMatchesEntry } from "./format.js";
3
3
  const DECIDED_WINDOW_MS = 24 * 60 * 60 * 1000;
4
4
  const GLYPH = {
5
5
  running: "\u25cf",
@@ -126,7 +126,7 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
126
126
  const head = latest
127
127
  .filter((entry) => isActive(entry.status))
128
128
  .sort((a, b) => a.position - b.position)[0] ?? null;
129
- const queueBlocked = Boolean(snapshot.queueBlock);
129
+ const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
130
130
  const byPr = new Map();
131
131
  for (const entry of latest) {
132
132
  const active = isActive(entry.status);
@@ -1,5 +1,9 @@
1
1
  import type { CheckResult, PostMergeStatus, QueueBlockState, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
2
2
  export declare function isPendingMainVerification(block: QueueBlockState | null | undefined): boolean;
3
+ export declare function queueBlockMatchesEntry(block: QueueBlockState | null | undefined, entry: {
4
+ id: string;
5
+ prNumber: number;
6
+ } | null | undefined): block is QueueBlockState;
3
7
  export declare function shortSha(value: string | null | undefined): string;
4
8
  export declare function relativeTime(iso: string | number | null | undefined): string;
5
9
  export declare function formatTokenAge(eventAt: number): string;
@@ -11,6 +11,15 @@ export function isPendingMainVerification(block) {
11
11
  && block.pendingChecks.length > 0
12
12
  && block.missingRequiredChecks.length === 0);
13
13
  }
14
+ export function queueBlockMatchesEntry(block, entry) {
15
+ if (!block || !entry) {
16
+ return false;
17
+ }
18
+ if (block.entryId) {
19
+ return block.entryId === entry.id;
20
+ }
21
+ return block.headPrNumber === entry.prNumber;
22
+ }
14
23
  export function shortSha(value) {
15
24
  if (!value) {
16
25
  return "-";
@@ -1,3 +1,4 @@
1
+ import { queueBlockMatchesEntry } from "./format.js";
1
2
  const MAIN_STATES = ["queued", "preparing_head", "validating", "merging", "merged"];
2
3
  const EXIT_STATES = ["evicted", "dequeued"];
3
4
  const STATE_LABELS = {
@@ -53,7 +54,7 @@ export function buildExternalRepairObservations(detail, options) {
53
54
  else if (entry.status === "evicted") {
54
55
  observations.push({ tone: "warn", text: "Removed after failed retries. Branch needs repair before re-admission." });
55
56
  }
56
- else if (options.isHead && options.queueBlock?.reason === "main_broken") {
57
+ else if (options.isHead && queueBlockMatchesEntry(options.queueBlock, entry)) {
57
58
  const failingNames = options.queueBlock.failingChecks.map((check) => check.name);
58
59
  const pendingNames = options.queueBlock.pendingChecks.map((check) => check.name);
59
60
  const missingNames = options.queueBlock.missingRequiredChecks;
@@ -110,10 +110,17 @@ export async function processWebhookEvent(event, service, config, logger) {
110
110
  if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
111
111
  return;
112
112
  logger.info({ prNumber: event.prNumber, label: event.label }, "Queue label added, checking eligibility");
113
+ if (event.label === config.priorityQueueLabel && service.updatePriorityByPR(event.prNumber, 1)) {
114
+ break;
115
+ }
113
116
  await service.tryAdmit(event.prNumber, event.branch, event.headSha);
114
117
  break;
115
118
  }
116
119
  case "pr_unlabeled": {
120
+ if (event.label === config.priorityQueueLabel) {
121
+ service.updatePriorityByPR(event.prNumber, 0);
122
+ break;
123
+ }
117
124
  if (event.label !== config.admissionLabel)
118
125
  return;
119
126
  // Label removed — dequeue if active.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.21.0",
3
+ "version": "0.22.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": {