merge-steward 0.35.0 → 0.35.2
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/README.md +4 -0
- package/dist/cli/commands/pr-status.js +4 -3
- package/dist/cli/commands/queue.js +1 -1
- package/dist/config.js +1 -1
- package/dist/db/schema.js +20 -6
- package/dist/github/pr-client.d.ts +6 -0
- package/dist/github/pr-client.js +6 -2
- package/dist/install.js +1 -1
- package/dist/reconciler-evict.d.ts +2 -0
- package/dist/reconciler-evict.js +20 -0
- package/dist/reconciler-merge.js +3 -13
- package/dist/reconciler-prepare.js +2 -3
- package/dist/reconciler-sanitize.js +5 -1
- package/dist/reconciler-validate.js +18 -4
- package/dist/reconciler.js +1 -1
- package/dist/service-queue.js +32 -39
- package/dist/types.d.ts +4 -3
- package/dist/types.js +1 -1
- package/dist/watch/dashboard-model.js +9 -1
- package/dist/watch/format.js +10 -0
- package/dist/webhook-handler.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
|
|
|
15
15
|
## How it works
|
|
16
16
|
|
|
17
17
|
1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
|
|
18
|
+
If GitHub names no required checks, every observed check on the exact head
|
|
19
|
+
must be settled without failure; one early green job is not enough.
|
|
18
20
|
2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
|
|
19
21
|
3. It freezes the approved PR head and resolves the exact future-`main`
|
|
20
22
|
candidate. If the prospective base is its ancestor, that head is the
|
|
@@ -22,6 +24,8 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
|
|
|
22
24
|
`merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
|
|
23
25
|
4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
|
|
24
26
|
5. Immediately before landing, it refreshes policy, approval, head, checks, and ancestry, then non-force pushes the same immutable SHA to `main`. It never substitutes a mutable branch ref.
|
|
27
|
+
If the PR head changes, the old admission becomes `superseded`; the new
|
|
28
|
+
head returns to feature review and branch CI before receiving a new place.
|
|
25
29
|
6. On conflict, the workspace remains at the prospective base; PatchRelay
|
|
26
30
|
derives the missing ancestry and non-force pushes a resolved candidate.
|
|
27
31
|
7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
|
|
@@ -5,7 +5,7 @@ import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.j
|
|
|
5
5
|
import { parseIntegerFlag } from "../args.js";
|
|
6
6
|
import { fetchPrGitHubOverview } from "./pr-github.js";
|
|
7
7
|
import { formatRuntimeActivity } from "../../runtime-format.js";
|
|
8
|
-
const TERMINAL_QUEUE_STATUSES = new Set(["merged", "evicted", "dequeued"]);
|
|
8
|
+
const TERMINAL_QUEUE_STATUSES = new Set(["merged", "evicted", "dequeued", "superseded"]);
|
|
9
9
|
export function computeActiveRank(entries, target) {
|
|
10
10
|
if (TERMINAL_QUEUE_STATUSES.has(target.status))
|
|
11
11
|
return undefined;
|
|
@@ -26,6 +26,7 @@ export function classifyQueueEntry(entry) {
|
|
|
26
26
|
case "merging": return "merging";
|
|
27
27
|
case "evicted": return "evicted";
|
|
28
28
|
case "dequeued": return "dequeued";
|
|
29
|
+
case "superseded": return "not_queued";
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
function exitCodeForKind(kind) {
|
|
@@ -78,7 +79,7 @@ export function classifyGitHubOverview(overview) {
|
|
|
78
79
|
return { kind: "not_queued", reason: "PR is not admitted to the merge queue" };
|
|
79
80
|
}
|
|
80
81
|
function findEntryForPr(snapshot, prNumber) {
|
|
81
|
-
const matches = snapshot.entries.filter((entry) => entry.prNumber === prNumber);
|
|
82
|
+
const matches = snapshot.entries.filter((entry) => entry.prNumber === prNumber && entry.status !== "superseded");
|
|
82
83
|
if (matches.length === 0)
|
|
83
84
|
return undefined;
|
|
84
85
|
matches.sort((left, right) => {
|
|
@@ -114,7 +115,7 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
114
115
|
const store = new SqliteStore(config.database.path);
|
|
115
116
|
try {
|
|
116
117
|
const all = store.listAll(config.repoId);
|
|
117
|
-
const entries = all.filter((entry) => entry.prNumber === prNumber);
|
|
118
|
+
const entries = all.filter((entry) => entry.prNumber === prNumber && entry.status !== "superseded");
|
|
118
119
|
if (entries.length === 0)
|
|
119
120
|
return { kind: "not_found" };
|
|
120
121
|
entries.sort((left, right) => {
|
|
@@ -82,7 +82,7 @@ export function formatQueueStatusText(source, snapshot) {
|
|
|
82
82
|
...(githubPolicy.fetchedAt ? [`GitHub policy fetched: ${githubPolicy.fetchedAt}`] : []),
|
|
83
83
|
`Active entries: ${snapshot.summary.active}`,
|
|
84
84
|
`Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
|
|
85
|
-
`Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
|
|
85
|
+
`Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued} superseded: ${snapshot.summary.superseded}`,
|
|
86
86
|
snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
|
|
87
87
|
...formatRuntimeActivity(snapshot.runtime),
|
|
88
88
|
...(snapshot.runtime.lastTickOutcome === "failed"
|
package/dist/config.js
CHANGED
|
@@ -14,7 +14,7 @@ export const stewardConfigSchema = z.object({
|
|
|
14
14
|
maxRetries: z.number().int().min(0).default(2),
|
|
15
15
|
flakyRetries: z.number().int().min(0).default(1),
|
|
16
16
|
/** Max speculative branches to maintain in parallel. 1 = serial mode. */
|
|
17
|
-
speculativeDepth: z.number().int().min(1).default(
|
|
17
|
+
speculativeDepth: z.number().int().min(1).default(3),
|
|
18
18
|
pollIntervalMs: z.number().int().min(1000).default(30_000),
|
|
19
19
|
reconcileStaleAfterMs: z.number().int().min(1000).default(5 * 60_000),
|
|
20
20
|
server: z.object({
|
package/dist/db/schema.js
CHANGED
|
@@ -6,6 +6,7 @@ export function ensureSchema(connection) {
|
|
|
6
6
|
`).all();
|
|
7
7
|
if (existingTables.length > 0) {
|
|
8
8
|
assertCurrentSchema(connection);
|
|
9
|
+
ensureActiveEntryIndex(connection);
|
|
9
10
|
return;
|
|
10
11
|
}
|
|
11
12
|
connection.exec(`
|
|
@@ -80,14 +81,27 @@ export function ensureSchema(connection) {
|
|
|
80
81
|
CREATE INDEX IF NOT EXISTS idx_queue_events_entry
|
|
81
82
|
ON queue_events(entry_id, id)
|
|
82
83
|
`);
|
|
83
|
-
|
|
84
|
-
connection.exec(`
|
|
85
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_per_pr
|
|
86
|
-
ON queue_entries(repo_id, pr_number)
|
|
87
|
-
WHERE status NOT IN ('merged', 'evicted', 'dequeued')
|
|
88
|
-
`);
|
|
84
|
+
ensureActiveEntryIndex(connection);
|
|
89
85
|
assertCurrentSchema(connection);
|
|
90
86
|
}
|
|
87
|
+
function ensureActiveEntryIndex(connection) {
|
|
88
|
+
// Must match TERMINAL_STATUSES in types.ts. Recreate this partial index so
|
|
89
|
+
// an existing database learns about newly-added terminal states.
|
|
90
|
+
const current = connection.prepare(`
|
|
91
|
+
SELECT sql FROM sqlite_master
|
|
92
|
+
WHERE type = 'index' AND name = 'idx_one_active_per_pr'
|
|
93
|
+
`).get();
|
|
94
|
+
if (String(current?.sql ?? "").toLowerCase().includes("'superseded'"))
|
|
95
|
+
return;
|
|
96
|
+
connection.transaction(() => {
|
|
97
|
+
connection.exec(`DROP INDEX IF EXISTS idx_one_active_per_pr`);
|
|
98
|
+
connection.exec(`
|
|
99
|
+
CREATE UNIQUE INDEX idx_one_active_per_pr
|
|
100
|
+
ON queue_entries(repo_id, pr_number)
|
|
101
|
+
WHERE status NOT IN ('merged', 'evicted', 'dequeued', 'superseded')
|
|
102
|
+
`);
|
|
103
|
+
})();
|
|
104
|
+
}
|
|
91
105
|
const QUEUE_ENTRY_COLUMNS = [
|
|
92
106
|
"id", "repo_id", "pr_number", "branch", "head_sha", "base_sha", "status",
|
|
93
107
|
"position", "priority", "generation", "ci_run_id", "ci_retries",
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { GitHubPRApi } from "../interfaces.ts";
|
|
2
2
|
import type { CheckResult, PRStatus } from "../types.ts";
|
|
3
|
+
export declare function hasApprovalForHead(reviews: Array<{
|
|
4
|
+
state?: string;
|
|
5
|
+
commit?: {
|
|
6
|
+
oid?: string;
|
|
7
|
+
};
|
|
8
|
+
}> | undefined, headSha: string): boolean;
|
|
3
9
|
/**
|
|
4
10
|
* GitHub PR operations via gh CLI and REST API.
|
|
5
11
|
*
|
package/dist/github/pr-client.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { mapGitHubCheckConclusion } from "../check-policy.js";
|
|
2
2
|
import { exec } from "../exec.js";
|
|
3
|
+
export function hasApprovalForHead(reviews, headSha) {
|
|
4
|
+
return (reviews ?? []).some((review) => review.state === "APPROVED" && review.commit?.oid === headSha);
|
|
5
|
+
}
|
|
3
6
|
/**
|
|
4
7
|
* GitHub PR operations via gh CLI and REST API.
|
|
5
8
|
*
|
|
@@ -26,7 +29,7 @@ export class GitHubPRClient {
|
|
|
26
29
|
"pr", "view", String(prNumber),
|
|
27
30
|
"--repo", this.repoFullName,
|
|
28
31
|
// The base ref lets admission detect stacked PRs.
|
|
29
|
-
"--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,state,mergeStateStatus",
|
|
32
|
+
"--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,reviews,state,mergeStateStatus",
|
|
30
33
|
], { githubRepoFullName: this.repoFullName });
|
|
31
34
|
const data = JSON.parse(result.stdout);
|
|
32
35
|
return {
|
|
@@ -38,7 +41,8 @@ export class GitHubPRClient {
|
|
|
38
41
|
mergeable: data.state === "OPEN",
|
|
39
42
|
mergeStateStatus: data.mergeStateStatus,
|
|
40
43
|
reviewDecision: data.reviewDecision,
|
|
41
|
-
reviewApproved: data.reviewDecision === "APPROVED"
|
|
44
|
+
reviewApproved: data.reviewDecision === "APPROVED"
|
|
45
|
+
&& hasApprovalForHead(data.reviews, data.headRefOid),
|
|
42
46
|
merged: data.state === "MERGED",
|
|
43
47
|
};
|
|
44
48
|
}
|
package/dist/install.js
CHANGED
|
@@ -163,7 +163,7 @@ export async function upsertRepoConfig(options) {
|
|
|
163
163
|
gitBin: existing?.gitBin ?? "git",
|
|
164
164
|
maxRetries: existing?.maxRetries ?? 2,
|
|
165
165
|
flakyRetries: existing?.flakyRetries ?? 1,
|
|
166
|
-
speculativeDepth: existing?.speculativeDepth ??
|
|
166
|
+
speculativeDepth: existing?.speculativeDepth ?? 3,
|
|
167
167
|
pollIntervalMs: existing?.pollIntervalMs ?? 30_000,
|
|
168
168
|
server: {
|
|
169
169
|
bind: existing?.server.bind ?? (homeConfig.server?.bind ?? "127.0.0.1"),
|
|
@@ -2,6 +2,8 @@ import type { FailureClass, QueueEntry } from "./types.ts";
|
|
|
2
2
|
import type { ReconcileContext } from "./reconciler-core.ts";
|
|
3
3
|
export declare function cleanupCandidate(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
|
|
4
4
|
export declare function invalidateDownstream(ctx: ReconcileContext, allActive: QueueEntry[], afterIndex: number): Promise<void>;
|
|
5
|
+
/** Retire the immutable admission when GitHub exposes a different PR head. */
|
|
6
|
+
export declare function supersedeAdmittedHead(ctx: ReconcileContext, entry: QueueEntry, newHeadSha: string): Promise<void>;
|
|
5
7
|
export declare function evictEntry(ctx: ReconcileContext, entry: QueueEntry, failureClass: FailureClass, extra?: {
|
|
6
8
|
conflictFiles?: string[];
|
|
7
9
|
failedChecks?: Array<{
|
package/dist/reconciler-evict.js
CHANGED
|
@@ -17,6 +17,26 @@ export async function invalidateDownstream(ctx, allActive, afterIndex) {
|
|
|
17
17
|
ctx.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, "invalidated: base changed");
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/** Retire the immutable admission when GitHub exposes a different PR head. */
|
|
21
|
+
export async function supersedeAdmittedHead(ctx, entry, newHeadSha) {
|
|
22
|
+
const allActive = ctx.store.listActive(ctx.repoId);
|
|
23
|
+
const index = allActive.findIndex((candidate) => candidate.id === entry.id);
|
|
24
|
+
emit(ctx, entry, "branch_mismatch", {
|
|
25
|
+
detail: `admitted head ${entry.headSha.slice(0, 12)} superseded by ${newHeadSha.slice(0, 12)}`,
|
|
26
|
+
});
|
|
27
|
+
await cleanupCandidate(ctx, entry);
|
|
28
|
+
ctx.store.transition(entry.id, "superseded", {
|
|
29
|
+
...CLEAN_CANDIDATE_REF,
|
|
30
|
+
candidateKind: null,
|
|
31
|
+
candidatePolicyFingerprint: null,
|
|
32
|
+
candidateSha: null,
|
|
33
|
+
ciRunId: null,
|
|
34
|
+
ciRetries: 0,
|
|
35
|
+
waitDetail: null,
|
|
36
|
+
}, `admitted head ${entry.headSha.slice(0, 12)} superseded by ${newHeadSha.slice(0, 12)}; new head must pass admission`);
|
|
37
|
+
if (index >= 0)
|
|
38
|
+
await invalidateDownstream(ctx, allActive, index);
|
|
39
|
+
}
|
|
20
40
|
export async function evictEntry(ctx, entry, failureClass, extra) {
|
|
21
41
|
await cleanupCandidate(ctx, entry);
|
|
22
42
|
let baseSha = entry.baseSha;
|
package/dist/reconciler-merge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CLEAN_CANDIDATE_REF, CLEAN_CI, CLEAR_CANDIDATE, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
|
|
2
|
-
import { cleanupCandidate, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
|
|
2
|
+
import { cleanupCandidate, evictEntry, invalidateDownstream, supersedeAdmittedHead } from "./reconciler-evict.js";
|
|
3
3
|
import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
|
|
4
4
|
import { evaluateCheckPolicy, formatRequiredCheck } from "./check-policy.js";
|
|
5
5
|
import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
|
|
@@ -157,11 +157,7 @@ export async function mergeHead(ctx, entry) {
|
|
|
157
157
|
return;
|
|
158
158
|
}
|
|
159
159
|
if (prStatus.headSha !== entry.headSha) {
|
|
160
|
-
|
|
161
|
-
const allActive = ctx.store.listActive(ctx.repoId);
|
|
162
|
-
await cleanupCandidate(ctx, entry);
|
|
163
|
-
ctx.store.updateHead(entry.id, prStatus.headSha);
|
|
164
|
-
await invalidateDownstream(ctx, allActive, 0);
|
|
160
|
+
await supersedeAdmittedHead(ctx, entry, prStatus.headSha);
|
|
165
161
|
return;
|
|
166
162
|
}
|
|
167
163
|
const liveBaseRefName = prStatus.baseRefName ?? ctx.baseBranch;
|
|
@@ -243,13 +239,7 @@ export async function mergeHead(ctx, entry) {
|
|
|
243
239
|
return;
|
|
244
240
|
}
|
|
245
241
|
if (landingPrStatus.headSha !== entry.headSha) {
|
|
246
|
-
|
|
247
|
-
detail: `PR head changed during landing: expected ${entry.headSha.slice(0, 8)}, got ${landingPrStatus.headSha.slice(0, 8)}`,
|
|
248
|
-
});
|
|
249
|
-
const allActive = ctx.store.listActive(ctx.repoId);
|
|
250
|
-
await cleanupCandidate(ctx, entry);
|
|
251
|
-
ctx.store.updateHead(entry.id, landingPrStatus.headSha);
|
|
252
|
-
await invalidateDownstream(ctx, allActive, 0);
|
|
242
|
+
await supersedeAdmittedHead(ctx, entry, landingPrStatus.headSha);
|
|
253
243
|
return;
|
|
254
244
|
}
|
|
255
245
|
const landingBaseRefName = landingPrStatus.baseRefName ?? ctx.baseBranch;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.js";
|
|
2
|
-
import { evictEntry } from "./reconciler-evict.js";
|
|
2
|
+
import { evictEntry, supersedeAdmittedHead } from "./reconciler-evict.js";
|
|
3
3
|
import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
|
|
4
4
|
export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
5
5
|
emit(ctx, entry, "fetch_started");
|
|
@@ -23,8 +23,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
23
23
|
const baseSha = await ctx.git.headSha(base);
|
|
24
24
|
const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
|
|
25
25
|
if (currentRef !== entry.headSha) {
|
|
26
|
-
|
|
27
|
-
ctx.store.updateHead(entry.id, currentRef);
|
|
26
|
+
await supersedeAdmittedHead(ctx, entry, currentRef);
|
|
28
27
|
return;
|
|
29
28
|
}
|
|
30
29
|
if (isHead) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CLEAN_CANDIDATE_REF, emit } from "./reconciler-core.js";
|
|
2
|
-
import { cleanupCandidate } from "./reconciler-evict.js";
|
|
2
|
+
import { cleanupCandidate, supersedeAdmittedHead } from "./reconciler-evict.js";
|
|
3
3
|
import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
|
|
4
4
|
export async function sanitizeEntry(ctx, entry) {
|
|
5
5
|
const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
|
|
@@ -31,6 +31,10 @@ export async function sanitizeEntry(ctx, entry) {
|
|
|
31
31
|
}, "merged externally (sanitize)");
|
|
32
32
|
return true;
|
|
33
33
|
}
|
|
34
|
+
if (prStatus.headSha !== entry.headSha) {
|
|
35
|
+
await supersedeAdmittedHead(ctx, entry, prStatus.headSha);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
34
38
|
const liveBaseRefName = prStatus.baseRefName ?? ctx.baseBranch;
|
|
35
39
|
const recordedBaseRefName = entry.baseRefName ?? ctx.baseBranch;
|
|
36
40
|
if (liveBaseRefName !== recordedBaseRefName) {
|
|
@@ -13,7 +13,9 @@ async function holdForIntegrationRepair(ctx, entry, allActive, index, checks) {
|
|
|
13
13
|
.map((check) => check.name)
|
|
14
14
|
.join(", ");
|
|
15
15
|
ctx.store.transition(entry.id, "validating", {
|
|
16
|
-
|
|
16
|
+
// A failed repaired candidate is still a repaired candidate. Preserve the
|
|
17
|
+
// provenance so a same-SHA rerun remains gated by integration review.
|
|
18
|
+
candidateKind: entry.candidateKind === "integration_repair" ? "integration_repair" : "integration",
|
|
17
19
|
ciRunId: entry.ciRunId ?? `head:${entry.candidateSha ?? entry.headSha}`,
|
|
18
20
|
candidateRef,
|
|
19
21
|
candidateSha: entry.candidateSha ?? entry.headSha,
|
|
@@ -142,9 +144,21 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
|
|
|
142
144
|
if (entry.candidateRef && !await ctx.git.isAncestor(entry.headSha, entry.candidateSha)) {
|
|
143
145
|
return;
|
|
144
146
|
}
|
|
145
|
-
if (entry.candidateRef && entry.
|
|
146
|
-
//
|
|
147
|
-
//
|
|
147
|
+
if (entry.candidateRef && entry.lastFailedBaseSha && entry.ciRunId) {
|
|
148
|
+
// Do not manufacture another CI run on every wakeup, but do re-read the
|
|
149
|
+
// exact SHA: an operator or GitHub may have rerun the failed workflow in
|
|
150
|
+
// place. A green rerun must resume the queue without a no-op commit.
|
|
151
|
+
const checks = (await ctx.github.listChecksForRef(entry.candidateSha))
|
|
152
|
+
.filter((check) => check.name.toLowerCase() !== INTEGRATION_REVIEW_CHECK);
|
|
153
|
+
const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
|
|
154
|
+
if (evaluation.status === "pass") {
|
|
155
|
+
await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, entry.ciRunId);
|
|
156
|
+
}
|
|
157
|
+
else if (evaluation.status === "pending") {
|
|
158
|
+
ctx.store.transition(entry.id, "validating", {
|
|
159
|
+
waitDetail: "waiting for exact-candidate rerun",
|
|
160
|
+
}, "exact-candidate rerun pending");
|
|
161
|
+
}
|
|
148
162
|
return;
|
|
149
163
|
}
|
|
150
164
|
if (entry.candidateKind === "head") {
|
package/dist/reconciler.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function reconcile(ctx) {
|
|
|
63
63
|
// spec, so our cumulative spec is still valid (speculative consistency).
|
|
64
64
|
if (entry.candidateBasedOn) {
|
|
65
65
|
const dep = ctx.store.getEntry(entry.candidateBasedOn);
|
|
66
|
-
if (!dep || dep.status === "dequeued" || dep.status === "evicted") {
|
|
66
|
+
if (!dep || dep.status === "dequeued" || dep.status === "evicted" || dep.status === "superseded") {
|
|
67
67
|
emit(ctx, entry, "invalidated", {
|
|
68
68
|
detail: `dependency ${entry.candidateBasedOn} is ${dep?.status ?? "removed"}`,
|
|
69
69
|
});
|
package/dist/service-queue.js
CHANGED
|
@@ -140,6 +140,10 @@ export class MergeStewardQueueCommands {
|
|
|
140
140
|
}
|
|
141
141
|
try {
|
|
142
142
|
const status = await this.github.getStatus(prNumber);
|
|
143
|
+
if (status.headSha !== headSha) {
|
|
144
|
+
this.logger.debug({ prNumber, eventHeadSha: headSha, liveHeadSha: status.headSha }, "Admission wakeup refers to a stale PR head");
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
143
147
|
if (!status.reviewApproved) {
|
|
144
148
|
this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
|
|
145
149
|
return false;
|
|
@@ -147,42 +151,17 @@ export class MergeStewardQueueCommands {
|
|
|
147
151
|
// Admission and ordering are derived from review/check/PR truth. Labels
|
|
148
152
|
// remain available for presentation but are not control inputs.
|
|
149
153
|
const priority = 0;
|
|
150
|
-
const checks = await this.github.
|
|
154
|
+
const checks = await this.github.listChecksForRef(status.headSha);
|
|
151
155
|
const requiredCheckRules = this.policy.getRequiredCheckRules();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
else if (this.policy.shouldRequireAllChecksOnEmptyRequiredSet()) {
|
|
165
|
-
if (checks.length === 0) {
|
|
166
|
-
this.logger.debug({ prNumber }, "GitHub requires checks but none are visible yet, skipping admission");
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
const hasPending = checks.some((check) => check.conclusion === "pending");
|
|
170
|
-
const hasFailures = checks.some((check) => check.conclusion === "failure");
|
|
171
|
-
if (hasPending || hasFailures) {
|
|
172
|
-
this.logger.debug({
|
|
173
|
-
prNumber,
|
|
174
|
-
checkNames: checks.map((check) => `${check.name}:${check.conclusion}`),
|
|
175
|
-
}, "GitHub requires all observed checks to pass before admission");
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
const nonSteward = checks.filter((c) => !c.name.startsWith("merge-steward"));
|
|
181
|
-
const hasGreen = nonSteward.some((c) => c.conclusion === "success");
|
|
182
|
-
if (!hasGreen) {
|
|
183
|
-
this.logger.debug({ prNumber }, "No green CI checks, skipping admission");
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
156
|
+
const evaluation = evaluateCheckPolicy(requiredCheckRules, this.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
|
|
157
|
+
if (evaluation.status !== "pass") {
|
|
158
|
+
this.logger.debug({
|
|
159
|
+
prNumber,
|
|
160
|
+
checks: checks.map((check) => `${check.name}:${check.conclusion}`),
|
|
161
|
+
requiredChecks: requiredCheckRules.map(formatRequiredCheck),
|
|
162
|
+
checkPolicyStatus: evaluation.status,
|
|
163
|
+
}, "Branch checks are not settled green, skipping admission");
|
|
164
|
+
return false;
|
|
186
165
|
}
|
|
187
166
|
// A stacked PR waits for its parent queue entry. Monotonic positions
|
|
188
167
|
// guarantee parent-before-child ordering, not strict adjacency.
|
|
@@ -228,8 +207,22 @@ export class MergeStewardQueueCommands {
|
|
|
228
207
|
this.logger.debug({ prNumber, entryId: entry.id, headSha }, "Ignoring synchronize webhook for unchanged head");
|
|
229
208
|
return;
|
|
230
209
|
}
|
|
231
|
-
|
|
232
|
-
|
|
210
|
+
if (entry.candidateRef) {
|
|
211
|
+
this.specBuilder.deleteSpeculative(entry.candidateRef).catch(() => { });
|
|
212
|
+
}
|
|
213
|
+
this.store.transition(entry.id, "superseded", {
|
|
214
|
+
candidateKind: null,
|
|
215
|
+
candidatePolicyFingerprint: null,
|
|
216
|
+
candidateRef: null,
|
|
217
|
+
candidateSha: null,
|
|
218
|
+
candidateBasedOn: null,
|
|
219
|
+
ciRunId: null,
|
|
220
|
+
ciRetries: 0,
|
|
221
|
+
waitDetail: null,
|
|
222
|
+
}, `admitted head ${entry.headSha.slice(0, 12)} superseded by ${headSha.slice(0, 12)}; new head must pass admission`);
|
|
223
|
+
this.invalidateDownstreamOf(entry);
|
|
224
|
+
this.clearQueueStateLabels(prNumber).catch(() => { });
|
|
225
|
+
this.logger.info({ prNumber, entryId: entry.id, previousHeadSha: entry.headSha, headSha }, "PR head changed; admission revoked");
|
|
233
226
|
}
|
|
234
227
|
}
|
|
235
228
|
async acknowledgeExternalMerge(prNumber) {
|
|
@@ -275,10 +268,10 @@ export class MergeStewardQueueCommands {
|
|
|
275
268
|
if (downstream.candidateRef) {
|
|
276
269
|
this.specBuilder.deleteSpeculative(downstream.candidateRef).catch(() => { });
|
|
277
270
|
}
|
|
278
|
-
this.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, `invalidated: entry ${removedEntry.id.slice(0, 8)}
|
|
271
|
+
this.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, `invalidated: entry ${removedEntry.id.slice(0, 8)} left the train`);
|
|
279
272
|
}
|
|
280
273
|
if (targets.length > 0) {
|
|
281
|
-
this.logger.info({ removedEntryId: removedEntry.id, invalidated: targets.length }, "Invalidated downstream entries after
|
|
274
|
+
this.logger.info({ removedEntryId: removedEntry.id, invalidated: targets.length }, "Invalidated downstream entries after train removal");
|
|
282
275
|
}
|
|
283
276
|
}
|
|
284
277
|
findAffectedEntriesAfterPriorityChange(before, after) {
|
package/dist/types.d.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* Failure: any state → evicted (after retry budget exhausted).
|
|
10
10
|
* Conflict retries are gated on base SHA change (non-spinning).
|
|
11
11
|
*
|
|
12
|
-
* Terminal states: merged, evicted, dequeued.
|
|
12
|
+
* Terminal states: merged, evicted, dequeued, superseded.
|
|
13
13
|
*/
|
|
14
|
-
export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued";
|
|
14
|
+
export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued" | "superseded";
|
|
15
15
|
export declare const TERMINAL_STATUSES: QueueEntryStatus[];
|
|
16
16
|
export type PostMergeStatus = "pending" | "pass" | "fail" | "unknown";
|
|
17
17
|
export type CandidateKind = "head" | "integration" | "integration_repair";
|
|
@@ -66,7 +66,7 @@ export interface QueueEntry {
|
|
|
66
66
|
updatedAt: string;
|
|
67
67
|
/**
|
|
68
68
|
* Set once, when the entry first reaches a terminal status
|
|
69
|
-
* (merged/evicted/dequeued), and never bumped afterward — unlike
|
|
69
|
+
* (merged/evicted/dequeued/superseded), and never bumped afterward — unlike
|
|
70
70
|
* updatedAt, which post-merge re-verification keeps moving. Lets the
|
|
71
71
|
* dashboard report an accurate "how long it took" (decidedAt - enqueuedAt)
|
|
72
72
|
* and "how long ago" (now - decidedAt). Null while still in flight.
|
|
@@ -185,6 +185,7 @@ export interface QueueStatusSummary {
|
|
|
185
185
|
merged: number;
|
|
186
186
|
evicted: number;
|
|
187
187
|
dequeued: number;
|
|
188
|
+
superseded: number;
|
|
188
189
|
headEntryId: string | null;
|
|
189
190
|
headPrNumber: number | null;
|
|
190
191
|
}
|
package/dist/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMINAL_STATUSES = ["merged", "evicted", "dequeued"];
|
|
1
|
+
export const TERMINAL_STATUSES = ["merged", "evicted", "dequeued", "superseded"];
|
|
@@ -59,6 +59,8 @@ function entryKind(entry) {
|
|
|
59
59
|
return "error";
|
|
60
60
|
case "dequeued":
|
|
61
61
|
return "cancelled";
|
|
62
|
+
case "superseded":
|
|
63
|
+
return "cancelled";
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
function entryPhrase(entry, isHead) {
|
|
@@ -87,6 +89,8 @@ function entryPhrase(entry, isHead) {
|
|
|
87
89
|
return "evicted";
|
|
88
90
|
case "dequeued":
|
|
89
91
|
return "dequeued";
|
|
92
|
+
case "superseded":
|
|
93
|
+
return "superseded by a newer PR head";
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
function entrySummary(entry) {
|
|
@@ -130,7 +134,7 @@ function repoEntriesFromSnapshot(snapshot, cutoff, now) {
|
|
|
130
134
|
const active = isActive(entry.status);
|
|
131
135
|
if (!active && timestamp(entry.updatedAt) < cutoff)
|
|
132
136
|
continue;
|
|
133
|
-
if (entry.status === "dequeued")
|
|
137
|
+
if (entry.status === "dequeued" || entry.status === "superseded")
|
|
134
138
|
continue;
|
|
135
139
|
const isHead = head !== null && entry.id === head.id;
|
|
136
140
|
const kind = entryKind(entry);
|
|
@@ -325,6 +329,7 @@ export function buildQueueSummary(entries) {
|
|
|
325
329
|
merged: 0,
|
|
326
330
|
evicted: 0,
|
|
327
331
|
dequeued: 0,
|
|
332
|
+
superseded: 0,
|
|
328
333
|
headEntryId: null,
|
|
329
334
|
headPrNumber: null,
|
|
330
335
|
};
|
|
@@ -357,6 +362,9 @@ export function buildQueueSummary(entries) {
|
|
|
357
362
|
case "dequeued":
|
|
358
363
|
summary.dequeued += 1;
|
|
359
364
|
break;
|
|
365
|
+
case "superseded":
|
|
366
|
+
summary.superseded += 1;
|
|
367
|
+
break;
|
|
360
368
|
}
|
|
361
369
|
}
|
|
362
370
|
return summary;
|
package/dist/watch/format.js
CHANGED
|
@@ -72,6 +72,7 @@ export function statusColor(status, entry) {
|
|
|
72
72
|
case "evicted":
|
|
73
73
|
return "red";
|
|
74
74
|
case "dequeued":
|
|
75
|
+
case "superseded":
|
|
75
76
|
return "gray";
|
|
76
77
|
}
|
|
77
78
|
}
|
|
@@ -99,6 +100,8 @@ export function humanStatus(status, entry) {
|
|
|
99
100
|
return "needs repair";
|
|
100
101
|
case "dequeued":
|
|
101
102
|
return "removed";
|
|
103
|
+
case "superseded":
|
|
104
|
+
return "superseded";
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
export function queueProgress(status) {
|
|
@@ -113,6 +116,7 @@ export function queueProgress(status) {
|
|
|
113
116
|
case "merged":
|
|
114
117
|
case "evicted":
|
|
115
118
|
case "dequeued":
|
|
119
|
+
case "superseded":
|
|
116
120
|
return { current: 4, total: 4 };
|
|
117
121
|
}
|
|
118
122
|
}
|
|
@@ -142,6 +146,8 @@ export function nextStepLabel(status, entry) {
|
|
|
142
146
|
return "needs branch repair before re-admission";
|
|
143
147
|
case "dequeued":
|
|
144
148
|
return "removed from queue";
|
|
149
|
+
case "superseded":
|
|
150
|
+
return "new PR head must pass approval and branch CI before admission";
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
/** Describe the immutable candidate chain for a queue entry. */
|
|
@@ -173,6 +179,7 @@ const STATUS_DISPLAY = {
|
|
|
173
179
|
merged: "merged",
|
|
174
180
|
evicted: "evicted",
|
|
175
181
|
dequeued: "removed",
|
|
182
|
+
superseded: "superseded",
|
|
176
183
|
};
|
|
177
184
|
function displayStatus(status) {
|
|
178
185
|
return STATUS_DISPLAY[status] ?? status;
|
|
@@ -233,6 +240,9 @@ export function formatEventNarrative(event, options = {}) {
|
|
|
233
240
|
if (event.toStatus === "dequeued") {
|
|
234
241
|
return withDetail(`${prPrefix}was removed from the queue.`, event.detail);
|
|
235
242
|
}
|
|
243
|
+
if (event.toStatus === "superseded") {
|
|
244
|
+
return withDetail(`${prPrefix}left the queue because its admitted head changed.`, event.detail);
|
|
245
|
+
}
|
|
236
246
|
if (event.toStatus === "queued" && event.fromStatus) {
|
|
237
247
|
return withDetail(`${prPrefix}was re-queued for another attempt.`, event.detail);
|
|
238
248
|
}
|
package/dist/webhook-handler.js
CHANGED
|
@@ -129,7 +129,8 @@ export async function processWebhookEvent(event, service, config, logger) {
|
|
|
129
129
|
break;
|
|
130
130
|
}
|
|
131
131
|
case "pr_synchronize": {
|
|
132
|
-
//
|
|
132
|
+
// A queued admission is immutable. Retire it when the PR head changes;
|
|
133
|
+
// the new head may re-enter only after its own approval and green CI.
|
|
133
134
|
service.updateHeadByPR(event.prNumber, event.headSha);
|
|
134
135
|
break;
|
|
135
136
|
}
|