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.
- package/dist/github/actions-runner.js +20 -7
- package/dist/install.js +12 -1
- package/dist/reconciler-merge.js +11 -1
- package/dist/service-runtime.js +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/watch/ProjectDetailView.js +1 -1
- package/dist/watch/display-filter.js +14 -1
- package/dist/watch/format.d.ts +3 -1
- package/dist/watch/format.js +4 -2
- package/package.json +4 -4
|
@@ -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
|
|
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
|
|
45
|
-
//
|
|
46
|
-
// required
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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",
|
|
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
|
}
|
package/dist/reconciler-merge.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/service-runtime.js
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
|
}
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/watch/format.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|