merge-steward 0.32.4 → 0.32.6
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/cli/commands/queue.js +0 -11
- package/dist/graceful-shutdown.d.ts +11 -0
- package/dist/graceful-shutdown.js +29 -0
- package/dist/server.js +20 -17
- package/dist/service-runtime.d.ts +1 -3
- package/dist/service-runtime.js +0 -8
- package/dist/service-watch.js +0 -1
- package/dist/types.d.ts +0 -12
- package/dist/watch/dashboard-model.js +5 -15
- package/dist/watch/format.d.ts +1 -7
- package/dist/watch/format.js +0 -46
- package/infra/merge-steward.service +1 -1
- package/package.json +1 -1
- package/dist/watch/DetailView.d.ts +0 -12
- package/dist/watch/DetailView.js +0 -23
- package/dist/watch/EntryStateGraph.d.ts +0 -7
- package/dist/watch/EntryStateGraph.js +0 -12
- package/dist/watch/ExternalRepairObservation.d.ts +0 -6
- package/dist/watch/ExternalRepairObservation.js +0 -15
- package/dist/watch/QueueListView.d.ts +0 -11
- package/dist/watch/QueueListView.js +0 -49
- package/dist/watch/state-visualization.d.ts +0 -22
- package/dist/watch/state-visualization.js +0 -120
|
@@ -51,7 +51,6 @@ async function readQueueSnapshot(config, eventLimit) {
|
|
|
51
51
|
lastTickOutcome: "idle",
|
|
52
52
|
lastTickError: null,
|
|
53
53
|
},
|
|
54
|
-
queueBlock: null,
|
|
55
54
|
entries,
|
|
56
55
|
recentEvents: store.listRecentEvents(config.repoId, { limit: eventLimit }),
|
|
57
56
|
},
|
|
@@ -92,16 +91,6 @@ export function formatQueueStatusText(source, snapshot) {
|
|
|
92
91
|
...(firstLine(snapshot.runtime.lastTickError) ? [`Last error: ${firstLine(snapshot.runtime.lastTickError)}`] : []),
|
|
93
92
|
]
|
|
94
93
|
: []),
|
|
95
|
-
...(snapshot.queueBlock
|
|
96
|
-
? [
|
|
97
|
-
`Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
|
|
98
|
-
`Missing required: ${snapshot.queueBlock.missingRequiredChecks.length > 0 ? snapshot.queueBlock.missingRequiredChecks.join(", ") : "(none)"}`,
|
|
99
|
-
`Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
|
|
100
|
-
...(snapshot.queueBlock.pendingChecks.length > 0
|
|
101
|
-
? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
|
|
102
|
-
: []),
|
|
103
|
-
]
|
|
104
|
-
: []),
|
|
105
94
|
"",
|
|
106
95
|
"Entries:",
|
|
107
96
|
...(snapshot.entries.length > 0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ShutdownLogger {
|
|
2
|
+
info(bindings: Record<string, unknown>, message: string): void;
|
|
3
|
+
warn(bindings: Record<string, unknown>, message: string): void;
|
|
4
|
+
error(bindings: Record<string, unknown>, message: string): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function createGracefulShutdown(options: {
|
|
7
|
+
service: string;
|
|
8
|
+
logger: ShutdownLogger;
|
|
9
|
+
cleanup: () => Promise<void>;
|
|
10
|
+
terminate?: (code: number) => void;
|
|
11
|
+
}): (trigger: string) => Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function createGracefulShutdown(options) {
|
|
2
|
+
let shutdownPromise;
|
|
3
|
+
let firstTrigger;
|
|
4
|
+
const terminate = options.terminate ?? ((code) => {
|
|
5
|
+
process.exitCode = code;
|
|
6
|
+
setImmediate(() => process.exit(code));
|
|
7
|
+
});
|
|
8
|
+
return (trigger) => {
|
|
9
|
+
if (shutdownPromise) {
|
|
10
|
+
options.logger.warn({ service: options.service, trigger, firstTrigger }, "Shutdown already in progress");
|
|
11
|
+
return shutdownPromise;
|
|
12
|
+
}
|
|
13
|
+
firstTrigger = trigger;
|
|
14
|
+
options.logger.info({ service: options.service, trigger }, "Shutdown requested");
|
|
15
|
+
shutdownPromise = options.cleanup()
|
|
16
|
+
.then(() => {
|
|
17
|
+
options.logger.info({ service: options.service, trigger }, "Shutdown complete");
|
|
18
|
+
})
|
|
19
|
+
.catch((error) => {
|
|
20
|
+
options.logger.error({
|
|
21
|
+
service: options.service,
|
|
22
|
+
trigger,
|
|
23
|
+
error: error instanceof Error ? error.message : String(error),
|
|
24
|
+
}, "Shutdown failed");
|
|
25
|
+
terminate(1);
|
|
26
|
+
});
|
|
27
|
+
return shutdownPromise;
|
|
28
|
+
};
|
|
29
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { buildMultiRepoHttpServer } from "./http-multi.js";
|
|
|
11
11
|
import { loadAllRepoConfigs } from "./install.js";
|
|
12
12
|
import { parseHomeConfigObject } from "./steward-home.js";
|
|
13
13
|
import { getMergeStewardPathLayout } from "./runtime-paths.js";
|
|
14
|
+
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
14
15
|
import { createGitHubAppTokenManager, generateJwt, resolveGitHubAuthConfig, resolveAppSlug } from "./github-auth.js";
|
|
15
16
|
import { discoverRepoSettings } from "./github-repo-discovery.js";
|
|
16
17
|
import { resolveSecretWithSource } from "./resolve-secret.js";
|
|
@@ -193,24 +194,26 @@ export async function startMultiServer() {
|
|
|
193
194
|
},
|
|
194
195
|
logger,
|
|
195
196
|
});
|
|
196
|
-
const shutdown =
|
|
197
|
-
|
|
198
|
-
logger
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
197
|
+
const shutdown = createGracefulShutdown({
|
|
198
|
+
service: "merge-steward",
|
|
199
|
+
logger,
|
|
200
|
+
cleanup: async () => {
|
|
201
|
+
shuttingDown = true;
|
|
202
|
+
githubAppTokenManager?.stop();
|
|
203
|
+
setRuntimeGitHubAuthProvider(undefined);
|
|
204
|
+
for (const record of repos.values()) {
|
|
205
|
+
const inst = record.instance;
|
|
206
|
+
if (!inst) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
await inst.service.stop();
|
|
210
|
+
inst.store.close();
|
|
205
211
|
}
|
|
206
|
-
await
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
};
|
|
212
|
-
process.on("SIGTERM", () => void shutdown());
|
|
213
|
-
process.on("SIGINT", () => void shutdown());
|
|
212
|
+
await app.close();
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
216
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
214
217
|
await app.listen({ host: bind, port });
|
|
215
218
|
logger.info({ bind, port, repos: repos.size }, "merge-steward listening");
|
|
216
219
|
const initializeRepo = async (record) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
|
|
3
3
|
import type { QueueStore } from "./store.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { QueueReconcileResult, QueueRuntimeStatus } from "./types.ts";
|
|
5
5
|
import type { StewardConfig } from "./config.ts";
|
|
6
6
|
import type { GitHubPolicyCache } from "./github-policy.ts";
|
|
7
7
|
export declare class MergeStewardRuntime {
|
|
@@ -23,13 +23,11 @@ export declare class MergeStewardRuntime {
|
|
|
23
23
|
private lastTickOutcome;
|
|
24
24
|
private lastTickError;
|
|
25
25
|
private lastReconcileEvent;
|
|
26
|
-
private currentQueueBlock;
|
|
27
26
|
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
|
|
28
27
|
start(): Promise<void>;
|
|
29
28
|
stop(): Promise<void>;
|
|
30
29
|
triggerReconcile(): Promise<QueueReconcileResult>;
|
|
31
30
|
getRuntimeStatus(): QueueRuntimeStatus;
|
|
32
|
-
getCurrentQueueBlock(): QueueBlockState | null;
|
|
33
31
|
getGitHubPolicy(): import("./github-policy.ts").GitHubPolicySnapshot;
|
|
34
32
|
private scheduleNextTick;
|
|
35
33
|
private getTickAgeMs;
|
package/dist/service-runtime.js
CHANGED
|
@@ -18,8 +18,6 @@ export class MergeStewardRuntime {
|
|
|
18
18
|
lastTickOutcome = "idle";
|
|
19
19
|
lastTickError = null;
|
|
20
20
|
lastReconcileEvent = null;
|
|
21
|
-
// The queue never blocks on main CI, so this stays null; kept for status-shape stability.
|
|
22
|
-
currentQueueBlock = null;
|
|
23
21
|
constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
|
|
24
22
|
this.config = config;
|
|
25
23
|
this.policy = policy;
|
|
@@ -71,9 +69,6 @@ export class MergeStewardRuntime {
|
|
|
71
69
|
lastReconcileEvent: this.lastReconcileEvent,
|
|
72
70
|
};
|
|
73
71
|
}
|
|
74
|
-
getCurrentQueueBlock() {
|
|
75
|
-
return this.currentQueueBlock;
|
|
76
|
-
}
|
|
77
72
|
getGitHubPolicy() {
|
|
78
73
|
return this.policy.getSnapshot();
|
|
79
74
|
}
|
|
@@ -153,13 +148,10 @@ export class MergeStewardRuntime {
|
|
|
153
148
|
this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
|
|
154
149
|
},
|
|
155
150
|
});
|
|
156
|
-
// The queue never blocks on main CI — main health is information-only.
|
|
157
|
-
this.currentQueueBlock = null;
|
|
158
151
|
this.lastTickOutcome = "succeeded";
|
|
159
152
|
}
|
|
160
153
|
catch (error) {
|
|
161
154
|
this.lastTickOutcome = "failed";
|
|
162
|
-
this.currentQueueBlock = null;
|
|
163
155
|
this.lastTickError = error instanceof Error
|
|
164
156
|
? `${error.message}${error.stack ? `\n${error.stack}` : ""}`
|
|
165
157
|
: String(error);
|
package/dist/service-watch.js
CHANGED
|
@@ -29,7 +29,6 @@ export class MergeStewardWatchQueries {
|
|
|
29
29
|
githubPolicy: this.runtime.getGitHubPolicy(),
|
|
30
30
|
summary: buildQueueSummary(entries),
|
|
31
31
|
runtime: this.runtime.getRuntimeStatus(),
|
|
32
|
-
queueBlock: this.runtime.getCurrentQueueBlock(),
|
|
33
32
|
entries,
|
|
34
33
|
recentEvents: this.store.listRecentEvents(this.config.repoId, { limit: options?.eventLimit ?? 40 }),
|
|
35
34
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -181,17 +181,6 @@ export interface QueueStatusSummary {
|
|
|
181
181
|
headEntryId: string | null;
|
|
182
182
|
headPrNumber: number | null;
|
|
183
183
|
}
|
|
184
|
-
export interface QueueBlockState {
|
|
185
|
-
reason: "main_broken";
|
|
186
|
-
entryId: string;
|
|
187
|
-
headPrNumber: number | null;
|
|
188
|
-
baseBranch: string;
|
|
189
|
-
baseSha: string | null;
|
|
190
|
-
observedAt: string;
|
|
191
|
-
failingChecks: CheckResult[];
|
|
192
|
-
pendingChecks: CheckResult[];
|
|
193
|
-
missingRequiredChecks: string[];
|
|
194
|
-
}
|
|
195
184
|
export interface GitHubPolicyState {
|
|
196
185
|
requiredChecks: string[];
|
|
197
186
|
requireAllChecksOnEmptyRequiredSet: boolean;
|
|
@@ -206,7 +195,6 @@ export interface QueueWatchSnapshot {
|
|
|
206
195
|
githubPolicy: GitHubPolicyState;
|
|
207
196
|
summary: QueueStatusSummary;
|
|
208
197
|
runtime: QueueRuntimeStatus;
|
|
209
|
-
queueBlock: QueueBlockState | null;
|
|
210
198
|
entries: QueueEntry[];
|
|
211
199
|
recentEvents: QueueEventSummary[];
|
|
212
200
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TERMINAL_STATUSES } from "../types.js";
|
|
2
|
-
import { mergeWaitState
|
|
2
|
+
import { mergeWaitState } from "./format.js";
|
|
3
3
|
const DECIDED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
4
4
|
const GLYPH = {
|
|
5
5
|
running: "\u25cf",
|
|
@@ -61,13 +61,10 @@ function entryKind(entry) {
|
|
|
61
61
|
return "cancelled";
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
function entryPhrase(entry,
|
|
65
|
-
if (opts.isHead && opts.queueBlocked) {
|
|
66
|
-
return "main broken";
|
|
67
|
-
}
|
|
64
|
+
function entryPhrase(entry, isHead) {
|
|
68
65
|
switch (entry.status) {
|
|
69
66
|
case "queued":
|
|
70
|
-
return
|
|
67
|
+
return isHead ? "queued" : "behind head";
|
|
71
68
|
case "preparing_head":
|
|
72
69
|
return entry.lastFailedBaseSha ? "has conflicts" : "preparing";
|
|
73
70
|
case "validating":
|
|
@@ -116,17 +113,11 @@ function tokenSortOrder(kind) {
|
|
|
116
113
|
return 3;
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
|
-
function overrideKind(kind, override) {
|
|
120
|
-
if (override === "main_broken")
|
|
121
|
-
return "error";
|
|
122
|
-
return kind;
|
|
123
|
-
}
|
|
124
116
|
function repoEntriesFromSnapshot(snapshot, cutoff, now) {
|
|
125
117
|
const latest = pickLatestPerPR(snapshot.entries);
|
|
126
118
|
const head = latest
|
|
127
119
|
.filter((entry) => isActive(entry.status))
|
|
128
120
|
.sort((a, b) => a.position - b.position)[0] ?? null;
|
|
129
|
-
const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
|
|
130
121
|
// Resolve speculative stacking: an entry's spec can be built on top of
|
|
131
122
|
// another entry's spec (specBasedOn -> that entry's id). Map ids to PR
|
|
132
123
|
// numbers so the views can show "stacked on #N". Only surface the link
|
|
@@ -142,11 +133,10 @@ function repoEntriesFromSnapshot(snapshot, cutoff, now) {
|
|
|
142
133
|
if (entry.status === "dequeued")
|
|
143
134
|
continue;
|
|
144
135
|
const isHead = head !== null && entry.id === head.id;
|
|
145
|
-
const
|
|
146
|
-
const kind = overrideKind(rawKind, isHead && queueBlocked ? "main_broken" : null);
|
|
136
|
+
const kind = entryKind(entry);
|
|
147
137
|
const glyph = GLYPH[kind];
|
|
148
138
|
const color = COLOR[kind];
|
|
149
|
-
const phrase = entryPhrase(entry,
|
|
139
|
+
const phrase = entryPhrase(entry, isHead);
|
|
150
140
|
const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
|
|
151
141
|
const stackedOnPr = parent && isActive(parent.status) ? parent.prNumber : null;
|
|
152
142
|
// decidedAt is the terminal-transition time (never bumped by post-merge
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import type { CheckResult, PostMergeStatus,
|
|
2
|
-
export declare function isPendingMainVerification(block: QueueBlockState | null | undefined): boolean;
|
|
3
|
-
export declare function queueBlockMatchesEntry(block: QueueBlockState | null | undefined, entry: {
|
|
4
|
-
id: string;
|
|
5
|
-
prNumber: number;
|
|
6
|
-
} | null | undefined): block is QueueBlockState;
|
|
1
|
+
import type { CheckResult, PostMergeStatus, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
|
|
7
2
|
export declare function shortSha(value: string | null | undefined): string;
|
|
8
3
|
export declare function relativeTime(iso: string | number | null | undefined): string;
|
|
9
4
|
export declare function formatTokenAge(eventAt: number): string;
|
|
@@ -71,4 +66,3 @@ export declare function ciStatusIcon(entry: {
|
|
|
71
66
|
icon: string;
|
|
72
67
|
color: string;
|
|
73
68
|
};
|
|
74
|
-
export declare function summarizeQueueBlock(block: QueueBlockState | null | undefined): string | null;
|
package/dist/watch/format.js
CHANGED
|
@@ -6,21 +6,6 @@ const QUEUE_SYMBOLS = {
|
|
|
6
6
|
checkPending: "\u25cf",
|
|
7
7
|
checkUnknown: "\u25cb",
|
|
8
8
|
};
|
|
9
|
-
export function isPendingMainVerification(block) {
|
|
10
|
-
return Boolean(block
|
|
11
|
-
&& block.failingChecks.length === 0
|
|
12
|
-
&& block.pendingChecks.length > 0
|
|
13
|
-
&& block.missingRequiredChecks.length === 0);
|
|
14
|
-
}
|
|
15
|
-
export function queueBlockMatchesEntry(block, entry) {
|
|
16
|
-
if (!block || !entry) {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
if (block.entryId) {
|
|
20
|
-
return block.entryId === entry.id;
|
|
21
|
-
}
|
|
22
|
-
return block.headPrNumber === entry.prNumber;
|
|
23
|
-
}
|
|
24
9
|
export function shortSha(value) {
|
|
25
10
|
if (!value) {
|
|
26
11
|
return "-";
|
|
@@ -341,34 +326,3 @@ export function ciStatusIcon(entry) {
|
|
|
341
326
|
return { icon: QUEUE_SYMBOLS.checkUnknown, color: "gray" };
|
|
342
327
|
}
|
|
343
328
|
}
|
|
344
|
-
export function summarizeQueueBlock(block) {
|
|
345
|
-
if (!block)
|
|
346
|
-
return null;
|
|
347
|
-
const missing = block.missingRequiredChecks.length > 0
|
|
348
|
-
? `operator fix needed on ${block.baseBranch}: missing required ${block.missingRequiredChecks.join(", ")}`
|
|
349
|
-
: null;
|
|
350
|
-
const failing = block.failingChecks.length > 0 ? `failing ${summarizeCheckNames(block.failingChecks)}` : null;
|
|
351
|
-
const pending = block.pendingChecks.length > 0 ? `${summarizeCheckNames(block.pendingChecks)} pending` : null;
|
|
352
|
-
if (missing && failing && pending) {
|
|
353
|
-
return `${missing}; ${failing}; ${pending}`;
|
|
354
|
-
}
|
|
355
|
-
if (missing && failing) {
|
|
356
|
-
return `${missing}; ${failing}`;
|
|
357
|
-
}
|
|
358
|
-
if (missing && pending) {
|
|
359
|
-
return `${missing}; ${pending}`;
|
|
360
|
-
}
|
|
361
|
-
if (missing) {
|
|
362
|
-
return missing;
|
|
363
|
-
}
|
|
364
|
-
if (failing && pending) {
|
|
365
|
-
return `waiting for ${block.baseBranch}: ${failing}; ${pending}`;
|
|
366
|
-
}
|
|
367
|
-
if (failing) {
|
|
368
|
-
return `waiting for ${block.baseBranch} recovery: ${failing}`;
|
|
369
|
-
}
|
|
370
|
-
if (pending) {
|
|
371
|
-
return `waiting for ${block.baseBranch} verification: ${pending}`;
|
|
372
|
-
}
|
|
373
|
-
return `waiting for ${block.baseBranch} checks`;
|
|
374
|
-
}
|
|
@@ -21,7 +21,7 @@ LoadCredentialEncrypted=merge-steward-webhook-secret:/etc/credstore.encrypted/me
|
|
|
21
21
|
LoadCredentialEncrypted=merge-steward-github-app-pem:/etc/credstore.encrypted/merge-steward-github-app-pem.cred
|
|
22
22
|
|
|
23
23
|
ExecStart=/usr/bin/env merge-steward serve
|
|
24
|
-
Restart=
|
|
24
|
+
Restart=always
|
|
25
25
|
RestartSec=5
|
|
26
26
|
|
|
27
27
|
NoNewPrivileges=true
|
package/package.json
CHANGED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { GitHubPolicyState, QueueBlockState, QueueEntryDetail } from "../types.ts";
|
|
2
|
-
interface DetailViewProps {
|
|
3
|
-
detail: QueueEntryDetail | null;
|
|
4
|
-
isHead: boolean;
|
|
5
|
-
activeIndex: number | null;
|
|
6
|
-
activeCount: number;
|
|
7
|
-
headPrNumber: number | null;
|
|
8
|
-
queueBlock: QueueBlockState | null;
|
|
9
|
-
githubPolicy: GitHubPolicyState;
|
|
10
|
-
}
|
|
11
|
-
export declare function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, githubPolicy }: DetailViewProps): React.JSX.Element;
|
|
12
|
-
export {};
|
package/dist/watch/DetailView.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
import { formatEntryEvent, humanStatus, nextStepLabel, relativeTime, shortSha, statusColor, summarizeQueueBlock } from "./format.js";
|
|
4
|
-
import { EntryStateGraph } from "./EntryStateGraph.js";
|
|
5
|
-
import { ExternalRepairObservation } from "./ExternalRepairObservation.js";
|
|
6
|
-
import { buildEntryStateGraph, buildExternalRepairObservations } from "./state-visualization.js";
|
|
7
|
-
export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, githubPolicy, }) {
|
|
8
|
-
if (!detail) {
|
|
9
|
-
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading entry detail..." }) }));
|
|
10
|
-
}
|
|
11
|
-
const { entry, events, incidents } = detail;
|
|
12
|
-
const graph = buildEntryStateGraph(detail);
|
|
13
|
-
const observations = buildExternalRepairObservations(detail, {
|
|
14
|
-
isHead,
|
|
15
|
-
activeIndex,
|
|
16
|
-
activeCount,
|
|
17
|
-
headPrNumber,
|
|
18
|
-
queueBlock,
|
|
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, 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
|
-
? `Fix ${queueBlock.baseBranch} first, then reconcile this repo to resume the queue.`
|
|
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
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { VisualizationNode } from "./state-visualization.ts";
|
|
2
|
-
interface EntryStateGraphProps {
|
|
3
|
-
main: VisualizationNode[];
|
|
4
|
-
exits: VisualizationNode[];
|
|
5
|
-
}
|
|
6
|
-
export declare function EntryStateGraph({ main, exits }: EntryStateGraphProps): React.JSX.Element;
|
|
7
|
-
export {};
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
export function EntryStateGraph({ main, exits }) {
|
|
4
|
-
const visibleExits = exits.filter((n) => n.status !== "upcoming");
|
|
5
|
-
return (_jsxs(Box, { marginTop: 1, gap: 0, children: [main.map((node, i) => {
|
|
6
|
-
const dot = node.status === "upcoming" ? "\u25cb" : "\u25cf"; // ○ or ●
|
|
7
|
-
const color = node.status === "current" ? "cyan"
|
|
8
|
-
: node.status === "visited" ? "green"
|
|
9
|
-
: "gray";
|
|
10
|
-
return (_jsxs(Box, { gap: 0, children: [i > 0 && _jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { color: color, bold: node.status === "current", children: dot }), _jsx(Text, { color: color, bold: node.status === "current", children: ` ${node.label}` })] }, node.state));
|
|
11
|
-
}), visibleExits.map((node) => (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { color: "red", children: `\u25cf ${node.label}` })] }, node.state)))] }));
|
|
12
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { ObservationLine } from "./state-visualization.ts";
|
|
2
|
-
interface ExternalRepairObservationProps {
|
|
3
|
-
observations: ObservationLine[];
|
|
4
|
-
}
|
|
5
|
-
export declare function ExternalRepairObservation({ observations }: ExternalRepairObservationProps): React.JSX.Element;
|
|
6
|
-
export {};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
function toneColor(tone) {
|
|
4
|
-
switch (tone) {
|
|
5
|
-
case "success":
|
|
6
|
-
return "green";
|
|
7
|
-
case "warn":
|
|
8
|
-
return "yellow";
|
|
9
|
-
case "info":
|
|
10
|
-
return "cyan";
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
export function ExternalRepairObservation({ observations }) {
|
|
14
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "External Repair" }), observations.map((observation, index) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: toneColor(observation.tone), children: "-" }), _jsx(Text, { children: observation.text })] }, `external-repair-${index}`)))] }));
|
|
15
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { QueueBlockState, QueueEntry, QueueEventSummary } from "../types.ts";
|
|
2
|
-
interface QueueListViewProps {
|
|
3
|
-
entries: QueueEntry[];
|
|
4
|
-
allEntries: QueueEntry[];
|
|
5
|
-
selectedEntryId: string | null;
|
|
6
|
-
recentEvents: QueueEventSummary[];
|
|
7
|
-
headEntryId: string | null;
|
|
8
|
-
queueBlock: QueueBlockState | null;
|
|
9
|
-
}
|
|
10
|
-
export declare function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock }: QueueListViewProps): React.JSX.Element;
|
|
11
|
-
export {};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
3
|
-
import { Box, Text, useStdout } from "ink";
|
|
4
|
-
import { TERMINAL_STATUSES } from "../types.js";
|
|
5
|
-
import { buildChainEntries } from "./display-filter.js";
|
|
6
|
-
import { ciStatusIcon, formatEventSummary, humanStatus, isPendingMainVerification, nextStepLabel, postMergeStatusLine, queueBlockMatchesEntry, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
|
|
7
|
-
const CHROME_ROWS = 13;
|
|
8
|
-
function QueueRow({ entry, selected, isHead, queueBlock, }) {
|
|
9
|
-
const isTerminal = TERMINAL_STATUSES.includes(entry.status);
|
|
10
|
-
if (isTerminal) {
|
|
11
|
-
const icon = entry.status === "merged" ? "\u2713" : "\u2717";
|
|
12
|
-
const iconColor = entry.status === "merged" ? "green" : "red";
|
|
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}` })] }));
|
|
17
|
-
}
|
|
18
|
-
const blockedOnMain = isHead && queueBlockMatchesEntry(queueBlock, entry);
|
|
19
|
-
const pendingMainVerification = blockedOnMain && isPendingMainVerification(queueBlock);
|
|
20
|
-
const status = blockedOnMain
|
|
21
|
-
? pendingMainVerification ? "verifying main" : "waiting for main"
|
|
22
|
-
: humanStatus(entry.status, entry);
|
|
23
|
-
const color = blockedOnMain ? (pendingMainVerification ? "yellow" : "red")
|
|
24
|
-
: entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
|
|
25
|
-
: statusColor(entry.status);
|
|
26
|
-
const nextStep = blockedOnMain
|
|
27
|
-
? summarizeQueueBlock(queueBlock) ?? "waiting for main checks to settle"
|
|
28
|
-
: nextStepLabel(entry.status, entry);
|
|
29
|
-
// Only show retry counter when retries have actually happened.
|
|
30
|
-
const retryNote = entry.retryAttempts > 0 ? ` \u00b7 retry ${entry.retryAttempts}/${entry.maxRetries}` : "";
|
|
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}` })] }));
|
|
32
|
-
}
|
|
33
|
-
export function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
|
|
34
|
-
const { stdout } = useStdout();
|
|
35
|
-
const rows = stdout?.rows ?? 24;
|
|
36
|
-
// All entries are 1 row now.
|
|
37
|
-
const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
|
|
38
|
-
const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
|
|
39
|
-
const effectiveQueueBlock = queueBlock && queueBlock.entryId === headEntryId ? queueBlock : null;
|
|
40
|
-
const queueBlockLabel = summarizeQueueBlock(effectiveQueueBlock);
|
|
41
|
-
// Chain header always shows the live queue, regardless of display filter.
|
|
42
|
-
const chainEntries = useMemo(() => buildChainEntries(allEntries), [allEntries]);
|
|
43
|
-
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) => {
|
|
44
|
-
const ci = ciStatusIcon(entry);
|
|
45
|
-
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));
|
|
46
|
-
})] })), effectiveQueueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "waiting for main checks", effectiveQueueBlock.baseSha ? ` at ${truncate(effectiveQueueBlock.baseSha, 10)}` : "", "."] }), _jsx(Text, { dimColor: true, children: effectiveQueueBlock.missingRequiredChecks.length > 0
|
|
47
|
-
? `Head PR #${effectiveQueueBlock.headPrNumber ?? "?"} is waiting for an operator fix on ${effectiveQueueBlock.baseBranch}.`
|
|
48
|
-
: `Head PR #${effectiveQueueBlock.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: effectiveQueueBlock }, 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}`))))] })] }));
|
|
49
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { QueueBlockState, QueueEntryDetail } from "../types.ts";
|
|
2
|
-
export type VisualizationNodeStatus = "current" | "visited" | "upcoming";
|
|
3
|
-
export interface VisualizationNode {
|
|
4
|
-
state: string;
|
|
5
|
-
label: string;
|
|
6
|
-
status: VisualizationNodeStatus;
|
|
7
|
-
}
|
|
8
|
-
export interface ObservationLine {
|
|
9
|
-
tone: "info" | "warn" | "success";
|
|
10
|
-
text: string;
|
|
11
|
-
}
|
|
12
|
-
export declare function buildEntryStateGraph(detail: QueueEntryDetail): {
|
|
13
|
-
main: VisualizationNode[];
|
|
14
|
-
exits: VisualizationNode[];
|
|
15
|
-
};
|
|
16
|
-
export declare function buildExternalRepairObservations(detail: QueueEntryDetail, options: {
|
|
17
|
-
isHead: boolean;
|
|
18
|
-
activeIndex: number | null;
|
|
19
|
-
activeCount: number;
|
|
20
|
-
headPrNumber: number | null;
|
|
21
|
-
queueBlock: QueueBlockState | null;
|
|
22
|
-
}): ObservationLine[];
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { queueBlockMatchesEntry } from "./format.js";
|
|
2
|
-
const MAIN_STATES = ["queued", "preparing_head", "validating", "merging", "merged"];
|
|
3
|
-
const EXIT_STATES = ["evicted", "dequeued"];
|
|
4
|
-
const STATE_LABELS = {
|
|
5
|
-
queued: "queued",
|
|
6
|
-
preparing_head: "preparing",
|
|
7
|
-
validating: "testing",
|
|
8
|
-
merging: "merging",
|
|
9
|
-
merged: "merged",
|
|
10
|
-
evicted: "evicted",
|
|
11
|
-
dequeued: "removed",
|
|
12
|
-
};
|
|
13
|
-
function labelForState(state) {
|
|
14
|
-
return STATE_LABELS[state] ?? state;
|
|
15
|
-
}
|
|
16
|
-
function buildNodes(states, visited, currentStatus) {
|
|
17
|
-
return states.map((state) => ({
|
|
18
|
-
state,
|
|
19
|
-
label: labelForState(state),
|
|
20
|
-
status: currentStatus === state
|
|
21
|
-
? "current"
|
|
22
|
-
: visited.has(state)
|
|
23
|
-
? "visited"
|
|
24
|
-
: "upcoming",
|
|
25
|
-
}));
|
|
26
|
-
}
|
|
27
|
-
function collectVisitedStates(detail) {
|
|
28
|
-
const visited = new Set([detail.entry.status]);
|
|
29
|
-
for (const event of detail.events) {
|
|
30
|
-
if (event.fromStatus) {
|
|
31
|
-
visited.add(event.fromStatus);
|
|
32
|
-
}
|
|
33
|
-
visited.add(event.toStatus);
|
|
34
|
-
}
|
|
35
|
-
return visited;
|
|
36
|
-
}
|
|
37
|
-
export function buildEntryStateGraph(detail) {
|
|
38
|
-
const visited = collectVisitedStates(detail);
|
|
39
|
-
return {
|
|
40
|
-
main: buildNodes(MAIN_STATES, visited, detail.entry.status),
|
|
41
|
-
exits: buildNodes(EXIT_STATES, visited, detail.entry.status),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
export function buildExternalRepairObservations(detail, options) {
|
|
45
|
-
const { entry, incidents } = detail;
|
|
46
|
-
const observations = [];
|
|
47
|
-
// What is this entry doing right now?
|
|
48
|
-
if (entry.status === "merged") {
|
|
49
|
-
observations.push({ tone: "success", text: "Landed on main." });
|
|
50
|
-
}
|
|
51
|
-
else if (entry.status === "dequeued") {
|
|
52
|
-
observations.push({ tone: "info", text: "Removed from queue." });
|
|
53
|
-
}
|
|
54
|
-
else if (entry.status === "evicted") {
|
|
55
|
-
observations.push({ tone: "warn", text: "Removed after failed retries. Branch needs repair before re-admission." });
|
|
56
|
-
}
|
|
57
|
-
else if (options.isHead && queueBlockMatchesEntry(options.queueBlock, entry)) {
|
|
58
|
-
const failingNames = options.queueBlock.failingChecks.map((check) => check.name);
|
|
59
|
-
const pendingNames = options.queueBlock.pendingChecks.map((check) => check.name);
|
|
60
|
-
const missingNames = options.queueBlock.missingRequiredChecks;
|
|
61
|
-
const detail = [
|
|
62
|
-
missingNames.length > 0 ? `missing required ${missingNames.join(", ")}` : null,
|
|
63
|
-
failingNames.length > 0 ? `failing ${failingNames.join(", ")}` : null,
|
|
64
|
-
pendingNames.length > 0 ? `pending ${pendingNames.join(", ")}` : null,
|
|
65
|
-
].filter(Boolean).join("; ");
|
|
66
|
-
observations.push({
|
|
67
|
-
tone: "warn",
|
|
68
|
-
text: missingNames.length > 0
|
|
69
|
-
? `Queue paused: ${options.queueBlock.baseBranch} is missing required checks${detail ? ` (${detail})` : ""}. Operator action is required on main before the queue can continue.`
|
|
70
|
-
: pendingNames.length > 0 && failingNames.length === 0
|
|
71
|
-
? `Queue paused: ${options.queueBlock.baseBranch} is still verifying${detail ? ` (${detail})` : ""}. Will resume when checks settle.`
|
|
72
|
-
: `Queue paused: ${options.queueBlock.baseBranch} is unhealthy${detail ? ` (${detail})` : ""}. Will resume when main is healthy.`,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
else if (options.isHead) {
|
|
76
|
-
observations.push({ tone: "info", text: "First in queue. Will advance on the next tick." });
|
|
77
|
-
}
|
|
78
|
-
else if (options.activeIndex !== null && options.headPrNumber !== null) {
|
|
79
|
-
observations.push({
|
|
80
|
-
tone: "info",
|
|
81
|
-
text: `Position ${options.activeIndex} of ${options.activeCount}. Being tested together with PRs ahead.`,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
observations.push({ tone: "info", text: "Waiting in queue." });
|
|
86
|
-
}
|
|
87
|
-
// What went wrong last time?
|
|
88
|
-
const latestIncident = incidents[incidents.length - 1];
|
|
89
|
-
if (latestIncident) {
|
|
90
|
-
observations.push({
|
|
91
|
-
tone: latestIncident.outcome === "open" ? "warn" : "info",
|
|
92
|
-
text: `Last failure: ${latestIncident.failureClass} (${latestIncident.outcome}).`,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
// What is blocking progress?
|
|
96
|
-
if (entry.lastFailedBaseSha) {
|
|
97
|
-
observations.push({
|
|
98
|
-
tone: "warn",
|
|
99
|
-
text: "Conflicts with main. Will retry automatically when another PR merges and main advances.",
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
else if (entry.status === "validating") {
|
|
103
|
-
const cascadeNote = entry.specBasedOn
|
|
104
|
-
? " Tests pass → merges automatically when PRs ahead finish."
|
|
105
|
-
: "";
|
|
106
|
-
observations.push({
|
|
107
|
-
tone: "info",
|
|
108
|
-
text: `CI running on combined changes.${cascadeNote}`,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
else if (entry.status === "merging") {
|
|
112
|
-
if (entry.waitDetail?.toLowerCase().includes("approval") || entry.waitDetail?.toLowerCase().includes("review")) {
|
|
113
|
-
observations.push({ tone: "warn", text: "CI passed. Merge is blocked until GitHub approval returns." });
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
observations.push({ tone: "info", text: "CI passed. Landing on main." });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return observations.slice(0, 4);
|
|
120
|
-
}
|