merge-steward 0.12.0 → 0.13.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
@@ -24,6 +24,10 @@ export function ensureSchema(connection) {
24
24
  spec_branch TEXT,
25
25
  spec_sha TEXT,
26
26
  spec_based_on TEXT,
27
+ post_merge_status TEXT,
28
+ post_merge_sha TEXT,
29
+ post_merge_summary TEXT,
30
+ post_merge_checked_at TEXT,
27
31
  enqueued_at TEXT NOT NULL,
28
32
  updated_at TEXT NOT NULL
29
33
  )
@@ -64,6 +68,16 @@ export function ensureSchema(connection) {
64
68
  connection.exec(`
65
69
  CREATE INDEX IF NOT EXISTS idx_queue_events_entry
66
70
  ON queue_events(entry_id, id)
71
+ `);
72
+ ensureColumn(connection, "queue_entries", "post_merge_status", "TEXT");
73
+ ensureColumn(connection, "queue_entries", "post_merge_sha", "TEXT");
74
+ ensureColumn(connection, "queue_entries", "post_merge_summary", "TEXT");
75
+ ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
76
+ connection.exec(`
77
+ UPDATE queue_entries
78
+ SET post_merge_status = 'pending'
79
+ WHERE status = 'merged'
80
+ AND post_merge_status IS NULL
67
81
  `);
68
82
  // Must match TERMINAL_STATUSES in types.ts: merged, evicted, dequeued
69
83
  connection.exec(`
@@ -72,3 +86,18 @@ export function ensureSchema(connection) {
72
86
  WHERE status NOT IN ('merged', 'evicted', 'dequeued')
73
87
  `);
74
88
  }
