merge-steward 0.14.0 → 0.14.2

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
@@ -24,6 +24,7 @@ export function ensureSchema(connection) {
24
24
  spec_branch TEXT,
25
25
  spec_sha TEXT,
26
26
  spec_based_on TEXT,
27
+ wait_detail TEXT,
27
28
  post_merge_status TEXT,
28
29
  post_merge_sha TEXT,
29
30
  post_merge_summary TEXT,
@@ -73,6 +74,7 @@ export function ensureSchema(connection) {
73
74
  ensureColumn(connection, "queue_entries", "post_merge_sha", "TEXT");
74
75
  ensureColumn(connection, "queue_entries", "post_merge_summary", "TEXT");
75
76
  ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
77
+ ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
76
78
  connection.exec(`
77
79
  UPDATE queue_entries
78
80
  SET post_merge_status = 'pending'
@@ -12,7 +12,7 @@ export declare class SqliteStore implements QueueStore {
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
14
  insert(entry: QueueEntry): void;
15
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
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
18
  insertIncident(incident: IncidentRecord): void;
@@ -22,6 +22,7 @@ function mapEntry(row) {
22
22
  specBranch: row.spec_branch === null || row.spec_branch === undefined ? null : String(row.spec_branch),
23
23
  specSha: row.spec_sha === null || row.spec_sha === undefined ? null : String(row.spec_sha),
24
24
  specBasedOn: row.spec_based_on === null || row.spec_based_on === undefined ? null : String(row.spec_based_on),
25
+ waitDetail: row.wait_detail === null || row.wait_detail === undefined ? null : String(row.wait_detail),
25
26
  postMergeStatus: row.post_merge_status === null || row.post_merge_status === undefined
26
27
  ? null
27
28
  : String(row.post_merge_status),
@@ -122,10 +123,10 @@ export class SqliteStore {
122
123
  this.conn.prepare(`INSERT INTO queue_entries
123
124
  (id, repo_id, pr_number, branch, head_sha, base_sha, status, position,
124
125
  priority, generation, ci_run_id, ci_retries, retry_attempts,
125
- max_retries, last_failed_base_sha, issue_key,
126
+ max_retries, last_failed_base_sha, issue_key, wait_detail,
126
127
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
127
128
  enqueued_at, updated_at)
128
- 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.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.enqueuedAt, entry.updatedAt);
129
+ 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.enqueuedAt, entry.updatedAt);
129
130
  this.writeEvent(entry.id, null, entry.status);
130
131
  })();
131
132
  }
@@ -173,6 +174,13 @@ export class SqliteStore {
173
174
  sets.push("spec_based_on = ?");
174
175
  values.push(patch.specBasedOn);
175
176
  }
