merge-steward 0.10.2 → 0.10.3

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.
@@ -1,10 +1,10 @@
1
1
  import { SqliteStore } from "../../db/sqlite-store.js";
2
- import { buildSummary } from "../../service.js";
3
2
  import { TERMINAL_STATUSES } from "../../types.js";
4
3
  import { UsageError } from "../types.js";
5
4
  import { parseIntegerFlag } from "../args.js";
6
5
  import { formatJson, writeOutput } from "../output.js";
7
6
  import { loadRepoConfigById, resolveRepoId, fetchLocalJson } from "../system.js";
7
+ import { buildQueueSummary } from "../../watch/dashboard-model.js";
8
8
  async function readQueueSnapshot(config, eventLimit) {
9
9
  try {
10
10
  const query = new URLSearchParams({ eventLimit: String(eventLimit) });
@@ -21,7 +21,7 @@ async function readQueueSnapshot(config, eventLimit) {
21
21
  repoId: config.repoId,
22
22
  repoFullName: config.repoFullName,
23
23
  baseBranch: config.baseBranch,
24
- summary: buildSummary(entries),
24
+ summary: buildQueueSummary(entries),
25
25
  runtime: {
26
26
  tickInProgress: false,
27
27
  lastTickStartedAt: null,
@@ -0,0 +1,33 @@
1
+ import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
2
+ import type { QueueStore } from "./store.ts";
3
+ import type { QueueEntry, ReconcileEvent, ReconcileAction } from "./types.ts";
4
+ export interface ReconcileContext {
5
+ store: QueueStore;
6
+ repoId: string;
7
+ baseBranch: string;
8
+ remotePrefix: string;
9
+ git: GitOperations;
10
+ ci: CIRunner;
11
+ github: GitHubPRApi;
12
+ eviction: EvictionReporter;
13
+ specBuilder: SpeculativeBranchBuilder;
14
+ speculativeDepth: number;
15
+ flakyRetries: number;
16
+ onEvent: (event: ReconcileEvent) => void;
17
+ }
18
+ export declare const SPEC_BRANCH_PREFIX = "mq-spec-";
19
+ export declare const FAILED_CONCLUSIONS: Set<string>;
20
+ export declare const CLEAN_SPEC: {
21
+ readonly specBranch: null;
22
+ readonly specSha: null;
23
+ readonly specBasedOn: null;
24
+ };
25
+ export declare const CLEAN_CI: {
26
+ readonly ciRunId: null;
27
+ readonly ciRetries: 0;
28
+ };
29
+ export declare function emit(ctx: ReconcileContext, entry: QueueEntry, action: ReconcileAction, extra?: Partial<ReconcileEvent>): void;
30
+ export declare function ref(ctx: ReconcileContext, name: string): string;
31
+ export declare function specBranchName(entryId: string): string;
32
+ export declare function isBudgetExhausted(entry: QueueEntry): boolean;
33
+ export declare function isRetryGated(entry: QueueEntry, currentBaseSha: string): boolean;
@@ -0,0 +1,19 @@
1
+ export const SPEC_BRANCH_PREFIX = "mq-spec-";
2
+ export const FAILED_CONCLUSIONS = new Set(["failure"]);
3
+ export const CLEAN_SPEC = { specBranch: null, specSha: null, specBasedOn: null };
4
+ export const CLEAN_CI = { ciRunId: null, ciRetries: 0 };
5
+ export function emit(ctx, entry, action, extra) {
6
+ ctx.onEvent({ at: new Date().toISOString(), entryId: entry.id, prNumber: entry.prNumber, action, ...extra });
7
+ }
8
+ export function ref(ctx, name) {
9
+ return ctx.remotePrefix + name;
10
+ }
11
+ export function specBranchName(entryId) {
12
+ return `${SPEC_BRANCH_PREFIX}${entryId}`;
13
+ }
14
+ export function isBudgetExhausted(entry) {
15
+ return entry.retryAttempts >= entry.maxRetries;
16
+ }
17
+ export function isRetryGated(entry, currentBaseSha) {
18
+ return entry.lastFailedBaseSha === currentBaseSha;
19
+ }
@@ -0,0 +1,12 @@
1
+ import type { FailureClass, QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export declare function cleanupSpec(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
4
+ export declare function invalidateDownstream(ctx: ReconcileContext, allActive: QueueEntry[], afterIndex: number): Promise<void>;
5
+ export declare function evictEntry(ctx: ReconcileContext, entry: QueueEntry, failureClass: FailureClass, extra?: {
6
+ conflictFiles?: string[];
7
+ failedChecks?: Array<{
8
+ name: string;
9
+ conclusion: string;
10
+ url?: string;
11
+ }>;
12
+ }): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { selectDownstream } from "./invalidation.js";
3
+ import { CLEAN_SPEC, emit, ref } from "./reconciler-core.js";
4
+ import { INVALIDATION_PATCH } from "./invalidation.js";
5
+ export async function cleanupSpec(ctx, entry) {
6
+ if (entry.specBranch) {
7
+ await ctx.specBuilder.deleteSpeculative(entry.specBranch).catch(() => {
8
+ // Best-effort cleanup — branch may not exist.
9
+ });
10
+ }
11
+ }
12
+ export async function invalidateDownstream(ctx, allActive, afterIndex) {
13
+ const targets = selectDownstream(allActive, allActive[afterIndex].position);
14
+ for (const downstream of targets) {
15
+ emit(ctx, downstream, "invalidated", { detail: `base changed after position ${afterIndex}` });
16
+ await cleanupSpec(ctx, downstream);
17
+ ctx.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, "invalidated: base changed");
18
+ }
19
+ }
20
+ export async function evictEntry(ctx, entry, failureClass, extra) {
21
+ await cleanupSpec(ctx, entry);
22
+ let baseSha = entry.baseSha;
23
+ if (!baseSha) {
24
+ try {
25
+ baseSha = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
26
+ }
27
+ catch {
28
+ baseSha = "unknown";
29
+ }
30
+ }
31
+ const events = ctx.store.listEvents(entry.id);
32
+ const retryHistory = [];
33
+ for (const event of events) {
34
+ const eventBaseSha = event.baseSha || "unknown";
35
+ if (event.fromStatus === "preparing_head" && event.toStatus === "validating") {
36
+ retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "passed_to_validation" });
37
+ }
38
+ else if (event.fromStatus === "validating" && event.toStatus === "preparing_head") {
39
+ retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "ci_failed_retry" });
40
+ }
41
+ else if (event.fromStatus === "preparing_head" && event.toStatus === "preparing_head") {
42
+ retryHistory.push({ at: event.at, baseSha: eventBaseSha, outcome: "conflict_retry" });
43
+ }
44
+ }
45
+ const context = {
46
+ version: 1,
47
+ failureClass,
48
+ baseSha,
49
+ prHeadSha: entry.headSha,
50
+ queuePosition: entry.position,
51
+ conflictFiles: extra?.conflictFiles,
52
+ failedChecks: extra?.failedChecks,
53
+ baseBranch: ctx.baseBranch,
54
+ branch: entry.branch,
55
+ issueKey: entry.issueKey,
56
+ retryHistory,
57
+ };
58
+ const incident = {
59
+ id: randomUUID(),
60
+ entryId: entry.id,
61
+ at: new Date().toISOString(),
62
+ failureClass,
63
+ context,
64
+ outcome: "open",
65
+ };
66
+ ctx.store.insertIncident(incident);
67
+ emit(ctx, entry, "evicted", { failureClass });
68
+ ctx.store.transition(entry.id, "evicted", CLEAN_SPEC, `evicted: ${failureClass}`);
69
+ await ctx.eviction.reportEviction(entry, incident);
70
+ }
@@ -0,0 +1,3 @@
1
+ import type { QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export declare function mergeHead(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
2
+ import { cleanupSpec, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
3
+ export async function mergeHead(ctx, entry) {
4
+ emit(ctx, entry, "merge_revalidating");
5
+ const prStatus = await ctx.github.getStatus(entry.prNumber);
6
+ if (prStatus.merged) {
7
+ emit(ctx, entry, "merge_external");
8
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged externally");
9
+ await cleanupSpec(ctx, entry);
10
+ return;
11
+ }
12
+ if (!prStatus.reviewApproved) {
13
+ const detail = prStatus.reviewDecision === "CHANGES_REQUESTED"
14
+ ? "blocking review present, waiting for approval"
15
+ : prStatus.reviewDecision === "REVIEW_REQUIRED"
16
+ ? "required approval missing"
17
+ : `review gate not satisfied (${prStatus.reviewDecision ?? "unknown"})`;
18
+ emit(ctx, entry, "merge_waiting_approval", { detail });
19
+ return;
20
+ }
21
+ if (prStatus.headSha !== entry.headSha) {
22
+ emit(ctx, entry, "branch_mismatch", { detail: `PR head: expected ${entry.headSha.slice(0, 8)}, got ${prStatus.headSha.slice(0, 8)}` });
23
+ const allActive = ctx.store.listActive(ctx.repoId);
24
+ ctx.store.updateHead(entry.id, prStatus.headSha);
25
+ await invalidateDownstream(ctx, allActive, 0);
26
+ return;
27
+ }
28
+ if (!entry.specBranch || !entry.specSha) {
29
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "no spec branch, re-prepare");
30
+ return;
31
+ }
32
+ try {
33
+ await ctx.git.fetch();
34
+ const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
35
+ const isFF = await ctx.git.isAncestor(currentBase, entry.specSha);
36
+ if (!isFF) {
37
+ emit(ctx, entry, "branch_mismatch", { detail: `spec is not a fast-forward from main (${currentBase.slice(0, 8)})` });
38
+ const allActive = ctx.store.listActive(ctx.repoId);
39
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main diverged, re-prepare");
40
+ await invalidateDownstream(ctx, allActive, 0);
41
+ return;
42
+ }
43
+ }
44
+ catch {
45
+ // Can't verify — proceed and let push fail if needed.
46
+ }
47
+ if (ctx.ci.getMainStatus) {
48
+ const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
49
+ if (mainStatus !== "pass") {
50
+ emit(ctx, entry, "main_broken", { detail: "main unhealthy at merge time, re-preparing" });
51
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main unhealthy at merge time");
52
+ return;
53
+ }
54
+ }
55
+ try {
56
+ await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
57
+ }
58
+ catch {
59
+ emit(ctx, entry, "merge_rejected", { detail: "push to main failed" });
60
+ const allActive = ctx.store.listActive(ctx.repoId);
61
+ if (isBudgetExhausted(entry)) {
62
+ emit(ctx, entry, "budget_exhausted");
63
+ await evictEntry(ctx, entry, "integration_conflict");
64
+ }
65
+ else {
66
+ ctx.store.transition(entry.id, "preparing_head", {
67
+ retryAttempts: entry.retryAttempts + 1,
68
+ ...CLEAN_CI,
69
+ ...CLEAN_SPEC,
70
+ }, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
71
+ }
72
+ await invalidateDownstream(ctx, allActive, 0);
73
+ return;
74
+ }
75
+ emit(ctx, entry, "merge_succeeded");
76
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
77
+ await cleanupSpec(ctx, entry);
78
+ try {
79
+ await ctx.github.deleteBranch(entry.prNumber);
80
+ }
81
+ catch {
82
+ /* cosmetic */
83
+ }
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export declare function prepareEntry(ctx: ReconcileContext, entry: QueueEntry, isHead: boolean, prevEntry: QueueEntry | null): Promise<void>;
@@ -0,0 +1,134 @@
1
+ import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, isRetryGated, ref, specBranchName } from "./reconciler-core.js";
2
+ import { evictEntry } from "./reconciler-evict.js";
3
+ function summarizeCheckNames(checks, limit = 3) {
4
+ const names = [...new Set(checks.map((check) => check.name))];
5
+ if (names.length <= limit) {
6
+ return names.join(", ");
7
+ }
8
+ return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
9
+ }
10
+ function describeMainBroken(failingChecks, pendingChecks) {
11
+ const parts = [];
12
+ if (failingChecks.length > 0) {
13
+ parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
14
+ }
15
+ if (pendingChecks.length > 0) {
16
+ parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
17
+ }
18
+ return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
19
+ }
20
+ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
21
+ emit(ctx, entry, "fetch_started");
22
+ await ctx.git.fetch();
23
+ const base = isHead ? ref(ctx, ctx.baseBranch) : prevEntry?.specBranch ?? null;
24
+ if (!base)
25
+ return;
26
+ const baseSha = await ctx.git.headSha(base);
27
+ const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
28
+ if (currentRef !== entry.headSha) {
29
+ emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
30
+ ctx.store.updateHead(entry.id, currentRef);
31
+ return;
32
+ }
33
+ if (isHead) {
34
+ if (ctx.ci.getMainStatus) {
35
+ const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
36
+ if (mainStatus !== "pass") {
37
+ let mainChecks = [];
38
+ try {
39
+ mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
40
+ }
41
+ catch {
42
+ mainChecks = [];
43
+ }
44
+ const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
45
+ const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
46
+ emit(ctx, entry, "main_broken", {
47
+ baseSha,
48
+ failingChecks,
49
+ pendingChecks,
50
+ detail: describeMainBroken(failingChecks, pendingChecks),
51
+ });
52
+ return;
53
+ }
54
+ }
55
+ if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
56
+ emit(ctx, entry, "budget_exhausted", { baseSha });
57
+ await evictEntry(ctx, entry, "integration_conflict");
58
+ return;
59
+ }
60
+ if (isRetryGated(entry, baseSha)) {
61
+ try {
62
+ const prStatus = await ctx.github.getStatus(entry.prNumber);
63
+ if (prStatus.mergeStateStatus === "DIRTY") {
64
+ emit(ctx, entry, "budget_exhausted", {
65
+ baseSha,
66
+ detail: "retry gated and GitHub still reports merge conflict",
67
+ });
68
+ await evictEntry(ctx, entry, "integration_conflict");
69
+ return;
70
+ }
71
+ emit(ctx, entry, "retry_gated", { baseSha, detail: "local conflict but GitHub reports CLEAN, retrying" });
72
+ ctx.store.transition(entry.id, "preparing_head", {
73
+ lastFailedBaseSha: null,
74
+ ...CLEAN_CI,
75
+ ...CLEAN_SPEC,
76
+ }, "GitHub reports CLEAN, clearing retry gate");
77
+ }
78
+ catch {
79
+ emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
80
+ }
81
+ return;
82
+ }
83
+ }
84
+ const specName = specBranchName(entry.id);
85
+ emit(ctx, entry, "spec_build_started", { specBranch: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
86
+ const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
87
+ const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
88
+ let result;
89
+ try {
90
+ result = await ctx.specBuilder.buildSpeculative(entry.branch, base, specName, mergeMessage);
91
+ }
92
+ catch (err) {
93
+ if (isHead) {
94
+ const detail = `git error during spec build: ${err instanceof Error ? err.message : String(err)}`;
95
+ emit(ctx, entry, "branch_unreachable", { baseSha, detail });
96
+ await evictEntry(ctx, entry, "branch_local");
97
+ }
98
+ else {
99
+ emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
100
+ ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
101
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
102
+ }
103
+ return;
104
+ }
105
+ if (!result.success) {
106
+ emit(ctx, entry, "spec_build_conflict", { baseSha, conflictFiles: result.conflictFiles });
107
+ if (isBudgetExhausted(entry)) {
108
+ emit(ctx, entry, "budget_exhausted");
109
+ await evictEntry(ctx, entry, "integration_conflict", result.conflictFiles ? { conflictFiles: result.conflictFiles } : undefined);
110
+ }
111
+ else {
112
+ ctx.store.transition(entry.id, "preparing_head", {
113
+ retryAttempts: entry.retryAttempts + 1,
114
+ lastFailedBaseSha: baseSha,
115
+ ...CLEAN_CI,
116
+ ...CLEAN_SPEC,
117
+ }, `conflict on ${baseSha.slice(0, 8)}, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
118
+ }
119
+ return;
120
+ }
121
+ const specSha = result.sha ?? entry.headSha;
122
+ emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
123
+ await ctx.git.push(specName, true);
124
+ const runId = await ctx.ci.triggerRun(specName, specSha);
125
+ emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
126
+ ctx.store.transition(entry.id, "validating", {
127
+ baseSha,
128
+ ciRunId: runId,
129
+ lastFailedBaseSha: null,
130
+ specBranch: specName,
131
+ specSha,
132
+ specBasedOn: isHead ? null : prevEntry.id,
133
+ }, `spec ready, CI ${runId.slice(0, 12)}`);
134
+ }
@@ -0,0 +1,3 @@
1
+ import type { QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export declare function sanitizeEntry(ctx: ReconcileContext, entry: QueueEntry): Promise<boolean>;
@@ -0,0 +1,36 @@
1
+ import { CLEAN_SPEC, emit } from "./reconciler-core.js";
2
+ import { cleanupSpec } from "./reconciler-evict.js";
3
+ export async function sanitizeEntry(ctx, entry) {
4
+ const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
5
+ if (canonical && canonical.id !== entry.id) {
6
+ emit(ctx, entry, "sanitized_duplicate", {
7
+ detail: `superseded by entry ${canonical.id}`,
8
+ });
9
+ await cleanupSpec(ctx, entry);
10
+ ctx.store.dequeue(entry.id);
11
+ return true;
12
+ }
13
+ try {
14
+ const prStatus = await ctx.github.getStatus(entry.prNumber);
15
+ if (prStatus.merged) {
16
+ emit(ctx, entry, "merge_external", {
17
+ detail: `PR #${entry.prNumber} already merged on GitHub (detected in sanitize)`,
18
+ });
19
+ await cleanupSpec(ctx, entry);
20
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged externally (sanitize)");
21
+ return true;
22
+ }
23
+ if (!prStatus.mergeable && !prStatus.merged) {
24
+ emit(ctx, entry, "sanitized_closed", {
25
+ detail: `PR #${entry.prNumber} is closed on GitHub`,
26
+ });
27
+ await cleanupSpec(ctx, entry);
28
+ ctx.store.dequeue(entry.id);
29
+ return true;
30
+ }
31
+ }
32
+ catch {
33
+ // GitHub probe failed — don't block the tick.
34
+ }
35
+ return false;
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { QueueEntry } from "./types.ts";
2
+ import type { ReconcileContext } from "./reconciler-core.ts";
3
+ export declare function checkValidation(ctx: ReconcileContext, entry: QueueEntry, allActive: QueueEntry[], index: number): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { CLEAN_CI, CLEAN_SPEC, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
2
+ import { classifyFailure } from "./classify.js";
3
+ import { evictEntry, invalidateDownstream } from "./reconciler-evict.js";
4
+ const FAILED_CONCLUSIONS = new Set(["failure"]);
5
+ export async function checkValidation(ctx, entry, allActive, index) {
6
+ if (!entry.ciRunId) {
7
+ const branch = entry.specBranch ?? entry.branch;
8
+ const sha = entry.specSha ?? entry.headSha;
9
+ const runId = await ctx.ci.triggerRun(branch, sha);
10
+ emit(ctx, entry, "ci_triggered", { ciRunId: runId });
11
+ ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId.slice(0, 12)}`);
12
+ return;
13
+ }
14
+ const status = await ctx.ci.getStatus(entry.ciRunId);
15
+ switch (status) {
16
+ case "pending":
17
+ emit(ctx, entry, "ci_pending", { ciRunId: entry.ciRunId });
18
+ break;
19
+ case "pass":
20
+ emit(ctx, entry, "ci_passed", { ciRunId: entry.ciRunId });
21
+ if (index === 0) {
22
+ ctx.store.transition(entry.id, "merging", undefined, "CI passed, ready to merge");
23
+ }
24
+ break;
25
+ case "fail": {
26
+ emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
27
+ if (entry.ciRetries < ctx.flakyRetries) {
28
+ emit(ctx, entry, "ci_flaky_retry", { detail: `retry ${entry.ciRetries + 1}/${ctx.flakyRetries}` });
29
+ const branch = entry.specBranch ?? entry.branch;
30
+ const sha = entry.specSha ?? entry.headSha;
31
+ const runId = await ctx.ci.triggerRun(branch, sha);
32
+ ctx.store.transition(entry.id, "validating", {
33
+ ciRunId: runId,
34
+ ciRetries: entry.ciRetries + 1,
35
+ }, `flaky retry ${entry.ciRetries + 1}/${ctx.flakyRetries}`);
36
+ }
37
+ else if (isBudgetExhausted(entry)) {
38
+ emit(ctx, entry, "budget_exhausted");
39
+ const branchChecks = await ctx.github.listChecks(entry.prNumber);
40
+ const mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
41
+ const failedChecks = branchChecks
42
+ .filter((c) => FAILED_CONCLUSIONS.has(c.conclusion))
43
+ .map((c) => ({ name: c.name, conclusion: c.conclusion, ...(c.url ? { url: c.url } : {}) }));
44
+ await evictEntry(ctx, entry, classifyFailure(branchChecks, mainChecks), { failedChecks });
45
+ await invalidateDownstream(ctx, allActive, index);
46
+ }
47
+ else {
48
+ ctx.store.transition(entry.id, "preparing_head", {
49
+ retryAttempts: entry.retryAttempts + 1,
50
+ ...CLEAN_CI,
51
+ ...CLEAN_SPEC,
52
+ }, `CI failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
53
+ await invalidateDownstream(ctx, allActive, index);
54
+ }
55
+ break;
56
+ }
57
+ }
58
+ }
@@ -1,18 +1,3 @@
1
- import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
2
- import type { QueueStore } from "./store.ts";
3
- import type { ReconcileEvent } from "./types.ts";
4
- export interface ReconcileContext {
5
- store: QueueStore;
6
- repoId: string;
7
- baseBranch: string;
8
- remotePrefix: string;
9
- git: GitOperations;
10
- ci: CIRunner;
11
- github: GitHubPRApi;
12
- eviction: EvictionReporter;
13
- specBuilder: SpeculativeBranchBuilder;
14
- speculativeDepth: number;
15
- flakyRetries: number;
16
- onEvent: (event: ReconcileEvent) => void;
17
- }
1
+ import type { ReconcileContext } from "./reconciler-core.ts";
2
+ export type { ReconcileContext } from "./reconciler-core.ts";
18
3
  export declare function reconcile(ctx: ReconcileContext): Promise<void>;