merge-steward 0.34.4 → 0.35.1
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 +31 -10
- package/dist/cli/commands/pr-status.js +4 -3
- package/dist/cli/commands/queue.js +1 -1
- package/dist/db/schema.js +20 -6
- package/dist/github/check-run-reporter.js +2 -0
- package/dist/github/pr-client.d.ts +6 -0
- package/dist/github/pr-client.js +6 -2
- package/dist/github/shell-git.d.ts +1 -0
- package/dist/github/shell-git.js +16 -3
- package/dist/interfaces.d.ts +2 -0
- package/dist/reconciler-core.d.ts +3 -2
- package/dist/reconciler-core.js +4 -3
- 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 +32 -34
- package/dist/reconciler-sanitize.js +5 -1
- package/dist/reconciler-validate.js +128 -27
- package/dist/reconciler.js +1 -1
- package/dist/service-queue.js +35 -47
- package/dist/types.d.ts +6 -5
- 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 +6 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Self-hosted merge queue for bot-managed and human-managed GitHub pull requests. Merge Steward turns reviewed PRs into a tested landing train: it runs CI on the exact future `main` SHAs, validates several PRs in parallel, and fast-forwards through the green sequence as soon as it is safe.
|
|
4
4
|
|
|
5
|
-
Independent of PatchRelay. Communicates through GitHub only — PRs,
|
|
5
|
+
Independent of PatchRelay. Communicates through GitHub state only — PRs,
|
|
6
|
+
reviews, candidate refs, ancestry, and checks. Labels and comments are not
|
|
7
|
+
control messages. Pairs with `review-quill`; neither requires the other.
|
|
6
8
|
|
|
7
9
|
For the background story and design trade-offs, read [merge-steward: speculative integration, parallel validation, fast-forward landing](https://blog.krasnoperov.me/posts/merge-steward).
|
|
8
10
|
|
|
@@ -13,12 +15,24 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
|
|
|
13
15
|
## How it works
|
|
14
16
|
|
|
15
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.
|
|
16
20
|
2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
|
|
17
|
-
3. It
|
|
21
|
+
3. It freezes the approved PR head and resolves the exact future-`main`
|
|
22
|
+
candidate. If the prospective base is its ancestor, that head is the
|
|
23
|
+
candidate. Otherwise it publishes
|
|
24
|
+
`merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
|
|
18
25
|
4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
|
|
19
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.
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
29
|
+
6. On conflict, the workspace remains at the prospective base; PatchRelay
|
|
30
|
+
derives the missing ancestry and non-force pushes a resolved candidate.
|
|
31
|
+
7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
|
|
32
|
+
branch. Review Quill verifies only that an agent repair preserved the
|
|
33
|
+
approved feature.
|
|
34
|
+
8. Ordinary integration failures retain queue position. Feature implementation
|
|
35
|
+
reopens only when integration review proves the feature must change.
|
|
22
36
|
|
|
23
37
|
This is structural, not an optional fast path. An exact head is safe precisely
|
|
24
38
|
when it already contains the prospective base; if it does not, Merge Steward
|
|
@@ -26,7 +40,10 @@ creates and tests the integration candidate. Checks never move between SHAs.
|
|
|
26
40
|
|
|
27
41
|
## Use with your own agent
|
|
28
42
|
|
|
29
|
-
For an agent that drives PRs through the queue and reacts to
|
|
43
|
+
For an agent that drives PRs through the queue and reacts to candidate state and
|
|
44
|
+
failing checks without running PatchRelay's full harness, install the
|
|
45
|
+
[`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the
|
|
46
|
+
companion Claude Code marketplace:
|
|
30
47
|
|
|
31
48
|
```
|
|
32
49
|
/plugin marketplace add krasnoperov/patchrelay-agents
|
|
@@ -73,7 +90,7 @@ Each repository reconcile tick is bounded by `reconcileStaleAfterMs` (five minut
|
|
|
73
90
|
| Code | Meaning |
|
|
74
91
|
|-|-|
|
|
75
92
|
| 0 | merged / approved with green required checks |
|
|
76
|
-
| 2 | changes_requested /
|
|
93
|
+
| 2 | changes_requested / integration review requires feature rework / policy failure / closed |
|
|
77
94
|
| 3 | still in flight (queued, preparing, validating, merging, pending) |
|
|
78
95
|
| 4 | `--wait` timed out |
|
|
79
96
|
| 1 | usage or configuration error |
|
|
@@ -95,10 +112,14 @@ The real gate is:
|
|
|
95
112
|
|
|
96
113
|
Independent services, GitHub as the shared bus:
|
|
97
114
|
|
|
98
|
-
1.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
1. The steward admits an open PR when GitHub shows an approved head and green
|
|
116
|
+
branch checks; no label is required.
|
|
117
|
+
2. It publishes a self-describing candidate ref. Missing approved-head ancestry
|
|
118
|
+
means conflict repair; settled red candidate checks mean test repair.
|
|
119
|
+
3. PatchRelay derives either condition from GitHub and non-force pushes only the
|
|
120
|
+
candidate ref.
|
|
121
|
+
4. Review Quill publishes `review-quill/integration` when PatchRelay changed the
|
|
122
|
+
candidate. The steward lands the exact green SHA when that check is satisfied.
|
|
102
123
|
|
|
103
124
|
Neither service calls the other's API.
|
|
104
125
|
|
|
@@ -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/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",
|
|
@@ -87,6 +87,8 @@ function formatTitle(incident) {
|
|
|
87
87
|
return "Queue eviction: CI failure (branch-specific)";
|
|
88
88
|
case "main_broken":
|
|
89
89
|
return "Queue eviction: main branch CI failing";
|
|
90
|
+
case "feature_changed":
|
|
91
|
+
return "Queue eviction: integration repair changed the approved feature";
|
|
90
92
|
case "policy_blocked":
|
|
91
93
|
return incident.context.openPrAncestors?.length
|
|
92
94
|
? "Queue eviction: candidate shares unlanded history with another open PR"
|
|
@@ -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
|
}
|
|
@@ -26,6 +26,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
|
|
|
26
26
|
mergeBase(left: string, right: string): Promise<string>;
|
|
27
27
|
isAncestor(ancestor: string, descendant: string): Promise<boolean>;
|
|
28
28
|
push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
|
|
29
|
+
createWorkspace(specName: string, baseBranch: string): Promise<string>;
|
|
29
30
|
/**
|
|
30
31
|
* Build a speculative merge branch using an isolated git worktree.
|
|
31
32
|
* Each call gets its own working directory — no shared mutable state.
|
package/dist/github/shell-git.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { exec } from "../exec.js";
|
|
4
4
|
/** Extract conflict file names from git merge stderr. */
|
|
5
5
|
function parseConflicts(stderr) {
|
|
@@ -98,6 +98,17 @@ export class ShellGitOperations {
|
|
|
98
98
|
await this.git(args, { timeoutMs: 60_000 });
|
|
99
99
|
}
|
|
100
100
|
// ─── SpeculativeBranchBuilder ───────────────────────────────
|
|
101
|
+
async createWorkspace(specName, baseBranch) {
|
|
102
|
+
const wtPath = join(this.worktreeBase, specName);
|
|
103
|
+
await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
|
|
104
|
+
await this.git(["branch", "-D", specName], { allowNonZero: true });
|
|
105
|
+
await this.git(["worktree", "prune"], { allowNonZero: true });
|
|
106
|
+
mkdirSync(dirname(wtPath), { recursive: true });
|
|
107
|
+
await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
|
|
108
|
+
const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
|
|
109
|
+
await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
|
|
110
|
+
return sha;
|
|
111
|
+
}
|
|
101
112
|
/**
|
|
102
113
|
* Build a speculative merge branch using an isolated git worktree.
|
|
103
114
|
* Each call gets its own working directory — no shared mutable state.
|
|
@@ -111,7 +122,7 @@ export class ShellGitOperations {
|
|
|
111
122
|
await this.git(["branch", "-D", specName], { allowNonZero: true });
|
|
112
123
|
await this.git(["worktree", "prune"], { allowNonZero: true });
|
|
113
124
|
// Create isolated worktree with spec branch starting at baseBranch.
|
|
114
|
-
mkdirSync(
|
|
125
|
+
mkdirSync(dirname(wtPath), { recursive: true });
|
|
115
126
|
await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
|
|
116
127
|
// Override git identity so merge commits are attributed to the steward, not the clone owner.
|
|
117
128
|
if (this.botIdentity) {
|
|
@@ -137,7 +148,9 @@ export class ShellGitOperations {
|
|
|
137
148
|
}
|
|
138
149
|
await this.gitIn(wtPath, ["merge", "--abort"], { allowNonZero: true });
|
|
139
150
|
await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
|
|
140
|
-
|
|
151
|
+
// Keep the workspace branch at the prospective base. Publishing this
|
|
152
|
+
// ref is the cross-service repair signal: it exists, but does not yet
|
|
153
|
+
// contain the approved PR head.
|
|
141
154
|
return { success: false, conflictFiles };
|
|
142
155
|
}
|
|
143
156
|
const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface GitOperations {
|
|
|
15
15
|
* Separate from GitOperations to keep the core interface minimal.
|
|
16
16
|
*/
|
|
17
17
|
export interface SpeculativeBranchBuilder {
|
|
18
|
+
/** Create/reset a candidate workspace at an exact base commit. */
|
|
19
|
+
createWorkspace(specName: string, baseBranch: string): Promise<string>;
|
|
18
20
|
/** Merge prBranch into baseBranch, store result as specName. */
|
|
19
21
|
buildSpeculative(prBranch: string, baseBranch: string, specName: string, mergeMessage?: string): Promise<MergeResult>;
|
|
20
22
|
/** Delete a speculative branch (cleanup after merge/eviction). */
|
|
@@ -24,7 +24,8 @@ export interface ReconcileContext {
|
|
|
24
24
|
};
|
|
25
25
|
onEvent: (event: ReconcileEvent) => void;
|
|
26
26
|
}
|
|
27
|
-
export declare const CANDIDATE_REF_PREFIX = "
|
|
27
|
+
export declare const CANDIDATE_REF_PREFIX = "merge-steward";
|
|
28
|
+
export declare const INTEGRATION_REVIEW_CHECK = "review-quill/integration";
|
|
28
29
|
export declare const FAILED_CONCLUSIONS: Set<string>;
|
|
29
30
|
export declare const CLEAR_CANDIDATE: {
|
|
30
31
|
readonly candidateKind: null;
|
|
@@ -44,6 +45,6 @@ export declare const CLEAN_CI: {
|
|
|
44
45
|
};
|
|
45
46
|
export declare function emit(ctx: ReconcileContext, entry: QueueEntry, action: ReconcileAction, extra?: Partial<ReconcileEvent>): void;
|
|
46
47
|
export declare function ref(ctx: ReconcileContext, name: string): string;
|
|
47
|
-
export declare function candidateRefName(
|
|
48
|
+
export declare function candidateRefName(baseBranch: string, prNumber: number): string;
|
|
48
49
|
export declare function isBudgetExhausted(entry: QueueEntry): boolean;
|
|
49
50
|
export declare function isRetryGated(entry: QueueEntry, currentBaseSha: string): boolean;
|
package/dist/reconciler-core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export const CANDIDATE_REF_PREFIX = "
|
|
1
|
+
export const CANDIDATE_REF_PREFIX = "merge-steward";
|
|
2
|
+
export const INTEGRATION_REVIEW_CHECK = "review-quill/integration";
|
|
2
3
|
export const FAILED_CONCLUSIONS = new Set(["failure"]);
|
|
3
4
|
export const CLEAR_CANDIDATE = {
|
|
4
5
|
candidateKind: null,
|
|
@@ -16,8 +17,8 @@ export function emit(ctx, entry, action, extra) {
|
|
|
16
17
|
export function ref(ctx, name) {
|
|
17
18
|
return ctx.remotePrefix + name;
|
|
18
19
|
}
|
|
19
|
-
export function candidateRefName(
|
|
20
|
-
return `${CANDIDATE_REF_PREFIX}
|
|
20
|
+
export function candidateRefName(baseBranch, prNumber) {
|
|
21
|
+
return `${CANDIDATE_REF_PREFIX}/${baseBranch}/pr-${prNumber}`;
|
|
21
22
|
}
|
|
22
23
|
export function isBudgetExhausted(entry) {
|
|
23
24
|
return entry.retryAttempts >= entry.maxRetries;
|
|
@@ -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,17 +1,29 @@
|
|
|
1
|
-
import { CLEAN_CI, CLEAR_CANDIDATE, emit,
|
|
2
|
-
import { evictEntry } from "./reconciler-evict.js";
|
|
1
|
+
import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.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");
|
|
6
6
|
await ctx.git.fetch();
|
|
7
|
+
const predecessorNeedsRepair = prevEntry?.lastFailedBaseSha !== null
|
|
8
|
+
&& prevEntry?.lastFailedBaseSha !== undefined
|
|
9
|
+
&& prevEntry?.ciRunId !== null
|
|
10
|
+
&& prevEntry?.ciRunId !== undefined;
|
|
11
|
+
if (prevEntry?.candidateSha && (predecessorNeedsRepair
|
|
12
|
+
|| !await ctx.git.isAncestor(prevEntry.headSha, prevEntry.candidateSha))) {
|
|
13
|
+
const detail = `predecessor PR #${prevEntry.prNumber} is awaiting integration repair`;
|
|
14
|
+
if (entry.waitDetail !== detail) {
|
|
15
|
+
emit(ctx, entry, "stack_dependency_waiting", { detail });
|
|
16
|
+
ctx.store.transition(entry.id, "preparing_head", { waitDetail: detail }, detail);
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
7
20
|
const base = isHead ? ref(ctx, ctx.baseBranch) : prevEntry?.candidateSha ?? null;
|
|
8
21
|
if (!base)
|
|
9
22
|
return;
|
|
10
23
|
const baseSha = await ctx.git.headSha(base);
|
|
11
24
|
const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
|
|
12
25
|
if (currentRef !== entry.headSha) {
|
|
13
|
-
|
|
14
|
-
ctx.store.updateHead(entry.id, currentRef);
|
|
26
|
+
await supersedeAdmittedHead(ctx, entry, currentRef);
|
|
15
27
|
return;
|
|
16
28
|
}
|
|
17
29
|
if (isHead) {
|
|
@@ -33,24 +45,6 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
33
45
|
// Preparing/validating the head never waits on main's CI. The exact
|
|
34
46
|
// candidate includes current main and is gated solely by its own checks.
|
|
35
47
|
//
|
|
36
|
-
// The conflict cache applies at every lookahead depth. A downstream child
|
|
37
|
-
// otherwise rebuilds the same impossible merge on every reconcile tick
|
|
38
|
-
// while its predecessor is still validating.
|
|
39
|
-
if (isRetryGated(entry, baseSha)) {
|
|
40
|
-
emit(ctx, entry, "retry_gated", {
|
|
41
|
-
baseSha,
|
|
42
|
-
detail: "same base and head already produced a deterministic conflict",
|
|
43
|
-
});
|
|
44
|
-
if (isHead) {
|
|
45
|
-
await evictEntry(ctx, entry, "integration_conflict");
|
|
46
|
-
}
|
|
47
|
-
else if (entry.waitDetail !== "deterministic conflict; waiting for prospective base to change") {
|
|
48
|
-
ctx.store.transition(entry.id, "preparing_head", {
|
|
49
|
-
waitDetail: "deterministic conflict; waiting for prospective base to change",
|
|
50
|
-
}, "deterministic conflict cached; waiting for prospective base to change");
|
|
51
|
-
}
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
48
|
// A remote merge can advance main before GitHub's PR API reports `merged`.
|
|
55
49
|
// The PR head is then already contained in main, so building and testing an
|
|
56
50
|
// "integration" candidate would only reproduce the current main commit.
|
|
@@ -92,7 +86,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
92
86
|
}, `head candidate ${entry.headSha.slice(0, 12)} selected on ${baseSha.slice(0, 12)}`);
|
|
93
87
|
return;
|
|
94
88
|
}
|
|
95
|
-
const specName = candidateRefName(entry.
|
|
89
|
+
const specName = candidateRefName(ctx.baseBranch, entry.prNumber);
|
|
96
90
|
emit(ctx, entry, "integration_build_started", { candidateRef: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
|
|
97
91
|
const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
|
|
98
92
|
const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
|
|
@@ -115,17 +109,21 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
115
109
|
}
|
|
116
110
|
if (!result.success) {
|
|
117
111
|
emit(ctx, entry, "integration_build_conflict", { baseSha, conflictFiles: result.conflictFiles });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
112
|
+
// buildSpeculative deliberately leaves the local workspace ref at the
|
|
113
|
+
// prospective base. Publish it so PatchRelay can derive the repair from
|
|
114
|
+
// GitHub ancestry alone and add a merge commit with a non-force push.
|
|
115
|
+
await ctx.git.push(specName, true);
|
|
116
|
+
ctx.store.transition(entry.id, "validating", {
|
|
117
|
+
baseSha,
|
|
118
|
+
...CLEAN_CI,
|
|
119
|
+
candidateKind: "integration",
|
|
120
|
+
candidatePolicyFingerprint: ctx.policy.getFingerprint(),
|
|
121
|
+
candidateRef: specName,
|
|
122
|
+
candidateSha: baseSha,
|
|
123
|
+
candidateBasedOn: isHead ? null : prevEntry.id,
|
|
124
|
+
lastFailedBaseSha: baseSha,
|
|
125
|
+
waitDetail: `integration conflict; workspace awaits repair${result.conflictFiles?.length ? ` in ${result.conflictFiles.join(", ")}` : ""}`,
|
|
126
|
+
}, `integration workspace published at ${baseSha.slice(0, 12)} for conflict repair`);
|
|
129
127
|
return;
|
|
130
128
|
}
|
|
131
129
|
const candidateSha = result.sha ?? entry.headSha;
|
|
@@ -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) {
|
|
@@ -1,22 +1,104 @@
|
|
|
1
|
-
import { emit, ref } from "./reconciler-core.js";
|
|
2
|
-
import { classifyFailure } from "./classify.js";
|
|
1
|
+
import { CLEAN_CI, CLEAR_CANDIDATE, INTEGRATION_REVIEW_CHECK, candidateRefName, emit, ref } from "./reconciler-core.js";
|
|
3
2
|
import { evictEntry, invalidateDownstream } from "./reconciler-evict.js";
|
|
4
3
|
import { evaluateCheckPolicy, formatRequiredCheck } from "./check-policy.js";
|
|
5
|
-
async function
|
|
6
|
-
|
|
4
|
+
async function holdForIntegrationRepair(ctx, entry, allActive, index, checks) {
|
|
5
|
+
let candidateRef = entry.candidateRef;
|
|
6
|
+
if (!candidateRef) {
|
|
7
|
+
candidateRef = candidateRefName(ctx.baseBranch, entry.prNumber);
|
|
8
|
+
await ctx.specBuilder.createWorkspace(candidateRef, entry.candidateSha ?? entry.headSha);
|
|
9
|
+
await ctx.git.push(candidateRef, true);
|
|
10
|
+
}
|
|
11
|
+
const failed = checks
|
|
7
12
|
.filter((check) => check.conclusion === "failure" || check.conclusion === "skipped")
|
|
8
|
-
.map((check) =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
:
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
.map((check) => check.name)
|
|
14
|
+
.join(", ");
|
|
15
|
+
ctx.store.transition(entry.id, "validating", {
|
|
16
|
+
candidateKind: "integration",
|
|
17
|
+
ciRunId: entry.ciRunId ?? `head:${entry.candidateSha ?? entry.headSha}`,
|
|
18
|
+
candidateRef,
|
|
19
|
+
candidateSha: entry.candidateSha ?? entry.headSha,
|
|
20
|
+
candidateBasedOn: entry.candidateBasedOn,
|
|
21
|
+
lastFailedBaseSha: entry.baseSha,
|
|
22
|
+
waitDetail: `candidate CI failed; integration workspace awaits repair${failed ? ` (${failed})` : ""}`,
|
|
23
|
+
}, "candidate retained for integration repair");
|
|
24
|
+
if (index >= 0)
|
|
19
25
|
await invalidateDownstream(ctx, allActive, index);
|
|
26
|
+
}
|
|
27
|
+
async function refreshIntegrationWorkspace(ctx, entry, allActive, index) {
|
|
28
|
+
if (!entry.candidateRef)
|
|
29
|
+
return entry;
|
|
30
|
+
await ctx.git.fetch();
|
|
31
|
+
const liveSha = await ctx.git.headSha(ref(ctx, entry.candidateRef));
|
|
32
|
+
const dependency = entry.candidateBasedOn ? ctx.store.getEntry(entry.candidateBasedOn) : null;
|
|
33
|
+
const expectedBaseSha = dependency && dependency.status !== "merged"
|
|
34
|
+
? dependency.candidateSha
|
|
35
|
+
: await ctx.git.headSha(ref(ctx, ctx.baseBranch));
|
|
36
|
+
if (!expectedBaseSha || !await ctx.git.isAncestor(expectedBaseSha, liveSha)) {
|
|
37
|
+
emit(ctx, entry, "invalidated", { detail: "integration workspace no longer descends from its prospective base" });
|
|
38
|
+
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAR_CANDIDATE }, "stale integration workspace; rebuilding");
|
|
39
|
+
if (index >= 0)
|
|
40
|
+
await invalidateDownstream(ctx, allActive, index);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (liveSha !== entry.candidateSha) {
|
|
44
|
+
emit(ctx, entry, "candidate_selected", {
|
|
45
|
+
candidateRef: entry.candidateRef,
|
|
46
|
+
candidateKind: "integration_repair",
|
|
47
|
+
candidateSha: liveSha,
|
|
48
|
+
baseSha: expectedBaseSha,
|
|
49
|
+
detail: "external non-force integration repair detected",
|
|
50
|
+
});
|
|
51
|
+
ctx.store.transition(entry.id, "validating", {
|
|
52
|
+
baseSha: expectedBaseSha,
|
|
53
|
+
...CLEAN_CI,
|
|
54
|
+
candidateKind: "integration_repair",
|
|
55
|
+
candidatePolicyFingerprint: ctx.policy.getFingerprint(),
|
|
56
|
+
candidateRef: entry.candidateRef,
|
|
57
|
+
candidateSha: liveSha,
|
|
58
|
+
candidateBasedOn: entry.candidateBasedOn,
|
|
59
|
+
lastFailedBaseSha: entry.lastFailedBaseSha ?? entry.baseSha,
|
|
60
|
+
waitDetail: "integration repair detected; validating exact candidate",
|
|
61
|
+
}, `integration workspace advanced to ${liveSha.slice(0, 12)}`);
|
|
62
|
+
if (index >= 0)
|
|
63
|
+
await invalidateDownstream(ctx, allActive, index);
|
|
64
|
+
return ctx.store.getEntry(entry.id) ?? null;
|
|
65
|
+
}
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
async function integrationReviewStatus(ctx, entry) {
|
|
69
|
+
if (entry.candidateKind !== "integration_repair")
|
|
70
|
+
return "pass";
|
|
71
|
+
const checks = await ctx.github.listChecksForRef(entry.candidateSha);
|
|
72
|
+
return evaluateCheckPolicy([{ name: INTEGRATION_REVIEW_CHECK, appId: null }], false, checks).status;
|
|
73
|
+
}
|
|
74
|
+
async function acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, ciRunId) {
|
|
75
|
+
const reviewStatus = await integrationReviewStatus(ctx, entry);
|
|
76
|
+
if (reviewStatus === "pending") {
|
|
77
|
+
ctx.store.transition(entry.id, "validating", {
|
|
78
|
+
waitDetail: `waiting for ${INTEGRATION_REVIEW_CHECK} on repaired candidate`,
|
|
79
|
+
}, `waiting for ${INTEGRATION_REVIEW_CHECK}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (reviewStatus === "fail") {
|
|
83
|
+
await evictEntry(ctx, entry, "feature_changed");
|
|
84
|
+
if (index >= 0)
|
|
85
|
+
await invalidateDownstream(ctx, allActive, index);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
emit(ctx, entry, "ci_passed", {
|
|
89
|
+
ciRunId,
|
|
90
|
+
candidateKind: entry.candidateKind ?? undefined,
|
|
91
|
+
candidateSha: entry.candidateSha ?? undefined,
|
|
92
|
+
policyFingerprint: entry.candidatePolicyFingerprint ?? undefined,
|
|
93
|
+
});
|
|
94
|
+
if (isLandingHead) {
|
|
95
|
+
ctx.store.transition(entry.id, "merging", { lastFailedBaseSha: null, waitDetail: null }, "CI and integration review passed, ready to merge");
|
|
96
|
+
}
|
|
97
|
+
else if (entry.lastFailedBaseSha) {
|
|
98
|
+
ctx.store.transition(entry.id, "validating", {
|
|
99
|
+
lastFailedBaseSha: null,
|
|
100
|
+
waitDetail: null,
|
|
101
|
+
}, "integration repair validated for speculative descendants");
|
|
20
102
|
}
|
|
21
103
|
}
|
|
22
104
|
async function requestBoundedRerun(ctx, entry, params) {
|
|
@@ -41,7 +123,7 @@ async function requestBoundedRerun(ctx, entry, params) {
|
|
|
41
123
|
detail: `candidate rerun unavailable (${attempt}/${ctx.flakyRetries}): ${detail}`,
|
|
42
124
|
});
|
|
43
125
|
if (attempt >= ctx.flakyRetries) {
|
|
44
|
-
await
|
|
126
|
+
await holdForIntegrationRepair(ctx, entry, params.allActive, params.index, params.checks);
|
|
45
127
|
}
|
|
46
128
|
else {
|
|
47
129
|
ctx.store.transition(entry.id, "validating", {
|
|
@@ -53,6 +135,18 @@ async function requestBoundedRerun(ctx, entry, params) {
|
|
|
53
135
|
}
|
|
54
136
|
}
|
|
55
137
|
export async function checkValidation(ctx, entry, allActive, index, isLandingHead) {
|
|
138
|
+
const refreshed = await refreshIntegrationWorkspace(ctx, entry, allActive, index);
|
|
139
|
+
if (!refreshed)
|
|
140
|
+
return;
|
|
141
|
+
entry = refreshed;
|
|
142
|
+
if (entry.candidateRef && !await ctx.git.isAncestor(entry.headSha, entry.candidateSha)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (entry.candidateRef && entry.candidateKind === "integration" && entry.lastFailedBaseSha && entry.ciRunId) {
|
|
146
|
+
// The same failed SHA is intentionally quiescent until PatchRelay moves
|
|
147
|
+
// the workspace ref. Do not manufacture another CI run on every wakeup.
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
56
150
|
if (entry.candidateKind === "head") {
|
|
57
151
|
const checks = await ctx.github.listChecksForRef(entry.candidateSha ?? entry.headSha);
|
|
58
152
|
const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
|
|
@@ -83,7 +177,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
|
|
|
83
177
|
});
|
|
84
178
|
return;
|
|
85
179
|
}
|
|
86
|
-
await
|
|
180
|
+
await holdForIntegrationRepair(ctx, entry, allActive, index, checks);
|
|
87
181
|
return;
|
|
88
182
|
}
|
|
89
183
|
emit(ctx, entry, "ci_passed", {
|
|
@@ -100,6 +194,21 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
|
|
|
100
194
|
if (!entry.ciRunId) {
|
|
101
195
|
const branch = entry.candidateRef ?? entry.branch;
|
|
102
196
|
const sha = entry.candidateSha ?? entry.headSha;
|
|
197
|
+
const existingChecks = (await ctx.github.listChecksForRef(sha))
|
|
198
|
+
.filter((check) => check.name.toLowerCase() !== INTEGRATION_REVIEW_CHECK);
|
|
199
|
+
if (existingChecks.length > 0) {
|
|
200
|
+
const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), existingChecks);
|
|
201
|
+
if (evaluation.status === "pass") {
|
|
202
|
+
await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, `checks:${sha}`);
|
|
203
|
+
}
|
|
204
|
+
else if (evaluation.status === "pending") {
|
|
205
|
+
emit(ctx, entry, "ci_pending", { detail: "existing exact-SHA checks pending" });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await holdForIntegrationRepair(ctx, entry, allActive, index, existingChecks);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
103
212
|
const runId = await ctx.ci.triggerRun(branch, sha);
|
|
104
213
|
emit(ctx, entry, "ci_triggered", { ciRunId: runId });
|
|
105
214
|
ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId.slice(0, 12)}`);
|
|
@@ -111,15 +220,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
|
|
|
111
220
|
emit(ctx, entry, "ci_pending", { ciRunId: entry.ciRunId });
|
|
112
221
|
break;
|
|
113
222
|
case "pass":
|
|
114
|
-
|
|
115
|
-
ciRunId: entry.ciRunId,
|
|
116
|
-
candidateKind: entry.candidateKind ?? undefined,
|
|
117
|
-
candidateSha: entry.candidateSha ?? undefined,
|
|
118
|
-
policyFingerprint: entry.candidatePolicyFingerprint ?? undefined,
|
|
119
|
-
});
|
|
120
|
-
if (isLandingHead) {
|
|
121
|
-
ctx.store.transition(entry.id, "merging", undefined, "CI passed, ready to merge");
|
|
122
|
-
}
|
|
223
|
+
await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, entry.ciRunId);
|
|
123
224
|
break;
|
|
124
225
|
case "fail": {
|
|
125
226
|
emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
|
|
@@ -140,7 +241,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
|
|
|
140
241
|
else {
|
|
141
242
|
const sha = entry.candidateSha ?? entry.headSha;
|
|
142
243
|
const checks = await ctx.github.listChecksForRef(sha);
|
|
143
|
-
await
|
|
244
|
+
await holdForIntegrationRepair(ctx, entry, allActive, index, checks);
|
|
144
245
|
}
|
|
145
246
|
break;
|
|
146
247
|
}
|
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,54 +140,28 @@ 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;
|
|
146
150
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
catch (error) {
|
|
152
|
-
this.logger.debug({ prNumber, err: error }, "Could not read labels for priority detection; continuing without priority label");
|
|
153
|
-
}
|
|
154
|
-
const priority = labels.includes(this.config.priorityQueueLabel) ? 1 : 0;
|
|
155
|
-
const checks = await this.github.listChecks(prNumber);
|
|
151
|
+
// Admission and ordering are derived from review/check/PR truth. Labels
|
|
152
|
+
// remain available for presentation but are not control inputs.
|
|
153
|
+
const priority = 0;
|
|
154
|
+
const checks = await this.github.listChecksForRef(status.headSha);
|
|
156
155
|
const requiredCheckRules = this.policy.getRequiredCheckRules();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
else if (this.policy.shouldRequireAllChecksOnEmptyRequiredSet()) {
|
|
170
|
-
if (checks.length === 0) {
|
|
171
|
-
this.logger.debug({ prNumber }, "GitHub requires checks but none are visible yet, skipping admission");
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
const hasPending = checks.some((check) => check.conclusion === "pending");
|
|
175
|
-
const hasFailures = checks.some((check) => check.conclusion === "failure");
|
|
176
|
-
if (hasPending || hasFailures) {
|
|
177
|
-
this.logger.debug({
|
|
178
|
-
prNumber,
|
|
179
|
-
checkNames: checks.map((check) => `${check.name}:${check.conclusion}`),
|
|
180
|
-
}, "GitHub requires all observed checks to pass before admission");
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
const nonSteward = checks.filter((c) => !c.name.startsWith("merge-steward"));
|
|
186
|
-
const hasGreen = nonSteward.some((c) => c.conclusion === "success");
|
|
187
|
-
if (!hasGreen) {
|
|
188
|
-
this.logger.debug({ prNumber }, "No green CI checks, skipping admission");
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
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;
|
|
191
165
|
}
|
|
192
166
|
// A stacked PR waits for its parent queue entry. Monotonic positions
|
|
193
167
|
// guarantee parent-before-child ordering, not strict adjacency.
|
|
@@ -233,8 +207,22 @@ export class MergeStewardQueueCommands {
|
|
|
233
207
|
this.logger.debug({ prNumber, entryId: entry.id, headSha }, "Ignoring synchronize webhook for unchanged head");
|
|
234
208
|
return;
|
|
235
209
|
}
|
|
236
|
-
|
|
237
|
-
|
|
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");
|
|
238
226
|
}
|
|
239
227
|
}
|
|
240
228
|
async acknowledgeExternalMerge(prNumber) {
|
|
@@ -280,10 +268,10 @@ export class MergeStewardQueueCommands {
|
|
|
280
268
|
if (downstream.candidateRef) {
|
|
281
269
|
this.specBuilder.deleteSpeculative(downstream.candidateRef).catch(() => { });
|
|
282
270
|
}
|
|
283
|
-
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`);
|
|
284
272
|
}
|
|
285
273
|
if (targets.length > 0) {
|
|
286
|
-
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");
|
|
287
275
|
}
|
|
288
276
|
}
|
|
289
277
|
findAffectedEntriesAfterPriorityChange(before, after) {
|
package/dist/types.d.ts
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
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
|
-
export type CandidateKind = "head" | "integration";
|
|
17
|
+
export type CandidateKind = "head" | "integration" | "integration_repair";
|
|
18
18
|
export interface QueueEntry {
|
|
19
19
|
id: string;
|
|
20
20
|
repoId: string;
|
|
@@ -66,14 +66,14 @@ 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.
|
|
73
73
|
*/
|
|
74
74
|
decidedAt: string | null;
|
|
75
75
|
}
|
|
76
|
-
export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "policy_blocked";
|
|
76
|
+
export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "feature_changed" | "policy_blocked";
|
|
77
77
|
export type CheckConclusion = "success" | "failure" | "pending" | "neutral" | "skipped";
|
|
78
78
|
export interface CheckResult {
|
|
79
79
|
name: string;
|
|
@@ -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
|
@@ -109,22 +109,15 @@ export async function processWebhookEvent(event, service, config, logger) {
|
|
|
109
109
|
case "pr_labeled": {
|
|
110
110
|
if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
|
|
111
111
|
return;
|
|
112
|
-
logger.info({ prNumber: event.prNumber, label: event.label }, "
|
|
113
|
-
if (event.label === config.priorityQueueLabel && service.updatePriorityByPR(event.prNumber, 1)) {
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
112
|
+
logger.info({ prNumber: event.prNumber, label: event.label }, "Presentation label changed, reconciling eligibility");
|
|
116
113
|
await service.tryAdmit(event.prNumber, event.branch, event.headSha);
|
|
117
114
|
break;
|
|
118
115
|
}
|
|
119
116
|
case "pr_unlabeled": {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
if (event.label !== config.admissionLabel)
|
|
117
|
+
// Labels are presentation-only. Losing one must not dequeue, reorder,
|
|
118
|
+
// admit, or otherwise control the queue.
|
|
119
|
+
if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
|
|
125
120
|
return;
|
|
126
|
-
// Label removed — dequeue if active.
|
|
127
|
-
service.dequeueByPR(event.prNumber);
|
|
128
121
|
break;
|
|
129
122
|
}
|
|
130
123
|
case "pr_merged": {
|
|
@@ -136,7 +129,8 @@ export async function processWebhookEvent(event, service, config, logger) {
|
|
|
136
129
|
break;
|
|
137
130
|
}
|
|
138
131
|
case "pr_synchronize": {
|
|
139
|
-
//
|
|
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.
|
|
140
134
|
service.updateHeadByPR(event.prNumber, event.headSha);
|
|
141
135
|
break;
|
|
142
136
|
}
|