merge-steward 0.29.0 → 0.30.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/dist/cli/commands/pr-status.d.ts +15 -0
- package/dist/cli/commands/pr-status.js +27 -3
- 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-queue-labels.d.ts +18 -0
- package/dist/reconciler-queue-labels.js +48 -0
- package/dist/reconciler.js +14 -1
- package/dist/service-runtime.js +4 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -4,6 +4,19 @@ import { type ResolveCommandRunner } from "../resolve.ts";
|
|
|
4
4
|
import { type PrGitHubOverview } from "./pr-github.ts";
|
|
5
5
|
export type PrStatusKind = "merged" | "merged_outside_queue" | "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "dequeued" | "closed" | "changes_requested" | "checks_failing" | "checks_pending" | "approved_clean" | "not_queued";
|
|
6
6
|
export type PrStatusSource = "queue" | "github";
|
|
7
|
+
/**
|
|
8
|
+
* Live position of an entry among the entries actually still in the queue,
|
|
9
|
+
* ordered the way the reconciler processes them (priority DESC, position
|
|
10
|
+
* ASC). This is what an operator means by "where am I in line" — unlike the
|
|
11
|
+
* raw `position`, which is a lifetime admission counter that only ever
|
|
12
|
+
* grows (every PR ever queued bumps it), so a value like 290 reads as "289
|
|
13
|
+
* ahead" when in reality only a couple of entries are active.
|
|
14
|
+
*/
|
|
15
|
+
export interface QueueActiveRank {
|
|
16
|
+
rank: number;
|
|
17
|
+
activeTotal: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function computeActiveRank(entries: QueueEntry[], target: QueueEntry): QueueActiveRank | undefined;
|
|
7
20
|
export interface PrStatusReport {
|
|
8
21
|
repoId: string;
|
|
9
22
|
repoFullName: string;
|
|
@@ -15,6 +28,7 @@ export interface PrStatusReport {
|
|
|
15
28
|
reason?: string;
|
|
16
29
|
queueEntry?: QueueEntry;
|
|
17
30
|
queueSource?: "service" | "database";
|
|
31
|
+
queueActiveRank?: QueueActiveRank;
|
|
18
32
|
queueRuntime?: QueueRuntimeStatus;
|
|
19
33
|
queueLatestEvent?: QueueEventSummary;
|
|
20
34
|
github?: PrGitHubOverview;
|
|
@@ -32,6 +46,7 @@ export interface BuildReportOptions {
|
|
|
32
46
|
prNumber: number;
|
|
33
47
|
queueEntry?: QueueEntry | undefined;
|
|
34
48
|
queueSource?: "service" | "database" | undefined;
|
|
49
|
+
queueActiveRank?: QueueActiveRank | undefined;
|
|
35
50
|
queueRuntime?: QueueRuntimeStatus | undefined;
|
|
36
51
|
queueLatestEvent?: QueueEventSummary | undefined;
|
|
37
52
|
github?: PrGitHubOverview | undefined;
|
|
@@ -5,6 +5,18 @@ 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"]);
|
|
9
|
+
export function computeActiveRank(entries, target) {
|
|
10
|
+
if (TERMINAL_QUEUE_STATUSES.has(target.status))
|
|
11
|
+
return undefined;
|
|
12
|
+
const active = entries
|
|
13
|
+
.filter((entry) => !TERMINAL_QUEUE_STATUSES.has(entry.status))
|
|
14
|
+
.sort((a, b) => (b.priority - a.priority) || (a.position - b.position));
|
|
15
|
+
const index = active.findIndex((entry) => entry.id === target.id);
|
|
16
|
+
if (index < 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
return { rank: index + 1, activeTotal: active.length };
|
|
19
|
+
}
|
|
8
20
|
export function classifyQueueEntry(entry) {
|
|
9
21
|
switch (entry.status) {
|
|
10
22
|
case "merged": return "merged";
|
|
@@ -85,11 +97,13 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
85
97
|
if (!entry)
|
|
86
98
|
return { kind: "not_found" };
|
|
87
99
|
const latestEvent = latestEventForPr(snapshot, prNumber);
|
|
100
|
+
const activeRank = computeActiveRank(snapshot.entries, entry);
|
|
88
101
|
return {
|
|
89
102
|
kind: "found",
|
|
90
103
|
entry,
|
|
91
104
|
source: "service",
|
|
92
105
|
runtime: snapshot.runtime,
|
|
106
|
+
...(activeRank ? { activeRank } : {}),
|
|
93
107
|
...(latestEvent ? { latestEvent } : {}),
|
|
94
108
|
};
|
|
95
109
|
}
|
|
@@ -99,7 +113,8 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
99
113
|
}
|
|
100
114
|
const store = new SqliteStore(config.database.path);
|
|
101
115
|
try {
|
|
102
|
-
const
|
|
116
|
+
const all = store.listAll(config.repoId);
|
|
117
|
+
const entries = all.filter((entry) => entry.prNumber === prNumber);
|
|
103
118
|
if (entries.length === 0)
|
|
104
119
|
return { kind: "not_found" };
|
|
105
120
|
entries.sort((left, right) => {
|
|
@@ -109,7 +124,9 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
109
124
|
return leftTerminal - rightTerminal;
|
|
110
125
|
return right.position - left.position;
|
|
111
126
|
});
|
|
112
|
-
|
|
127
|
+
const entry = entries[0];
|
|
128
|
+
const activeRank = computeActiveRank(all, entry);
|
|
129
|
+
return { kind: "found", entry, source: "database", ...(activeRank ? { activeRank } : {}) };
|
|
113
130
|
}
|
|
114
131
|
finally {
|
|
115
132
|
store.close();
|
|
@@ -138,6 +155,7 @@ export function buildPrStatusReport(options) {
|
|
|
138
155
|
exitCode: exitCodeForKind(kind),
|
|
139
156
|
queueEntry: options.queueEntry,
|
|
140
157
|
...(options.queueSource ? { queueSource: options.queueSource } : {}),
|
|
158
|
+
...(options.queueActiveRank ? { queueActiveRank: options.queueActiveRank } : {}),
|
|
141
159
|
...(options.queueRuntime ? { queueRuntime: options.queueRuntime } : {}),
|
|
142
160
|
...(options.queueLatestEvent ? { queueLatestEvent: options.queueLatestEvent } : {}),
|
|
143
161
|
checkedAt,
|
|
@@ -172,7 +190,12 @@ export function formatReportText(report) {
|
|
|
172
190
|
lines.push(`Reason: ${report.reason}`);
|
|
173
191
|
if (report.queueEntry) {
|
|
174
192
|
const entry = report.queueEntry;
|
|
175
|
-
|
|
193
|
+
// Show live rank among active entries — NOT the raw `position`, which is
|
|
194
|
+
// a lifetime admission counter and reads as a huge phantom backlog.
|
|
195
|
+
if (report.queueActiveRank) {
|
|
196
|
+
const { rank, activeTotal } = report.queueActiveRank;
|
|
197
|
+
lines.push(`Queue position: ${rank} of ${activeTotal} active`);
|
|
198
|
+
}
|
|
176
199
|
lines.push(`Branch: ${entry.branch}`);
|
|
177
200
|
lines.push(`Head SHA: ${entry.headSha}`);
|
|
178
201
|
if (entry.waitDetail)
|
|
@@ -260,6 +283,7 @@ export async function handlePrStatus(options) {
|
|
|
260
283
|
prNumber: resolvedPr.prNumber,
|
|
261
284
|
queueEntry: queueResult.entry,
|
|
262
285
|
queueSource: queueResult.source,
|
|
286
|
+
...(queueResult.activeRank ? { queueActiveRank: queueResult.activeRank } : {}),
|
|
263
287
|
...(queueResult.runtime ? { queueRuntime: queueResult.runtime } : {}),
|
|
264
288
|
...(queueResult.latestEvent ? { queueLatestEvent: queueResult.latestEvent } : {}),
|
|
265
289
|
});
|
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-";
|
|
@@ -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
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -138,6 +138,10 @@ export class MergeStewardRuntime {
|
|
|
138
138
|
eviction: this.eviction,
|
|
139
139
|
flakyRetries: this.config.flakyRetries,
|
|
140
140
|
policy: this.policy,
|
|
141
|
+
queueStateLabels: {
|
|
142
|
+
testing: this.config.queueTestingLabel,
|
|
143
|
+
merging: this.config.queueMergingLabel,
|
|
144
|
+
},
|
|
141
145
|
onEvent: (event) => {
|
|
142
146
|
this.lastReconcileEvent = summarizeReconcileEvent(event);
|
|
143
147
|
const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
|
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;
|