merge-steward 0.5.5 → 0.6.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 +9 -1
- 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 +210 -182
- package/dist/server.js +1 -2
- package/dist/service.d.ts +2 -2
- package/dist/service.js +28 -3
- package/dist/types.d.ts +5 -1
- 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
|
@@ -24,7 +24,7 @@ export class GitHubPRClient {
|
|
|
24
24
|
const result = await exec("gh", [
|
|
25
25
|
"pr", "view", String(prNumber),
|
|
26
26
|
"--repo", this.repoFullName,
|
|
27
|
-
"--json", "number,headRefName,headRefOid,reviewDecision,state",
|
|
27
|
+
"--json", "number,headRefName,headRefOid,reviewDecision,state,mergeStateStatus",
|
|
28
28
|
], { githubRepoFullName: this.repoFullName });
|
|
29
29
|
const data = JSON.parse(result.stdout);
|
|
30
30
|
return {
|
|
@@ -32,6 +32,7 @@ export class GitHubPRClient {
|
|
|
32
32
|
branch: data.headRefName,
|
|
33
33
|
headSha: data.headRefOid,
|
|
34
34
|
mergeable: data.state === "OPEN",
|
|
35
|
+
mergeStateStatus: data.mergeStateStatus,
|
|
35
36
|
reviewApproved: data.reviewDecision === "APPROVED",
|
|
36
37
|
merged: data.state === "MERGED",
|
|
37
38
|
};
|
|
@@ -63,6 +64,13 @@ export class GitHubPRClient {
|
|
|
63
64
|
return [];
|
|
64
65
|
}
|
|
65
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
|
+
}
|
|
66
74
|
async findPRByBranch(branch) {
|
|
67
75
|
const result = await exec("gh", [
|
|
68
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,122 +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;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
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;
|
|
71
105
|
}
|
|
72
106
|
}
|
|
107
|
+
catch {
|
|
108
|
+
// GitHub probe failed — don't block the tick.
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
73
111
|
}
|
|
74
|
-
// ───
|
|
75
|
-
async function
|
|
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
|
-
|
|
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");
|
|
190
|
+
}
|
|
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");
|
|
196
|
+
}
|
|
117
197
|
return;
|
|
118
198
|
}
|
|
119
|
-
await performBranchRefresh(ctx, entry, baseSha);
|
|
120
|
-
}
|
|
121
|
-
function describeMainBroken(failingChecks, pendingChecks) {
|
|
122
|
-
const parts = [];
|
|
123
|
-
if (failingChecks.length > 0) {
|
|
124
|
-
parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
|
|
125
|
-
}
|
|
126
|
-
if (pendingChecks.length > 0) {
|
|
127
|
-
parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
|
|
128
|
-
}
|
|
129
|
-
return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
|
|
130
|
-
}
|
|
131
|
-
function summarizeCheckNames(checks, limit = 3) {
|
|
132
|
-
const names = [...new Set(checks.map((check) => check.name))];
|
|
133
|
-
if (names.length <= limit) {
|
|
134
|
-
return names.join(", ");
|
|
135
|
-
}
|
|
136
|
-
return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
|
|
137
|
-
}
|
|
138
|
-
async function performBranchRefresh(ctx, entry, baseSha) {
|
|
139
|
-
emit(ctx, entry, "rebase_started", { baseSha, detail: `refreshing ${entry.branch} with ${ctx.baseBranch}` });
|
|
140
|
-
const result = await ctx.git.mergeBaseInto(entry.branch, ref(ctx, ctx.baseBranch));
|
|
141
199
|
if (!result.success) {
|
|
142
200
|
emit(ctx, entry, "rebase_conflict", { baseSha, conflictFiles: result.conflictFiles });
|
|
143
201
|
if (isBudgetExhausted(entry)) {
|
|
@@ -153,85 +211,39 @@ async function performBranchRefresh(ctx, entry, baseSha) {
|
|
|
153
211
|
}
|
|
154
212
|
return;
|
|
155
213
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
`latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`;
|
|
162
|
-
emit(ctx, entry, "branch_mismatch", { detail });
|
|
163
|
-
ctx.store.updateHead(entry.id, latestRemoteHead);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
await ctx.git.push(entry.branch, false);
|
|
167
|
-
emit(ctx, entry, "rebase_succeeded", { baseSha, detail: `refreshed ${entry.branch} with ${ctx.baseBranch}` });
|
|
168
|
-
// Build speculative branch for downstream entries.
|
|
169
|
-
let specBranch = null;
|
|
170
|
-
let specSha = null;
|
|
171
|
-
if (ctx.specBuilder) {
|
|
172
|
-
specBranch = specBranchName(entry.id);
|
|
173
|
-
emit(ctx, entry, "spec_build_started", { specBranch, baseSha });
|
|
174
|
-
const specResult = await ctx.specBuilder.buildSpeculative(entry.branch, ref(ctx, ctx.baseBranch), specBranch);
|
|
175
|
-
if (specResult.success) {
|
|
176
|
-
specSha = specResult.sha ?? headSha;
|
|
177
|
-
emit(ctx, entry, "spec_build_succeeded", { specBranch });
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
emit(ctx, entry, "spec_build_conflict", { specBranch });
|
|
181
|
-
specBranch = null;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const runId = await ctx.ci.triggerRun(entry.branch, headSha);
|
|
185
|
-
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 });
|
|
186
219
|
ctx.store.transition(entry.id, "validating", {
|
|
187
|
-
|
|
188
|
-
specBranch, specSha, specBasedOn: null,
|
|
189
|
-
}, `
|
|
220
|
+
baseSha, ciRunId: runId, lastFailedBaseSha: null,
|
|
221
|
+
specBranch: specName, specSha, specBasedOn: isHead ? null : prevEntry.id,
|
|
222
|
+
}, `spec ${specName} ready, CI ${runId}`);
|
|
190
223
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
const specName = specBranchName(entry.id);
|
|
196
|
-
emit(ctx, entry, "spec_build_started", { specBranch: specName, dependsOn: prevEntry.id });
|
|
197
|
-
let result;
|
|
198
|
-
try {
|
|
199
|
-
result = await ctx.specBuilder.buildSpeculative(entry.branch, prevEntry.specBranch, specName);
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
// Stale spec branch — the previous entry's branch doesn't exist
|
|
203
|
-
// (e.g., after restart with fresh clone). Reset both entries.
|
|
204
|
-
emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
|
|
205
|
-
ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
|
|
206
|
-
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
|
|
207
|
-
return;
|
|
224
|
+
function describeMainBroken(failingChecks, pendingChecks) {
|
|
225
|
+
const parts = [];
|
|
226
|
+
if (failingChecks.length > 0) {
|
|
227
|
+
parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
|
|
208
228
|
}
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, dependsOn: prevEntry.id });
|
|
212
|
-
const runId = await ctx.ci.triggerRun(specName, specSha);
|
|
213
|
-
emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
|
|
214
|
-
ctx.store.transition(entry.id, "validating", {
|
|
215
|
-
ciRunId: runId, specBranch: specName, specSha, specBasedOn: prevEntry.id,
|
|
216
|
-
}, `spec ${specName} based on ${prevEntry.id}, CI ${runId}`);
|
|
229
|
+
if (pendingChecks.length > 0) {
|
|
230
|
+
parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
|
|
217
231
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
await evictEntry(ctx, entry, "integration_conflict");
|
|
227
|
-
}
|
|
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(", ");
|
|
228
238
|
}
|
|
239
|
+
return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
|
|
229
240
|
}
|
|
230
241
|
// ─── CI validation ──────────────────────────────────────────────
|
|
231
242
|
async function checkValidation(ctx, entry, allActive, index) {
|
|
232
243
|
if (!entry.ciRunId) {
|
|
233
|
-
const
|
|
234
|
-
const
|
|
244
|
+
const branch = entry.specBranch ?? entry.branch;
|
|
245
|
+
const sha = entry.specSha ?? entry.headSha;
|
|
246
|
+
const runId = await ctx.ci.triggerRun(branch, sha);
|
|
235
247
|
emit(ctx, entry, "ci_triggered", { ciRunId: runId });
|
|
236
248
|
ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId}`);
|
|
237
249
|
return;
|
|
@@ -252,8 +264,9 @@ async function checkValidation(ctx, entry, allActive, index) {
|
|
|
252
264
|
emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
|
|
253
265
|
if (entry.ciRetries < ctx.flakyRetries) {
|
|
254
266
|
emit(ctx, entry, "ci_flaky_retry", { detail: `retry ${entry.ciRetries + 1}/${ctx.flakyRetries}` });
|
|
255
|
-
const
|
|
256
|
-
const
|
|
267
|
+
const branch = entry.specBranch ?? entry.branch;
|
|
268
|
+
const sha = entry.specSha ?? entry.headSha;
|
|
269
|
+
const runId = await ctx.ci.triggerRun(branch, sha);
|
|
257
270
|
ctx.store.transition(entry.id, "validating", {
|
|
258
271
|
ciRunId: runId, ciRetries: entry.ciRetries + 1,
|
|
259
272
|
}, `flaky retry ${entry.ciRetries + 1}/${ctx.flakyRetries}`);
|
|
@@ -278,8 +291,8 @@ async function checkValidation(ctx, entry, allActive, index) {
|
|
|
278
291
|
}
|
|
279
292
|
}
|
|
280
293
|
}
|
|
281
|
-
// ─── Merge (head only)
|
|
282
|
-
async function mergeHead(ctx, entry
|
|
294
|
+
// ─── Merge: push spec branch to main (head only) ───────────────
|
|
295
|
+
async function mergeHead(ctx, entry) {
|
|
283
296
|
emit(ctx, entry, "merge_revalidating");
|
|
284
297
|
const prStatus = await ctx.github.getStatus(entry.prNumber);
|
|
285
298
|
if (prStatus.merged) {
|
|
@@ -290,36 +303,46 @@ async function mergeHead(ctx, entry, allActive) {
|
|
|
290
303
|
}
|
|
291
304
|
if (!prStatus.reviewApproved) {
|
|
292
305
|
emit(ctx, entry, "merge_rejected", { detail: "approval withdrawn" });
|
|
306
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
293
307
|
await evictEntry(ctx, entry, "policy_blocked");
|
|
294
308
|
await invalidateDownstream(ctx, allActive, 0);
|
|
295
309
|
return;
|
|
296
310
|
}
|
|
297
311
|
if (prStatus.headSha !== entry.headSha) {
|
|
298
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);
|
|
299
314
|
ctx.store.updateHead(entry.id, prStatus.headSha);
|
|
300
315
|
await invalidateDownstream(ctx, allActive, 0);
|
|
301
316
|
return;
|
|
302
317
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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;
|
|
316
334
|
}
|
|
317
335
|
}
|
|
336
|
+
catch {
|
|
337
|
+
// Can't verify — proceed and let push fail if needed.
|
|
338
|
+
}
|
|
339
|
+
// Push the spec branch to main (fast-forward).
|
|
318
340
|
try {
|
|
319
|
-
await ctx.
|
|
341
|
+
await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
|
|
320
342
|
}
|
|
321
343
|
catch {
|
|
322
|
-
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);
|
|
323
346
|
if (isBudgetExhausted(entry)) {
|
|
324
347
|
emit(ctx, entry, "budget_exhausted");
|
|
325
348
|
await evictEntry(ctx, entry, "integration_conflict");
|
|
@@ -327,14 +350,21 @@ async function mergeHead(ctx, entry, allActive) {
|
|
|
327
350
|
else {
|
|
328
351
|
ctx.store.transition(entry.id, "preparing_head", {
|
|
329
352
|
retryAttempts: entry.retryAttempts + 1, ...CLEAN_CI, ...CLEAN_SPEC,
|
|
330
|
-
}, `
|
|
353
|
+
}, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
|
|
331
354
|
}
|
|
355
|
+
// Head is rebuilding — downstream specs are stale (they were built
|
|
356
|
+
// on the old head spec which will change after re-preparation).
|
|
332
357
|
await invalidateDownstream(ctx, allActive, 0);
|
|
333
358
|
return;
|
|
334
359
|
}
|
|
335
360
|
emit(ctx, entry, "merge_succeeded");
|
|
336
|
-
ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "
|
|
361
|
+
ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
|
|
337
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 */ }
|
|
338
368
|
}
|
|
339
369
|
// ─── Invalidation + eviction ────────────────────────────────────
|
|
340
370
|
async function invalidateDownstream(ctx, allActive, afterIndex) {
|
|
@@ -348,7 +378,7 @@ async function invalidateDownstream(ctx, allActive, afterIndex) {
|
|
|
348
378
|
}
|
|
349
379
|
}
|
|
350
380
|
async function cleanupSpec(ctx, entry) {
|
|
351
|
-
if (entry.specBranch
|
|
381
|
+
if (entry.specBranch) {
|
|
352
382
|
await ctx.specBuilder.deleteSpeculative(entry.specBranch).catch(() => {
|
|
353
383
|
// Best-effort cleanup — branch may not exist.
|
|
354
384
|
});
|
|
@@ -356,7 +386,6 @@ async function cleanupSpec(ctx, entry) {
|
|
|
356
386
|
}
|
|
357
387
|
async function evictEntry(ctx, entry, failureClass, extra) {
|
|
358
388
|
await cleanupSpec(ctx, entry);
|
|
359
|
-
// Use recorded baseSha if available, else resolve current base.
|
|
360
389
|
let baseSha = entry.baseSha;
|
|
361
390
|
if (!baseSha) {
|
|
362
391
|
try {
|
|
@@ -366,7 +395,6 @@ async function evictEntry(ctx, entry, failureClass, extra) {
|
|
|
366
395
|
baseSha = "unknown";
|
|
367
396
|
}
|
|
368
397
|
}
|
|
369
|
-
// Build retry history from queue events (each event snapshots baseSha at transition time).
|
|
370
398
|
const events = ctx.store.listEvents(entry.id);
|
|
371
399
|
const retryHistory = [];
|
|
372
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
|
*
|
|
@@ -158,6 +161,7 @@ export interface PRStatus {
|
|
|
158
161
|
branch: string;
|
|
159
162
|
headSha: string;
|
|
160
163
|
mergeable: boolean;
|
|
164
|
+
mergeStateStatus?: string | undefined;
|
|
161
165
|
reviewApproved: boolean;
|
|
162
166
|
merged: boolean;
|
|
163
167
|
}
|
|
@@ -169,7 +173,7 @@ export interface QueueConfig {
|
|
|
169
173
|
pollIntervalMs: number;
|
|
170
174
|
requiredChecks: string[];
|
|
171
175
|
}
|
|
172
|
-
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";
|
|
173
177
|
export interface ReconcileEvent {
|
|
174
178
|
at: string;
|
|
175
179
|
entryId: string;
|