merge-steward 0.5.6 → 0.7.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/sqlite-store.js +1 -0
- package/dist/github/pr-client.d.ts +1 -0
- package/dist/github/pr-client.js +7 -0
- package/dist/github/shell-git.d.ts +1 -1
- package/dist/github/shell-git.js +3 -2
- package/dist/http-multi.js +2 -0
- package/dist/interfaces.d.ts +3 -1
- package/dist/reconciler.d.ts +1 -1
- package/dist/reconciler.js +208 -193
- package/dist/server.js +1 -2
- package/dist/service.d.ts +2 -2
- package/dist/service.js +28 -3
- package/dist/types.d.ts +4 -1
- package/dist/watch/DetailView.js +1 -1
- package/dist/watch/QueueListView.js +13 -8
- package/dist/watch/format.d.ts +18 -2
- package/dist/watch/format.js +22 -6
- package/dist/watch/state-visualization.js +9 -3
- package/package.json +1 -1
package/dist/db/sqlite-store.js
CHANGED
|
@@ -97,6 +97,7 @@ export class SqliteStore {
|
|
|
97
97
|
getEntryByPR(repoId, prNumber) {
|
|
98
98
|
const row = this.conn.prepare(`SELECT * FROM queue_entries
|
|
99
99
|
WHERE repo_id = ? AND pr_number = ? AND status NOT IN (${NOT_TERMINAL_SQL})
|
|
100
|
+
ORDER BY position ASC
|
|
100
101
|
LIMIT 1`).get(repoId, prNumber, ...TERMINAL_STATUSES);
|
|
101
102
|
return row ? mapEntry(row) : undefined;
|
|
102
103
|
}
|
|
@@ -16,6 +16,7 @@ export declare class GitHubPRClient implements GitHubPRApi {
|
|
|
16
16
|
getStatus(prNumber: number): Promise<PRStatus>;
|
|
17
17
|
listChecks(prNumber: number): Promise<CheckResult[]>;
|
|
18
18
|
listChecksForRef(ref: string): Promise<CheckResult[]>;
|
|
19
|
+
deleteBranch(prNumber: number): Promise<void>;
|
|
19
20
|
findPRByBranch(branch: string): Promise<number | null>;
|
|
20
21
|
listLabels(prNumber: number): Promise<string[]>;
|
|
21
22
|
}
|
package/dist/github/pr-client.js
CHANGED
|
@@ -64,6 +64,13 @@ export class GitHubPRClient {
|
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
async deleteBranch(prNumber) {
|
|
68
|
+
const status = await this.getStatus(prNumber);
|
|
69
|
+
await exec("gh", [
|
|
70
|
+
"api", "--method", "DELETE",
|
|
71
|
+
`repos/${this.repoFullName}/git/refs/heads/${status.branch}`,
|
|
72
|
+
], { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
73
|
+
}
|
|
67
74
|
async findPRByBranch(branch) {
|
|
68
75
|
const result = await exec("gh", [
|
|
69
76
|
"pr", "list",
|
|
@@ -10,7 +10,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
|
|
|
10
10
|
headSha(branch: string): Promise<string>;
|
|
11
11
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
12
12
|
mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
|
|
13
|
-
push(branch: string, force?: boolean): Promise<void>;
|
|
13
|
+
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
14
14
|
buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
|
|
15
15
|
deleteSpeculative(specName: string): Promise<void>;
|
|
16
16
|
}
|
package/dist/github/shell-git.js
CHANGED
|
@@ -58,11 +58,12 @@ export class ShellGitOperations {
|
|
|
58
58
|
const newSha = await this.headSha("HEAD");
|
|
59
59
|
return { success: true, sha: newSha };
|
|
60
60
|
}
|
|
61
|
-
async push(branch, force = false) {
|
|
61
|
+
async push(branch, force = false, targetBranch) {
|
|
62
62
|
const args = ["push"];
|
|
63
63
|
if (force)
|
|
64
64
|
args.push("--force-with-lease");
|
|
65
|
-
|
|
65
|
+
const refspec = targetBranch ? `${branch}:${targetBranch}` : branch;
|
|
66
|
+
args.push("origin", refspec);
|
|
66
67
|
await this.git(args, { timeoutMs: 60_000 });
|
|
67
68
|
}
|
|
68
69
|
// ─── SpeculativeBranchBuilder ───────────────────────────────
|
package/dist/http-multi.js
CHANGED
|
@@ -143,6 +143,8 @@ export async function buildMultiRepoHttpServer(options) {
|
|
|
143
143
|
if (body.priority !== undefined)
|
|
144
144
|
params.priority = body.priority;
|
|
145
145
|
const entry = inst.service.enqueue(params);
|
|
146
|
+
if (!entry)
|
|
147
|
+
return reply.status(409).send({ ok: false, error: "Failed to enqueue PR" });
|
|
146
148
|
return reply.status(201).send({ ok: true, entryId: entry.id });
|
|
147
149
|
}
|
|
148
150
|
catch (err) {
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface GitOperations {
|
|
|
8
8
|
headSha(branch: string): Promise<string>;
|
|
9
9
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
10
10
|
mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
|
|
11
|
-
push(branch: string, force?: boolean): Promise<void>;
|
|
11
|
+
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Builds and manages speculative cumulative branches.
|
|
@@ -43,6 +43,8 @@ export interface GitHubPRApi {
|
|
|
43
43
|
listLabels(prNumber: number): Promise<string[]>;
|
|
44
44
|
/** Find the open PR number for a branch, or null if none exists. */
|
|
45
45
|
findPRByBranch(branch: string): Promise<number | null>;
|
|
46
|
+
/** Delete the PR's head branch from the remote (best-effort cleanup). */
|
|
47
|
+
deleteBranch(prNumber: number): Promise<void>;
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
48
50
|
* Reports evictions to external systems. The production implementation
|
package/dist/reconciler.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface ReconcileContext {
|
|
|
10
10
|
ci: CIRunner;
|
|
11
11
|
github: GitHubPRApi;
|
|
12
12
|
eviction: EvictionReporter;
|
|
13
|
-
specBuilder: SpeculativeBranchBuilder
|
|
13
|
+
specBuilder: SpeculativeBranchBuilder;
|
|
14
14
|
speculativeDepth: number;
|
|
15
15
|
flakyRetries: number;
|
|
16
16
|
onEvent: (event: ReconcileEvent) => void;
|
package/dist/reconciler.js
CHANGED
|
@@ -22,135 +22,180 @@ function isBudgetExhausted(entry) {
|
|
|
22
22
|
function isRetryGated(entry, currentBaseSha) {
|
|
23
23
|
return entry.lastFailedBaseSha === currentBaseSha;
|
|
24
24
|
}
|
|
25
|
-
/** The branch and SHA to use for CI — spec branch if available, else PR branch. */
|
|
26
|
-
function ciTarget(entry) {
|
|
27
|
-
return {
|
|
28
|
-
branch: entry.specBranch ?? entry.branch,
|
|
29
|
-
sha: entry.specSha ?? entry.headSha,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
25
|
// ─── Main reconcile loop ────────────────────────────────────────
|
|
33
26
|
export async function reconcile(ctx) {
|
|
34
27
|
const allActive = ctx.store.listActive(ctx.repoId);
|
|
35
28
|
if (allActive.length === 0)
|
|
36
29
|
return;
|
|
37
|
-
|
|
30
|
+
// Process up to speculativeDepth entries. GitHub truth checks are
|
|
31
|
+
// bounded by this window — we never scan the full queue.
|
|
32
|
+
const depth = Math.min(ctx.speculativeDepth, allActive.length);
|
|
38
33
|
for (let i = 0; i < depth; i++) {
|
|
39
34
|
const entryId = allActive[i].id;
|
|
40
35
|
const entry = ctx.store.getEntry(entryId);
|
|
41
36
|
if (!entry || TERMINAL_STATUSES.includes(entry.status))
|
|
42
37
|
continue;
|
|
38
|
+
// Truth guard: verify entry against GitHub before processing.
|
|
39
|
+
if (await sanitizeEntry(ctx, entry))
|
|
40
|
+
continue;
|
|
43
41
|
const isHead = i === 0;
|
|
44
|
-
const prevEntry = i > 0 ? ctx.store.getEntry(allActive[i - 1].id) : null;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
const prevEntry = i > 0 ? ctx.store.getEntry(allActive[i - 1].id) ?? null : null;
|
|
43
|
+
const phase = entry.status;
|
|
44
|
+
try {
|
|
45
|
+
switch (phase) {
|
|
46
|
+
case "queued":
|
|
47
|
+
emit(ctx, entry, "promoted");
|
|
48
|
+
ctx.store.transition(entry.id, "preparing_head", undefined, "promoted");
|
|
49
|
+
break;
|
|
50
|
+
case "preparing_head":
|
|
51
|
+
await prepareEntry(ctx, entry, isHead, prevEntry);
|
|
52
|
+
break;
|
|
53
|
+
case "validating": {
|
|
54
|
+
const freshActive = ctx.store.listActive(ctx.repoId);
|
|
55
|
+
const freshIdx = freshActive.findIndex((e) => e.id === entry.id);
|
|
56
|
+
await checkValidation(ctx, entry, freshActive, freshIdx >= 0 ? freshIdx : i);
|
|
57
|
+
break;
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
case "merging":
|
|
60
|
+
if (isHead) {
|
|
61
|
+
await mergeHead(ctx, entry);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
break;
|
|
63
66
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
70
|
+
const wrapped = new Error(`[PR #${entry.prNumber} ${entry.id} phase=${phase}] ${msg}`);
|
|
71
|
+
if (error instanceof Error && error.stack)
|
|
72
|
+
wrapped.stack = error.stack;
|
|
73
|
+
throw wrapped;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
|
-
// ───
|
|
75
|
-
async function
|
|
77
|
+
// ─── Truth guard ────────────────────────────────────────────────
|
|
78
|
+
async function sanitizeEntry(ctx, entry) {
|
|
79
|
+
const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
|
|
80
|
+
if (canonical && canonical.id !== entry.id) {
|
|
81
|
+
emit(ctx, entry, "sanitized_duplicate", {
|
|
82
|
+
detail: `superseded by entry ${canonical.id}`,
|
|
83
|
+
});
|
|
84
|
+
await cleanupSpec(ctx, entry);
|
|
85
|
+
ctx.store.dequeue(entry.id);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const prStatus = await ctx.github.getStatus(entry.prNumber);
|
|
90
|
+
if (prStatus.merged) {
|
|
91
|
+
emit(ctx, entry, "merge_external", {
|
|
92
|
+
detail: `PR #${entry.prNumber} already merged on GitHub (detected in sanitize)`,
|
|
93
|
+
});
|
|
94
|
+
await cleanupSpec(ctx, entry);
|
|
95
|
+
ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged externally (sanitize)");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (!prStatus.mergeable && !prStatus.merged) {
|
|
99
|
+
emit(ctx, entry, "sanitized_closed", {
|
|
100
|
+
detail: `PR #${entry.prNumber} is closed on GitHub`,
|
|
101
|
+
});
|
|
102
|
+
await cleanupSpec(ctx, entry);
|
|
103
|
+
ctx.store.dequeue(entry.id);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// GitHub probe failed — don't block the tick.
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// ─── Entry preparation (unified for head and non-head) ──────────
|
|
113
|
+
async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
76
114
|
emit(ctx, entry, "fetch_started");
|
|
77
115
|
await ctx.git.fetch();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
// Determine base: head merges onto main, non-head onto prev entry's spec.
|
|
117
|
+
const base = isHead ? ref(ctx, ctx.baseBranch) : prevEntry?.specBranch ?? null;
|
|
118
|
+
if (!base)
|
|
119
|
+
return; // Non-head: prev hasn't built its spec yet, wait.
|
|
120
|
+
const baseSha = await ctx.git.headSha(base);
|
|
121
|
+
// ── Head-only gates ───────────────────────────────────────────
|
|
122
|
+
if (isHead) {
|
|
123
|
+
// Gate: main CI must be green.
|
|
124
|
+
if (ctx.ci.getMainStatus) {
|
|
125
|
+
const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
|
|
126
|
+
if (mainStatus === "fail") {
|
|
127
|
+
let mainChecks = [];
|
|
128
|
+
try {
|
|
129
|
+
mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
mainChecks = [];
|
|
133
|
+
}
|
|
134
|
+
const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
|
|
135
|
+
const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
|
|
136
|
+
emit(ctx, entry, "main_broken", {
|
|
137
|
+
baseSha,
|
|
138
|
+
failingChecks,
|
|
139
|
+
pendingChecks,
|
|
140
|
+
detail: describeMainBroken(failingChecks, pendingChecks),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Gate: detect external pushes to the PR branch.
|
|
146
|
+
const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
|
|
147
|
+
if (currentRef !== entry.headSha) {
|
|
148
|
+
emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
|
|
149
|
+
ctx.store.updateHead(entry.id, currentRef);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Gate: budget exhausted after previous conflict.
|
|
153
|
+
if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
|
|
154
|
+
emit(ctx, entry, "budget_exhausted", { baseSha });
|
|
155
|
+
await evictEntry(ctx, entry, "integration_conflict");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Gate: non-spinning — skip if base hasn't changed since last conflict.
|
|
159
|
+
if (isRetryGated(entry, baseSha)) {
|
|
160
|
+
emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
|
|
84
161
|
try {
|
|
85
|
-
|
|
162
|
+
const prStatus = await ctx.github.getStatus(entry.prNumber);
|
|
163
|
+
if (prStatus.mergeStateStatus === "DIRTY") {
|
|
164
|
+
emit(ctx, entry, "budget_exhausted", {
|
|
165
|
+
baseSha,
|
|
166
|
+
detail: "retry gated and GitHub still reports merge conflict",
|
|
167
|
+
});
|
|
168
|
+
await evictEntry(ctx, entry, "integration_conflict");
|
|
169
|
+
}
|
|
86
170
|
}
|
|
87
171
|
catch {
|
|
88
|
-
|
|
172
|
+
// Best-effort check.
|
|
89
173
|
}
|
|
90
|
-
const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
|
|
91
|
-
const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
|
|
92
|
-
emit(ctx, entry, "main_broken", {
|
|
93
|
-
baseSha,
|
|
94
|
-
failingChecks,
|
|
95
|
-
pendingChecks,
|
|
96
|
-
detail: describeMainBroken(failingChecks, pendingChecks),
|
|
97
|
-
});
|
|
98
174
|
return;
|
|
99
175
|
}
|
|
100
176
|
}
|
|
101
|
-
//
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
// Gate: budget exhausted after previous conflict.
|
|
109
|
-
if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
|
|
110
|
-
emit(ctx, entry, "budget_exhausted", { baseSha });
|
|
111
|
-
await evictEntry(ctx, entry, "integration_conflict");
|
|
112
|
-
return;
|
|
177
|
+
// ── Build spec branch: merge PR into base ─────────────────────
|
|
178
|
+
const specName = specBranchName(entry.id);
|
|
179
|
+
emit(ctx, entry, "spec_build_started", { specBranch: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
result = await ctx.specBuilder.buildSpeculative(entry.branch, base, specName);
|
|
113
183
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
emit(ctx, entry, "budget_exhausted", {
|
|
121
|
-
baseSha,
|
|
122
|
-
detail: "retry gated and GitHub still reports merge conflict",
|
|
123
|
-
});
|
|
124
|
-
await evictEntry(ctx, entry, "integration_conflict");
|
|
125
|
-
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (isHead) {
|
|
186
|
+
// Branch gone or unreachable.
|
|
187
|
+
const detail = `git error during spec build: ${err instanceof Error ? err.message : String(err)}`;
|
|
188
|
+
emit(ctx, entry, "branch_unreachable", { baseSha, detail });
|
|
189
|
+
await evictEntry(ctx, entry, "branch_local");
|
|
126
190
|
}
|
|
127
|
-
|
|
128
|
-
//
|
|
191
|
+
else {
|
|
192
|
+
// Stale spec from prev entry — reset both.
|
|
193
|
+
emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
|
|
194
|
+
ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
|
|
195
|
+
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
|
|
129
196
|
}
|
|
130
197
|
return;
|
|
131
198
|
}
|
|
132
|
-
await performBranchRefresh(ctx, entry, baseSha);
|
|
133
|
-
}
|
|
134
|
-
function describeMainBroken(failingChecks, pendingChecks) {
|
|
135
|
-
const parts = [];
|
|
136
|
-
if (failingChecks.length > 0) {
|
|
137
|
-
parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
|
|
138
|
-
}
|
|
139
|
-
if (pendingChecks.length > 0) {
|
|
140
|
-
parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
|
|
141
|
-
}
|
|
142
|
-
return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
|
|
143
|
-
}
|
|
144
|
-
function summarizeCheckNames(checks, limit = 3) {
|
|
145
|
-
const names = [...new Set(checks.map((check) => check.name))];
|
|
146
|
-
if (names.length <= limit) {
|
|
147
|
-
return names.join(", ");
|
|
148
|
-
}
|
|
149
|
-
return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
|
|
150
|
-
}
|
|
151
|
-
async function performBranchRefresh(ctx, entry, baseSha) {
|
|
152
|
-
emit(ctx, entry, "rebase_started", { baseSha, detail: `refreshing ${entry.branch} with ${ctx.baseBranch}` });
|
|
153
|
-
const result = await ctx.git.mergeBaseInto(entry.branch, ref(ctx, ctx.baseBranch));
|
|
154
199
|
if (!result.success) {
|
|
155
200
|
emit(ctx, entry, "rebase_conflict", { baseSha, conflictFiles: result.conflictFiles });
|
|
156
201
|
if (isBudgetExhausted(entry)) {
|
|
@@ -166,85 +211,39 @@ async function performBranchRefresh(ctx, entry, baseSha) {
|
|
|
166
211
|
}
|
|
167
212
|
return;
|
|
168
213
|
}
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`;
|
|
175
|
-
emit(ctx, entry, "branch_mismatch", { detail });
|
|
176
|
-
ctx.store.updateHead(entry.id, latestRemoteHead);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
await ctx.git.push(entry.branch, false);
|
|
180
|
-
emit(ctx, entry, "rebase_succeeded", { baseSha, detail: `refreshed ${entry.branch} with ${ctx.baseBranch}` });
|
|
181
|
-
// Build speculative branch for downstream entries.
|
|
182
|
-
let specBranch = null;
|
|
183
|
-
let specSha = null;
|
|
184
|
-
if (ctx.specBuilder) {
|
|
185
|
-
specBranch = specBranchName(entry.id);
|
|
186
|
-
emit(ctx, entry, "spec_build_started", { specBranch, baseSha });
|
|
187
|
-
const specResult = await ctx.specBuilder.buildSpeculative(entry.branch, ref(ctx, ctx.baseBranch), specBranch);
|
|
188
|
-
if (specResult.success) {
|
|
189
|
-
specSha = specResult.sha ?? headSha;
|
|
190
|
-
emit(ctx, entry, "spec_build_succeeded", { specBranch });
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
emit(ctx, entry, "spec_build_conflict", { specBranch });
|
|
194
|
-
specBranch = null;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
const runId = await ctx.ci.triggerRun(entry.branch, headSha);
|
|
198
|
-
emit(ctx, entry, "ci_triggered", { ciRunId: runId });
|
|
214
|
+
const specSha = result.sha ?? entry.headSha;
|
|
215
|
+
emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
|
|
216
|
+
// Trigger CI on the spec branch.
|
|
217
|
+
const runId = await ctx.ci.triggerRun(specName, specSha);
|
|
218
|
+
emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
|
|
199
219
|
ctx.store.transition(entry.id, "validating", {
|
|
200
|
-
|
|
201
|
-
specBranch, specSha, specBasedOn: null,
|
|
202
|
-
}, `
|
|
220
|
+
baseSha, ciRunId: runId, lastFailedBaseSha: null,
|
|
221
|
+
specBranch: specName, specSha, specBasedOn: isHead ? null : prevEntry.id,
|
|
222
|
+
}, `spec ${specName} ready, CI ${runId}`);
|
|
203
223
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
const specName = specBranchName(entry.id);
|
|
209
|
-
emit(ctx, entry, "spec_build_started", { specBranch: specName, dependsOn: prevEntry.id });
|
|
210
|
-
let result;
|
|
211
|
-
try {
|
|
212
|
-
result = await ctx.specBuilder.buildSpeculative(entry.branch, prevEntry.specBranch, specName);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Stale spec branch — the previous entry's branch doesn't exist
|
|
216
|
-
// (e.g., after restart with fresh clone). Reset both entries.
|
|
217
|
-
emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
|
|
218
|
-
ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
|
|
219
|
-
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
|
|
220
|
-
return;
|
|
224
|
+
function describeMainBroken(failingChecks, pendingChecks) {
|
|
225
|
+
const parts = [];
|
|
226
|
+
if (failingChecks.length > 0) {
|
|
227
|
+
parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
|
|
221
228
|
}
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, dependsOn: prevEntry.id });
|
|
225
|
-
const runId = await ctx.ci.triggerRun(specName, specSha);
|
|
226
|
-
emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
|
|
227
|
-
ctx.store.transition(entry.id, "validating", {
|
|
228
|
-
ciRunId: runId, specBranch: specName, specSha, specBasedOn: prevEntry.id,
|
|
229
|
-
}, `spec ${specName} based on ${prevEntry.id}, CI ${runId}`);
|
|
229
|
+
if (pendingChecks.length > 0) {
|
|
230
|
+
parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
|
|
230
231
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
await evictEntry(ctx, entry, "integration_conflict");
|
|
240
|
-
}
|
|
232
|
+
return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
|
|
233
|
+
}
|
|
234
|
+
function summarizeCheckNames(checks, limit = 3) {
|
|
235
|
+
const names = [...new Set(checks.map((check) => check.name))];
|
|
236
|
+
if (names.length <= limit) {
|
|
237
|
+
return names.join(", ");
|
|
241
238
|
}
|
|
239
|
+
return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
|
|
242
240
|
}
|
|
243
241
|
// ─── CI validation ──────────────────────────────────────────────
|
|
244
242
|
async function checkValidation(ctx, entry, allActive, index) {
|
|
245
243
|
if (!entry.ciRunId) {
|
|
246
|
-
const
|
|
247
|
-
const
|
|
244
|
+
const branch = entry.specBranch ?? entry.branch;
|
|
245
|
+
const sha = entry.specSha ?? entry.headSha;
|
|
246
|
+
const runId = await ctx.ci.triggerRun(branch, sha);
|
|
248
247
|
emit(ctx, entry, "ci_triggered", { ciRunId: runId });
|
|
249
248
|
ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId}`);
|
|
250
249
|
return;
|
|
@@ -265,8 +264,9 @@ async function checkValidation(ctx, entry, allActive, index) {
|
|
|
265
264
|
emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
|
|
266
265
|
if (entry.ciRetries < ctx.flakyRetries) {
|
|
267
266
|
emit(ctx, entry, "ci_flaky_retry", { detail: `retry ${entry.ciRetries + 1}/${ctx.flakyRetries}` });
|
|
268
|
-
const
|
|
269
|
-
const
|
|
267
|
+
const branch = entry.specBranch ?? entry.branch;
|
|
268
|
+
const sha = entry.specSha ?? entry.headSha;
|
|
269
|
+
const runId = await ctx.ci.triggerRun(branch, sha);
|
|
270
270
|
ctx.store.transition(entry.id, "validating", {
|
|
271
271
|
ciRunId: runId, ciRetries: entry.ciRetries + 1,
|
|
272
272
|
}, `flaky retry ${entry.ciRetries + 1}/${ctx.flakyRetries}`);
|
|
@@ -291,8 +291,8 @@ async function checkValidation(ctx, entry, allActive, index) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
-
// ─── Merge (head only)
|
|
295
|
-
async function mergeHead(ctx, entry
|
|
294
|
+
// ─── Merge: push spec branch to main (head only) ───────────────
|
|
295
|
+
async function mergeHead(ctx, entry) {
|
|
296
296
|
emit(ctx, entry, "merge_revalidating");
|
|
297
297
|
const prStatus = await ctx.github.getStatus(entry.prNumber);
|
|
298
298
|
if (prStatus.merged) {
|
|
@@ -303,36 +303,46 @@ async function mergeHead(ctx, entry, allActive) {
|
|
|
303
303
|
}
|
|
304
304
|
if (!prStatus.reviewApproved) {
|
|
305
305
|
emit(ctx, entry, "merge_rejected", { detail: "approval withdrawn" });
|
|
306
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
306
307
|
await evictEntry(ctx, entry, "policy_blocked");
|
|
307
308
|
await invalidateDownstream(ctx, allActive, 0);
|
|
308
309
|
return;
|
|
309
310
|
}
|
|
310
311
|
if (prStatus.headSha !== entry.headSha) {
|
|
311
312
|
emit(ctx, entry, "branch_mismatch", { detail: `PR head: expected ${entry.headSha.slice(0, 8)}, got ${prStatus.headSha.slice(0, 8)}` });
|
|
313
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
312
314
|
ctx.store.updateHead(entry.id, prStatus.headSha);
|
|
313
315
|
await invalidateDownstream(ctx, allActive, 0);
|
|
314
316
|
return;
|
|
315
317
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
318
|
+
if (!entry.specBranch || !entry.specSha) {
|
|
319
|
+
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "no spec branch, re-prepare");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Guard: verify our spec is a fast-forward from current main.
|
|
323
|
+
// If someone pushed directly to main outside the queue, this catches it.
|
|
324
|
+
try {
|
|
325
|
+
await ctx.git.fetch();
|
|
326
|
+
const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
|
|
327
|
+
const isFF = await ctx.git.isAncestor(currentBase, entry.specSha);
|
|
328
|
+
if (!isFF) {
|
|
329
|
+
emit(ctx, entry, "branch_mismatch", { detail: `spec is not a fast-forward from main (${currentBase.slice(0, 8)})` });
|
|
330
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
331
|
+
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main diverged, re-prepare");
|
|
332
|
+
await invalidateDownstream(ctx, allActive, 0);
|
|
333
|
+
return;
|
|
329
334
|
}
|
|
330
335
|
}
|
|
336
|
+
catch {
|
|
337
|
+
// Can't verify — proceed and let push fail if needed.
|
|
338
|
+
}
|
|
339
|
+
// Push the spec branch to main (fast-forward).
|
|
331
340
|
try {
|
|
332
|
-
await ctx.
|
|
341
|
+
await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
|
|
333
342
|
}
|
|
334
343
|
catch {
|
|
335
|
-
emit(ctx, entry, "merge_rejected", { detail: "
|
|
344
|
+
emit(ctx, entry, "merge_rejected", { detail: "push to main failed" });
|
|
345
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
336
346
|
if (isBudgetExhausted(entry)) {
|
|
337
347
|
emit(ctx, entry, "budget_exhausted");
|
|
338
348
|
await evictEntry(ctx, entry, "integration_conflict");
|
|
@@ -340,14 +350,21 @@ async function mergeHead(ctx, entry, allActive) {
|
|
|
340
350
|
else {
|
|
341
351
|
ctx.store.transition(entry.id, "preparing_head", {
|
|
342
352
|
retryAttempts: entry.retryAttempts + 1, ...CLEAN_CI, ...CLEAN_SPEC,
|
|
343
|
-
}, `
|
|
353
|
+
}, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
|
|
344
354
|
}
|
|
355
|
+
// Head is rebuilding — downstream specs are stale (they were built
|
|
356
|
+
// on the old head spec which will change after re-preparation).
|
|
345
357
|
await invalidateDownstream(ctx, allActive, 0);
|
|
346
358
|
return;
|
|
347
359
|
}
|
|
348
360
|
emit(ctx, entry, "merge_succeeded");
|
|
349
|
-
ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "
|
|
361
|
+
ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
|
|
350
362
|
await cleanupSpec(ctx, entry);
|
|
363
|
+
// Best-effort: delete the PR branch from remote.
|
|
364
|
+
try {
|
|
365
|
+
await ctx.github.deleteBranch(entry.prNumber);
|
|
366
|
+
}
|
|
367
|
+
catch { /* cosmetic */ }
|
|
351
368
|
}
|
|
352
369
|
// ─── Invalidation + eviction ────────────────────────────────────
|
|
353
370
|
async function invalidateDownstream(ctx, allActive, afterIndex) {
|
|
@@ -361,7 +378,7 @@ async function invalidateDownstream(ctx, allActive, afterIndex) {
|
|
|
361
378
|
}
|
|
362
379
|
}
|
|
363
380
|
async function cleanupSpec(ctx, entry) {
|
|
364
|
-
if (entry.specBranch
|
|
381
|
+
if (entry.specBranch) {
|
|
365
382
|
await ctx.specBuilder.deleteSpeculative(entry.specBranch).catch(() => {
|
|
366
383
|
// Best-effort cleanup — branch may not exist.
|
|
367
384
|
});
|
|
@@ -369,7 +386,6 @@ async function cleanupSpec(ctx, entry) {
|
|
|
369
386
|
}
|
|
370
387
|
async function evictEntry(ctx, entry, failureClass, extra) {
|
|
371
388
|
await cleanupSpec(ctx, entry);
|
|
372
|
-
// Use recorded baseSha if available, else resolve current base.
|
|
373
389
|
let baseSha = entry.baseSha;
|
|
374
390
|
if (!baseSha) {
|
|
375
391
|
try {
|
|
@@ -379,7 +395,6 @@ async function evictEntry(ctx, entry, failureClass, extra) {
|
|
|
379
395
|
baseSha = "unknown";
|
|
380
396
|
}
|
|
381
397
|
}
|
|
382
|
-
// Build retry history from queue events (each event snapshots baseSha at transition time).
|
|
383
398
|
const events = ctx.store.listEvents(entry.id);
|
|
384
399
|
const retryHistory = [];
|
|
385
400
|
for (const event of events) {
|
package/dist/server.js
CHANGED
|
@@ -25,8 +25,7 @@ async function createRepoInstance(config, logger) {
|
|
|
25
25
|
const ci = new GitHubActionsRunner(config.repoFullName, config.requiredChecks);
|
|
26
26
|
const github = new GitHubPRClient(config.repoFullName);
|
|
27
27
|
const eviction = new GitHubCheckRunReporter(config.repoFullName, config.server.bind, config.server.port, config.server.publicBaseUrl, config.admissionLabel, config.mergeQueueCheckName);
|
|
28
|
-
const
|
|
29
|
-
const service = new MergeStewardService(config, store, git, ci, github, eviction, specBuilder, logger);
|
|
28
|
+
const service = new MergeStewardService(config, store, git, ci, github, eviction, git, logger);
|
|
30
29
|
return { config, service, store };
|
|
31
30
|
}
|
|
32
31
|
export async function startMultiServer() {
|
package/dist/service.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare class MergeStewardService {
|
|
|
24
24
|
private lastTickOutcome;
|
|
25
25
|
private lastTickError;
|
|
26
26
|
private currentQueueBlock;
|
|
27
|
-
constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder
|
|
27
|
+
constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder, logger: Logger);
|
|
28
28
|
/** Expose the GitHub client for webhook handler branch→PR lookups. */
|
|
29
29
|
get githubApi(): GitHubPRApi;
|
|
30
30
|
start(): void;
|
|
@@ -35,7 +35,7 @@ export declare class MergeStewardService {
|
|
|
35
35
|
headSha: string;
|
|
36
36
|
issueKey?: string;
|
|
37
37
|
priority?: number;
|
|
38
|
-
}): QueueEntry;
|
|
38
|
+
}): QueueEntry | undefined;
|
|
39
39
|
dequeueEntry(entryId: string): boolean;
|
|
40
40
|
updateEntryHead(entryId: string, headSha: string): boolean;
|
|
41
41
|
getStatus(): QueueEntry[];
|
package/dist/service.js
CHANGED
|
@@ -58,6 +58,15 @@ export class MergeStewardService {
|
|
|
58
58
|
this.logger.info("Steward service stopped");
|
|
59
59
|
}
|
|
60
60
|
enqueue(params) {
|
|
61
|
+
// Pre-check: if an active entry already exists for this PR, don't
|
|
62
|
+
// attempt the insert. This is also enforced by the UNIQUE partial
|
|
63
|
+
// index (idx_one_active_per_pr), but handling it here gives a clear
|
|
64
|
+
// log message instead of a raw constraint error.
|
|
65
|
+
const existing = this.store.getEntryByPR(this.config.repoId, params.prNumber);
|
|
66
|
+
if (existing) {
|
|
67
|
+
this.logger.warn({ prNumber: params.prNumber, existingEntryId: existing.id }, "Duplicate enqueue rejected: active entry already exists for PR");
|
|
68
|
+
return existing;
|
|
69
|
+
}
|
|
61
70
|
const entry = {
|
|
62
71
|
id: randomUUID(),
|
|
63
72
|
repoId: this.config.repoId,
|
|
@@ -81,7 +90,19 @@ export class MergeStewardService {
|
|
|
81
90
|
enqueuedAt: new Date().toISOString(),
|
|
82
91
|
updatedAt: new Date().toISOString(),
|
|
83
92
|
};
|
|
84
|
-
|
|
93
|
+
try {
|
|
94
|
+
this.store.insert(entry);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// UNIQUE constraint race: another entry was inserted between our
|
|
98
|
+
// pre-check and the insert. Return the existing entry.
|
|
99
|
+
const raced = this.store.getEntryByPR(this.config.repoId, params.prNumber);
|
|
100
|
+
if (raced) {
|
|
101
|
+
this.logger.warn({ prNumber: params.prNumber, existingEntryId: raced.id }, "Duplicate enqueue caught by constraint: returning existing entry");
|
|
102
|
+
return raced;
|
|
103
|
+
}
|
|
104
|
+
throw err; // Not a duplicate constraint — re-throw the real error.
|
|
105
|
+
}
|
|
85
106
|
this.logger.info({ prNumber: params.prNumber, entryId: entry.id }, "PR enqueued");
|
|
86
107
|
return entry;
|
|
87
108
|
}
|
|
@@ -277,8 +298,12 @@ export class MergeStewardService {
|
|
|
277
298
|
}
|
|
278
299
|
catch (error) {
|
|
279
300
|
this.lastTickOutcome = "failed";
|
|
280
|
-
|
|
281
|
-
|
|
301
|
+
// Preserve stack + message for the watch API. The reconciler wraps
|
|
302
|
+
// per-entry errors with [PR #N entryId phase=X] context.
|
|
303
|
+
this.lastTickError = error instanceof Error
|
|
304
|
+
? `${error.message}${error.stack ? `\n${error.stack}` : ""}`
|
|
305
|
+
: String(error);
|
|
306
|
+
this.logger.error({ err: error instanceof Error ? { message: error.message, stack: error.stack } : error }, "Reconcile tick failed");
|
|
282
307
|
}
|
|
283
308
|
finally {
|
|
284
309
|
this.tickInProgress = false;
|
package/dist/types.d.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* queued → preparing_head → validating → merging → merged
|
|
5
5
|
*
|
|
6
|
+
* Every entry builds a cumulative spec branch and runs CI on it.
|
|
7
|
+
* Head entry pushes its spec to main on merge (fast-forward).
|
|
8
|
+
*
|
|
6
9
|
* Failure: any state → evicted (after retry budget exhausted).
|
|
7
10
|
* Conflict retries are gated on base SHA change (non-spinning).
|
|
8
11
|
*
|
|
@@ -170,7 +173,7 @@ export interface QueueConfig {
|
|
|
170
173
|
pollIntervalMs: number;
|
|
171
174
|
requiredChecks: string[];
|
|
172
175
|
}
|
|
173
|
-
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "rebase_started" | "rebase_succeeded" | "rebase_conflict" | "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" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted";
|
|
176
|
+
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "rebase_started" | "rebase_succeeded" | "rebase_conflict" | "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" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "sanitized_closed" | "sanitized_duplicate" | "branch_unreachable";
|
|
174
177
|
export interface ReconcileEvent {
|
|
175
178
|
at: string;
|
|
176
179
|
entryId: string;
|
package/dist/watch/DetailView.js
CHANGED
|
@@ -18,5 +18,5 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
|
|
|
18
18
|
queueBlock,
|
|
19
19
|
});
|
|
20
20
|
const pipeline = queueProgress(entry.status);
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status) }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status, entry) }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })] }), _jsx(Text, { children: entry.branch }), _jsxs(Box, { gap: 2, children: [_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: "spec" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: shortSha(entry.specSha) }), _jsx(Text, { dimColor: true, children: "\u2190" }), _jsx(Text, { dimColor: true, children: entry.specBasedOn ? `entry ${shortSha(entry.specBasedOn)}` : "main" })] })), _jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "progress" }), _jsx(Text, { children: progressBar(pipeline.current, pipeline.total, 12) }), _jsx(Text, { dimColor: true, children: nextStepLabel(entry.status, entry) })] }), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? "main branch is unhealthy", "."] }), _jsxs(Text, { dimColor: true, children: [queueBlock.baseBranch, queueBlock.baseSha ? ` @ ${shortSha(queueBlock.baseSha)}` : ""] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? entry.prNumber, " will resume automatically once main recovers."] })] })), entry.maxRetries > 0 && (_jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "retry" }), _jsx(Text, { children: progressBar(entry.retryAttempts, entry.maxRetries, 10) }), _jsxs(Text, { dimColor: true, children: [entry.retryAttempts, "/", entry.maxRetries] })] })), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No 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}`))))] })] }));
|
|
22
22
|
}
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { Box, Text, useStdout } from "ink";
|
|
4
|
-
import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
|
|
4
|
+
import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
|
|
5
5
|
const ENTRY_ROW_HEIGHT = 2;
|
|
6
6
|
const CHROME_ROWS = 13;
|
|
7
|
-
function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, }) {
|
|
7
|
+
function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries, }) {
|
|
8
8
|
const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
|
|
9
9
|
const ciText = entry.ciRetries > 0 ? `CI retries ${entry.ciRetries}` : null;
|
|
10
10
|
const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
|
|
11
|
-
const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status);
|
|
12
|
-
const renderedColor = blockedOnMain
|
|
11
|
+
const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status, entry);
|
|
12
|
+
const renderedColor = blockedOnMain
|
|
13
|
+
? "red"
|
|
14
|
+
: entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
|
|
15
|
+
: statusColor(entry.status);
|
|
13
16
|
const progress = queueProgress(entry.status);
|
|
14
|
-
const
|
|
17
|
+
const specLabel = entry.specBranch
|
|
18
|
+
? specChainLabel(entry, allEntries)
|
|
19
|
+
: truncate(entry.branch, Math.max(12, infoWidth - 34));
|
|
15
20
|
const nextStep = blockedOnMain
|
|
16
21
|
? summarizeQueueBlock(queueBlock) ?? "waiting for main to recover"
|
|
17
|
-
: nextStepLabel(entry.status);
|
|
18
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "#" : " " }), _jsx(Text, { bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` pos ${entry.position}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: renderedColor, children: renderedStatus })] }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(progress.current, progress.total, 8) }), _jsx(Text, { dimColor: true, children:
|
|
22
|
+
: nextStepLabel(entry.status, entry);
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "#" : " " }), _jsx(Text, { bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` pos ${entry.position}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: renderedColor, children: renderedStatus })] }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(progress.current, progress.total, 8) }), _jsx(Text, { dimColor: true, children: specLabel }), _jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: nextStep }), _jsx(Text, { dimColor: true, children: ` | retry ${retryText}` }), ciText ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: ciText })] })) : null] })] }));
|
|
19
24
|
}
|
|
20
25
|
export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
|
|
21
26
|
const { stdout } = useStdout();
|
|
@@ -25,5 +30,5 @@ export function QueueListView({ entries, selectedEntryId, recentEvents, headEntr
|
|
|
25
30
|
const eventRows = Math.min(8, Math.max(4, rows - (entries.length * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
|
|
26
31
|
const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
|
|
27
32
|
const queueBlockLabel = summarizeQueueBlock(queueBlock);
|
|
28
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), 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, infoWidth: infoWidth, 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}`))))] })] }));
|
|
33
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), 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, infoWidth: infoWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock, allEntries: entries }, 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}`))))] })] }));
|
|
29
34
|
}
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -2,12 +2,28 @@ import type { CheckResult, QueueBlockState, QueueEntryStatus, QueueEventRecord,
|
|
|
2
2
|
export declare function shortSha(value: string | null | undefined): string;
|
|
3
3
|
export declare function relativeTime(iso: string | null | undefined): string;
|
|
4
4
|
export declare function statusColor(status: QueueEntryStatus): "yellow" | "cyan" | "green" | "red" | "gray";
|
|
5
|
-
export declare function humanStatus(status: QueueEntryStatus
|
|
5
|
+
export declare function humanStatus(status: QueueEntryStatus, entry?: {
|
|
6
|
+
lastFailedBaseSha: string | null;
|
|
7
|
+
specBranch: string | null;
|
|
8
|
+
}): string;
|
|
6
9
|
export declare function queueProgress(status: QueueEntryStatus): {
|
|
7
10
|
current: number;
|
|
8
11
|
total: number;
|
|
9
12
|
};
|
|
10
|
-
export declare function nextStepLabel(status: QueueEntryStatus
|
|
13
|
+
export declare function nextStepLabel(status: QueueEntryStatus, entry?: {
|
|
14
|
+
lastFailedBaseSha: string | null;
|
|
15
|
+
specBasedOn: string | null;
|
|
16
|
+
}): string;
|
|
17
|
+
/** Describe the spec chain for a queue entry. */
|
|
18
|
+
export declare function specChainLabel(entry: {
|
|
19
|
+
specBranch: string | null;
|
|
20
|
+
specBasedOn: string | null;
|
|
21
|
+
specSha: string | null;
|
|
22
|
+
}, allEntries: Array<{
|
|
23
|
+
id: string;
|
|
24
|
+
prNumber: number;
|
|
25
|
+
specBranch: string | null;
|
|
26
|
+
}>): string;
|
|
11
27
|
export declare function runtimeLabel(runtime: QueueRuntimeStatus): string;
|
|
12
28
|
export declare function formatEventSummary(event: QueueEventSummary): string;
|
|
13
29
|
export declare function formatEntryEvent(event: QueueEventRecord): string;
|
package/dist/watch/format.js
CHANGED
|
@@ -38,12 +38,14 @@ export function statusColor(status) {
|
|
|
38
38
|
return "gray";
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
export function humanStatus(status) {
|
|
41
|
+
export function humanStatus(status, entry) {
|
|
42
42
|
switch (status) {
|
|
43
43
|
case "queued":
|
|
44
44
|
return "queued";
|
|
45
45
|
case "preparing_head":
|
|
46
|
-
|
|
46
|
+
if (entry?.lastFailedBaseSha)
|
|
47
|
+
return "retry-gated";
|
|
48
|
+
return "building spec";
|
|
47
49
|
case "validating":
|
|
48
50
|
return "running CI";
|
|
49
51
|
case "merging":
|
|
@@ -71,16 +73,18 @@ export function queueProgress(status) {
|
|
|
71
73
|
return { current: 4, total: 4 };
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
|
-
export function nextStepLabel(status) {
|
|
76
|
+
export function nextStepLabel(status, entry) {
|
|
75
77
|
switch (status) {
|
|
76
78
|
case "queued":
|
|
77
79
|
return "waiting for head-of-line turn";
|
|
78
80
|
case "preparing_head":
|
|
79
|
-
|
|
81
|
+
if (entry?.lastFailedBaseSha)
|
|
82
|
+
return "waiting for base to advance";
|
|
83
|
+
return "building cumulative spec branch";
|
|
80
84
|
case "validating":
|
|
81
|
-
return "waiting for CI
|
|
85
|
+
return "waiting for CI on spec branch";
|
|
82
86
|
case "merging":
|
|
83
|
-
return "
|
|
87
|
+
return "pushing spec to main";
|
|
84
88
|
case "merged":
|
|
85
89
|
return "landed on main";
|
|
86
90
|
case "evicted":
|
|
@@ -89,6 +93,18 @@ export function nextStepLabel(status) {
|
|
|
89
93
|
return "removed manually";
|
|
90
94
|
}
|
|
91
95
|
}
|
|
96
|
+
/** Describe the spec chain for a queue entry. */
|
|
97
|
+
export function specChainLabel(entry, allEntries) {
|
|
98
|
+
if (!entry.specBranch)
|
|
99
|
+
return "no spec yet";
|
|
100
|
+
const parent = entry.specBasedOn
|
|
101
|
+
? allEntries.find((e) => e.id === entry.specBasedOn)
|
|
102
|
+
: null;
|
|
103
|
+
const base = parent
|
|
104
|
+
? `#${parent.prNumber}`
|
|
105
|
+
: "main";
|
|
106
|
+
return `${shortSha(entry.specSha)} \u2190 ${base}`;
|
|
107
|
+
}
|
|
92
108
|
export function runtimeLabel(runtime) {
|
|
93
109
|
if (runtime.tickInProgress) {
|
|
94
110
|
return "running";
|
|
@@ -96,19 +96,25 @@ export function buildExternalRepairObservations(detail, options) {
|
|
|
96
96
|
if (entry.lastFailedBaseSha) {
|
|
97
97
|
observations.push({
|
|
98
98
|
tone: "warn",
|
|
99
|
-
text: `
|
|
99
|
+
text: `Retry-gated: conflict on base ${entry.lastFailedBaseSha.slice(0, 7)}. Waiting for base to advance before rebuilding spec.`,
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
else if (entry.status === "validating") {
|
|
103
|
+
const specNote = entry.specBranch
|
|
104
|
+
? `CI running on spec branch ${entry.specBranch}.`
|
|
105
|
+
: "Waiting on CI for the spec branch.";
|
|
106
|
+
const cascadeNote = entry.specBasedOn
|
|
107
|
+
? " Will merge automatically when head clears (cascade)."
|
|
108
|
+
: "";
|
|
103
109
|
observations.push({
|
|
104
110
|
tone: "info",
|
|
105
|
-
text:
|
|
111
|
+
text: `${specNote}${cascadeNote}`,
|
|
106
112
|
});
|
|
107
113
|
}
|
|
108
114
|
else if (entry.status === "merging") {
|
|
109
115
|
observations.push({
|
|
110
116
|
tone: "info",
|
|
111
|
-
text: "
|
|
117
|
+
text: "CI passed; pushing spec branch to main (fast-forward).",
|
|
112
118
|
});
|
|
113
119
|
}
|
|
114
120
|
if (entry.generation > 0) {
|