89
+ function hasColumn(connection, table, column) {
90
+ const rows = connection.prepare(`PRAGMA table_info(${table})`).all();
91
+ for (const row of rows) {
92
+ if (String(row.name) === column) {
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+ function ensureColumn(connection, table, column, type) {
99
+ if (hasColumn(connection, table, column)) {
100
+ return;
101
+ }
102
+ connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
103
+ }
@@ -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">>, detail?: string): 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;
16
16
  dequeue(entryId: string): void;
17
17
  updateHead(entryId: string, newHeadSha: string): void;
18
18
  insertIncident(incident: IncidentRecord): void;
@@ -22,6 +22,12 @@ 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
+ postMergeStatus: row.post_merge_status === null || row.post_merge_status === undefined
26
+ ? null
27
+ : String(row.post_merge_status),
28
+ postMergeSha: row.post_merge_sha === null || row.post_merge_sha === undefined ? null : String(row.post_merge_sha),
29
+ postMergeSummary: row.post_merge_summary === null || row.post_merge_summary === undefined ? null : String(row.post_merge_summary),
30
+ postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
25
31
  enqueuedAt: String(row.enqueued_at),
26
32
  updatedAt: String(row.updated_at),
27
33
  };
@@ -116,8 +122,10 @@ export class SqliteStore {
116
122
  this.conn.prepare(`INSERT INTO queue_entries
117
123
  (id, repo_id, pr_number, branch, head_sha, base_sha, status, position,
118
124
  priority, generation, ci_run_id, ci_retries, retry_attempts,
119
- max_retries, last_failed_base_sha, issue_key, enqueued_at, updated_at)
120
- 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, entry.enqueuedAt, entry.updatedAt);
125
+ max_retries, last_failed_base_sha, issue_key,
126
+ post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
127
+ 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);
121
129
  this.writeEvent(entry.id, null, entry.status);
122
130
  })();
123
131
  }
@@ -165,6 +173,22 @@ export class SqliteStore {
165
173
  sets.push("spec_based_on = ?");
166
174
  values.push(patch.specBasedOn);
167
175
  }
176
+ if (patch?.postMergeStatus !== undefined) {
177
+ sets.push("post_merge_status = ?");
178
+ values.push(patch.postMergeStatus);
179
+ }
180
+ if (patch?.postMergeSha !== undefined) {
181
+ sets.push("post_merge_sha = ?");
182
+ values.push(patch.postMergeSha);
183
+ }
184
+ if (patch?.postMergeSummary !== undefined) {
185
+ sets.push("post_merge_summary = ?");
186
+ values.push(patch.postMergeSummary);
187
+ }
188
+ if (patch?.postMergeCheckedAt !== undefined) {
189
+ sets.push("post_merge_checked_at = ?");
190
+ values.push(patch.postMergeCheckedAt);
191
+ }
168
192
  this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
169
193
  this.writeEvent(entryId, from, to, detail);
170
194
  })();
@@ -186,6 +210,7 @@ export class SqliteStore {
186
210
  ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
187
211
  last_failed_base_sha = NULL,
188
212
  spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
213
+ post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
189
214
  updated_at = ?
190
215
  WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
191
216
  this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
@@ -1,5 +1,6 @@
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
+ import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
3
4
  export async function mergeHead(ctx, entry) {
4
5
  emit(ctx, entry, "merge_revalidating");
5
6
  const prStatus = await ctx.github.getStatus(entry.prNumber);
@@ -99,7 +100,17 @@ export async function mergeHead(ctx, entry) {
99
100
  return;
100
101
  }
101
102
  emit(ctx, entry, "merge_succeeded");
102
- ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
103
+ const verificationResult = await verifyPostMergeStatus(ctx, {
104
+ ...entry,
105
+ postMergeSha: entry.specSha ?? entry.headSha,
106
+ });
107
+ ctx.store.transition(entry.id, "merged", {
108
+ ...CLEAN_SPEC,
109
+ postMergeStatus: verificationResult.postMergeStatus,
110
+ postMergeSha: verificationResult.postMergeSha,
111
+ postMergeSummary: verificationResult.postMergeSummary,
112
+ postMergeCheckedAt: new Date().toISOString(),
113
+ }, `spec pushed to main; ${verificationResult.postMergeSummary}`);
103
114
  await cleanupSpec(ctx, entry);
104
115
  try {
105
116
  await ctx.github.deleteBranch(entry.prNumber);
@@ -0,0 +1,8 @@
1
+ import type { PostMergeStatus, QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export interface PostMergeVerificationResult {
4
+ postMergeStatus: PostMergeStatus;
5
+ postMergeSummary: string;
6
+ postMergeSha: string;
7
+ }
8
+ export declare function verifyPostMergeStatus(ctx: ReconcileContext, entry: QueueEntry): Promise<PostMergeVerificationResult>;
@@ -0,0 +1,85 @@
1
+ function normalizeCheckName(name) {
2
+ return name.trim().toLowerCase();
3
+ }
4
+ function joinItems(items) {
5
+ if (items.length === 0) {
6
+ return "";
7
+ }
8
+ return items.slice(0, 5).join(", ");
9
+ }
10
+ function evaluateChecks(requiredChecks, checks) {
11
+ const isPassingConclusion = (conclusion) => conclusion === "success";
12
+ if (requiredChecks.length > 0) {
13
+ const byName = new Map(checks.map((check) => [normalizeCheckName(check.name), check]));
14
+ const failed = [];
15
+ const pending = [];
16
+ for (const required of requiredChecks) {
17
+ const check = byName.get(normalizeCheckName(required));
18
+ if (!check) {
19
+ pending.push(required);
20
+ continue;
21
+ }
22
+ if (check.conclusion === "pending") {
23
+ pending.push(check.name);
24
+ }
25
+ else if (!isPassingConclusion(check.conclusion)) {
26
+ failed.push(check.name);
27
+ }
28
+ }
29
+ if (failed.length > 0) {
30
+ return {
31
+ postMergeStatus: "fail",
32
+ summary: failed.length === 1 ? `check failed: ${failed[0]}` : `checks failed: ${joinItems(failed)}`,
33
+ };
34
+ }
35
+ if (pending.length > 0) {
36
+ return {
37
+ postMergeStatus: "pending",
38
+ summary: pending.length === 1 ? `check pending: ${pending[0]}` : `checks pending: ${joinItems(pending)}`,
39
+ };
40
+ }
41
+ return { postMergeStatus: "pass", summary: "all required checks passed" };
42
+ }
43
+ const pending = checks.filter((check) => check.conclusion === "pending").map((check) => check.name);
44
+ const failed = checks.filter((check) => !isPassingConclusion(check.conclusion)).map((check) => check.name);
45
+ if (checks.length === 0) {
46
+ return { postMergeStatus: "unknown", summary: "no checks found yet" };
47
+ }
48
+ if (failed.length > 0) {
49
+ return {
50
+ postMergeStatus: "fail",
51
+ summary: failed.length === 1 ? `check failed: ${failed[0]}` : `checks failed: ${joinItems(failed)}`,
52
+ };
53
+ }
54
+ if (pending.length > 0) {
55
+ return {
56
+ postMergeStatus: "pending",
57
+ summary: pending.length === 1 ? `check pending: ${pending[0]}` : `checks pending: ${joinItems(pending)}`,
58
+ };
59
+ }
60
+ return { postMergeStatus: "pass", summary: "all checks passed" };
61
+ }
62
+ export async function verifyPostMergeStatus(ctx, entry) {
63
+ const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
64
+ const requiredChecks = ctx.policy.getRequiredChecks();
65
+ if (!postMergeSha) {
66
+ return {
67
+ postMergeStatus: "unknown",
68
+ postMergeSummary: "post-merge SHA is unknown",
69
+ postMergeSha: entry.headSha,
70
+ };
71
+ }
72
+ let checks;
73
+ try {
74
+ checks = await ctx.github.listChecksForRef(postMergeSha);
75
+ }
76
+ catch {
77
+ checks = [];
78
+ }
79
+ const evaluation = evaluateChecks(requiredChecks, checks);
80
+ return {
81
+ postMergeStatus: evaluation.postMergeStatus,
82
+ postMergeSummary: evaluation.summary,
83
+ postMergeSha,
84
+ };
85
+ }
@@ -1,5 +1,6 @@
1
1
  import { CLEAN_SPEC, emit } from "./reconciler-core.js";
2
2
  import { cleanupSpec } from "./reconciler-evict.js";
3
+ import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
3
4
  export async function sanitizeEntry(ctx, entry) {
4
5
  const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
5
6
  if (canonical && canonical.id !== entry.id) {
@@ -13,11 +14,21 @@ export async function sanitizeEntry(ctx, entry) {
13
14
  try {
14
15
  const prStatus = await ctx.github.getStatus(entry.prNumber);
15
16
  if (prStatus.merged) {
17
+ const verification = await verifyPostMergeStatus(ctx, {
18
+ ...entry,
19
+ postMergeSha: entry.headSha,
20
+ });
16
21
  emit(ctx, entry, "merge_external", {
17
22
  detail: `PR #${entry.prNumber} already merged on GitHub (detected in sanitize)`,
18
23
  });
19
24
  await cleanupSpec(ctx, entry);
20
- ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged externally (sanitize)");
25
+ ctx.store.transition(entry.id, "merged", {
26
+ ...CLEAN_SPEC,
27
+ postMergeStatus: verification.postMergeStatus,
28
+ postMergeSha: verification.postMergeSha,
29
+ postMergeSummary: verification.postMergeSummary,
30
+ postMergeCheckedAt: new Date().toISOString(),
31
+ }, "merged externally (sanitize)");
21
32
  return true;
22
33
  }
23
34
  if (!prStatus.mergeable && !prStatus.merged) {
@@ -6,6 +6,7 @@ import { checkValidation } from "./reconciler-validate.js";
6
6
  import { mergeHead } from "./reconciler-merge.js";
7
7
  import { cleanupSpec } from "./reconciler-evict.js";
8
8
  import { INVALIDATION_PATCH } from "./invalidation.js";
9
+ import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
9
10
  // ─── Main reconcile loop ────────────────────────────────────────
10
11
  export async function reconcile(ctx) {
11
12
  const allActive = ctx.store.listActive(ctx.repoId);
@@ -74,5 +75,42 @@ export async function reconcile(ctx) {
74
75
  throw wrapped;
75
76
  }
76
77
  }
78
+ await verifyMergedEntriesPostPush(ctx);
79
+ }
80
+ async function verifyMergedEntriesPostPush(ctx) {
81
+ const mergedEntries = ctx.store.listAll(ctx.repoId).filter((entry) => entry.status === "merged");
82
+ for (const entry of mergedEntries) {
83
+ if (entry.postMergeStatus === "pass" || entry.postMergeStatus === "fail")
84
+ continue;
85
+ const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
86
+ if (!postMergeSha) {
87
+ continue;
88
+ }
89
+ emit(ctx, entry, "post_merge_verification_started", {
90
+ detail: `post-merge check on ${postMergeSha.slice(0, 8)}`,
91
+ });
92
+ const verification = await verifyPostMergeStatus(ctx, entry);
93
+ emit(ctx, entry, "post_merge_verification_completed", {
94
+ detail: `post-merge checks ${verification.postMergeStatus}: ${verification.postMergeSummary}`,
95
+ });
96
+ const now = new Date().toISOString();
97
+ const previousStatus = entry.postMergeStatus ?? "pending";
98
+ if (previousStatus !== verification.postMergeStatus
99
+ || entry.postMergeSummary !== verification.postMergeSummary
100
+ || entry.postMergeCheckedAt === null
101
+ || entry.postMergeCheckedAt === undefined) {
102
+ ctx.store.transition(entry.id, "merged", {
103
+ postMergeStatus: verification.postMergeStatus,
104
+ postMergeSha: verification.postMergeSha,
105
+ postMergeSummary: verification.postMergeSummary,
106
+ postMergeCheckedAt: now,
107
+ }, `post-merge verification: ${verification.postMergeStatus}`);
108
+ }
109
+ else {
110
+ ctx.store.transition(entry.id, "merged", {
111
+ postMergeCheckedAt: now,
112
+ }, "post-merge verification");
113
+ }
114
+ }
77
115
  }
78
116
  // phase implementations live in dedicated modules now
@@ -48,6 +48,10 @@ export class MergeStewardQueueCommands {
48
48
  specBranch: null,
49
49
  specSha: null,
50
50
  specBasedOn: null,
51
+ postMergeStatus: null,
52
+ postMergeSha: null,
53
+ postMergeSummary: null,
54
+ postMergeCheckedAt: null,
51
55
  enqueuedAt: new Date().toISOString(),
52
56
  updatedAt: new Date().toISOString(),
53
57
  };
@@ -156,7 +160,12 @@ export class MergeStewardQueueCommands {
156
160
  acknowledgeExternalMerge(prNumber) {
157
161
  const entry = this.store.getEntryByPR(this.config.repoId, prNumber);
158
162
  if (entry) {
159
- this.store.transition(entry.id, "merged");
163
+ this.store.transition(entry.id, "merged", {
164
+ postMergeStatus: "pending",
165
+ postMergeSha: entry.headSha,
166
+ postMergeSummary: "external merge detected, verification pending",
167
+ postMergeCheckedAt: new Date().toISOString(),
168
+ });
160
169
  this.invalidateDownstreamOf(entry);
161
170
  this.logger.info({ prNumber, entryId: entry.id }, "External merge acknowledged");
162
171
  }
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">>, detail?: string): 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;
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
@@ -13,6 +13,7 @@
13
13
  */
14
14
  export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued";
15
15
  export declare const TERMINAL_STATUSES: QueueEntryStatus[];
16
+ export type PostMergeStatus = "pending" | "pass" | "fail" | "unknown";
16
17
  export interface QueueEntry {
17
18
  id: string;
18
19
  repoId: string;
@@ -37,6 +38,14 @@ export interface QueueEntry {
37
38
  specSha: string | null;
38
39
  /** Entry ID of the previous entry this spec branch is based on. Null if based on main. */
39
40
  specBasedOn: string | null;
41
+ /** Post-merge verification status for the landed commit on main. */
42
+ postMergeStatus: PostMergeStatus | null;
43
+ /** Commit SHA used when checking post-merge status. */
44
+ postMergeSha: string | null;
45
+ /** Optional one-line summary of post-merge checks. */
46
+ postMergeSummary: string | null;
47
+ /** Timestamp of the most recent post-merge verification attempt. */
48
+ postMergeCheckedAt: string | null;
40
49
  enqueuedAt: string;
41
50
  updatedAt: string;
42
51
  }
@@ -181,7 +190,7 @@ export interface QueueConfig {
181
190
  flakyRetries: number;
182
191
  pollIntervalMs: number;
183
192
  }
184
- 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" | "sanitized_closed" | "sanitized_duplicate" | "branch_unreachable";
193
+ 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" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "branch_unreachable";
185
194
  export interface ReconcileEvent {
186
195
  at: string;
187
196
  entryId: string;
@@ -39,6 +39,6 @@ export function OverviewView({ repos, selectedRepoId, gatewayError }) {
39
39
  }).join(" ")
40
40
  : compact ? "empty" : "queue is empty"
41
41
  : "queue data unavailable";
42
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: truncate(repo.repoId, idWidth) }), compact ? null : _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot, compact)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, chainWidth)}` }) }), compact ? null : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) }))] }, repo.repoId));
42
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? ">" : " " }), _jsx(Text, { bold: true, children: truncate(repo.repoId, idWidth) }), compact ? null : _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot, compact)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, chainWidth)}` }) }), compact ? null : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) }))] }, repo.repoId));
43
43
  }) }))] }));
