merge-steward 0.30.3 → 0.32.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/db/schema.js CHANGED
@@ -29,6 +29,7 @@ export function ensureSchema(connection) {
29
29
  post_merge_sha TEXT,
30
30
  post_merge_summary TEXT,
31
31
  post_merge_checked_at TEXT,
32
+ decided_at TEXT,
32
33
  enqueued_at TEXT NOT NULL,
33
34
  updated_at TEXT NOT NULL
34
35
  )
@@ -81,11 +82,20 @@ export function ensureSchema(connection) {
81
82
  // Plan §5.3: cached identity for patch-id-aware updateHead.
82
83
  ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
83
84
  ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
85
+ ensureColumn(connection, "queue_entries", "decided_at", "TEXT");
84
86
  connection.exec(`
85
87
  UPDATE queue_entries
86
88
  SET post_merge_status = 'pending'
87
89
  WHERE status = 'merged'
88
90
  AND post_merge_status IS NULL
91
+ `);
92
+ // Backfill decided_at for already-terminal rows so historic entries show a
93
+ // sensible duration/age; updated_at is the best estimate we have for them.
94
+ connection.exec(`
95
+ UPDATE queue_entries
96
+ SET decided_at = updated_at
97
+ WHERE decided_at IS NULL
98
+ AND status IN ('merged', 'evicted', 'dequeued')
89
99
  `);
90
100
  // Must match TERMINAL_STATUSES in types.ts: merged, evicted, dequeued
91
101
  connection.exec(`
@@ -11,6 +11,7 @@ export declare class SqliteStore implements QueueStore {
11
11
  getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
+ listPostMergePending(repoId: string): QueueEntry[];
14
15
  insert(entry: QueueEntry): void;
15
16
  transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
16
17
  dequeue(entryId: string): void;
@@ -33,6 +33,7 @@ function mapEntry(row) {
33
33
  baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
34
34
  headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
35
35
  specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
36
+ decidedAt: row.decided_at === null || row.decided_at === undefined ? null : String(row.decided_at),
36
37
  enqueuedAt: String(row.enqueued_at),
37
38
  updatedAt: String(row.updated_at),
38
39
  };
@@ -122,6 +123,13 @@ export class SqliteStore {
122
123
  const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
123
124
  return rows.map(mapEntry);
124
125
  }
126
+ listPostMergePending(repoId) {
127
+ const rows = this.conn.prepare(`SELECT * FROM queue_entries
128
+ WHERE repo_id = ? AND status = 'merged'
129
+ AND (post_merge_status IS NULL OR post_merge_status NOT IN ('pass', 'fail'))
130
+ ORDER BY position ASC`).all(repoId);
131
+ return rows.map(mapEntry);
132
+ }
125
133
  insert(entry) {
126
134
  this.conn.transaction(() => {
127
135
  this.conn.prepare(`INSERT INTO queue_entries
@@ -129,20 +137,27 @@ export class SqliteStore {
129
137
  priority, generation, ci_run_id, ci_retries, retry_attempts,
130
138
  max_retries, last_failed_base_sha, issue_key, wait_detail,
131
139
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
132
- pr_title, base_ref_name,
140
+ pr_title, base_ref_name, decided_at,
133
141
  enqueued_at, updated_at)
134
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.enqueuedAt, entry.updatedAt);
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.decidedAt ?? null, entry.enqueuedAt, entry.updatedAt);
135
143
  this.writeEvent(entry.id, null, entry.status);
136
144
  })();
137
145
  }
