merge-steward 0.28.0 → 0.30.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 +3 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -0
- package/dist/github/pr-client.d.ts +4 -0
- package/dist/github/pr-client.js +11 -0
- package/dist/interfaces.d.ts +5 -0
- package/dist/reconciler-core.d.ts +7 -0
- package/dist/reconciler-merge.js +6 -38
- package/dist/reconciler-prepare.js +2 -78
- package/dist/reconciler-queue-labels.d.ts +18 -0
- package/dist/reconciler-queue-labels.js +48 -0
- package/dist/reconciler.js +14 -1
- package/dist/service-runtime.d.ts +0 -4
- package/dist/service-runtime.js +8 -69
- package/dist/types.d.ts +1 -1
- package/dist/watch/state-visualization.js +0 -3
- package/package.json +1 -1
- package/dist/reconciler-main-status.d.ts +0 -8
- package/dist/reconciler-main-status.js +0 -17
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ PRs delivered through the queue are tested against `main` as it was at admission
|
|
|
14
14
|
2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
|
|
15
15
|
3. It builds a speculative branch — `main + PR` at the head, cumulative downstream (`main + A + B`, `main + A + B + C`).
|
|
16
16
|
4. CI runs on that speculative SHA.
|
|
17
|
-
5.
|
|
17
|
+
5. Once the speculative SHA's checks are green and it is still a fast-forward from current `main`, the steward pushes that SHA to `main` immediately. It does **not** wait for `main`'s own CI to settle, and **never** pauses the queue because `main` is red — the green speculative SHA *is* the next `main`.
|
|
18
18
|
6. On CI failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run.
|
|
19
19
|
7. PatchRelay, the `ship-pr` skill, or any agent sees the check run failure and fixes the branch; when CI passes again the PR can be re-admitted.
|
|
20
20
|
|
|
@@ -79,6 +79,8 @@ The real gate is:
|
|
|
79
79
|
|
|
80
80
|
`review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested speculative SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](../../docs/merge-steward.md) for the full App permission set.
|
|
81
81
|
|
|
82
|
+
**`main`'s own CI is information-only.** The speculative SHA the steward tests *is* the exact tree that becomes `main`, so re-testing `main` after the push adds no signal — it only catches flakiness or out-of-band changes (direct pushes, hotfixes). The queue therefore **ignores `main`'s CI entirely** for advancement: it does not gate landing on `main` being green, does not wait for `main` CI before the next landing, and is never "paused" by a red `main`. A red `main` with a green speculative SHA simply means the red was flaky or is fixed by landing — so the steward lands. Use `main`'s CI as a project-health canary, not a queue control.
|
|
83
|
+
|
|
82
84
|
## Interaction with PatchRelay
|
|
83
85
|
|
|
84
86
|
Independent services, GitHub as the shared bus:
|
package/dist/config.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare const stewardConfigSchema: z.ZodObject<{
|
|
|
30
30
|
}, z.core.$strip>>;
|
|
31
31
|
admissionLabel: z.ZodDefault<z.ZodString>;
|
|
32
32
|
priorityQueueLabel: z.ZodDefault<z.ZodString>;
|
|
33
|
+
queueTestingLabel: z.ZodDefault<z.ZodString>;
|
|
34
|
+
queueMergingLabel: z.ZodDefault<z.ZodString>;
|
|
33
35
|
mergeQueueCheckName: z.ZodDefault<z.ZodString>;
|
|
34
36
|
evictionCheckName: z.ZodDefault<z.ZodString>;
|
|
35
37
|
specReadyCheckName: z.ZodDefault<z.ZodString>;
|
package/dist/config.js
CHANGED
|
@@ -31,6 +31,12 @@ export const stewardConfigSchema = z.object({
|
|
|
31
31
|
}).default({ level: "info" }),
|
|
32
32
|
admissionLabel: z.string().default("queue"),
|
|
33
33
|
priorityQueueLabel: z.string().default("queue:priority"),
|
|
34
|
+
// Queue sub-state labels — applied/cleared by the reconciler so a PR's
|
|
35
|
+
// live position in the merge queue is visible on GitHub and readable by
|
|
36
|
+
// patchrelay for its Linear "In Merge Queue" status. `queue:testing` =
|
|
37
|
+
// spec CI validating; `queue:merging` = head, merge in progress.
|
|
38
|
+
queueTestingLabel: z.string().default("queue:testing"),
|
|
39
|
+
queueMergingLabel: z.string().default("queue:merging"),
|
|
34
40
|
mergeQueueCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
|
|
35
41
|
// Plan §2.4 — bus-contract artifact names exposed for cross-service
|
|
36
42
|
// alignment. Defaults preserve current behavior. The steward writes
|
|
@@ -29,4 +29,8 @@ export declare class GitHubPRClient implements GitHubPRApi {
|
|
|
29
29
|
deleteBranch(prNumber: number): Promise<void>;
|
|
30
30
|
findPRByBranch(branch: string): Promise<number | null>;
|
|
31
31
|
listLabels(prNumber: number): Promise<string[]>;
|
|
32
|
+
setLabels(prNumber: number, opts: {
|
|
33
|
+
add?: string[];
|
|
34
|
+
remove?: string[];
|
|
35
|
+
}): Promise<void>;
|
|
32
36
|
}
|
package/dist/github/pr-client.js
CHANGED
|
@@ -146,6 +146,17 @@ export class GitHubPRClient {
|
|
|
146
146
|
return [];
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
async setLabels(prNumber, opts) {
|
|
150
|
+
const args = ["pr", "edit", String(prNumber), "--repo", this.repoFullName];
|
|
151
|
+
for (const label of opts.add ?? [])
|
|
152
|
+
args.push("--add-label", label);
|
|
153
|
+
for (const label of opts.remove ?? [])
|
|
154
|
+
args.push("--remove-label", label);
|
|
155
|
+
// Nothing to change beyond the base args — skip the call.
|
|
156
|
+
if (args.length === 5)
|
|
157
|
+
return;
|
|
158
|
+
await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
159
|
+
}
|
|
149
160
|
}
|
|
150
161
|
/** Map GitHub REST API check-run status/conclusion to our union. */
|
|
151
162
|
function mapRestConclusion(status, conclusion) {
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -50,6 +50,11 @@ export interface GitHubPRApi {
|
|
|
50
50
|
listChecks(prNumber: number): Promise<CheckResult[]>;
|
|
51
51
|
listChecksForRef(ref: string): Promise<CheckResult[]>;
|
|
52
52
|
listLabels(prNumber: number): Promise<string[]>;
|
|
53
|
+
/** Add and/or remove labels on a PR in a single edit. */
|
|
54
|
+
setLabels(prNumber: number, opts: {
|
|
55
|
+
add?: string[];
|
|
56
|
+
remove?: string[];
|
|
57
|
+
}): Promise<void>;
|
|
53
58
|
/** List open PRs for restart-time eligibility scans. */
|
|
54
59
|
listOpenPRs(): Promise<Array<{
|
|
55
60
|
number: number;
|
|
@@ -15,6 +15,13 @@ export interface ReconcileContext {
|
|
|
15
15
|
speculativeDepth: number;
|
|
16
16
|
flakyRetries: number;
|
|
17
17
|
policy: GitHubPolicyCache;
|
|
18
|
+
/** Queue sub-state label names. When set, the reconciler keeps the
|
|
19
|
+
* `testing`/`merging` labels on each PR in sync with its phase so the
|
|
20
|
+
* queue position is visible on GitHub. Omit to disable label sync. */
|
|
21
|
+
queueStateLabels?: {
|
|
22
|
+
testing: string;
|
|
23
|
+
merging: string;
|
|
24
|
+
};
|
|
18
25
|
onEvent: (event: ReconcileEvent) => void;
|
|
19
26
|
}
|
|
20
27
|
export declare const SPEC_BRANCH_PREFIX = "mq-spec-";
|
package/dist/reconciler-merge.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
|
|
2
2
|
import { cleanupSpec, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
|
|
3
3
|
import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
|
|
4
|
-
import { getEffectiveMainStatus } from "./reconciler-main-status.js";
|
|
5
4
|
const DEFAULT_PR_MERGED_POLL_ATTEMPTS = 6;
|
|
6
5
|
const DEFAULT_PR_MERGED_POLL_DELAY_MS = 2_000;
|
|
7
6
|
function delay(ms) {
|
|
@@ -183,43 +182,12 @@ export async function mergeHead(ctx, entry) {
|
|
|
183
182
|
catch {
|
|
184
183
|
// Can't verify — proceed and let push fail if needed.
|
|
185
184
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
if (mainStatus.status === "pending") {
|
|
196
|
-
// Main is still verifying itself (typically post-merge CI from the
|
|
197
|
-
// previous entry). The spec was built on top of main-as-it-was
|
|
198
|
-
// (isFF check above guarantees main hasn't diverged) and already
|
|
199
|
-
// passed CI, so keep the spec + CI state and retry next tick.
|
|
200
|
-
// Throwing the spec away here would force a rebuild + re-run of
|
|
201
|
-
// CI that produces the same tree — wasting the speculation.
|
|
202
|
-
emit(ctx, entry, "merge_waiting_main", { detail: "main checks still pending, holding merge" });
|
|
203
|
-
ctx.store.transition(entry.id, "merging", { waitDetail: "main checks still pending, holding merge" }, "main checks still pending, holding merge");
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (mainStatus.status === "fail") {
|
|
207
|
-
try {
|
|
208
|
-
const refresh = await ctx.policy.refreshOnIssue("main_unhealthy_at_merge");
|
|
209
|
-
if (refresh.attempted && refresh.changed) {
|
|
210
|
-
emit(ctx, entry, "policy_changed", {
|
|
211
|
-
detail: `GitHub required checks changed from [${refresh.previousRequiredChecks.join(", ") || "(none)"}] to [${refresh.requiredChecks.join(", ") || "(none)"}]`,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
// Keep using cached GitHub policy and pause the queue conservatively.
|
|
217
|
-
}
|
|
218
|
-
emit(ctx, entry, "main_broken", { detail: "main unhealthy at merge time, re-preparing" });
|
|
219
|
-
ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main unhealthy at merge time");
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
185
|
+
// The queue gates only on its own spec CI. main's CI status is irrelevant to
|
|
186
|
+
// landing: the spec was built on current main (the fast-forward check above
|
|
187
|
+
// guarantees main hasn't diverged) and its checks passed, so pushing it advances
|
|
188
|
+
// main to a green SHA. We never wait for main's own CI to settle, and never pause
|
|
189
|
+
// the queue because main is red — a red main is either flaky or fixed by landing
|
|
190
|
+
// this green spec. main CI is information-only (out-of-band breakage canary).
|
|
223
191
|
try {
|
|
224
192
|
await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
|
|
225
193
|
}
|
|
@@ -1,36 +1,5 @@
|
|
|
1
1
|
import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, isRetryGated, ref, specBranchName } from "./reconciler-core.js";
|
|
2
2
|
import { evictEntry } from "./reconciler-evict.js";
|
|
3
|
-
import { getEffectiveMainStatus } from "./reconciler-main-status.js";
|
|
4
|
-
function summarizeCheckNames(checks, limit = 3) {
|
|
5
|
-
const names = [...new Set(checks.map((check) => check.name))];
|
|
6
|
-
if (names.length <= limit) {
|
|
7
|
-
return names.join(", ");
|
|
8
|
-
}
|
|
9
|
-
return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
|
|
10
|
-
}
|
|
11
|
-
function normalizeCheckName(name) {
|
|
12
|
-
return name.trim().toLowerCase();
|
|
13
|
-
}
|
|
14
|
-
function getMissingRequiredChecks(requiredChecks, checks) {
|
|
15
|
-
if (requiredChecks.length === 0) {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
const available = new Set(checks.map((check) => normalizeCheckName(check.name)).filter(Boolean));
|
|
19
|
-
return requiredChecks.filter((check) => !available.has(normalizeCheckName(check)));
|
|
20
|
-
}
|
|
21
|
-
function describeMainBroken(failingChecks, pendingChecks, missingRequiredChecks) {
|
|
22
|
-
const parts = [];
|
|
23
|
-
if (missingRequiredChecks.length > 0) {
|
|
24
|
-
parts.push(`missing required ${missingRequiredChecks.join(", ")}`);
|
|
25
|
-
}
|
|
26
|
-
if (failingChecks.length > 0) {
|
|
27
|
-
parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
|
|
28
|
-
}
|
|
29
|
-
if (pendingChecks.length > 0) {
|
|
30
|
-
parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
|
|
31
|
-
}
|
|
32
|
-
return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
|
|
33
|
-
}
|
|
34
3
|
export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
35
4
|
emit(ctx, entry, "fetch_started");
|
|
36
5
|
await ctx.git.fetch();
|
|
@@ -57,53 +26,8 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
|
|
|
57
26
|
return;
|
|
58
27
|
}
|
|
59
28
|
if (isHead) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const mainStatus = await getEffectiveMainStatus(ctx, baseSha);
|
|
63
|
-
if (mainStatus.trustedMergedEntryId) {
|
|
64
|
-
emit(ctx, entry, "main_pending_bypassed", {
|
|
65
|
-
baseSha,
|
|
66
|
-
detail: `main checks pending for already-validated merge of PR #${mainStatus.trustedMergedPrNumber}`,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
if (mainStatus.status !== "pass") {
|
|
70
|
-
let mainChecks = [];
|
|
71
|
-
try {
|
|
72
|
-
mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
mainChecks = [];
|
|
76
|
-
}
|
|
77
|
-
const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
|
|
78
|
-
const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
|
|
79
|
-
let missingRequiredChecks = getMissingRequiredChecks(ctx.policy.getRequiredChecks(), mainChecks);
|
|
80
|
-
if (missingRequiredChecks.length > 0) {
|
|
81
|
-
try {
|
|
82
|
-
const refresh = await ctx.policy.refreshOnIssue("main_missing_required_checks");
|
|
83
|
-
if (refresh.attempted && refresh.changed) {
|
|
84
|
-
missingRequiredChecks = getMissingRequiredChecks(ctx.policy.getRequiredChecks(), mainChecks);
|
|
85
|
-
emit(ctx, entry, "policy_changed", {
|
|
86
|
-
detail: `GitHub required checks changed from [${refresh.previousRequiredChecks.join(", ") || "(none)"}] to [${refresh.requiredChecks.join(", ") || "(none)"}]`,
|
|
87
|
-
});
|
|
88
|
-
if (missingRequiredChecks.length === 0 && failingChecks.length === 0 && pendingChecks.length === 0) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
// Keep using the last known GitHub policy and surface the current block.
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
emit(ctx, entry, "main_broken", {
|
|
98
|
-
baseSha,
|
|
99
|
-
failingChecks,
|
|
100
|
-
pendingChecks,
|
|
101
|
-
missingRequiredChecks,
|
|
102
|
-
detail: describeMainBroken(failingChecks, pendingChecks, missingRequiredChecks),
|
|
103
|
-
});
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
29
|
+
// Preparing/validating the head never waits on main's CI: the spec is built on
|
|
30
|
+
// current main and gated solely on its own checks. main's health is information-only.
|
|
107
31
|
if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
|
|
108
32
|
emit(ctx, entry, "budget_exhausted", { baseSha });
|
|
109
33
|
await evictEntry(ctx, entry, "integration_conflict");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReconcileContext } from "./reconciler-core.ts";
|
|
2
|
+
import type { QueueEntry } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Edge-triggered GitHub label sync for queue sub-state.
|
|
5
|
+
*
|
|
6
|
+
* The label is a pure function of the entry's phase, so a PR's live
|
|
7
|
+
* position in the merge queue is obvious from GitHub — and readable by
|
|
8
|
+
* patchrelay to drive its Linear "In Merge Queue" status:
|
|
9
|
+
*
|
|
10
|
+
* validating → testing label (spec CI is running / awaiting turn)
|
|
11
|
+
* merging → merging label (head of queue, merge in progress)
|
|
12
|
+
* everything else (queued, preparing_head, merged, evicted, …) → neither
|
|
13
|
+
*
|
|
14
|
+
* Idempotent: reads the PR's current labels and only edits the delta, so
|
|
15
|
+
* it is safe to call every tick and after a restart. Cosmetic-only — any
|
|
16
|
+
* GitHub error is swallowed so it can never break the reconcile loop.
|
|
17
|
+
*/
|
|
18
|
+
export declare function syncQueueStateLabels(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { emit } from "./reconciler-core.js";
|
|
2
|
+
/**
|
|
3
|
+
* Edge-triggered GitHub label sync for queue sub-state.
|
|
4
|
+
*
|
|
5
|
+
* The label is a pure function of the entry's phase, so a PR's live
|
|
6
|
+
* position in the merge queue is obvious from GitHub — and readable by
|
|
7
|
+
* patchrelay to drive its Linear "In Merge Queue" status:
|
|
8
|
+
*
|
|
9
|
+
* validating → testing label (spec CI is running / awaiting turn)
|
|
10
|
+
* merging → merging label (head of queue, merge in progress)
|
|
11
|
+
* everything else (queued, preparing_head, merged, evicted, …) → neither
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: reads the PR's current labels and only edits the delta, so
|
|
14
|
+
* it is safe to call every tick and after a restart. Cosmetic-only — any
|
|
15
|
+
* GitHub error is swallowed so it can never break the reconcile loop.
|
|
16
|
+
*/
|
|
17
|
+
export async function syncQueueStateLabels(ctx, entry) {
|
|
18
|
+
const labels = ctx.queueStateLabels;
|
|
19
|
+
if (!labels)
|
|
20
|
+
return;
|
|
21
|
+
const managed = [labels.testing, labels.merging];
|
|
22
|
+
const desired = entry.status === "validating" ? labels.testing
|
|
23
|
+
: entry.status === "merging" ? labels.merging
|
|
24
|
+
: null;
|
|
25
|
+
let current;
|
|
26
|
+
try {
|
|
27
|
+
current = await ctx.github.listLabels(entry.prNumber);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const add = desired && !current.includes(desired) ? [desired] : [];
|
|
33
|
+
const remove = managed.filter((l) => l !== desired && current.includes(l));
|
|
34
|
+
if (add.length === 0 && remove.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
await ctx.github.setLabels(entry.prNumber, { add, remove });
|
|
38
|
+
const parts = [];
|
|
39
|
+
if (add.length)
|
|
40
|
+
parts.push(`+${add.join(",")}`);
|
|
41
|
+
if (remove.length)
|
|
42
|
+
parts.push(`-${remove.join(",")}`);
|
|
43
|
+
emit(ctx, entry, "queue_label_synced", { detail: `${entry.status}: ${parts.join(" ")}` });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// best-effort; the next tick retries
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/reconciler.js
CHANGED
|
@@ -7,6 +7,7 @@ import { mergeHead } from "./reconciler-merge.js";
|
|
|
7
7
|
import { cleanupSpec } from "./reconciler-evict.js";
|
|
8
8
|
import { INVALIDATION_PATCH } from "./invalidation.js";
|
|
9
9
|
import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
|
|
10
|
+
import { syncQueueStateLabels } from "./reconciler-queue-labels.js";
|
|
10
11
|
// ─── Main reconcile loop ────────────────────────────────────────
|
|
11
12
|
export async function reconcile(ctx) {
|
|
12
13
|
const allActive = ctx.store.listActive(ctx.repoId);
|
|
@@ -21,8 +22,14 @@ export async function reconcile(ctx) {
|
|
|
21
22
|
if (!entry || TERMINAL_STATUSES.includes(entry.status))
|
|
22
23
|
continue;
|
|
23
24
|
// Truth guard: verify entry against GitHub before processing.
|
|
24
|
-
if (await sanitizeEntry(ctx, entry))
|
|
25
|
+
if (await sanitizeEntry(ctx, entry)) {
|
|
26
|
+
// Entry may have been terminalized (closed/duplicate) — clear any
|
|
27
|
+
// stale queue sub-state label it still carries.
|
|
28
|
+
const sanitized = ctx.store.getEntry(entry.id);
|
|
29
|
+
if (sanitized)
|
|
30
|
+
await syncQueueStateLabels(ctx, sanitized);
|
|
25
31
|
continue;
|
|
32
|
+
}
|
|
26
33
|
// Stale dependency guard: if this entry's spec was built on top of
|
|
27
34
|
// another entry that was dequeued or evicted (without downstream
|
|
28
35
|
// invalidation), the spec is contaminated with the removed entry's
|
|
@@ -74,6 +81,12 @@ export async function reconcile(ctx) {
|
|
|
74
81
|
wrapped.stack = error.stack;
|
|
75
82
|
throw wrapped;
|
|
76
83
|
}
|
|
84
|
+
// Sync the GitHub queue sub-state label to the (possibly just
|
|
85
|
+
// transitioned) phase: queue:testing while validating, queue:merging
|
|
86
|
+
// while merging, cleared once merged/preparing/terminal.
|
|
87
|
+
const post = ctx.store.getEntry(entry.id);
|
|
88
|
+
if (post)
|
|
89
|
+
await syncQueueStateLabels(ctx, post);
|
|
77
90
|
}
|
|
78
91
|
await verifyMergedEntriesPostPush(ctx);
|
|
79
92
|
}
|
|
@@ -24,8 +24,6 @@ export declare class MergeStewardRuntime {
|
|
|
24
24
|
private lastTickError;
|
|
25
25
|
private lastReconcileEvent;
|
|
26
26
|
private currentQueueBlock;
|
|
27
|
-
private readonly waitingMainInfoAt;
|
|
28
|
-
private static readonly WAITING_MAIN_INFO_INTERVAL_MS;
|
|
29
27
|
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
|
|
30
28
|
start(): Promise<void>;
|
|
31
29
|
stop(): Promise<void>;
|
|
@@ -38,6 +36,4 @@ export declare class MergeStewardRuntime {
|
|
|
38
36
|
private scheduleStaleTickWarning;
|
|
39
37
|
private clearStaleTickTimer;
|
|
40
38
|
private runTick;
|
|
41
|
-
private describeMainBroken;
|
|
42
|
-
private getMissingRequiredChecks;
|
|
43
39
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -18,13 +18,8 @@ export class MergeStewardRuntime {
|
|
|
18
18
|
lastTickOutcome = "idle";
|
|
19
19
|
lastTickError = null;
|
|
20
20
|
lastReconcileEvent = null;
|
|
21
|
+
// The queue never blocks on main CI, so this stays null; kept for status-shape stability.
|
|
21
22
|
currentQueueBlock = null;
|
|
22
|
-
// Per-entry timestamp of the last info-level `merge_waiting_main` log.
|
|
23
|
-
// The event fires every tick while the gate waits, so subsequent ticks
|
|
24
|
-
// log at debug to avoid spamming; a re-emission of info every ~5 min keeps
|
|
25
|
-
// long waits visible in `service logs` without drowning it out.
|
|
26
|
-
waitingMainInfoAt = new Map();
|
|
27
|
-
static WAITING_MAIN_INFO_INTERVAL_MS = 5 * 60 * 1000;
|
|
28
23
|
constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
|
|
29
24
|
this.config = config;
|
|
30
25
|
this.policy = policy;
|
|
@@ -130,7 +125,6 @@ export class MergeStewardRuntime {
|
|
|
130
125
|
this.scheduleStaleTickWarning(this.lastTickStartedAt);
|
|
131
126
|
try {
|
|
132
127
|
await this.beforeTick?.();
|
|
133
|
-
let tickQueueBlockEvent = null;
|
|
134
128
|
await reconcile({
|
|
135
129
|
store: this.store,
|
|
136
130
|
repoId: this.config.repoId,
|
|
@@ -144,6 +138,10 @@ export class MergeStewardRuntime {
|
|
|
144
138
|
eviction: this.eviction,
|
|
145
139
|
flakyRetries: this.config.flakyRetries,
|
|
146
140
|
policy: this.policy,
|
|
141
|
+
queueStateLabels: {
|
|
142
|
+
testing: this.config.queueTestingLabel,
|
|
143
|
+
merging: this.config.queueMergingLabel,
|
|
144
|
+
},
|
|
147
145
|
onEvent: (event) => {
|
|
148
146
|
this.lastReconcileEvent = summarizeReconcileEvent(event);
|
|
149
147
|
const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
|
|
@@ -151,29 +149,12 @@ export class MergeStewardRuntime {
|
|
|
151
149
|
|| event.action === "merge_rejected" || event.action === "budget_exhausted";
|
|
152
150
|
const isDebug = event.action === "ci_pending" || event.action === "retry_gated"
|
|
153
151
|
|| event.action === "fetch_started";
|
|
154
|
-
|
|
155
|
-
if (event.action === "merge_waiting_main") {
|
|
156
|
-
// The event fires every ~tick while the gate waits on main's own
|
|
157
|
-
// CI. Log at info the first time (so an operator can see *why*
|
|
158
|
-
// the head entry is parked in merging) and once every interval
|
|
159
|
-
// after, debug on the intermediate ticks to avoid spamming.
|
|
160
|
-
const last = this.waitingMainInfoAt.get(event.entryId);
|
|
161
|
-
const now = Date.now();
|
|
162
|
-
if (last === undefined || now - last >= MergeStewardRuntime.WAITING_MAIN_INFO_INTERVAL_MS) {
|
|
163
|
-
level = "info";
|
|
164
|
-
this.waitingMainInfoAt.set(event.entryId, now);
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
level = "debug";
|
|
168
|
-
}
|
|
169
|
-
}
|
|
152
|
+
const level = isWarn ? "warn" : isDebug ? "debug" : "info";
|
|
170
153
|
this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
|
|
171
|
-
if (event.action === "main_broken" && tickQueueBlockEvent === null) {
|
|
172
|
-
tickQueueBlockEvent = event;
|
|
173
|
-
}
|
|
174
154
|
},
|
|
175
155
|
});
|
|
176
|
-
|
|
156
|
+
// The queue never blocks on main CI — main health is information-only.
|
|
157
|
+
this.currentQueueBlock = null;
|
|
177
158
|
this.lastTickOutcome = "succeeded";
|
|
178
159
|
}
|
|
179
160
|
catch (error) {
|
|
@@ -192,48 +173,6 @@ export class MergeStewardRuntime {
|
|
|
192
173
|
}
|
|
193
174
|
return true;
|
|
194
175
|
}
|
|
195
|
-
async describeMainBroken(event) {
|
|
196
|
-
const baseRef = `origin/${this.config.baseBranch}`;
|
|
197
|
-
let baseSha = null;
|
|
198
|
-
try {
|
|
199
|
-
baseSha = await this.git.headSha(baseRef);
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
try {
|
|
203
|
-
baseSha = await this.git.headSha(this.config.baseBranch);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
baseSha = event.baseSha ?? null;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
const checkRef = event.baseSha ?? baseSha ?? baseRef;
|
|
210
|
-
let checks = [];
|
|
211
|
-
try {
|
|
212
|
-
checks = await this.github.listChecksForRef(checkRef);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
checks = [];
|
|
216
|
-
}
|
|
217
|
-
return {
|
|
218
|
-
reason: "main_broken",
|
|
219
|
-
entryId: event.entryId,
|
|
220
|
-
headPrNumber: event.prNumber,
|
|
221
|
-
baseBranch: this.config.baseBranch,
|
|
222
|
-
baseSha,
|
|
223
|
-
observedAt: event.at,
|
|
224
|
-
failingChecks: event.failingChecks ?? checks.filter((check) => check.conclusion === "failure"),
|
|
225
|
-
pendingChecks: event.pendingChecks ?? checks.filter((check) => check.conclusion === "pending"),
|
|
226
|
-
missingRequiredChecks: event.missingRequiredChecks ?? this.getMissingRequiredChecks(checks),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
getMissingRequiredChecks(checks) {
|
|
230
|
-
const requiredChecks = this.policy.getRequiredChecks();
|
|
231
|
-
if (requiredChecks.length === 0) {
|
|
232
|
-
return [];
|
|
233
|
-
}
|
|
234
|
-
const available = new Set(checks.map((check) => check.name.trim().toLowerCase()).filter(Boolean));
|
|
235
|
-
return requiredChecks.filter((check) => !available.has(check.trim().toLowerCase()));
|
|
236
|
-
}
|
|
237
176
|
}
|
|
238
177
|
function summarizeReconcileEvent(event) {
|
|
239
178
|
return {
|
package/dist/types.d.ts
CHANGED
|
@@ -242,7 +242,7 @@ export interface QueueConfig {
|
|
|
242
242
|
flakyRetries: number;
|
|
243
243
|
pollIntervalMs: number;
|
|
244
244
|
}
|
|
245
|
-
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "branch_unreachable";
|
|
245
|
+
export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "policy_changed" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "merge_waiting_main" | "main_pending_bypassed" | "pr_branch_cleanup_deferred" | "pr_branch_cleanup_failed" | "sanitized_closed" | "sanitized_duplicate" | "post_merge_verification_started" | "post_merge_verification_completed" | "queue_label_synced" | "branch_unreachable";
|
|
246
246
|
export interface ReconcileEvent {
|
|
247
247
|
at: string;
|
|
248
248
|
entryId: string;
|
|
@@ -112,9 +112,6 @@ export function buildExternalRepairObservations(detail, options) {
|
|
|
112
112
|
if (entry.waitDetail?.toLowerCase().includes("approval") || entry.waitDetail?.toLowerCase().includes("review")) {
|
|
113
113
|
observations.push({ tone: "warn", text: "CI passed. Merge is blocked until GitHub approval returns." });
|
|
114
114
|
}
|
|
115
|
-
else if (entry.waitDetail?.toLowerCase().includes("main")) {
|
|
116
|
-
observations.push({ tone: "info", text: "CI passed. Waiting for main verification before landing." });
|
|
117
|
-
}
|
|
118
115
|
else {
|
|
119
116
|
observations.push({ tone: "info", text: "CI passed. Landing on main." });
|
|
120
117
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { CIStatus } from "./types.ts";
|
|
2
|
-
import type { ReconcileContext } from "./reconciler-core.ts";
|
|
3
|
-
export interface EffectiveMainStatus {
|
|
4
|
-
status: CIStatus;
|
|
5
|
-
trustedMergedEntryId?: string | undefined;
|
|
6
|
-
trustedMergedPrNumber?: number | undefined;
|
|
7
|
-
}
|
|
8
|
-
export declare function getEffectiveMainStatus(ctx: ReconcileContext, baseSha: string): Promise<EffectiveMainStatus>;
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export async function getEffectiveMainStatus(ctx, baseSha) {
|
|
2
|
-
const status = await ctx.ci.getMainStatus?.(ctx.baseBranch) ?? "pass";
|
|
3
|
-
if (status !== "pending") {
|
|
4
|
-
return { status };
|
|
5
|
-
}
|
|
6
|
-
const trustedMergedEntry = ctx.store.listAll(ctx.repoId).find((entry) => entry.status === "merged"
|
|
7
|
-
&& entry.postMergeStatus === "pass"
|
|
8
|
-
&& entry.postMergeSha === baseSha);
|
|
9
|
-
if (!trustedMergedEntry) {
|
|
10
|
-
return { status };
|
|
11
|
-
}
|
|
12
|
-
return {
|
|
13
|
-
status: "pass",
|
|
14
|
-
trustedMergedEntryId: trustedMergedEntry.id,
|
|
15
|
-
trustedMergedPrNumber: trustedMergedEntry.prNumber,
|
|
16
|
-
};
|
|
17
|
-
}
|