177
+ if (patch?.waitDetail !== undefined) {
178
+ sets.push("wait_detail = ?");
179
+ values.push(patch.waitDetail);
180
+ }
181
+ else if (from !== to) {
182
+ sets.push("wait_detail = NULL");
183
+ }
176
184
  if (patch?.postMergeStatus !== undefined) {
177
185
  sets.push("post_merge_status = ?");
178
186
  values.push(patch.postMergeStatus);
@@ -210,6 +218,7 @@ export class SqliteStore {
210
218
  ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
211
219
  last_failed_base_sha = NULL,
212
220
  spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
221
+ wait_detail = NULL,
213
222
  post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
214
223
  updated_at = ?
215
224
  WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
@@ -17,6 +17,7 @@ export async function mergeHead(ctx, entry) {
17
17
  ? "required approval missing"
18
18
  : `review gate not satisfied (${prStatus.reviewDecision ?? "unknown"})`;
19
19
  emit(ctx, entry, "merge_waiting_approval", { detail });
20
+ ctx.store.transition(entry.id, "merging", { waitDetail: detail }, detail);
20
21
  return;
21
22
  }
22
23
  if (prStatus.headSha !== entry.headSha) {
@@ -55,6 +56,7 @@ export async function mergeHead(ctx, entry) {
55
56
  // Throwing the spec away here would force a rebuild + re-run of
56
57
  // CI that produces the same tree — wasting the speculation.
57
58
  emit(ctx, entry, "merge_waiting_main", { detail: "main checks still pending, holding merge" });
59
+ ctx.store.transition(entry.id, "merging", { waitDetail: "main checks still pending, holding merge" }, "main checks still pending, holding merge");
58
60
  return;
59
61
  }
60
62
  if (mainStatus === "fail") {
@@ -7,6 +7,16 @@ function matchGlob(pattern, value) {
7
7
  const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
8
8
  return regex.test(value);
9
9
  }
10
+ function getLatestEvictedEntry(entries, prNumber) {
11
+ const evicted = entries.filter((entry) => entry.prNumber === prNumber && entry.status === "evicted");
12
+ evicted.sort((left, right) => {
13
+ const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
14
+ if (updatedDelta !== 0)
15
+ return updatedDelta;
16
+ return right.position - left.position;
17
+ });
18
+ return evicted[0];
19
+ }
10
20
  export class MergeStewardQueueCommands {
11
21
  config;
12
22
  policy;
@@ -48,6 +58,7 @@ export class MergeStewardQueueCommands {
48
58
  specBranch: null,
49
59
  specSha: null,
50
60
  specBasedOn: null,
61
+ waitDetail: null,
51
62
  postMergeStatus: null,
52
63
  postMergeSha: null,
53
64
  postMergeSummary: null,
@@ -100,6 +111,11 @@ export class MergeStewardQueueCommands {
100
111
  this.logger.debug({ prNumber }, "PR already queued, skipping admission");
101
112
  return false;
102
113
  }
114
+ const latestEvicted = getLatestEvictedEntry(this.store.listAll(this.config.repoId), prNumber);
115
+ if (latestEvicted?.headSha === headSha) {
116
+ this.logger.debug({ prNumber, headSha, evictedEntryId: latestEvicted.id }, "PR head matches latest evicted entry, skipping admission until a new push");
117
+ return false;
118
+ }
103
119
  try {
104
120
  const status = await this.github.getStatus(prNumber);
105
121
  if (!status.reviewApproved) {
package/dist/store.d.ts CHANGED
@@ -11,7 +11,7 @@ export interface QueueStore {
11
11
  listActive(repoId: string): QueueEntry[];
12
12
  listAll(repoId: string): QueueEntry[];
13
13
  insert(entry: QueueEntry): void;
14
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
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
17
  insertIncident(incident: IncidentRecord): void;
package/dist/types.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface QueueEntry {
38
38
  specSha: string | null;
39
39
  /** Entry ID of the previous entry this spec branch is based on. Null if based on main. */
40
40
  specBasedOn: string | null;
41
+ /** Optional human-facing wait reason when the entry is blocked inside its current state. */
42
+ waitDetail?: string | null;
41
43
  /** Post-merge verification status for the landed commit on main. */
42
44
  postMergeStatus: PostMergeStatus | null;
43
45
  /** Commit SHA used when checking post-merge status. */
@@ -17,7 +17,7 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
17
17
  headPrNumber,
18
18
  queueBlock,
19
19
  });
20
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status, entry) }), _jsx(Text, { dimColor: true, children: `· ${nextStepLabel(entry.status, entry)}` })] }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: entry.branch }), _jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "tested as" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: `(${shortSha(entry.specSha)} ← ${entry.specBasedOn ? "PR ahead" : "main"})` })] })), entry.retryAttempts > 0 && (_jsxs(Text, { color: "yellow", children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? `waiting for ${queueBlock.baseBranch} checks`, "."] }), _jsx(Text, { dimColor: true, children: queueBlock.missingRequiredChecks.length > 0
20
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status, entry), children: humanStatus(entry.status, entry) }), _jsx(Text, { dimColor: true, children: `· ${nextStepLabel(entry.status, entry)}` })] }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: entry.branch }), _jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "tested as" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: `(${shortSha(entry.specSha)} ← ${entry.specBasedOn ? "PR ahead" : "main"})` })] })), entry.retryAttempts > 0 && (_jsxs(Text, { color: "yellow", children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? `waiting for ${queueBlock.baseBranch} checks`, "."] }), _jsx(Text, { dimColor: true, children: queueBlock.missingRequiredChecks.length > 0
21
21
  ? `Fix ${queueBlock.baseBranch} first, then reconcile this repo to resume the queue.`
22
22
  : `Will resume automatically once ${queueBlock.baseBranch} is healthy.` })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "GitHub Policy" }), _jsxs(Text, { dimColor: true, children: ["Required checks: ", githubPolicy.requiredChecks.length > 0 ? githubPolicy.requiredChecks.join(", ") : "(none)"] }), githubPolicy.fetchedAt ? _jsxs(Text, { dimColor: true, children: ["Fetched: ", githubPolicy.fetchedAt] }) : null, githubPolicy.lastRefreshReason ? (_jsxs(Text, { dimColor: true, children: ["Last refresh: ", githubPolicy.lastRefreshReason, githubPolicy.lastRefreshChanged === null ? "" : githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"] })) : null] }), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), incidents.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4) }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4) }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
23
23
  }
@@ -75,7 +75,7 @@ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
75
75
  return `#${entry.prNumber}${ci.icon}`;
76
76
  }).join(" ")
77
77
  : "empty";
78
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label })] }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(snapshot, compact)}` }), compact ? null : _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: compact
78
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label })] }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(snapshot, compact)}` }), compact ? null : _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: compact
79
79
  ? `Queue: ${truncate(chainText, width - 10)}`