138
146
  transition(entryId, to, patch, detail) {
139
147
  this.conn.transaction(() => {
140
- const current = this.conn.prepare("SELECT status FROM queue_entries WHERE id = ?").get(entryId);
148
+ const current = this.conn.prepare("SELECT status, decided_at FROM queue_entries WHERE id = ?").get(entryId);
141
149
  if (!current)
142
150
  return;
143
151
  const from = String(current.status);
152
+ const now = isoNow();
144
153
  const sets = ["status = ?", "updated_at = ?"];
145
- const values = [to, isoNow()];
154
+ const values = [to, now];
155
+ // Stamp decidedAt the first time the entry becomes terminal; never move it
156
+ // afterward (post-merge re-verification keeps transitioning to 'merged').
157
+ if (TERMINAL_STATUSES.includes(to) && (current.decided_at === null || current.decided_at === undefined)) {
158
+ sets.push("decided_at = ?");
159
+ values.push(now);
160
+ }
146
161
  if (patch?.headSha !== undefined) {
147
162
  sets.push("head_sha = ?");
148
163
  values.push(patch.headSha);
@@ -11,8 +11,10 @@ import { syncQueueStateLabels } from "./reconciler-queue-labels.js";
11
11
  // ─── Main reconcile loop ────────────────────────────────────────
12
12
  export async function reconcile(ctx) {
13
13
  const allActive = ctx.store.listActive(ctx.repoId);
14
- if (allActive.length === 0)
15
- return;
14
+ // Note: do NOT early-return when the active queue is empty. A drained queue
15
+ // can still hold merged entries whose post-merge verification is unresolved
16
+ // (e.g. an externally-merged PR), and verifyMergedEntriesPostPush below is
17
+ // the only thing that advances them. depth=0 simply skips the active loop.
16
18
  // Process up to speculativeDepth entries. GitHub truth checks are
17
19
  // bounded by this window — we never scan the full queue.
18
20
  const depth = Math.min(ctx.speculativeDepth, allActive.length);
@@ -91,10 +93,10 @@ export async function reconcile(ctx) {
91
93
  await verifyMergedEntriesPostPush(ctx);
92
94
  }
93
95
  async function verifyMergedEntriesPostPush(ctx) {
94
- const mergedEntries = ctx.store.listAll(ctx.repoId).filter((entry) => entry.status === "merged");
96
+ // Targeted query (not listAll) so an idle repo with a large merged history
97
+ // doesn't scan every terminal row each tick — only the unresolved ones.
98
+ const mergedEntries = ctx.store.listPostMergePending(ctx.repoId);
95
99
  for (const entry of mergedEntries) {
96
- if (entry.postMergeStatus === "pass" || entry.postMergeStatus === "fail")
97
- continue;
98
100
  const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
99
101
  if (!postMergeSha) {
100
102
  continue;
@@ -67,6 +67,7 @@ export class MergeStewardQueueCommands {
67
67
  specTreeId: null,
68
68
  prTitle: params.prTitle ?? null,
69
69
  baseRefName: params.baseRefName ?? null,
70
+ decidedAt: null,
70
71
  enqueuedAt: new Date().toISOString(),
71
72
  updatedAt: new Date().toISOString(),
72
73
  };
package/dist/store.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface QueueStore {
10
10
  getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
11
11
  listActive(repoId: string): QueueEntry[];
12
12
  listAll(repoId: string): QueueEntry[];
13
+ /** Merged entries whose post-merge verification is unresolved (not pass/fail). */
14
+ listPostMergePending(repoId: string): QueueEntry[];
13
15
  insert(entry: QueueEntry): void;
14
16
  transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
15
17
  dequeue(entryId: string): void;
package/dist/types.d.ts CHANGED
@@ -74,6 +74,14 @@ export interface QueueEntry {
74
74
  specTreeId: string | null;
75
75
  enqueuedAt: string;
76
76
  updatedAt: string;
77
+ /**
78
+ * Set once, when the entry first reaches a terminal status
79
+ * (merged/evicted/dequeued), and never bumped afterward — unlike
80
+ * updatedAt, which post-merge re-verification keeps moving. Lets the
81
+ * dashboard report an accurate "how long it took" (decidedAt - enqueuedAt)
82
+ * and "how long ago" (now - decidedAt). Null while still in flight.
83
+ */
84
+ decidedAt: string | null;
77
85
  }
78
86
  export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "policy_blocked";
79
87
  export type CheckConclusion = "success" | "failure" | "pending";
@@ -11,6 +11,9 @@ type ContentLine = {
11
11
  } | {
12
12
  kind: "entry-header";
13
13
  entry: DashboardPrEntry;
14
+ } | {
15
+ kind: "stack-line";
16
+ text: string;
14
17
  } | {
15
18
  kind: "summary-line";
16
19
  text: string;
@@ -2,11 +2,17 @@ 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
+ import { relativeTime } from "./format.js";
6
+ import { formatDurationMs } from "../runtime-format.js";
6
7
  const PR_ID_WIDTH = 7;
7
8
  const PR_PHRASE_WIDTH = 20;
8
9
  const SUMMARY_INDENT = 9;
9
- const AGE_WIDTH = 4;
10
+ const TIMING_WIDTH = 22;
11
+ // "took 7m · 3m ago" for a finished entry; "running 12m" while in flight.
12
+ function timingLabel(entry) {
13
+ const duration = formatDurationMs(entry.durationMs);
14
+ return entry.recencyAt != null ? `took ${duration} · ${relativeTime(entry.recencyAt)} ago` : `running ${duration}`;
15
+ }
10
16
  function truncate(value, maxWidth) {
11
17
  if (value.length <= maxWidth)
12
18
  return value;
@@ -20,6 +26,9 @@ export function buildContentLines(repo, width) {
20
26
  if (index > 0)
21
27
  lines.push({ kind: "blank" });
22
28
  lines.push({ kind: "entry-header", entry });
29
+ if (entry.stackedOnPr != null) {
30
+ lines.push({ kind: "stack-line", text: `↳ stacked on #${entry.stackedOnPr} (merges after it)` });
31
+ }
23
32
  if (entry.summary) {
24
33
  const summaryText = clipSummary(entry.summary, {
25
34
  maxLines: 3,
@@ -35,16 +44,19 @@ export function buildContentLines(repo, width) {
35
44
  function EntryHeaderRow({ entry, width }) {
36
45
  const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
37
46
  const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
38
- const age = formatTokenAge(entry.eventAt);
39
- const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 3 + AGE_WIDTH));
47
+ const timing = timingLabel(entry).padEnd(TIMING_WIDTH, " ");
48
+ const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 3 + TIMING_WIDTH));
40
49
  const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
41
50
  ? truncate(entry.title, titleSpace)
42
51
  : "";
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] }));
52
+ 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: ` ${timing}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
44
53
  }
45
54
  function renderLine(line, key, width) {
46
55
  if (line.kind === "blank")
47
56
  return _jsx(Box, { children: _jsx(Text, { children: " " }) }, key);
57
+ if (line.kind === "stack-line") {
58
+ return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { color: "gray", children: line.text })] }, key));
59
+ }
48
60
  if (line.kind === "summary-line") {
49
61
  return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: line.text })] }, key));
50
62
  }
@@ -20,6 +20,16 @@ export interface DashboardToken {
20
20
  color: DashboardTokenColor;
21
21
  kind: DashboardTokenKind;
22
22
  eventAt: number;
23
+ /** Queue position; active entries are ordered by this so the stack/queue order shows. */
24
+ position: number;
25
+ /** Whether the entry is still in flight (not merged/evicted/dequeued). */
26
+ active: boolean;
27
+ /** PR number this entry's speculative spec is stacked on, when that parent is still active. */
28
+ stackedOnPr: number | null;
29
+ /** How long the queue processing took: enqueue -> decided (terminal) or enqueue -> now (active). */
30
+ durationMs: number;
31
+ /** Completion instant (decidedAt) the views age against; null while in flight. */
32
+ recencyAt: number | null;
23
33
  }
24
34
  export interface DashboardPrEntry extends DashboardToken {
25
35
  phrase: string;
@@ -121,12 +121,19 @@ function overrideKind(kind, override) {
121
121
  return "error";
122
122
  return kind;
123
123
  }
124
- function repoEntriesFromSnapshot(snapshot, cutoff) {
124
+ function repoEntriesFromSnapshot(snapshot, cutoff, now) {
125
125
  const latest = pickLatestPerPR(snapshot.entries);
126
126
  const head = latest
127
127
  .filter((entry) => isActive(entry.status))
128
128
  .sort((a, b) => a.position - b.position)[0] ?? null;
129
129
  const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
130
+ // Resolve speculative stacking: an entry's spec can be built on top of
131
+ // another entry's spec (specBasedOn -> that entry's id). Map ids to PR
132
+ // numbers so the views can show "stacked on #N". Only surface the link
133
+ // when the parent is still active, since a merged parent collapses the stack.
134
+ const idToEntry = new Map();
135
+ for (const entry of latest)
136
+ idToEntry.set(entry.id, entry);
130
137
  const byPr = new Map();
131
138
  for (const entry of latest) {
132
139
  const active = isActive(entry.status);
@@ -140,13 +147,26 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
140
147
  const glyph = GLYPH[kind];
141
148
  const color = COLOR[kind];
142
149
  const phrase = entryPhrase(entry, { isHead, queueBlocked });
150
+ const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
151
+ const stackedOnPr = parent && isActive(parent.status) ? parent.prNumber : null;
152
+ // decidedAt is the terminal-transition time (never bumped by post-merge
153
+ // re-checks). Duration = enqueue -> decided (or -> now while in flight);
154
+ // recencyAt = the completion instant the views age against.
155
+ const enqueuedAt = timestamp(entry.enqueuedAt);
156
+ const decidedAt = active ? null : timestamp(entry.decidedAt) || timestamp(entry.updatedAt);
157
+ const durationMs = Math.max(0, (decidedAt ?? now) - enqueuedAt);
143
158
  const item = {
144
159
  prNumber: entry.prNumber,
145
160
  glyph,
146
161
  color,
147
162
  kind,
148
163
  phrase,
149
- eventAt: timestamp(entry.updatedAt),
164
+ eventAt: decidedAt ?? timestamp(entry.updatedAt),
165
+ position: entry.position,
166
+ active,
167
+ stackedOnPr,
168
+ durationMs,
169
+ recencyAt: decidedAt,
150
170
  };
151
171
  const summary = entrySummary(entry);
152
172
  if (summary)
@@ -156,7 +176,16 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
156
176
  item.title = title;
157
177
  byPr.set(entry.prNumber, item);
158
178
  }
179
+ // Active entries first, in queue/stack order (by position, head first) so
180
+ // speculative stacks read top-to-bottom; decided entries follow, newest-first.
159
181
  return [...byPr.values()].sort((left, right) => {
182
+ if (left.active !== right.active)
183
+ return left.active ? -1 : 1;
184
+ if (left.active) {
185
+ if (left.position !== right.position)
186
+ return left.position - right.position;
187
+ return left.prNumber - right.prNumber;
188
+ }
160
189
  const leftOrder = tokenSortOrder(left.kind);
161
190
  const rightOrder = tokenSortOrder(right.kind);
162
191
  if (leftOrder !== rightOrder)
@@ -188,12 +217,12 @@ export function buildDashboard(repos, opts = {}) {
188
217
  offlineMessage: message,
189
218
  };
190
219
  }
191
- const entries = repoEntriesFromSnapshot(snapshot, cutoff);
220
+ const entries = repoEntriesFromSnapshot(snapshot, cutoff, now);
192
221
  const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
193
222
  return {
194
223
  repoId: repo.repoId,
195
224
  repoFullName: repo.repoFullName,
196
- tokens: entries.map(({ prNumber, glyph, color, kind, eventAt }) => ({ prNumber, glyph, color, kind, eventAt })),
225
+ tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt })),
197
226
  entries,
198
227
  latestActivityAt,
199
228
  hasActivity: entries.length > 0,
@@ -11,6 +11,9 @@ export declare function formatRepoTokenText(token: {
11
11
  prNumber: number;
12
12
  glyph: string;
13
13
  eventAt: number;
14
+ stackedOnPr?: number | null;
15
+ durationMs?: number | null;
16
+ recencyAt?: number | null;
14
17
  }): string;
15
18
  export declare function mergeWaitState(entry: {
16
19
  status: QueueEntryStatus;
@@ -1,3 +1,4 @@
1
+ import { formatDurationMs } from "../runtime-format.js";
1
2
  const QUEUE_SYMBOLS = {
2
3
  inProgress: "\u25cf",
3
4
  checkPassed: "\u2713",
@@ -49,7 +50,15 @@ export function formatTokenAge(eventAt) {
49
50
  return relativeTime(eventAt).padStart(4, " ");
50
51
  }
51
52
  export function formatRepoTokenText(token) {
52
- return `#${token.prNumber} ${token.glyph} ${relativeTime(token.eventAt)}`;
53
+ // A leading connector marks an entry whose spec is speculatively stacked on
54
+ // the PR shown just before it, so the queue order reads as a stack.
55
+ const stackMark = token.stackedOnPr != null ? "↳" : "";
56
+ // Two times: how long it took (duration) and, for finished entries, how long
57
+ // ago it finished (recency), joined as "took·ago" e.g. "7m·3m". In-flight
58
+ // entries show only the running duration.
59
+ const duration = token.durationMs != null ? formatDurationMs(token.durationMs) : relativeTime(token.eventAt);
60
+ const timing = token.recencyAt != null ? `${duration}·${relativeTime(token.recencyAt)}` : duration;
61
+ return `${stackMark}#${token.prNumber} ${token.glyph} ${timing}`;
53
62
  }
54
63
  export function mergeWaitState(entry) {
55
64
  if (!entry || entry.status !== "merging" || !entry.waitDetail) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.30.3",
3
+ "version": "0.32.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": {