44
44
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text, useStdout } from "ink";
3
3
  import { buildDisplayEntries } from "./display-filter.js";
4
4
  import { describeEntry, getChainEntries, getRepoHealth, projectStatsSummary, runtimeSummary } from "./dashboard-model.js";
5
- import { ciStatusIcon, formatEventNarrative, humanStatus, relativeTime, shortSha, specChainLabel, summarizeQueueBlock, truncate } from "./format.js";
5
+ import { ciStatusIcon, formatEventNarrative, humanStatus, postMergeStatusLine, relativeTime, shortSha, specChainLabel, summarizeQueueBlock, truncate } from "./format.js";
6
6
  function clampWindowStart(selectedIndex, itemCount, maxItems) {
7
7
  if (itemCount <= maxItems) {
8
8
  return 0;
@@ -84,9 +84,10 @@ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
84
84
  const isHead = snapshot.summary.headEntryId === entry.id;
85
85
  const isSelected = entry.id === selectedEntryId;
86
86
  const statusColor = entry.status === "evicted" ? "red" : ci.color;
87
- const statusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
88
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? "\u25b8" : " " }), _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
- }))] }), 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)}` }), detail?.incidents.length
87
+ const baseStatusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
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));
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
90
91
  ? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
91
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), compact ? Math.max(12, width - 8) : 106) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
92
93
  }
@@ -3,14 +3,17 @@ 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, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
6
+ import { ciStatusIcon, formatEventSummary, humanStatus, isPendingMainVerification, nextStepLabel, postMergeStatusLine, 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);
10
10
  if (isTerminal) {
11
11
  const icon = entry.status === "merged" ? "\u2713" : "\u2717";
12
12
  const iconColor = entry.status === "merged" ? "green" : "red";
13
- 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)}` })] }));
13
+ const statusSuffix = entry.status === "merged"
14
+ ? ` \u00b7 ${postMergeStatusLine(entry)}`
15
+ : "";
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}` })] }));
14
17
  }
15
18
  const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
16
19
  const pendingMainVerification = blockedOnMain && isPendingMainVerification(queueBlock);
@@ -25,7 +28,7 @@ function QueueRow({ entry, selected, isHead, queueBlock, }) {
25
28
  : nextStepLabel(entry.status, entry);
26
29
  // Only show retry counter when retries have actually happened.
27
30
  const retryNote = entry.retryAttempts > 0 ? ` \u00b7 retry ${entry.retryAttempts}/${entry.maxRetries}` : "";
28
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { ...(isHead ? { color: "green" } : {}), bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: color, children: status }), _jsx(Text, { dimColor: true, children: ` \u00b7 ${nextStep}${retryNote}` })] }));
31
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? ">" : " " }), _jsx(Text, { ...(isHead ? { color: "green" } : {}), bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: color, children: status }), _jsx(Text, { dimColor: true, children: ` \u00b7 ${nextStep}${retryNote}` })] }));
29
32
  }
30
33
  export function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
31
34
  const { stdout } = useStdout();
@@ -38,7 +41,7 @@ export function QueueListView({ entries, allEntries, selectedEntryId, recentEven
38
41
  const chainEntries = useMemo(() => buildChainEntries(allEntries), [allEntries]);
39
42
  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) => {
40
43
  const ci = ciStatusIcon(entry);
41
- 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));
44
+ 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));
42
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
43
46
  ? `Head PR #${queueBlock.headPrNumber ?? "?"} is waiting for an operator fix on ${queueBlock.baseBranch}.`
