merge-steward 0.24.0 → 0.26.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/schema.js +5 -0
- package/dist/db/sqlite-store.d.ts +9 -1
- package/dist/db/sqlite-store.js +36 -2
- package/dist/github/pr-client.js +4 -1
- package/dist/github/shell-git.d.ts +5 -0
- package/dist/github/shell-git.js +62 -0
- package/dist/interfaces.d.ts +10 -0
- package/dist/reconciler-prepare.js +101 -0
- package/dist/service-queue.d.ts +7 -0
- package/dist/service-queue.js +33 -0
- package/dist/store.d.ts +19 -1
- package/dist/types.d.ts +28 -0
- package/package.json +1 -1
package/dist/db/schema.js
CHANGED
|
@@ -76,6 +76,11 @@ export function ensureSchema(connection) {
|
|
|
76
76
|
ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
|
|
77
77
|
ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
|
|
78
78
|
ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
|
|
79
|
+
// Plan §8.4: stack-aware admission ordering needs the PR's base ref.
|
|
80
|
+
ensureColumn(connection, "queue_entries", "base_ref_name", "TEXT");
|
|
81
|
+
// Plan §5.3: cached identity for patch-id-aware updateHead.
|
|
82
|
+
ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
|
|
83
|
+
ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
|
|
79
84
|
connection.exec(`
|
|
80
85
|
UPDATE queue_entries
|
|
81
86
|
SET post_merge_status = 'pending'
|
|
@@ -12,9 +12,17 @@ export declare class SqliteStore implements QueueStore {
|
|
|
12
12
|
listActive(repoId: string): QueueEntry[];
|
|
13
13
|
listAll(repoId: string): QueueEntry[];
|
|
14
14
|
insert(entry: QueueEntry): void;
|
|
15
|
-
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
|
|
15
|
+
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
|
|
16
16
|
dequeue(entryId: string): void;
|
|
17
17
|
updateHead(entryId: string, newHeadSha: string): void;
|
|
18
|
+
rebuildSpecHeadEquivalent(entryId: string, patch: {
|
|
19
|
+
headSha: string;
|
|
20
|
+
specSha: string;
|
|
21
|
+
specBranch: string;
|
|
22
|
+
headPatchId: string;
|
|
23
|
+
specTreeId: string;
|
|
24
|
+
ciRunId: string | null;
|
|
25
|
+
}, detail?: string): void;
|
|
18
26
|
updatePriority(entryId: string, priority: number, detail?: string): void;
|
|
19
27
|
insertIncident(incident: IncidentRecord): void;
|
|
20
28
|
listIncidents(entryId: string): IncidentRecord[];
|
package/dist/db/sqlite-store.js
CHANGED
|
@@ -30,6 +30,9 @@ function mapEntry(row) {
|
|
|
30
30
|
postMergeSummary: row.post_merge_summary === null || row.post_merge_summary === undefined ? null : String(row.post_merge_summary),
|
|
31
31
|
postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
|
|
32
32
|
prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
|
|
33
|
+
baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
|
|
34
|
+
headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
|
|
35
|
+
specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
|
|
33
36
|
enqueuedAt: String(row.enqueued_at),
|
|
34
37
|
updatedAt: String(row.updated_at),
|
|
35
38
|
};
|
|
@@ -126,9 +129,9 @@ export class SqliteStore {
|
|
|
126
129
|
priority, generation, ci_run_id, ci_retries, retry_attempts,
|
|
127
130
|
max_retries, last_failed_base_sha, issue_key, wait_detail,
|
|
128
131
|
post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
|
|
129
|
-
pr_title,
|
|
132
|
+
pr_title, base_ref_name,
|
|
130
133
|
enqueued_at, updated_at)
|
|
131
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.enqueuedAt, entry.updatedAt);
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.enqueuedAt, entry.updatedAt);
|
|
132
135
|
this.writeEvent(entry.id, null, entry.status);
|
|
133
136
|
})();
|
|
134
137
|
}
|
|
@@ -199,6 +202,14 @@ export class SqliteStore {
|
|
|
199
202
|
sets.push("post_merge_checked_at = ?");
|
|
200
203
|
values.push(patch.postMergeCheckedAt);
|
|
201
204
|
}
|
|
205
|
+
if (patch?.headPatchId !== undefined) {
|
|
206
|
+
sets.push("head_patch_id = ?");
|
|
207
|
+
values.push(patch.headPatchId);
|
|
208
|
+
}
|
|
209
|
+
if (patch?.specTreeId !== undefined) {
|
|
210
|
+
sets.push("spec_tree_id = ?");
|
|
211
|
+
values.push(patch.specTreeId);
|
|
212
|
+
}
|
|
202
213
|
this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
|
|
203
214
|
this.writeEvent(entryId, from, to, detail);
|
|
204
215
|
})();
|
|
@@ -222,11 +233,34 @@ export class SqliteStore {
|
|
|
222
233
|
spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
|
|
223
234
|
wait_detail = NULL,
|
|
224
235
|
post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
|
|
236
|
+
head_patch_id = NULL, spec_tree_id = NULL,
|
|
225
237
|
updated_at = ?
|
|
226
238
|
WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
|
|
227
239
|
this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
|
|
228
240
|
})();
|
|
229
241
|
}
|
|
242
|
+
rebuildSpecHeadEquivalent(entryId, patch, detail) {
|
|
243
|
+
this.conn.transaction(() => {
|
|
244
|
+
const current = this.conn.prepare("SELECT status, generation FROM queue_entries WHERE id = ?").get(entryId);
|
|
245
|
+
if (!current)
|
|
246
|
+
return;
|
|
247
|
+
const from = String(current.status);
|
|
248
|
+
if (TERMINAL_STATUSES.includes(from))
|
|
249
|
+
return;
|
|
250
|
+
const newGen = Number(current.generation) + 1;
|
|
251
|
+
this.conn.prepare(`UPDATE queue_entries SET
|
|
252
|
+
head_sha = ?, status = 'validating', generation = ?,
|
|
253
|
+
ci_run_id = ?, ci_retries = 0, retry_attempts = 0,
|
|
254
|
+
last_failed_base_sha = NULL,
|
|
255
|
+
spec_branch = ?, spec_sha = ?,
|
|
256
|
+
head_patch_id = ?, spec_tree_id = ?,
|
|
257
|
+
wait_detail = NULL,
|
|
258
|
+
post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
|
|
259
|
+
updated_at = ?
|
|
260
|
+
WHERE id = ?`).run(patch.headSha, newGen, patch.ciRunId, patch.specBranch, patch.specSha, patch.headPatchId, patch.specTreeId, isoNow(), entryId);
|
|
261
|
+
this.writeEvent(entryId, from, "validating", detail ?? `patch-id-equivalent rebuild: generation ${newGen}`);
|
|
262
|
+
})();
|
|
263
|
+
}
|
|
230
264
|
updatePriority(entryId, priority, detail) {
|
|
231
265
|
this.conn.transaction(() => {
|
|
232
266
|
const current = this.conn.prepare("SELECT status, priority FROM queue_entries WHERE id = ?").get(entryId);
|
package/dist/github/pr-client.js
CHANGED
|
@@ -24,7 +24,9 @@ export class GitHubPRClient {
|
|
|
24
24
|
const result = await exec("gh", [
|
|
25
25
|
"pr", "view", String(prNumber),
|
|
26
26
|
"--repo", this.repoFullName,
|
|
27
|
-
|
|
27
|
+
// Plan §8.4 change 3a: include baseRefName so the queue can
|
|
28
|
+
// detect stacked PRs at admission time.
|
|
29
|
+
"--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,state,mergeStateStatus",
|
|
28
30
|
], { githubRepoFullName: this.repoFullName });
|
|
29
31
|
const data = JSON.parse(result.stdout);
|
|
30
32
|
return {
|
|
@@ -32,6 +34,7 @@ export class GitHubPRClient {
|
|
|
32
34
|
branch: data.headRefName,
|
|
33
35
|
headSha: data.headRefOid,
|
|
34
36
|
...(data.title ? { title: data.title } : {}),
|
|
37
|
+
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
|
|
35
38
|
mergeable: data.state === "OPEN",
|
|
36
39
|
mergeStateStatus: data.mergeStateStatus,
|
|
37
40
|
reviewDecision: data.reviewDecision,
|
|
@@ -25,6 +25,11 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
|
|
|
25
25
|
headSha(branch: string): Promise<string>;
|
|
26
26
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
27
27
|
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
28
|
+
patchIdAgainst(base: string, headSha: string): Promise<string | undefined>;
|
|
29
|
+
integrationTreeId(base: string, headSha: string): Promise<string | undefined>;
|
|
30
|
+
treeId(commitSha: string): Promise<string | undefined>;
|
|
31
|
+
commitTree(tree: string, parents: string[], message: string): Promise<string | undefined>;
|
|
32
|
+
pushCommit(commitSha: string, branch: string): Promise<void>;
|
|
28
33
|
/**
|
|
29
34
|
* Build a speculative merge branch using an isolated git worktree.
|
|
30
35
|
* Each call gets its own working directory — no shared mutable state.
|
package/dist/github/shell-git.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { exec } from "../exec.js";
|
|
4
|
+
/** Shell-quote a string for sh -c invocation (single-quote, escape inner quotes). */
|
|
5
|
+
function shellQuote(value) {
|
|
6
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
7
|
+
}
|
|
4
8
|
/** Extract conflict file names from git merge stderr. */
|
|
5
9
|
function parseConflicts(stderr) {
|
|
6
10
|
const files = stderr
|
|
@@ -90,6 +94,64 @@ export class ShellGitOperations {
|
|
|
90
94
|
args.push("origin", refspec);
|
|
91
95
|
await this.git(args, { timeoutMs: 60_000 });
|
|
92
96
|
}
|
|
97
|
+
// Plan §5.3: identity primitives for patch-id-aware updateHead.
|
|
98
|
+
async patchIdAgainst(base, headSha) {
|
|
99
|
+
// `git diff base..head | git patch-id --stable` — invoke as one
|
|
100
|
+
// pipeline through a shell so we don't have to plumb stdin/stdout.
|
|
101
|
+
const result = await exec("sh", ["-c", `${this.gitBin} -C ${shellQuote(this.clonePath)} diff ${shellQuote(base)}..${shellQuote(headSha)} | ${this.gitBin} patch-id --stable`], {
|
|
102
|
+
timeoutMs: 30_000,
|
|
103
|
+
allowNonZero: true,
|
|
104
|
+
githubRepoFullName: this.repoFullName,
|
|
105
|
+
});
|
|
106
|
+
if (result.exitCode !== 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
const first = result.stdout.split(/\s+/, 1)[0]?.trim();
|
|
109
|
+
return first ? first : undefined;
|
|
110
|
+
}
|
|
111
|
+
async integrationTreeId(base, headSha) {
|
|
112
|
+
const result = await this.git(["merge-tree", "--write-tree", "--no-messages", base, headSha], { allowNonZero: true, timeoutMs: 60_000 });
|
|
113
|
+
if (result.exitCode !== 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const tree = result.stdout.trim().split(/\s+/, 1)[0];
|
|
116
|
+
return tree || undefined;
|
|
117
|
+
}
|
|
118
|
+
async treeId(commitSha) {
|
|
119
|
+
const result = await this.git(["rev-parse", `${commitSha}^{tree}`], { allowNonZero: true });
|
|
120
|
+
if (result.exitCode !== 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const tree = result.stdout.trim();
|
|
123
|
+
return tree || undefined;
|
|
124
|
+
}
|
|
125
|
+
async commitTree(tree, parents, message) {
|
|
126
|
+
const args = ["commit-tree", tree];
|
|
127
|
+
for (const parent of parents) {
|
|
128
|
+
args.push("-p", parent);
|
|
129
|
+
}
|
|
130
|
+
args.push("-m", message);
|
|
131
|
+
const env = {};
|
|
132
|
+
if (this.botIdentity) {
|
|
133
|
+
env.GIT_AUTHOR_NAME = this.botIdentity.name;
|
|
134
|
+
env.GIT_AUTHOR_EMAIL = this.botIdentity.email;
|
|
135
|
+
env.GIT_COMMITTER_NAME = this.botIdentity.name;
|
|
136
|
+
env.GIT_COMMITTER_EMAIL = this.botIdentity.email;
|
|
137
|
+
}
|
|
138
|
+
const result = await exec(this.gitBin, ["-C", this.clonePath, ...args], {
|
|
139
|
+
timeoutMs: 30_000,
|
|
140
|
+
allowNonZero: true,
|
|
141
|
+
githubRepoFullName: this.repoFullName,
|
|
142
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
143
|
+
});
|
|
144
|
+
if (result.exitCode !== 0)
|
|
145
|
+
return undefined;
|
|
146
|
+
const sha = result.stdout.trim();
|
|
147
|
+
return sha || undefined;
|
|
148
|
+
}
|
|
149
|
+
async pushCommit(commitSha, branch) {
|
|
150
|
+
// Force-update the named branch on origin to a known commit
|
|
151
|
+
// SHA. Used by the patch-id-aware rebuild path which writes a
|
|
152
|
+
// synthetic commit-tree result without checking it out.
|
|
153
|
+
await this.git(["push", "--force", "origin", `${commitSha}:refs/heads/${branch}`], { timeoutMs: 60_000 });
|
|
154
|
+
}
|
|
93
155
|
// ─── SpeculativeBranchBuilder ───────────────────────────────
|
|
94
156
|
/**
|
|
95
157
|
* Build a speculative merge branch using an isolated git worktree.
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -8,6 +8,16 @@ export interface GitOperations {
|
|
|
8
8
|
headSha(branch: string): Promise<string>;
|
|
9
9
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
10
10
|
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
11
|
+
/** Stable patch-id of head's diff against base. Returns undefined on git error. */
|
|
12
|
+
patchIdAgainst?(base: string, headSha: string): Promise<string | undefined>;
|
|
13
|
+
/** `git merge-tree --write-tree base headSha` — returns tree-id, or undefined on conflict/error. */
|
|
14
|
+
integrationTreeId?(base: string, headSha: string): Promise<string | undefined>;
|
|
15
|
+
/** Tree id of a commit (`commit^{tree}`). */
|
|
16
|
+
treeId?(commitSha: string): Promise<string | undefined>;
|
|
17
|
+
/** `git commit-tree tree -p p1 -p p2 -m message`. Returns the new commit SHA. */
|
|
18
|
+
commitTree?(tree: string, parents: string[], message: string): Promise<string | undefined>;
|
|
19
|
+
/** Force-push a known commit SHA to a branch (overrides the target ref directly). */
|
|
20
|
+
pushCommit?(commitSha: string, branch: string): Promise<void>;
|
|
11
21
|
}
|
|
12
22
|
/**
|
|
13
23
|
* Builds and manages speculative cumulative branches.
|
|
@@ -41,6 +41,18 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
41
41
|
const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
|
|
42
42
|
if (currentRef !== entry.headSha) {
|
|
43
43
|
emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
|
|
44
|
+
// Plan §5.3: patch-id-aware short-circuit. If the new head is
|
|
45
|
+
// patch-id-equivalent to the prior head AND the freshly merged
|
|
46
|
+
// tree matches the cached spec tree, rebuild the spec commit
|
|
47
|
+
// with the new head as its second parent so GitHub still marks
|
|
48
|
+
// the PR merged after the lander's fast-forward — but skip the
|
|
49
|
+
// prepare cycle. CI must re-run on the new spec SHA (check_runs
|
|
50
|
+
// are SHA-anchored). Falls back to the standard `updateHead`
|
|
51
|
+
// path if the git plumbing is unavailable or if the identities
|
|
52
|
+
// don't match.
|
|
53
|
+
const shortCircuited = await tryPatchIdEquivalentRebuild(ctx, entry, base, currentRef);
|
|
54
|
+
if (shortCircuited)
|
|
55
|
+
return;
|
|
44
56
|
ctx.store.updateHead(entry.id, currentRef);
|
|
45
57
|
return;
|
|
46
58
|
}
|
|
@@ -170,6 +182,16 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
170
182
|
}
|
|
171
183
|
const runId = await ctx.ci.triggerRun(specName, specSha);
|
|
172
184
|
emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
|
|
185
|
+
// Plan §5.3: cache identity for the patch-id-aware short-circuit
|
|
186
|
+
// on subsequent updateHead detections. Best-effort — if the git
|
|
187
|
+
// primitives are missing the cached identity stays null and the
|
|
188
|
+
// short-circuit never fires (existing behavior).
|
|
189
|
+
const headPatchId = ctx.git.patchIdAgainst
|
|
190
|
+
? await ctx.git.patchIdAgainst(base, entry.headSha).catch(() => undefined)
|
|
191
|
+
: undefined;
|
|
192
|
+
const specTreeId = ctx.git.treeId
|
|
193
|
+
? await ctx.git.treeId(specSha).catch(() => undefined)
|
|
194
|
+
: undefined;
|
|
173
195
|
ctx.store.transition(entry.id, "validating", {
|
|
174
196
|
baseSha,
|
|
175
197
|
ciRunId: runId,
|
|
@@ -177,5 +199,84 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
177
199
|
specBranch: specName,
|
|
178
200
|
specSha,
|
|
179
201
|
specBasedOn: isHead ? null : prevEntry.id,
|
|
202
|
+
...(headPatchId !== undefined ? { headPatchId } : {}),
|
|
203
|
+
...(specTreeId !== undefined ? { specTreeId } : {}),
|
|
180
204
|
}, `spec ready, CI ${runId.slice(0, 12)}`);
|
|
181
205
|
}
|
|
206
|
+
async function tryPatchIdEquivalentRebuild(ctx, entry, base, newHead) {
|
|
207
|
+
// Need the cached identity *and* the git primitives required to
|
|
208
|
+
// verify the equivalence and rebuild the spec commit. If any
|
|
209
|
+
// piece is missing, fall back.
|
|
210
|
+
if (!entry.headPatchId || !entry.specTreeId || !entry.specBranch || !entry.specSha) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const git = ctx.git;
|
|
214
|
+
if (!git.patchIdAgainst || !git.integrationTreeId || !git.commitTree || !git.pushCommit) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
let newPatchId;
|
|
218
|
+
let newTreeId;
|
|
219
|
+
let baseSha;
|
|
220
|
+
try {
|
|
221
|
+
[newPatchId, newTreeId, baseSha] = await Promise.all([
|
|
222
|
+
git.patchIdAgainst(base, newHead),
|
|
223
|
+
git.integrationTreeId(base, newHead),
|
|
224
|
+
git.headSha(base),
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (!newPatchId || !newTreeId || !baseSha)
|
|
231
|
+
return false;
|
|
232
|
+
if (newPatchId !== entry.headPatchId)
|
|
233
|
+
return false;
|
|
234
|
+
if (newTreeId !== entry.specTreeId)
|
|
235
|
+
return false;
|
|
236
|
+
// Tree-equivalent. Rebuild spec commit with the new head as the
|
|
237
|
+
// second parent so post-merge PR detection still works.
|
|
238
|
+
const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
|
|
239
|
+
const message = `Merge PR #${entry.prNumber}: ${branchSuffix} (rebased)`;
|
|
240
|
+
let newSpecSha;
|
|
241
|
+
try {
|
|
242
|
+
newSpecSha = await git.commitTree(entry.specTreeId, [baseSha, newHead], message);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (!newSpecSha)
|
|
248
|
+
return false;
|
|
249
|
+
try {
|
|
250
|
+
await git.pushCommit(newSpecSha, entry.specBranch);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
emit(ctx, entry, "spec_build_succeeded", {
|
|
256
|
+
specBranch: entry.specBranch,
|
|
257
|
+
detail: "patch-id-equivalent rebuild",
|
|
258
|
+
});
|
|
259
|
+
// Plan §5.2: re-announce spec availability on the new head.
|
|
260
|
+
if (ctx.eviction.reportSpecReady) {
|
|
261
|
+
await ctx.eviction.reportSpecReady(entry, entry.specBranch, newSpecSha).catch(() => undefined);
|
|
262
|
+
}
|
|
263
|
+
let runId = null;
|
|
264
|
+
try {
|
|
265
|
+
runId = await ctx.ci.triggerRun(entry.specBranch, newSpecSha);
|
|
266
|
+
emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: entry.specBranch });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
runId = null;
|
|
270
|
+
}
|
|
271
|
+
ctx.store.rebuildSpecHeadEquivalent(entry.id, {
|
|
272
|
+
headSha: newHead,
|
|
273
|
+
specSha: newSpecSha,
|
|
274
|
+
specBranch: entry.specBranch,
|
|
275
|
+
headPatchId: newPatchId,
|
|
276
|
+
specTreeId: newTreeId,
|
|
277
|
+
ciRunId: runId,
|
|
278
|
+
}, runId
|
|
279
|
+
? `patch-id-equivalent rebuild, CI ${runId.slice(0, 12)}`
|
|
280
|
+
: "patch-id-equivalent rebuild (CI trigger pending)");
|
|
281
|
+
return true;
|
|
282
|
+
}
|
package/dist/service-queue.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export declare class MergeStewardQueueCommands {
|
|
|
19
19
|
issueKey?: string;
|
|
20
20
|
priority?: number;
|
|
21
21
|
prTitle?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Plan §8.4: PR's base ref. When this matches another open PR's
|
|
24
|
+
* `branch` (head ref), the entry is stacked and admission orders
|
|
25
|
+
* it immediately behind the parent.
|
|
26
|
+
*/
|
|
27
|
+
baseRefName?: string;
|
|
22
28
|
}): QueueEntry | undefined;
|
|
23
29
|
scanStartupAdmissions(): Promise<void>;
|
|
24
30
|
scanEligibleOpenPrs(): Promise<{
|
|
@@ -34,4 +40,5 @@ export declare class MergeStewardQueueCommands {
|
|
|
34
40
|
private findAffectedEntriesAfterPriorityChange;
|
|
35
41
|
private requeueAffectedEntries;
|
|
36
42
|
private nextPosition;
|
|
43
|
+
private findActiveEntryByBranch;
|
|
37
44
|
}
|
package/dist/service-queue.js
CHANGED
|
@@ -63,7 +63,10 @@ export class MergeStewardQueueCommands {
|
|
|
63
63
|
postMergeSha: null,
|
|
64
64
|
postMergeSummary: null,
|
|
65
65
|
postMergeCheckedAt: null,
|
|
66
|
+
headPatchId: null,
|
|
67
|
+
specTreeId: null,
|
|
66
68
|
prTitle: params.prTitle ?? null,
|
|
69
|
+
baseRefName: params.baseRefName ?? null,
|
|
67
70
|
enqueuedAt: new Date().toISOString(),
|
|
68
71
|
updatedAt: new Date().toISOString(),
|
|
69
72
|
};
|
|
@@ -183,12 +186,36 @@ export class MergeStewardQueueCommands {
|
|
|
183
186
|
return false;
|
|
184
187
|
}
|
|
185
188
|
}
|
|
189
|
+
// Plan §8.4: stack-aware admission. When the PR's base ref
|
|
190
|
+
// names another open PR's branch (head ref), the entry is
|
|
191
|
+
// stacked. Enqueue it only when the parent is already in the
|
|
192
|
+
// queue — otherwise the child's spec would build against the
|
|
193
|
+
// unmerged parent's pre-spec content. Positions are monotonic
|
|
194
|
+
// (`nextPosition`), so the child's position is always greater
|
|
195
|
+
// than the parent's; sibling PRs admitted between the parent's
|
|
196
|
+
// enqueue and the child's tryAdmit can sit between them. That's
|
|
197
|
+
// fine: queue head selection orders by (priority, position), so
|
|
198
|
+
// the parent is still processed before the child. The functional
|
|
199
|
+
// guarantee is "parent before child", not strict adjacency.
|
|
200
|
+
const baseRefName = status.baseRefName ?? null;
|
|
201
|
+
if (baseRefName && baseRefName !== this.config.baseBranch) {
|
|
202
|
+
const parentEntry = this.findActiveEntryByBranch(baseRefName);
|
|
203
|
+
if (!parentEntry) {
|
|
204
|
+
this.logger.debug({
|
|
205
|
+
prNumber,
|
|
206
|
+
baseRefName,
|
|
207
|
+
repoBaseBranch: this.config.baseBranch,
|
|
208
|
+
}, "Deferring admission for stacked PR — parent branch is not in the queue yet");
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
186
212
|
this.enqueue({
|
|
187
213
|
prNumber,
|
|
188
214
|
branch,
|
|
189
215
|
headSha,
|
|
190
216
|
priority,
|
|
191
217
|
...(status.title ? { prTitle: status.title } : {}),
|
|
218
|
+
...(baseRefName ? { baseRefName } : {}),
|
|
192
219
|
});
|
|
193
220
|
return true;
|
|
194
221
|
}
|
|
@@ -277,4 +304,10 @@ export class MergeStewardQueueCommands {
|
|
|
277
304
|
}
|
|
278
305
|
return next;
|
|
279
306
|
}
|
|
307
|
+
// Plan §8.4: lookup helper used by stack-aware admission. Returns
|
|
308
|
+
// the active queue entry whose `branch` (head ref) matches `name`,
|
|
309
|
+
// or undefined when no such entry is in the queue.
|
|
310
|
+
findActiveEntryByBranch(name) {
|
|
311
|
+
return this.store.listActive(this.config.repoId).find((entry) => entry.branch === name);
|
|
312
|
+
}
|
|
280
313
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -11,9 +11,27 @@ export interface QueueStore {
|
|
|
11
11
|
listActive(repoId: string): QueueEntry[];
|
|
12
12
|
listAll(repoId: string): QueueEntry[];
|
|
13
13
|
insert(entry: QueueEntry): void;
|
|
14
|
-
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
|
|
14
|
+
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
|
|
15
15
|
dequeue(entryId: string): void;
|
|
16
16
|
updateHead(entryId: string, newHeadSha: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Plan §5.3: patch-id-aware short-circuit. The new head is
|
|
19
|
+
* patch-id-equivalent to the cached identity AND the freshly
|
|
20
|
+
* computed merge tree matches the cached spec tree. We rebuild the
|
|
21
|
+
* spec commit with the new head as its second parent (so GitHub
|
|
22
|
+
* marks the PR merged after fast-forward) but do not blow away the
|
|
23
|
+
* spec branch or restart the prepare cycle. CI must re-run on the
|
|
24
|
+
* new spec SHA — check_runs are SHA-anchored and there is no API
|
|
25
|
+
* to reuse a passing verdict.
|
|
26
|
+
*/
|
|
27
|
+
rebuildSpecHeadEquivalent(entryId: string, patch: {
|
|
28
|
+
headSha: string;
|
|
29
|
+
specSha: string;
|
|
30
|
+
specBranch: string;
|
|
31
|
+
headPatchId: string;
|
|
32
|
+
specTreeId: string;
|
|
33
|
+
ciRunId: string | null;
|
|
34
|
+
}, detail?: string): void;
|
|
17
35
|
updatePriority(entryId: string, priority: number, detail?: string): void;
|
|
18
36
|
insertIncident(incident: IncidentRecord): void;
|
|
19
37
|
listIncidents(entryId: string): IncidentRecord[];
|
package/dist/types.d.ts
CHANGED
|
@@ -50,6 +50,28 @@ export interface QueueEntry {
|
|
|
50
50
|
postMergeSummary: string | null;
|
|
51
51
|
/** Timestamp of the most recent post-merge verification attempt. */
|
|
52
52
|
postMergeCheckedAt: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Plan §8.4: PR's base ref captured at admission. When this names
|
|
55
|
+
* another open PR's `branch` (head ref), the entry is stacked and
|
|
56
|
+
* the queue holds it behind the parent until the parent is itself
|
|
57
|
+
* admitted. `null` when the PR was opened against the repo default.
|
|
58
|
+
*/
|
|
59
|
+
baseRefName: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Plan §5.3: stable patch-id (`git patch-id --stable`) of the
|
|
62
|
+
* current `headSha` against the entry's `baseSha`. Cached at spec
|
|
63
|
+
* build time so the prepare path can short-circuit on
|
|
64
|
+
* patch-id-equivalent updateHeads without re-running the merge.
|
|
65
|
+
* Null until the spec is first built.
|
|
66
|
+
*/
|
|
67
|
+
headPatchId: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* Plan §5.3: tree id of `specSha` (i.e. `git rev-parse specSha^{tree}`).
|
|
70
|
+
* Cached at spec build time so we can compare against a freshly
|
|
71
|
+
* computed `merge-tree --write-tree` for the new head and decide
|
|
72
|
+
* whether the merged tree is unchanged.
|
|
73
|
+
*/
|
|
74
|
+
specTreeId: string | null;
|
|
53
75
|
enqueuedAt: string;
|
|
54
76
|
updatedAt: string;
|
|
55
77
|
}
|
|
@@ -183,6 +205,12 @@ export interface PRStatus {
|
|
|
183
205
|
branch: string;
|
|
184
206
|
headSha: string;
|
|
185
207
|
title?: string | undefined;
|
|
208
|
+
/**
|
|
209
|
+
* Plan §8.4: PR's base ref. When this names another open PR's
|
|
210
|
+
* `branch` (head ref), the PR is stacked and admission must defer
|
|
211
|
+
* until the parent is in the queue.
|
|
212
|
+
*/
|
|
213
|
+
baseRefName?: string | undefined;
|
|
186
214
|
mergeable: boolean;
|
|
187
215
|
mergeStateStatus?: string | undefined;
|
|
188
216
|
reviewDecision?: string | undefined;
|