80
80
  : `Queue: main -> ${truncate(chainText, width - 18)}` }), compact ? null : queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, compact ? null : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["GitHub required checks: ", snapshot.githubPolicy.requiredChecks.length > 0 ? snapshot.githubPolicy.requiredChecks.join(", ") : "(none)"] }), snapshot.githubPolicy.fetchedAt ? (_jsxs(Text, { dimColor: true, children: ["Policy fetched ", relativeTime(snapshot.githubPolicy.fetchedAt), " ago", snapshot.githubPolicy.lastRefreshReason
81
81
  ? ` via ${snapshot.githubPolicy.lastRefreshReason}${snapshot.githubPolicy.lastRefreshChanged === null ? "" : snapshot.githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"}`
@@ -86,8 +86,8 @@ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
86
86
  const statusColor = entry.status === "evicted" ? "red" : ci.color;
87
87
  const baseStatusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
88
88
  const statusText = entry.status === "merged" ? `${baseStatusText}: ${postMergeStatusLine(entry)}` : baseStatusText;
89
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? ">" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { color: statusColor, children: ` ${statusText}` }), compact ? null : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` }), isHead ? _jsx(Text, { dimColor: true, children: ` · ${humanStatus(entry.status, entry)} head` }) : null] }))] }), (isSelected || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: truncate(describeEntry(entry, { isHead, queueBlockSummary }), compact ? width - 4 : 110) }) }))] }, entry.id));
89
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? ">" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { color: statusColor, children: ` ${statusText}` }), compact ? null : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` }), isHead ? _jsx(Text, { dimColor: true, children: ` · ${humanStatus(entry.status, entry)} head` }) : null] }))] }), (isSelected || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: describeEntry(entry, { isHead, queueBlockSummary }) }) }))] }, entry.id));
90
90
  }))] }), selectedEntry ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), compact ? (_jsx(Text, { dimColor: true, children: `head ${shortSha(selectedEntry.headSha)} · base ${shortSha(selectedEntry.baseSha)}` })) : (_jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] })), compact ? null : _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), selectedEntry.status === "merged" ? (_jsx(Text, { dimColor: true, children: `Post-merge: ${postMergeStatusLine(selectedEntry)}${selectedEntry.postMergeCheckedAt ? ` · ${relativeTime(selectedEntry.postMergeCheckedAt)} ago` : ""}` })) : null, detail?.incidents.length
