merge-steward 0.19.2 → 0.21.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
@@ -28,6 +28,7 @@ export declare const stewardConfigSchema: z.ZodObject<{
28
28
  }>>;
29
29
  }, z.core.$strip>>;
30
30
  admissionLabel: z.ZodDefault<z.ZodString>;
31
+ priorityQueueLabel: z.ZodDefault<z.ZodString>;
31
32
  mergeQueueCheckName: z.ZodDefault<z.ZodString>;
32
33
  excludeBranches: z.ZodDefault<z.ZodArray<z.ZodString>>;
33
34
  autoResolvePatterns: z.ZodDefault<z.ZodArray<z.ZodObject<{
package/dist/config.js CHANGED
@@ -29,6 +29,7 @@ export const stewardConfigSchema = z.object({
29
29
  level: z.enum(["debug", "info", "warn", "error"]).default("info"),
30
30
  }).default({ level: "info" }),
31
31
  admissionLabel: z.string().default("queue"),
32
+ priorityQueueLabel: z.string().default("queue:priority"),
32
33
  mergeQueueCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
33
34
  /** Branch name patterns to exclude from admission (glob-style). */
34
35
  excludeBranches: z.array(z.string()).default([]),
@@ -95,7 +95,7 @@ export class SqliteStore {
95
95
  getHead(repoId) {
96
96
  const row = this.conn.prepare(`SELECT * FROM queue_entries
97
97
  WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
98
- ORDER BY position ASC LIMIT 1`).get(repoId, ...TERMINAL_STATUSES);
98
+ ORDER BY priority DESC, position ASC LIMIT 1`).get(repoId, ...TERMINAL_STATUSES);
99
99
  return row ? mapEntry(row) : undefined;
100
100
  }
101
101
  getEntry(entryId) {
@@ -105,18 +105,18 @@ export class SqliteStore {
105
105
  getEntryByPR(repoId, prNumber) {
106
106
  const row = this.conn.prepare(`SELECT * FROM queue_entries
107
107
  WHERE repo_id = ? AND pr_number = ? AND status NOT IN (${NOT_TERMINAL_SQL})
108
- ORDER BY position ASC
108
+ ORDER BY priority DESC, position ASC
109
109
  LIMIT 1`).get(repoId, prNumber, ...TERMINAL_STATUSES);
110
110
  return row ? mapEntry(row) : undefined;
111
111
  }
112
112
  listActive(repoId) {
113
113
  const rows = this.conn.prepare(`SELECT * FROM queue_entries
114
114
  WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
115
- ORDER BY position ASC`).all(repoId, ...TERMINAL_STATUSES);
115
+ ORDER BY priority DESC, position ASC`).all(repoId, ...TERMINAL_STATUSES);
116
116
  return rows.map(mapEntry);
117
117
  }
118
118
  listAll(repoId) {
119
- const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY position ASC").all(repoId);
119
+ const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
120
120
  return rows.map(mapEntry);
121
121
  }
122
122
  insert(entry) {
@@ -116,6 +116,7 @@ export async function buildMultiRepoHttpServer(options) {
116
116
  }
117
117
  await processWebhookEvent(event, record.instance.service, {
118
118
  admissionLabel: record.config.admissionLabel,
119
+ priorityQueueLabel: record.config.priorityQueueLabel,
119
120
  baseBranch: record.config.baseBranch,
120
121
  repoFullName: record.config.repoFullName,
121
122
  github: record.instance.service.githubApi,
@@ -9,5 +9,5 @@ export declare const INVALIDATION_PATCH: {
9
9
  readonly retryAttempts: 0;
10
10
  readonly lastFailedBaseSha: null;
11
11
  };
12
- /** Select non-terminal entries after `afterPosition` from a position-sorted list. */
13
- export declare function selectDownstream(allActive: QueueEntry[], afterPosition: number): QueueEntry[];
12
+ /** Select non-terminal entries after `entryId` from an already ordered active queue list. */
13
+ export declare function selectDownstream(allActive: QueueEntry[], entryId: string): QueueEntry[];
@@ -9,7 +9,10 @@ export const INVALIDATION_PATCH = {
9
9
  retryAttempts: 0,
10
10
  lastFailedBaseSha: null,
11
11
  };
12
- /** Select non-terminal entries after `afterPosition` from a position-sorted list. */
13
- export function selectDownstream(allActive, afterPosition) {
14
- return allActive.filter((e) => e.position > afterPosition && !TERMINAL_STATUSES.includes(e.status));
12
+ /** Select non-terminal entries after `entryId` from an already ordered active queue list. */
13
+ export function selectDownstream(allActive, entryId) {
14
+ const index = allActive.findIndex((entry) => entry.id === entryId);
15
+ if (index < 0)
16
+ return [];
17
+ return allActive.slice(index + 1).filter((entry) => !TERMINAL_STATUSES.includes(entry.status));
15
18
  }
@@ -10,7 +10,7 @@ export async function cleanupSpec(ctx, entry) {
10
10
  }
11
11
  }
12
12
  export async function invalidateDownstream(ctx, allActive, afterIndex) {
13
- const targets = selectDownstream(allActive, allActive[afterIndex].position);
13
+ const targets = selectDownstream(allActive, allActive[afterIndex].id);
14
14
  for (const downstream of targets) {
15
15
  emit(ctx, downstream, "invalidated", { detail: `base changed after position ${afterIndex}` });
16
16
  await cleanupSpec(ctx, downstream);
@@ -46,7 +46,7 @@ export async function mergeHead(ctx, entry) {
46
46
  catch {
47
47
  // Can't verify — proceed and let push fail if needed.
48
48
  }
49
- if (ctx.ci.getMainStatus) {
49
+ if (ctx.ci.getMainStatus && entry.priority <= 0) {
50
50
  const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
51
51
  if (mainStatus === "pending") {
52
52
  // Main is still verifying itself (typically post-merge CI from the
@@ -44,7 +44,8 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
44
44
  return;
45
45
  }
46
46
  if (isHead) {
47
- if (ctx.ci.getMainStatus) {
47
+ const bypassMainBlock = entry.priority > 0;
48
+ if (ctx.ci.getMainStatus && !bypassMainBlock) {
48
49
  const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
49
50
  if (mainStatus !== "pass") {
50
51
  let mainChecks = [];
@@ -79,6 +79,9 @@ export class MergeStewardQueueCommands {
79
79
  throw error;
80
80
  }
81
81
  this.logger.info({ prNumber: params.prNumber, entryId: entry.id }, "PR enqueued");
82
+ if (entry.priority > 0) {
83
+ this.invalidateDownstreamOf(entry);
84
+ }
82
85
  return entry;
83
86
  }
84
87
  async scanStartupAdmissions() {
@@ -123,6 +126,8 @@ export class MergeStewardQueueCommands {
123
126
  this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
124
127
  return false;
125
128
  }
129
+ const labels = await this.github.listLabels(prNumber);
130
+ const priority = labels.includes(this.config.priorityQueueLabel) ? 1 : 0;
126
131
  const checks = await this.github.listChecks(prNumber);
127
132
  const requiredChecks = this.policy.getRequiredChecks();
128
133
  if (requiredChecks.length > 0) {
@@ -162,7 +167,13 @@ export class MergeStewardQueueCommands {
162
167
  return false;
163
168
  }
164
169
  }
165
- this.enqueue({ prNumber, branch, headSha, ...(status.title ? { prTitle: status.title } : {}) });
170
+ this.enqueue({
171
+ prNumber,
172
+ branch,
173
+ headSha,
174
+ priority,
175
+ ...(status.title ? { prTitle: status.title } : {}),
176
+ });
166
177
  return true;
167
178
  }
168
179
  catch (error) {
@@ -204,7 +215,7 @@ export class MergeStewardQueueCommands {
204
215
  }
205
216
  invalidateDownstreamOf(removedEntry) {
206
217
  const allActive = this.store.listActive(this.config.repoId);
207
- const targets = selectDownstream(allActive, removedEntry.position);
218
+ const targets = selectDownstream(allActive, removedEntry.id);
208
219
  for (const downstream of targets) {
209
220
  if (downstream.specBranch) {
210
221
  this.specBuilder.deleteSpeculative(downstream.specBranch).catch(() => { });
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
+ import { formatRepoTokenText } from "./format.js";
3
4
  function RepoTokens({ tokens, width }) {
4
5
  if (tokens.length === 0 || width < 6)
5
6
  return null;
6
7
  const parts = [];
7
8
  let used = 0;
8
9
  for (const token of tokens) {
9
- const text = `#${token.prNumber} ${token.glyph}`;
10
+ const text = formatRepoTokenText(token);
10
11
  const separatorWidth = parts.length === 0 ? 0 : 2;
11
12
  if (used + separatorWidth + text.length > width)
12
13
  break;
@@ -2,9 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
3
  import { RepoRow } from "./OverviewView.js";
4
4
  import { clipSummary } from "./dashboard-model.js";
5
+ import { formatTokenAge } from "./format.js";
5
6
  const PR_ID_WIDTH = 7;
6
7
  const PR_PHRASE_WIDTH = 20;
7
8
  const SUMMARY_INDENT = 9;
9
+ const AGE_WIDTH = 4;
8
10
  function truncate(value, maxWidth) {
9
11
  if (value.length <= maxWidth)
10
12
  return value;
@@ -33,11 +35,12 @@ export function buildContentLines(repo, width) {
33
35
  function EntryHeaderRow({ entry, width }) {
34
36
  const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
35
37
  const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
36
- const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 1));
38
+ const age = formatTokenAge(entry.eventAt);
39
+ const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 3 + AGE_WIDTH));
37
40
  const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
38
41
  ? truncate(entry.title, titleSpace)
39
42
  : "";
40
- return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
43
+ return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), _jsx(Text, { dimColor: true, children: ` ${age}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
41
44
  }
42
45
  function renderLine(line, key, width) {
43
46
  if (line.kind === "blank")
@@ -19,6 +19,7 @@ export interface DashboardToken {
19
19
  glyph: string;
20
20
  color: DashboardTokenColor;
21
21
  kind: DashboardTokenKind;
22
+ eventAt: number;
22
23
  }
23
24
  export interface DashboardPrEntry extends DashboardToken {
24
25
  phrase: string;
@@ -193,7 +193,7 @@ export function buildDashboard(repos, opts = {}) {
193
193
  return {
194
194
  repoId: repo.repoId,
195
195
  repoFullName: repo.repoFullName,
196
- tokens: entries.map(({ prNumber, glyph, color, kind }) => ({ prNumber, glyph, color, kind })),
196
+ tokens: entries.map(({ prNumber, glyph, color, kind, eventAt }) => ({ prNumber, glyph, color, kind, eventAt })),
197
197
  entries,
198
198
  latestActivityAt,
199
199
  hasActivity: entries.length > 0,
@@ -1,7 +1,13 @@
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
3
  export declare function shortSha(value: string | null | undefined): string;
4
- export declare function relativeTime(iso: string | null | undefined): string;
4
+ export declare function relativeTime(iso: string | number | null | undefined): string;
5
+ export declare function formatTokenAge(eventAt: number): string;
6
+ export declare function formatRepoTokenText(token: {
7
+ prNumber: number;
8
+ glyph: string;
9
+ eventAt: number;
10
+ }): string;
5
11
  export declare function mergeWaitState(entry: {
6
12
  status: QueueEntryStatus;
7
13
  waitDetail?: string | null | undefined;
@@ -21,7 +21,8 @@ export function relativeTime(iso) {
21
21
  if (!iso) {
22
22
  return "-";
23
23
  }
24
- const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
24
+ const timestamp = typeof iso === "number" ? iso : new Date(iso).getTime();
25
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
25
26
  if (seconds < 5)
26
27
  return "now";
27
28
  if (seconds < 60)
@@ -35,6 +36,12 @@ export function relativeTime(iso) {
35
36
  const days = Math.floor(hours / 24);
36
37
  return `${days}d`;
37
38
  }
39
+ export function formatTokenAge(eventAt) {
40
+ return relativeTime(eventAt).padStart(4, " ");
41
+ }
42
+ export function formatRepoTokenText(token) {
43
+ return `#${token.prNumber} ${token.glyph} ${relativeTime(token.eventAt)}`;
44
+ }
38
45
  export function mergeWaitState(entry) {
39
46
  if (!entry || entry.status !== "merging" || !entry.waitDetail) {
40
47
  return null;
@@ -60,6 +60,7 @@ export declare function normalizeWebhook(eventType: string, payload: Record<stri
60
60
  */
61
61
  export declare function processWebhookEvent(event: StewardWebhookEvent, service: MergeStewardService, config: {
62
62
  admissionLabel: string;
63
+ priorityQueueLabel: string;
63
64
  baseBranch: string;
64
65
  repoFullName: string;
65
66
  github?: GitHubPRApi;
@@ -107,9 +107,9 @@ export function normalizeWebhook(eventType, payload) {
107
107
  export async function processWebhookEvent(event, service, config, logger) {
108
108
  switch (event.type) {
109
109
  case "pr_labeled": {
110
- if (event.label !== config.admissionLabel)
110
+ if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
111
111
  return;
112
- logger.info({ prNumber: event.prNumber }, "Admission label added, checking eligibility");
112
+ logger.info({ prNumber: event.prNumber, label: event.label }, "Queue label added, checking eligibility");
113
113
  await service.tryAdmit(event.prNumber, event.branch, event.headSha);
114
114
  break;
115
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.19.2",
3
+ "version": "0.21.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": {