44
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}`))))] })] }));
@@ -1,5 +1,5 @@
1
1
  import { buildChainEntries, buildDisplayEntries } from "./display-filter.js";
2
- import { formatDuration, humanStatus, nextStepLabel, relativeTime, summarizeQueueBlock } from "./format.js";
2
+ import { formatDuration, humanStatus, nextStepLabel, postMergeStatusLine, relativeTime, summarizeQueueBlock } from "./format.js";
3
3
  const RECENT_EVICTION_MS = 60 * 60 * 1_000;
4
4
  const STUCK_ENTRY_MS = 25 * 60 * 1_000;
5
5
  const OFFLINE_STALE_MS = 10 * 1_000;
@@ -285,6 +285,10 @@ export function describeEntry(entry, options) {
285
285
  if (options.isHead && options.queueBlockSummary) {
286
286
  return options.queueBlockSummary;
287
287
  }
288
+ if (entry.status === "merged") {
289
+ return `Landed on main. ${postMergeStatusLine(entry)}`
290
+ + (entry.postMergeCheckedAt ? ` (${relativeTime(entry.postMergeCheckedAt)} ago).` : ".");
291
+ }
288
292
  if (entry.status === "queued") {
289
293
  if (entry.position > 1) {
290
294
  return `Waiting behind ${entry.position - 1} earlier PRs.`;
@@ -1,4 +1,4 @@
1
- import type { CheckResult, QueueBlockState, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
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
4
  export declare function relativeTime(iso: string | null | undefined): string;
@@ -32,11 +32,17 @@ export declare function formatEventNarrative(event: QueueEventRecord | QueueEven
32
32
  export declare function formatDuration(ms: number): string;
33
33
  export declare function progressBar(current: number, total: number, width: number): string;
34
34
  export declare function truncate(value: string, width: number): string;
35
+ export declare function postMergeLabel(status: PostMergeStatus | null | undefined): string;
36
+ export declare function postMergeStatusLine(entry: {
37
+ postMergeStatus?: PostMergeStatus | null;
38
+ postMergeSummary?: string | null;
39
+ }): string;
35
40
  export declare function summarizeCheckNames(checks: CheckResult[], limit?: number): string;
36
41
  /** CI status icon for the spec chain summary line. */
37
42
  export declare function ciStatusIcon(entry: {
38
43
  status: QueueEntryStatus;
39
44
  ciRunId: string | null;
45
+ postMergeStatus?: PostMergeStatus | null;
40
46
  }): {
41
47
  icon: string;
42
48
  color: string;
@@ -1,3 +1,10 @@
1
+ const QUEUE_SYMBOLS = {
2
+ inProgress: "\u25cf",
3
+ checkPassed: "\u2713",
4
+ checkFailed: "\u2717",
5
+ checkPending: "\u25cf",
6
+ checkUnknown: "\u25cb",
7
+ };
1
8
  export function isPendingMainVerification(block) {
2
9
  return Boolean(block
3
10
  && block.failingChecks.length === 0
@@ -211,6 +218,40 @@ export function truncate(value, width) {
211
218
  return value.slice(0, width);
212
219
  return `${value.slice(0, width - 1)}…`;
213
220
  }
221
+ function postMergeSymbol(status) {
222
+ if (status === "pass")
223
+ return QUEUE_SYMBOLS.checkPassed;
224
+ if (status === "fail")
225
+ return QUEUE_SYMBOLS.checkFailed;
226
+ if (status === "pending")
227
+ return QUEUE_SYMBOLS.checkPending;
228
+ return QUEUE_SYMBOLS.checkUnknown;
229
+ }
230
+ function postMergeColor(status) {
231
+ if (status === "pass")
232
+ return "green";
233
+ if (status === "fail")
234
+ return "red";
235
+ if (status === "pending")
236
+ return "yellow";
237
+ return "gray";
238
+ }
239
+ export function postMergeLabel(status) {
240
+ if (status === "pass")
241
+ return "checks passed";
242
+ if (status === "fail")
243
+ return "checks failed";
244
+ if (status === "pending")
245
+ return "checks running";
246
+ return "checks unknown";
247
+ }
248
+ export function postMergeStatusLine(entry) {
249
+ const status = postMergeLabel(entry.postMergeStatus);
250
+ if (!entry.postMergeSummary) {
251
+ return status;
252
+ }
253
+ return `${status}: ${entry.postMergeSummary}`;
254
+ }
214
255
  export function summarizeCheckNames(checks, limit = 3) {
215
256
  const names = [...new Set(checks.map((check) => check.name))];
216
257
  if (names.length === 0)
@@ -222,17 +263,21 @@ export function summarizeCheckNames(checks, limit = 3) {
222
263
  /** CI status icon for the spec chain summary line. */
223
264
  export function ciStatusIcon(entry) {
224
265
  switch (entry.status) {
225
- case "merged": return { icon: "\u2713", color: "green" }; // ✓
266
+ case "merged":
267
+ return {
268
+ icon: postMergeSymbol(entry.postMergeStatus),
269
+ color: postMergeColor(entry.postMergeStatus),
270
+ };
226
271
  case "merging": return { icon: "\u2713", color: "cyan" }; // ✓
227
272
  case "evicted": return { icon: "\u2717", color: "red" }; // ✗
228
273
  case "dequeued": return { icon: "\u2012", color: "gray" }; // ‒
229
274
  case "validating":
230
- return entry.ciRunId ? { icon: "\u25cf", color: "cyan" } : { icon: "\u25cb", color: "gray" }; // ● or ○
275
+ return entry.ciRunId ? { icon: QUEUE_SYMBOLS.inProgress, color: "cyan" } : { icon: QUEUE_SYMBOLS.checkUnknown, color: "gray" };
231
276
  case "preparing_head":
232
- return { icon: "\u25cb", color: "yellow" }; // ○
277
+ return { icon: QUEUE_SYMBOLS.checkUnknown, color: "yellow" };
233
278
  case "queued":
234
279
  default:
235
- return { icon: "\u25cb", color: "gray" }; // ○
280
+ return { icon: QUEUE_SYMBOLS.checkUnknown, color: "gray" };
236
281
  }
237
282
  }
238
283
  export function summarizeQueueBlock(block) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {