merge-steward 0.31.0 → 0.32.1

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(`
@@ -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
  };
@@ -136,20 +137,27 @@ export class SqliteStore {
136
137
  priority, generation, ci_run_id, ci_retries, retry_attempts,
137
138
  max_retries, last_failed_base_sha, issue_key, wait_detail,
138
139
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
139
- pr_title, base_ref_name,
140
+ pr_title, base_ref_name, decided_at,
140
141
  enqueued_at, updated_at)
141
- 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);
142
143
  this.writeEvent(entry.id, null, entry.status);
143
144
  })();
144
145
  }
145
146
  transition(entryId, to, patch, detail) {
146
147
  this.conn.transaction(() => {
147
- 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);
148
149
  if (!current)
149
150
  return;
150
151
  const from = String(current.status);
152
+ const now = isoNow();
151
153
  const sets = ["status = ?", "updated_at = ?"];
152
- 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
+ }
153
161
  if (patch?.headSha !== undefined) {
154
162
  sets.push("head_sha = ?");
155
163
  values.push(patch.headSha);
@@ -132,19 +132,15 @@ export class GitHubPRClient {
132
132
  }
133
133
  async listLabels(prNumber) {
134
134
  const result = await exec("gh", [
135
- "pr", "view", String(prNumber),
136
- "--repo", this.repoFullName,
137
- "--json", "labels",
135
+ "api",
136
+ `repos/${this.repoFullName}/issues/${prNumber}/labels`,
137
+ "--paginate",
138
+ "--jq", ".[].name",
138
139
  ], { allowNonZero: true, githubRepoFullName: this.repoFullName });
139
- if (result.exitCode !== 0)
140
- return [];
141
- try {
142
- const data = JSON.parse(result.stdout);
143
- return data.labels.map((l) => l.name);
144
- }
145
- catch {
146
- return [];
140
+ if (result.exitCode !== 0) {
141
+ throw new Error(`Failed to list labels for PR #${prNumber}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
147
142
  }
143
+ return result.stdout.split(/\r?\n/).map((label) => label.trim()).filter(Boolean);
148
144
  }
149
145
  async setLabels(prNumber, opts) {
150
146
  const args = ["pr", "edit", String(prNumber), "--repo", this.repoFullName];
@@ -155,7 +151,10 @@ export class GitHubPRClient {
155
151
  // Nothing to change beyond the base args — skip the call.
156
152
  if (args.length === 5)
157
153
  return;
158
- await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
154
+ const result = await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
155
+ if (result.exitCode !== 0) {
156
+ throw new Error(`Failed to edit labels for PR #${prNumber}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
157
+ }
159
158
  }
160
159
  }
161
160
  /** Map GitHub REST API check-run status/conclusion to our union. */
@@ -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
  };
@@ -145,7 +146,13 @@ export class MergeStewardQueueCommands {
145
146
  this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
146
147
  return false;
147
148
  }
148
- const labels = await this.github.listLabels(prNumber);
149
+ let labels = [];
150
+ try {
151
+ labels = await this.github.listLabels(prNumber);
152
+ }
153
+ catch (error) {
154
+ this.logger.debug({ prNumber, err: error }, "Could not read labels for priority detection; continuing without priority label");
155
+ }
149
156
  const priority = labels.includes(this.config.priorityQueueLabel) ? 1 : 0;
150
157
  const checks = await this.github.listChecks(prNumber);
151
158
  const requiredChecks = this.policy.getRequiredChecks();
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";
@@ -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;
@@ -38,12 +44,12 @@ export function buildContentLines(repo, width) {
38
44
  function EntryHeaderRow({ entry, width }) {
39
45
  const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
40
46
  const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
41
- const age = formatTokenAge(entry.eventAt);
42
- 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));
43
49
  const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
44
50
  ? truncate(entry.title, titleSpace)
45
51
  : "";
46
- 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] }));
47
53
  }
48
54
  function renderLine(line, key, width) {
49
55
  if (line.kind === "blank")
@@ -26,6 +26,10 @@ export interface DashboardToken {
26
26
  active: boolean;
27
27
  /** PR number this entry's speculative spec is stacked on, when that parent is still active. */
28
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;
29
33
  }
30
34
  export interface DashboardPrEntry extends DashboardToken {
31
35
  phrase: string;
@@ -121,7 +121,7 @@ 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))
@@ -149,16 +149,24 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
149
149
  const phrase = entryPhrase(entry, { isHead, queueBlocked });
150
150
  const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
151
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);
152
158
  const item = {
153
159
  prNumber: entry.prNumber,
154
160
  glyph,
155
161
  color,
156
162
  kind,
157
163
  phrase,
158
- eventAt: timestamp(entry.updatedAt),
164
+ eventAt: decidedAt ?? timestamp(entry.updatedAt),
159
165
  position: entry.position,
160
166
  active,
161
167
  stackedOnPr,
168
+ durationMs,
169
+ recencyAt: decidedAt,
162
170
  };
163
171
  const summary = entrySummary(entry);
164
172
  if (summary)
@@ -209,12 +217,12 @@ export function buildDashboard(repos, opts = {}) {
209
217
  offlineMessage: message,
210
218
  };
211
219
  }
212
- const entries = repoEntriesFromSnapshot(snapshot, cutoff);
220
+ const entries = repoEntriesFromSnapshot(snapshot, cutoff, now);
213
221
  const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
214
222
  return {
215
223
  repoId: repo.repoId,
216
224
  repoFullName: repo.repoFullName,
217
- tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr })),
225
+ tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt })),
218
226
  entries,
219
227
  latestActivityAt,
220
228
  hasActivity: entries.length > 0,
@@ -12,6 +12,8 @@ export declare function formatRepoTokenText(token: {
12
12
  glyph: string;
13
13
  eventAt: number;
14
14
  stackedOnPr?: number | null;
15
+ durationMs?: number | null;
16
+ recencyAt?: number | null;
15
17
  }): string;
16
18
  export declare function mergeWaitState(entry: {
17
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",
@@ -52,7 +53,12 @@ export function formatRepoTokenText(token) {
52
53
  // A leading connector marks an entry whose spec is speculatively stacked on
53
54
  // the PR shown just before it, so the queue order reads as a stack.
54
55
  const stackMark = token.stackedOnPr != null ? "↳" : "";
55
- return `${stackMark}#${token.prNumber} ${token.glyph} ${relativeTime(token.eventAt)}`;
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}`;
56
62
  }
57
63
  export function mergeWaitState(entry) {
58
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.31.0",
3
+ "version": "0.32.1",
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": {