merge-steward 0.13.0 → 0.14.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.
@@ -34,19 +34,32 @@ export class GitHubActionsRunner {
34
34
  return "pending";
35
35
  const requiredChecks = this.getRequiredChecks();
36
36
  const normalizedRequired = requiredChecks.map(normalizeCheckName);
37
- const relevant = requiredChecks.length > 0
37
+ const hasRequired = requiredChecks.length > 0;
38
+ const relevant = hasRequired
38
39
  ? checkRuns.filter((c) => normalizedRequired.includes(normalizeCheckName(c.name)))
39
40
  : checkRuns;
40
41
  if (relevant.length === 0)
41
42
  return "pending";
42
43
  if (relevant.some((c) => c.status !== "completed"))
43
44
  return "pending";
44
- // REST API returns lowercase conclusions; any non-success completed
45
- // check is a failure. "skipped" is NOT accepted — if a check is
46
- // required it must actually execute. A skipped required check means
47
- // the CI workflow doesn't trigger on the spec branch, which would
48
- // let untested code through (see: MAF-49 incident).
49
- if (relevant.some((c) => c.conclusion !== "success" && c.conclusion !== "neutral"))
45
+ // REST API returns lowercase conclusions. For required checks,
46
+ // "skipped" is rejected — a gate job can report success while the
47
+ // underlying required job was skipped by a workflow branch filter,
48
+ // letting untested code through. When no required checks are
49
+ // configured, "skipped" is accepted as passing, matching
50
+ // mapRestConclusion in pr-client.ts — otherwise getMainStatus reports
51
+ // "fail" whenever main has conditional workflow jobs that are skipped
52
+ // (e.g. deploy-stage on main), even though listChecksForRef treats
53
+ // those same checks as success, producing a "main_broken" block with
54
+ // an empty failing-check list and stalling the queue.
55
+ const acceptSkipped = !hasRequired;
56
+ if (relevant.some((c) => {
57
+ if (c.conclusion === "success" || c.conclusion === "neutral")
58
+ return false;
59
+ if (acceptSkipped && c.conclusion === "skipped")
60
+ return false;
61
+ return true;
62
+ }))
50
63
  return "fail";
51
64
  return "pass";
52
65
  }
