merge-steward 0.34.3 → 0.35.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/README.md +27 -10
- package/dist/github/check-run-reporter.js +2 -0
- 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-prepare.js +30 -31
- package/dist/reconciler-validate.js +128 -27
- package/dist/service-queue.js +3 -8
- package/dist/types.d.ts +2 -2
- package/dist/webhook-handler.js +4 -11
- package/package.json +6 -6
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
|
|
|
@@ -14,11 +16,19 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
|
|
|
14
16
|
|
|
15
17
|
1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
|
|
16
18
|
2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
|
|
17
|
-
3. It
|
|
19
|
+
3. It freezes the approved PR head and resolves the exact future-`main`
|
|
20
|
+
candidate. If the prospective base is its ancestor, that head is the
|
|
21
|
+
candidate. Otherwise it publishes
|
|
22
|
+
`merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
|
|
18
23
|
4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
|
|
19
24
|
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
|
-
6. On
|
|
21
|
-
|
|
25
|
+
6. On conflict, the workspace remains at the prospective base; PatchRelay
|
|
26
|
+
derives the missing ancestry and non-force pushes a resolved candidate.
|
|
27
|
+
7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
|
|
28
|
+
branch. Review Quill verifies only that an agent repair preserved the
|
|
29
|
+
approved feature.
|
|
30
|
+
8. Ordinary integration failures retain queue position. Feature implementation
|
|
31
|
+
reopens only when integration review proves the feature must change.
|
|
22
32
|
|
|
23
33
|
This is structural, not an optional fast path. An exact head is safe precisely
|
|
24
34
|
when it already contains the prospective base; if it does not, Merge Steward
|
|
@@ -26,7 +36,10 @@ creates and tests the integration candidate. Checks never move between SHAs.
|
|
|
26
36
|
|
|
27
37
|
## Use with your own agent
|
|
28
38
|
|
|
29
|
-
For an agent that drives PRs through the queue and reacts to
|
|
39
|
+
For an agent that drives PRs through the queue and reacts to candidate state and
|
|
40
|
+
failing checks without running PatchRelay's full harness, install the
|
|
41
|
+
[`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the
|
|
42
|
+
companion Claude Code marketplace:
|
|
30
43
|
|
|
31
44
|
```
|
|
32
45
|
/plugin marketplace add krasnoperov/patchrelay-agents
|
|
@@ -73,7 +86,7 @@ Each repository reconcile tick is bounded by `reconcileStaleAfterMs` (five minut
|
|
|
73
86
|
| Code | Meaning |
|
|
74
87
|
|-|-|
|
|
75
88
|
| 0 | merged / approved with green required checks |
|
|
76
|
-
| 2 | changes_requested /
|
|
89
|
+
| 2 | changes_requested / integration review requires feature rework / policy failure / closed |
|
|
77
90
|
| 3 | still in flight (queued, preparing, validating, merging, pending) |
|
|
78
91
|
| 4 | `--wait` timed out |
|
|
79
92
|
| 1 | usage or configuration error |
|
|
@@ -95,10 +108,14 @@ The real gate is:
|
|
|
95
108
|
|
|
96
109
|
Independent services, GitHub as the shared bus:
|
|
97
110
|
|
|
98
|
-
1.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
1. The steward admits an open PR when GitHub shows an approved head and green
|
|
112
|
+
branch checks; no label is required.
|
|
113
|
+
2. It publishes a self-describing candidate ref. Missing approved-head ancestry
|
|
114
|
+
means conflict repair; settled red candidate checks mean test repair.
|
|
115
|
+
3. PatchRelay derives either condition from GitHub and non-force pushes only the
|
|
116
|
+
candidate ref.
|
|
117
|
+
4. Review Quill publishes `review-quill/integration` when PatchRelay changed the
|
|
118
|
+
candidate. The steward lands the exact green SHA when that check is satisfied.
|
|
102
119
|
|
|
103
120
|
Neither service calls the other's API.
|
|
104
121
|
|
|
@@ -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"
|
|
@@ -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;
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
import { CLEAN_CI, CLEAR_CANDIDATE, emit,
|
|
1
|
+
import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.js";
|
|
2
2
|
import { evictEntry } 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;
|
|
@@ -33,24 +46,6 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
33
46
|
// Preparing/validating the head never waits on main's CI. The exact
|
|
34
47
|
// candidate includes current main and is gated solely by its own checks.
|
|
35
48
|
//
|
|
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
49
|
// A remote merge can advance main before GitHub's PR API reports `merged`.
|
|
55
50
|
// The PR head is then already contained in main, so building and testing an
|
|
56
51
|
// "integration" candidate would only reproduce the current main commit.
|
|
@@ -92,7 +87,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
92
87
|
}, `head candidate ${entry.headSha.slice(0, 12)} selected on ${baseSha.slice(0, 12)}`);
|
|
93
88
|
return;
|
|
94
89
|
}
|
|
95
|
-
const specName = candidateRefName(entry.
|
|
90
|
+
const specName = candidateRefName(ctx.baseBranch, entry.prNumber);
|
|
96
91
|
emit(ctx, entry, "integration_build_started", { candidateRef: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
|
|
97
92
|
const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
|
|
98
93
|
const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
|
|
@@ -115,17 +110,21 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
115
110
|
}
|
|
116
111
|
if (!result.success) {
|
|
117
112
|
emit(ctx, entry, "integration_build_conflict", { baseSha, conflictFiles: result.conflictFiles });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
113
|
+
// buildSpeculative deliberately leaves the local workspace ref at the
|
|
114
|
+
// prospective base. Publish it so PatchRelay can derive the repair from
|
|
115
|
+
// GitHub ancestry alone and add a merge commit with a non-force push.
|
|
116
|
+
await ctx.git.push(specName, true);
|
|
117
|
+
ctx.store.transition(entry.id, "validating", {
|
|
118
|
+
baseSha,
|
|
119
|
+
...CLEAN_CI,
|
|
120
|
+
candidateKind: "integration",
|
|
121
|
+
candidatePolicyFingerprint: ctx.policy.getFingerprint(),
|
|
122
|
+
candidateRef: specName,
|
|
123
|
+
candidateSha: baseSha,
|
|
124
|
+
candidateBasedOn: isHead ? null : prevEntry.id,
|
|
125
|
+
lastFailedBaseSha: baseSha,
|
|
126
|
+
waitDetail: `integration conflict; workspace awaits repair${result.conflictFiles?.length ? ` in ${result.conflictFiles.join(", ")}` : ""}`,
|
|
127
|
+
}, `integration workspace published at ${baseSha.slice(0, 12)} for conflict repair`);
|
|
129
128
|
return;
|
|
130
129
|
}
|
|
131
130
|
const candidateSha = result.sha ?? entry.headSha;
|
|
@@ -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/service-queue.js
CHANGED
|
@@ -144,14 +144,9 @@ export class MergeStewardQueueCommands {
|
|
|
144
144
|
this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
|
|
145
145
|
return false;
|
|
146
146
|
}
|
|
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;
|
|
147
|
+
// Admission and ordering are derived from review/check/PR truth. Labels
|
|
148
|
+
// remain available for presentation but are not control inputs.
|
|
149
|
+
const priority = 0;
|
|
155
150
|
const checks = await this.github.listChecks(prNumber);
|
|
156
151
|
const requiredCheckRules = this.policy.getRequiredCheckRules();
|
|
157
152
|
if (requiredCheckRules.length > 0) {
|
package/dist/types.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued";
|
|
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;
|
|
@@ -73,7 +73,7 @@ export interface QueueEntry {
|
|
|
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;
|
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": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "merge-steward",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"fastify": "^5.12.1",
|
|
39
39
|
"fastify-raw-body": "^6.0.1",
|
|
40
|
-
"ink": "^7.1.
|
|
40
|
+
"ink": "^7.1.1",
|
|
41
41
|
"pino": "^10.3.1",
|
|
42
|
-
"react": "^19.2.
|
|
42
|
+
"react": "^19.2.8",
|
|
43
43
|
"zod": "^4.4.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^24.13.3",
|
|
47
|
-
"@types/react": "^19.2.
|
|
47
|
+
"@types/react": "^19.2.18",
|
|
48
48
|
"@typescript/native-preview": "7.0.0-dev.20260427.1",
|
|
49
49
|
"fast-check": "^4.9.0",
|
|
50
|
-
"isomorphic-git": "^1.
|
|
51
|
-
"memfs": "^4.
|
|
50
|
+
"isomorphic-git": "^1.41.9",
|
|
51
|
+
"memfs": "^4.68.1"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"dev": "node --watch --experimental-transform-types src/index.ts",
|