91
91
  ? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
92
- : null] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: truncate(formatEventNarrative(event, selectedEntry ? { prNumber: selectedEntry.prNumber } : {}), compact ? Math.max(12, width - 8) : 106) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
92
+ : null] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventNarrative(event, selectedEntry ? { prNumber: selectedEntry.prNumber } : {}) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
93
93
  }
@@ -2,10 +2,17 @@ import type { CheckResult, PostMergeStatus, QueueBlockState, QueueEntryStatus, Q
2
2
  export declare function isPendingMainVerification(block: QueueBlockState | null | undefined): boolean;
3
3
  export declare function shortSha(value: string | null | undefined): string;
4
4
  export declare function relativeTime(iso: string | null | undefined): string;
5
- export declare function statusColor(status: QueueEntryStatus): "yellow" | "cyan" | "green" | "red" | "gray";
5
+ export declare function mergeWaitState(entry: {
6
+ status: QueueEntryStatus;
7
+ waitDetail?: string | null | undefined;
8
+ } | undefined): "approval" | "main" | null;
9
+ export declare function statusColor(status: QueueEntryStatus, entry?: {
10
+ waitDetail?: string | null | undefined;
11
+ }): "yellow" | "cyan" | "green" | "red" | "gray";
6
12
  export declare function humanStatus(status: QueueEntryStatus, entry?: {
7
13
  lastFailedBaseSha: string | null;
8
14
  specBranch: string | null;
15
+ waitDetail?: string | null | undefined;
9
16
  }): string;
10
17
  export declare function queueProgress(status: QueueEntryStatus): {
11
18
  current: number;
@@ -14,6 +21,7 @@ export declare function queueProgress(status: QueueEntryStatus): {
14
21
  export declare function nextStepLabel(status: QueueEntryStatus, entry?: {
15
22
  lastFailedBaseSha: string | null;
16
23
  specBasedOn: string | null;
24
+ waitDetail?: string | null | undefined;
17
25
  }): string;
18
26
  /** Describe the spec chain for a queue entry. */
19
27
  export declare function specChainLabel(entry: {
@@ -44,6 +52,7 @@ export declare function summarizeCheckNames(checks: CheckResult[], limit?: numbe
44
52
  export declare function ciStatusIcon(entry: {
45
53
  status: QueueEntryStatus;
46
54
  ciRunId: string | null;
55
+ waitDetail?: string | null | undefined;
47
56
  postMergeStatus?: PostMergeStatus | null;
48
57
  }): {
49
58
  icon: string;
@@ -35,14 +35,28 @@ export function relativeTime(iso) {
35
35
  const days = Math.floor(hours / 24);
36
36
  return `${days}d`;
37
37
  }
38
- export function statusColor(status) {
38
+ export function mergeWaitState(entry) {
39
+ if (!entry || entry.status !== "merging" || !entry.waitDetail) {
40
+ return null;
41
+ }
42
+ const detail = entry.waitDetail.toLowerCase();
43
+ if (detail.includes("approval") || detail.includes("review")) {
44
+ return "approval";
45
+ }
46
+ if (detail.includes("main")) {
47
+ return "main";
48
+ }
49
+ return null;
50
+ }
51
+ export function statusColor(status, entry) {
39
52
  switch (status) {
40
53
  case "queued":
41
54
  return "yellow";
42
55
  case "preparing_head":
43
56
  case "validating":
44
- case "merging":
45
57
  return "cyan";
58
+ case "merging":
59
+ return mergeWaitState(entry ? { status, waitDetail: entry.waitDetail } : undefined) ? "yellow" : "cyan";
46
60
  case "merged":
47
61
  return "green";
48
62
  case "evicted":
@@ -62,6 +76,12 @@ export function humanStatus(status, entry) {
62
76
  case "validating":
63
77
  return "testing";
64
78
  case "merging":
79
+ if (mergeWaitState(entry ? { status, waitDetail: entry.waitDetail } : undefined) === "approval") {
80
+ return "waiting for approval";
81
+ }
82
+ if (mergeWaitState(entry ? { status, waitDetail: entry.waitDetail } : undefined) === "main") {
83
+ return "waiting for main";
84
+ }
65
85
  return "merging";
66
86
  case "merged":
67
87
  return "merged";
@@ -99,6 +119,12 @@ export function nextStepLabel(status, entry) {
99
119
  ? "CI running, tested together with PRs ahead"
100
120
  : "CI running on combined changes";
101
121
  case "merging":
122
+ if (mergeWaitState(entry ? { status, waitDetail: entry.waitDetail } : undefined) === "approval") {
123
+ return "waiting for GitHub approval before landing on main";
124
+ }
125
+ if (mergeWaitState(entry ? { status, waitDetail: entry.waitDetail } : undefined) === "main") {
126
+ return "waiting for main checks to settle before landing";
127
+ }
102
128
  return "landing on main";
103
129
  case "merged":
104
130
  return "landed on main";
@@ -179,6 +205,13 @@ export function formatEventNarrative(event, options = {}) {
179
205
  return withDetail(`${prPrefix}started running CI.`, event.detail);
180
206
  }
181
207
  if (event.toStatus === "merging") {
208
+ const detail = event.detail?.toLowerCase() ?? "";
209
+ if (detail.includes("approval") || detail.includes("review")) {
210
+ return withDetail(`${prPrefix}is waiting for approval before merging to main.`, event.detail);
211
+ }
212
+ if (detail.includes("main")) {
213
+ return withDetail(`${prPrefix}is waiting for main verification before merging.`, event.detail);
214
+ }
182
215
  return withDetail(`${prPrefix}passed CI and started merging to main.`, event.detail);
183
216
  }
184
217
  if (event.toStatus === "merged") {
@@ -270,7 +303,8 @@ export function ciStatusIcon(entry) {
270
303
  icon: postMergeSymbol(entry.postMergeStatus),
271
304
  color: postMergeColor(entry.postMergeStatus),
272
305
  };
273
- case "merging": return { icon: "\u2713", color: "cyan" }; // ✓
306
+ case "merging":
307
+ return mergeWaitState(entry) ? { icon: QUEUE_SYMBOLS.inProgress, color: "yellow" } : { icon: "\u2713", color: "cyan" };
274
308
  case "evicted": return { icon: "\u2717", color: "red" }; // ✗
275
309
  case "dequeued": return { icon: "\u2012", color: "gray" }; // ‒
276
310
  case "validating":
@@ -108,7 +108,15 @@ export function buildExternalRepairObservations(detail, options) {
108
108
  });
109
109
  }
110
110
  else if (entry.status === "merging") {
111
- observations.push({ tone: "info", text: "CI passed. Landing on main." });
111
+ if (entry.waitDetail?.toLowerCase().includes("approval") || entry.waitDetail?.toLowerCase().includes("review")) {
112
+ observations.push({ tone: "warn", text: "CI passed. Merge is blocked until GitHub approval returns." });
113
+ }
114
+ else if (entry.waitDetail?.toLowerCase().includes("main")) {
115
+ observations.push({ tone: "info", text: "CI passed. Waiting for main verification before landing." });
116
+ }
117
+ else {
118
+ observations.push({ tone: "info", text: "CI passed. Landing on main." });
119
+ }
112
120
  }
113
121
  return observations.slice(0, 4);
114
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
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": {