package/dist/install.js CHANGED
@@ -9,6 +9,17 @@ function renderTemplate(template, replacements) {
9
9
  const home = homedir();
10
10
  const user = basename(home);
11
11
  const layout = getMergeStewardPathLayout();
12
+ // When running from source (e.g. during tests or `npm run dev`) the dist/
13
+ // entrypoint isn't built yet. Fall back to the PATH-resolved binary so the
14
+ // systemd template still renders — the ExecStart will need `merge-steward`
15
+ // on PATH at service start, which is true for any normal install.
16
+ let cliEntry;
17
+ try {
18
+ cliEntry = getBuiltCliEntryPath();
19
+ }
20
+ catch {
21
+ cliEntry = "/usr/bin/env merge-steward";
22
+ }
12
23
  let rendered = template
13
24
  .replaceAll("/home/your-user", home)
14
25
  .replaceAll("your-user", user)
@@ -19,7 +30,7 @@ function renderTemplate(template, replacements) {
19
30
  .replaceAll("/home/your-user/.config/merge-steward/repos", layout.repoConfigDir)
20
31
  .replaceAll("/home/your-user/.local/state/merge-steward", layout.stateDir)
21
32
  .replaceAll("/home/your-user/.local/share/merge-steward", layout.dataDir)
22
- .replaceAll("/usr/bin/env merge-steward", `${getBuiltCliEntryPath()}`);
33
+ .replaceAll("/usr/bin/env merge-steward", cliEntry);
23
34
  if (replacements?.publicBaseUrl) {
24
35
  rendered = rendered.replaceAll("https://queue.example.com", replacements.publicBaseUrl);
25
36
  }
@@ -47,7 +47,17 @@ export async function mergeHead(ctx, entry) {
47
47
  }
48
48
  if (ctx.ci.getMainStatus) {
49
49
  const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
50
- if (mainStatus !== "pass") {
50
+ if (mainStatus === "pending") {
51
+ // Main is still verifying itself (typically post-merge CI from the
52
+ // previous entry). The spec was built on top of main-as-it-was
53
+ // (isFF check above guarantees main hasn't diverged) and already
54
+ // passed CI, so keep the spec + CI state and retry next tick.
55
+ // Throwing the spec away here would force a rebuild + re-run of
56
+ // CI that produces the same tree — wasting the speculation.
57
+ emit(ctx, entry, "merge_waiting_main", { detail: "main checks still pending, holding merge" });
58
+ return;
59
+ }
60
+ if (mainStatus === "fail") {
51
61
  try {
52
62
  const refresh = await ctx.policy.refreshOnIssue("main_unhealthy_at_merge");
53
63
  if (refresh.attempted && refresh.changed) {
@@ -101,7 +101,7 @@ export class MergeStewardRuntime {
101
101
  || event.action === "ci_failed"
102
102
  || event.action === "merge_rejected" || event.action === "budget_exhausted";
103
103
  const isDebug = event.action === "ci_pending" || event.action === "retry_gated"
104
- || event.action === "fetch_started";
104
+ || event.action === "fetch_started" || event.action === "merge_waiting_main";
105
105
  const level = isWarn ? "warn" : isDebug ? "debug" : "info";
106
106
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
107
107
  if (event.action === "main_broken" && tickQueueBlockEvent === null) {
package/dist/types.d.ts CHANGED
@@ -190,7 +190,7 @@ export interface QueueConfig {
190
190
  flakyRetries: number;
191
191
  pollIntervalMs: number;
192
192
  }
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";
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" | "merge_waiting_main" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "branch_unreachable";
194
194
  export interface ReconcileEvent {
195
195
  at: string;
196
196
  entryId: string;
@@ -89,5 +89,5 @@ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
89
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
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), 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: 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}`))))] })] }));
93
93
  }
@@ -1,9 +1,22 @@
1
1
  import { TERMINAL_STATUSES } from "../types.js";
2
2
  const RECENTLY_COMPLETED_MS = 60_000;
3
+ function isPostMergeUnresolved(entry) {
4
+ if (entry.status !== "merged")
5
+ return false;
6
+ return entry.postMergeStatus === null
7
+ || entry.postMergeStatus === "pending"
8
+ || entry.postMergeStatus === "unknown";
9
+ }
3
10
  function isActiveFilterVisible(entry, cutoff) {
4
11
  if (!TERMINAL_STATUSES.includes(entry.status)) {
5
12
  return true;
6
13
  }
14
+ // Keep a merged entry visible until main's own post-merge CI resolves so
15
+ // the operator can watch the verification land. Once it's pass/fail we
16
+ // fall back to the recently-completed window.
17
+ if (isPostMergeUnresolved(entry)) {
18
+ return true;
19
+ }
7
20
  return new Date(entry.updatedAt).getTime() > cutoff;
8
21
  }
9
22
  function isLaterEntry(left, right) {
@@ -50,7 +63,7 @@ export function buildChainEntries(entries) {
50
63
  .filter((entry) => {
51
64
  const isActive = !TERMINAL_STATUSES.includes(entry.status);
52
65
  const isRecent = !isActive && new Date(entry.updatedAt).getTime() > cutoff;
53
- return isActive || isRecent;
66
+ return isActive || isRecent || isPostMergeUnresolved(entry);
54
67
  })
55
68
  .sort((a, b) => a.position - b.position);
56
69
  }
@@ -28,7 +28,9 @@ export declare function specChainLabel(entry: {
28
28
  export declare function runtimeLabel(runtime: QueueRuntimeStatus): string;
29
29
  export declare function formatEventSummary(event: QueueEventSummary): string;
30
30
  export declare function formatEntryEvent(event: QueueEventRecord): string;
31
- export declare function formatEventNarrative(event: QueueEventRecord | QueueEventSummary): string;
31
+ export declare function formatEventNarrative(event: QueueEventRecord | QueueEventSummary, options?: {
32
+ prNumber?: number;
33
+ }): string;
32
34
  export declare function formatDuration(ms: number): string;
33
35
  export declare function progressBar(current: number, total: number, width: number): string;
34
36
  export declare function truncate(value: string, width: number): string;
@@ -165,8 +165,10 @@ function withDetail(text, detail) {
165
165
  }
166
166
  return `${text} ${shortenHashes(detail)}`;
167
167
  }
168
- export function formatEventNarrative(event) {
169
- const prPrefix = "prNumber" in event ? `PR #${event.prNumber} ` : "This PR ";
168
+ export function formatEventNarrative(event, options = {}) {
169
+ const eventPrNumber = "prNumber" in event ? event.prNumber : undefined;
170
+ const resolvedPr = options.prNumber ?? eventPrNumber;
171
+ const prPrefix = resolvedPr !== undefined ? `PR #${resolvedPr} ` : "This PR ";
170
172
  if (!event.fromStatus && event.toStatus === "queued") {
171
173
  return withDetail(`${prPrefix}entered the merge queue.`, event.detail);
172
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {
@@ -40,9 +40,9 @@
40
40
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
41
41
  "start": "node dist/index.js serve",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "node --experimental-transform-types --test test/**/*.test.ts",
44
- "test:scenarios": "node --experimental-transform-types --test test/scenarios/*.test.ts",
45
- "test:property": "node --experimental-transform-types --test test/property/*.test.ts"
43
+ "test": "node --experimental-transform-types --test 'test/**/*.test.ts'",
44
+ "test:scenarios": "node --experimental-transform-types --test 'test/scenarios/*.test.ts'",
45
+ "test:property": "node --experimental-transform-types --test 'test/property/*.test.ts'"
46
46
  },
47
47
  "dependencies": {
48
48
  "fastify": "^5.8.2",