merge-steward 0.32.6 → 0.33.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 +12 -7
- package/dist/check-policy.d.ts +11 -0
- package/dist/check-policy.js +88 -0
- package/dist/config.d.ts +0 -2
- package/dist/config.js +1 -12
- package/dist/db/schema.d.ts +3 -2
- package/dist/db/schema.js +62 -9
- package/dist/db/sqlite-store.d.ts +3 -9
- package/dist/db/sqlite-store.js +55 -44
- package/dist/github/actions-runner.d.ts +6 -2
- package/dist/github/actions-runner.js +63 -47
- package/dist/github/check-run-reporter.d.ts +1 -3
- package/dist/github/check-run-reporter.js +1 -52
- package/dist/github/pr-client.d.ts +1 -0
- package/dist/github/pr-client.js +4 -16
- package/dist/github/shell-git.d.ts +0 -5
- package/dist/github/shell-git.js +0 -62
- package/dist/github-policy.d.ts +7 -0
- package/dist/github-policy.js +37 -0
- package/dist/github-repo-discovery.d.ts +2 -0
- package/dist/github-repo-discovery.js +44 -9
- package/dist/interfaces.d.ts +2 -19
- package/dist/invalidation.d.ts +10 -4
- package/dist/invalidation.js +21 -8
- package/dist/queue-order.d.ts +25 -0
- package/dist/queue-order.js +93 -0
- package/dist/reconciler-core.d.ts +13 -6
- package/dist/reconciler-core.js +12 -4
- package/dist/reconciler-evict.d.ts +1 -1
- package/dist/reconciler-evict.js +7 -7
- package/dist/reconciler-merge.js +171 -56
- package/dist/reconciler-post-merge.js +24 -49
- package/dist/reconciler-prepare.js +79 -158
- package/dist/reconciler-queue-labels.d.ts +1 -1
- package/dist/reconciler-queue-labels.js +1 -1
- package/dist/reconciler-sanitize.js +16 -6
- package/dist/reconciler-validate.d.ts +1 -1
- package/dist/reconciler-validate.js +121 -31
- package/dist/reconciler.js +35 -12
- package/dist/server.js +11 -2
- package/dist/service-queue.js +16 -20
- package/dist/service-runtime.d.ts +1 -0
- package/dist/service-runtime.js +35 -2
- package/dist/store.d.ts +4 -19
- package/dist/types.d.ts +33 -28
- package/dist/watch/dashboard-model.js +2 -2
- package/dist/watch/format.d.ts +8 -8
- package/dist/watch/format.js +8 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,18 +8,22 @@ For the background story and design trade-offs, read [merge-steward: speculative
|
|
|
8
8
|
|
|
9
9
|
## Why this matters
|
|
10
10
|
|
|
11
|
-
The queue keeps delivery fast without pretending branch CI is enough.
|
|
11
|
+
The queue keeps delivery fast without pretending branch CI is always enough. For each dependency-ready PR it resolves one immutable candidate: the exact PR head when it already contains the prospective base, or an integration commit when it does not. Downstream candidates are cumulative: `main + A`, then `main + A + B`, then `main + A + B + C`.
|
|
12
12
|
|
|
13
13
|
## How it works
|
|
14
14
|
|
|
15
15
|
1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
|
|
16
16
|
2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
|
|
17
|
-
3. It
|
|
18
|
-
4.
|
|
19
|
-
5.
|
|
17
|
+
3. It resolves the exact future-`main` candidate. If the prospective base is an ancestor of the PR head, the candidate is that already-tested head SHA. Otherwise it builds a cumulative integration commit.
|
|
18
|
+
4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
|
|
19
|
+
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
20
|
6. On CI failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run.
|
|
21
21
|
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.
|
|
22
22
|
|
|
23
|
+
This is structural, not an optional fast path. An exact head is safe precisely
|
|
24
|
+
when it already contains the prospective base; if it does not, Merge Steward
|
|
25
|
+
creates and tests the integration candidate. Checks never move between SHAs.
|
|
26
|
+
|
|
23
27
|
## Use with your own agent
|
|
24
28
|
|
|
25
29
|
For an agent that drives PRs through the queue and reacts to evictions / failing checks without running PatchRelay's full harness, install the [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the companion Claude Code marketplace:
|
|
@@ -78,11 +82,12 @@ The real gate is:
|
|
|
78
82
|
|
|
79
83
|
- GitHub says the PR review state is approved
|
|
80
84
|
- configured required checks are green
|
|
81
|
-
- the
|
|
85
|
+
- the exact landing candidate is green under the current check policy
|
|
86
|
+
- current `main` is an ancestor of that same immutable candidate SHA
|
|
82
87
|
|
|
83
|
-
`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
|
|
88
|
+
`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 candidate 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](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md) for the full App permission set.
|
|
84
89
|
|
|
85
|
-
**`main`'s own CI is information-only.** The
|
|
90
|
+
**`main`'s own CI is information-only.** The candidate SHA the steward validates is exactly what becomes `main`, so re-testing it after the push adds no eligibility signal. Use `main` CI as a project-health canary, not a queue control.
|
|
86
91
|
|
|
87
92
|
## Interaction with PatchRelay
|
|
88
93
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CheckResult, RequiredCheck } from "./types.ts";
|
|
2
|
+
export interface CheckPolicyEvaluation {
|
|
3
|
+
status: "pass" | "pending" | "fail";
|
|
4
|
+
missing: RequiredCheck[];
|
|
5
|
+
failing: CheckResult[];
|
|
6
|
+
pending: CheckResult[];
|
|
7
|
+
}
|
|
8
|
+
export declare function mapGitHubCheckConclusion(status: string | undefined, conclusion: string | null): CheckResult["conclusion"];
|
|
9
|
+
/** Evaluate the exact check runs against the current GitHub policy identity. */
|
|
10
|
+
export declare function evaluateCheckPolicy(requiredChecks: RequiredCheck[], requireAllChecksOnEmptyRequiredSet: boolean, checks: CheckResult[]): CheckPolicyEvaluation;
|
|
11
|
+
export declare function formatRequiredCheck(check: RequiredCheck): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
function normalizeName(name) {
|
|
2
|
+
return name.trim().toLowerCase();
|
|
3
|
+
}
|
|
4
|
+
function matchesRule(check, rule) {
|
|
5
|
+
return normalizeName(check.name) === normalizeName(rule.name)
|
|
6
|
+
&& (rule.appId === null || check.appId === rule.appId);
|
|
7
|
+
}
|
|
8
|
+
function selectLatestRerun(checks) {
|
|
9
|
+
if (checks.length <= 1)
|
|
10
|
+
return checks;
|
|
11
|
+
if (checks.some((check) => check.runId === undefined))
|
|
12
|
+
return checks;
|
|
13
|
+
const ids = checks.map((check) => check.runId);
|
|
14
|
+
if (new Set(ids).size !== ids.length)
|
|
15
|
+
return checks;
|
|
16
|
+
return [checks.reduce((latest, check) => check.runId > latest.runId ? check : latest)];
|
|
17
|
+
}
|
|
18
|
+
function coalesceReruns(checks) {
|
|
19
|
+
const groups = new Map();
|
|
20
|
+
for (const check of checks) {
|
|
21
|
+
const key = `${normalizeName(check.name)}\0${check.appId ?? "any"}`;
|
|
22
|
+
const group = groups.get(key) ?? [];
|
|
23
|
+
group.push(check);
|
|
24
|
+
groups.set(key, group);
|
|
25
|
+
}
|
|
26
|
+
return [...groups.values()].flatMap(selectLatestRerun);
|
|
27
|
+
}
|
|
28
|
+
export function mapGitHubCheckConclusion(status, conclusion) {
|
|
29
|
+
if (status !== "completed" || conclusion === null)
|
|
30
|
+
return "pending";
|
|
31
|
+
switch (conclusion) {
|
|
32
|
+
case "success": return "success";
|
|
33
|
+
case "neutral": return "neutral";
|
|
34
|
+
case "skipped": return "skipped";
|
|
35
|
+
case "failure":
|
|
36
|
+
case "cancelled":
|
|
37
|
+
case "timed_out":
|
|
38
|
+
case "stale":
|
|
39
|
+
case "action_required":
|
|
40
|
+
return "failure";
|
|
41
|
+
default:
|
|
42
|
+
return "pending";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Evaluate the exact check runs against the current GitHub policy identity. */
|
|
46
|
+
export function evaluateCheckPolicy(requiredChecks, requireAllChecksOnEmptyRequiredSet, checks) {
|
|
47
|
+
if (requiredChecks.length > 0) {
|
|
48
|
+
const missing = [];
|
|
49
|
+
const failing = [];
|
|
50
|
+
const pending = [];
|
|
51
|
+
for (const required of requiredChecks) {
|
|
52
|
+
const matching = selectLatestRerun(checks.filter((check) => matchesRule(check, required)));
|
|
53
|
+
if (matching.length === 0) {
|
|
54
|
+
missing.push(required);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// A named required job that was skipped did not validate this
|
|
58
|
+
// candidate. Neutral is explicitly accepted by GitHub.
|
|
59
|
+
failing.push(...matching.filter((check) => check.conclusion === "failure" || check.conclusion === "skipped"));
|
|
60
|
+
pending.push(...matching.filter((check) => check.conclusion === "pending"));
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: failing.length > 0 ? "fail" : missing.length > 0 || pending.length > 0 ? "pending" : "pass",
|
|
64
|
+
missing,
|
|
65
|
+
failing,
|
|
66
|
+
pending,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (checks.length === 0) {
|
|
70
|
+
return { status: "pending", missing: [], failing: [], pending: [] };
|
|
71
|
+
}
|
|
72
|
+
// With no named required set, skipped conditional jobs are non-applicable.
|
|
73
|
+
const currentChecks = coalesceReruns(checks);
|
|
74
|
+
const failing = currentChecks.filter((check) => check.conclusion === "failure");
|
|
75
|
+
const pending = currentChecks.filter((check) => check.conclusion === "pending");
|
|
76
|
+
if (requireAllChecksOnEmptyRequiredSet || requiredChecks.length === 0) {
|
|
77
|
+
return {
|
|
78
|
+
status: failing.length > 0 ? "fail" : pending.length > 0 ? "pending" : "pass",
|
|
79
|
+
missing: [],
|
|
80
|
+
failing,
|
|
81
|
+
pending,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { status: "pass", missing: [], failing: [], pending: [] };
|
|
85
|
+
}
|
|
86
|
+
export function formatRequiredCheck(check) {
|
|
87
|
+
return check.appId === null ? check.name : `${check.name} (app ${check.appId})`;
|
|
88
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -34,8 +34,6 @@ export declare const stewardConfigSchema: z.ZodObject<{
|
|
|
34
34
|
queueMergingLabel: z.ZodDefault<z.ZodString>;
|
|
35
35
|
mergeQueueCheckName: z.ZodDefault<z.ZodString>;
|
|
36
36
|
evictionCheckName: z.ZodDefault<z.ZodString>;
|
|
37
|
-
specReadyCheckName: z.ZodDefault<z.ZodString>;
|
|
38
|
-
specBranchPrefix: z.ZodDefault<z.ZodString>;
|
|
39
37
|
excludeBranches: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
40
38
|
autoResolvePatterns: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
41
39
|
glob: z.ZodString;
|
package/dist/config.js
CHANGED
|
@@ -34,25 +34,14 @@ export const stewardConfigSchema = z.object({
|
|
|
34
34
|
// Queue sub-state labels — applied/cleared by the reconciler so a PR's
|
|
35
35
|
// live position in the merge queue is visible on GitHub and readable by
|
|
36
36
|
// patchrelay for its Linear "In Merge Queue" status. `queue:testing` =
|
|
37
|
-
//
|
|
37
|
+
// candidate checks validating; `queue:merging` = head, landing in progress.
|
|
38
38
|
queueTestingLabel: z.string().default("queue:testing"),
|
|
39
39
|
queueMergingLabel: z.string().default("queue:merging"),
|
|
40
40
|
mergeQueueCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
|
|
41
|
-
// Plan §2.4 — bus-contract artifact names exposed for cross-service
|
|
42
|
-
// alignment. Defaults preserve current behavior. The steward writes
|
|
43
|
-
// `evictionCheckName` (today: same as mergeQueueCheckName, kept for
|
|
44
|
-
// contract clarity) and `specReadyCheckName`; consumers read them.
|
|
45
41
|
/** Eviction check run name. Synonym for mergeQueueCheckName, surfaced
|
|
46
42
|
* under the bus-contract name so consumer code can be naming-aligned
|
|
47
43
|
* with patchrelay's resolver. */
|
|
48
44
|
evictionCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
|
|
49
|
-
/** Spec-ready check run name (default: "merge-steward/spec-ready").
|
|
50
|
-
* Read by review-quill in integration_tree mode (plan §3.5). */
|
|
51
|
-
specReadyCheckName: z.string().default("merge-steward/spec-ready"),
|
|
52
|
-
/** Prefix for spec branch names (default: "mq-spec-"); matches the
|
|
53
|
-
* existing SPEC_BRANCH_PREFIX in reconciler-core.ts. The pattern
|
|
54
|
-
* review-quill matches against is `${prefix}*`. */
|
|
55
|
-
specBranchPrefix: z.string().default("mq-spec-"),
|
|
56
45
|
/** Branch name patterns to exclude from admission (glob-style). */
|
|
57
46
|
excludeBranches: z.array(z.string()).default([]),
|
|
58
47
|
/**
|
package/dist/db/schema.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DatabaseConnection } from "./shared.ts";
|
|
2
2
|
/**
|
|
3
|
-
* Create the steward's SQLite schema. Idempotent — safe to call
|
|
4
|
-
*
|
|
3
|
+
* Create and upgrade the steward's SQLite schema. Idempotent — safe to call
|
|
4
|
+
* on every startup. Migrations preserve active queue state, then remove
|
|
5
|
+
* obsolete columns so the runtime has one candidate model.
|
|
5
6
|
*/
|
|
6
7
|
export declare function ensureSchema(connection: DatabaseConnection): void;
|
package/dist/db/schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Create the steward's SQLite schema. Idempotent — safe to call
|
|
3
|
-
*
|
|
2
|
+
* Create and upgrade the steward's SQLite schema. Idempotent — safe to call
|
|
3
|
+
* on every startup. Migrations preserve active queue state, then remove
|
|
4
|
+
* obsolete columns so the runtime has one candidate model.
|
|
4
5
|
*/
|
|
5
6
|
export function ensureSchema(connection) {
|
|
6
7
|
connection.exec(`
|
|
@@ -21,9 +22,11 @@ export function ensureSchema(connection) {
|
|
|
21
22
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
22
23
|
last_failed_base_sha TEXT,
|
|
23
24
|
issue_key TEXT,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
candidate_kind TEXT,
|
|
26
|
+
candidate_policy_fingerprint TEXT,
|
|
27
|
+
candidate_ref TEXT,
|
|
28
|
+
candidate_sha TEXT,
|
|
29
|
+
candidate_based_on TEXT,
|
|
27
30
|
wait_detail TEXT,
|
|
28
31
|
post_merge_status TEXT,
|
|
29
32
|
post_merge_sha TEXT,
|
|
@@ -77,12 +80,25 @@ export function ensureSchema(connection) {
|
|
|
77
80
|
ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
|
|
78
81
|
ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
|
|
79
82
|
ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
|
|
80
|
-
|
|
83
|
+
ensureColumn(connection, "queue_entries", "candidate_kind", "TEXT");
|
|
84
|
+
ensureColumn(connection, "queue_entries", "candidate_policy_fingerprint", "TEXT");
|
|
85
|
+
ensureColumn(connection, "queue_entries", "candidate_ref", "TEXT");
|
|
86
|
+
ensureColumn(connection, "queue_entries", "candidate_sha", "TEXT");
|
|
87
|
+
ensureColumn(connection, "queue_entries", "candidate_based_on", "TEXT");
|
|
81
88
|
ensureColumn(connection, "queue_entries", "base_ref_name", "TEXT");
|
|
82
|
-
// Plan §5.3: cached identity for patch-id-aware updateHead.
|
|
83
|
-
ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
|
|
84
|
-
ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
|
|
85
89
|
ensureColumn(connection, "queue_entries", "decided_at", "TEXT");
|
|
90
|
+
migrateLegacyCandidateColumns(connection);
|
|
91
|
+
// Retire patch-equivalence caches from older installations. Candidate
|
|
92
|
+
// validity is SHA-bound; preserving these columns would imply otherwise.
|
|
93
|
+
dropColumnIfPresent(connection, "queue_entries", "head_patch_id");
|
|
94
|
+
dropColumnIfPresent(connection, "queue_entries", "spec_tree_id");
|
|
95
|
+
connection.exec(`
|
|
96
|
+
UPDATE queue_entries
|
|
97
|
+
SET candidate_kind = 'integration'
|
|
98
|
+
WHERE candidate_kind IS NULL
|
|
99
|
+
AND candidate_sha IS NOT NULL
|
|
100
|
+
AND candidate_ref IS NOT NULL
|
|
101
|
+
`);
|
|
86
102
|
connection.exec(`
|
|
87
103
|
UPDATE queue_entries
|
|
88
104
|
SET post_merge_status = 'pending'
|
|
@@ -104,6 +120,37 @@ export function ensureSchema(connection) {
|
|
|
104
120
|
WHERE status NOT IN ('merged', 'evicted', 'dequeued')
|
|
105
121
|
`);
|
|
106
122
|
}
|
|
123
|
+
function migrateLegacyCandidateColumns(connection) {
|
|
124
|
+
const legacyColumns = [
|
|
125
|
+
["spec_branch", "candidate_ref"],
|
|
126
|
+
["spec_sha", "candidate_sha"],
|
|
127
|
+
["spec_based_on", "candidate_based_on"],
|
|
128
|
+
];
|
|
129
|
+
for (const [legacy, candidate] of legacyColumns) {
|
|
130
|
+
if (!hasColumn(connection, "queue_entries", legacy))
|
|
131
|
+
continue;
|
|
132
|
+
connection.exec(`
|
|
133
|
+
UPDATE queue_entries
|
|
134
|
+
SET ${candidate} = ${legacy}
|
|
135
|
+
WHERE ${candidate} IS NULL
|
|
136
|
+
AND ${legacy} IS NOT NULL
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
// Derive kind while the legacy ref is still available, then remove all
|
|
140
|
+
// old candidate representations in this same startup migration.
|
|
141
|
+
connection.exec(`
|
|
142
|
+
UPDATE queue_entries
|
|
143
|
+
SET candidate_kind = CASE
|
|
144
|
+
WHEN candidate_sha = head_sha AND candidate_ref IS NULL THEN 'head'
|
|
145
|
+
WHEN candidate_sha IS NOT NULL THEN 'integration'
|
|
146
|
+
ELSE NULL
|
|
147
|
+
END
|
|
148
|
+
WHERE candidate_kind IS NULL
|
|
149
|
+
`);
|
|
150
|
+
for (const [legacy] of legacyColumns) {
|
|
151
|
+
dropColumnIfPresent(connection, "queue_entries", legacy);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
107
154
|
function hasColumn(connection, table, column) {
|
|
108
155
|
const rows = connection.prepare(`PRAGMA table_info(${table})`).all();
|
|
109
156
|
for (const row of rows) {
|
|
@@ -119,3 +166,9 @@ function ensureColumn(connection, table, column, type) {
|
|
|
119
166
|
}
|
|
120
167
|
connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
121
168
|
}
|
|
169
|
+
function dropColumnIfPresent(connection, table, column) {
|
|
170
|
+
if (!hasColumn(connection, table, column)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
connection.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
|
174
|
+
}
|
|
@@ -12,18 +12,12 @@ export declare class SqliteStore implements QueueStore {
|
|
|
12
12
|
listActive(repoId: string): QueueEntry[];
|
|
13
13
|
listAll(repoId: string): QueueEntry[];
|
|
14
14
|
listPostMergePending(repoId: string): QueueEntry[];
|
|
15
|
+
listRecentTerminal(repoId: string, limit?: number): QueueEntry[];
|
|
15
16
|
insert(entry: QueueEntry): void;
|
|
16
|
-
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "
|
|
17
|
+
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "candidateRef" | "candidateSha" | "candidateBasedOn" | "waitDetail" | "candidateKind" | "candidatePolicyFingerprint" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
|
|
17
18
|
dequeue(entryId: string): void;
|
|
18
19
|
updateHead(entryId: string, newHeadSha: string): void;
|
|
19
|
-
|
|
20
|
-
headSha: string;
|
|
21
|
-
specSha: string;
|
|
22
|
-
specBranch: string;
|
|
23
|
-
headPatchId: string;
|
|
24
|
-
specTreeId: string;
|
|
25
|
-
ciRunId: string | null;
|
|
26
|
-
}, detail?: string): void;
|
|
20
|
+
updateBaseRef(entryId: string, baseRefName: string | null, detail?: string): void;
|
|
27
21
|
updatePriority(entryId: string, priority: number, detail?: string): void;
|
|
28
22
|
insertIncident(incident: IncidentRecord): void;
|
|
29
23
|
listIncidents(entryId: string): IncidentRecord[];
|
package/dist/db/sqlite-store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TERMINAL_STATUSES } from "../types.js";
|
|
2
2
|
import { SqliteConnection, isoNow } from "./shared.js";
|
|
3
3
|
import { ensureSchema } from "./schema.js";
|
|
4
|
+
import { orderActiveQueue } from "../queue-order.js";
|
|
4
5
|
function mapEntry(row) {
|
|
5
6
|
return {
|
|
6
7
|
id: String(row.id),
|
|
@@ -19,9 +20,15 @@ function mapEntry(row) {
|
|
|
19
20
|
maxRetries: Number(row.max_retries),
|
|
20
21
|
lastFailedBaseSha: row.last_failed_base_sha === null ? null : String(row.last_failed_base_sha),
|
|
21
22
|
issueKey: row.issue_key === null ? null : String(row.issue_key),
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
candidateKind: row.candidate_kind === null || row.candidate_kind === undefined
|
|
24
|
+
? null
|
|
25
|
+
: String(row.candidate_kind),
|
|
26
|
+
candidatePolicyFingerprint: row.candidate_policy_fingerprint === null || row.candidate_policy_fingerprint === undefined
|
|
27
|
+
? null
|
|
28
|
+
: String(row.candidate_policy_fingerprint),
|
|
29
|
+
candidateRef: row.candidate_ref === null || row.candidate_ref === undefined ? null : String(row.candidate_ref),
|
|
30
|
+
candidateSha: row.candidate_sha === null || row.candidate_sha === undefined ? null : String(row.candidate_sha),
|
|
31
|
+
candidateBasedOn: row.candidate_based_on === null || row.candidate_based_on === undefined ? null : String(row.candidate_based_on),
|
|
25
32
|
waitDetail: row.wait_detail === null || row.wait_detail === undefined ? null : String(row.wait_detail),
|
|
26
33
|
postMergeStatus: row.post_merge_status === null || row.post_merge_status === undefined
|
|
27
34
|
? null
|
|
@@ -31,8 +38,6 @@ function mapEntry(row) {
|
|
|
31
38
|
postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
|
|
32
39
|
prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
|
|
33
40
|
baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
|
|
34
|
-
headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
|
|
35
|
-
specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
|
|
36
41
|
decidedAt: row.decided_at === null || row.decided_at === undefined ? null : String(row.decided_at),
|
|
37
42
|
enqueuedAt: String(row.enqueued_at),
|
|
38
43
|
updatedAt: String(row.updated_at),
|
|
@@ -97,10 +102,7 @@ export class SqliteStore {
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
getHead(repoId) {
|
|
100
|
-
|
|
101
|
-
WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
|
|
102
|
-
ORDER BY priority DESC, position ASC LIMIT 1`).get(repoId, ...TERMINAL_STATUSES);
|
|
103
|
-
return row ? mapEntry(row) : undefined;
|
|
105
|
+
return this.listActive(repoId)[0];
|
|
104
106
|
}
|
|
105
107
|
getEntry(entryId) {
|
|
106
108
|
const row = this.conn.prepare("SELECT * FROM queue_entries WHERE id = ?").get(entryId);
|
|
@@ -116,8 +118,8 @@ export class SqliteStore {
|
|
|
116
118
|
listActive(repoId) {
|
|
117
119
|
const rows = this.conn.prepare(`SELECT * FROM queue_entries
|
|
118
120
|
WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
|
|
119
|
-
ORDER BY
|
|
120
|
-
return rows.map(mapEntry);
|
|
121
|
+
ORDER BY position ASC`).all(repoId, ...TERMINAL_STATUSES);
|
|
122
|
+
return orderActiveQueue(rows.map(mapEntry));
|
|
121
123
|
}
|
|
122
124
|
listAll(repoId) {
|
|
123
125
|
const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
|
|
@@ -130,16 +132,25 @@ export class SqliteStore {
|
|
|
130
132
|
ORDER BY position ASC`).all(repoId);
|
|
131
133
|
return rows.map(mapEntry);
|
|
132
134
|
}
|
|
135
|
+
listRecentTerminal(repoId, limit = 3) {
|
|
136
|
+
const rows = this.conn.prepare(`SELECT * FROM queue_entries
|
|
137
|
+
WHERE repo_id = ? AND status IN (${TERMINAL_STATUSES.map(() => "?").join(", ")})
|
|
138
|
+
ORDER BY COALESCE(decided_at, updated_at) DESC
|
|
139
|
+
LIMIT ?`).all(repoId, ...TERMINAL_STATUSES, limit);
|
|
140
|
+
return rows.map(mapEntry);
|
|
141
|
+
}
|
|
133
142
|
insert(entry) {
|
|
134
143
|
this.conn.transaction(() => {
|
|
135
144
|
this.conn.prepare(`INSERT INTO queue_entries
|
|
136
145
|
(id, repo_id, pr_number, branch, head_sha, base_sha, status, position,
|
|
137
146
|
priority, generation, ci_run_id, ci_retries, retry_attempts,
|
|
138
|
-
max_retries, last_failed_base_sha, issue_key,
|
|
147
|
+
max_retries, last_failed_base_sha, issue_key,
|
|
148
|
+
candidate_kind, candidate_policy_fingerprint,
|
|
149
|
+
candidate_ref, candidate_sha, candidate_based_on, wait_detail,
|
|
139
150
|
post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
|
|
140
151
|
pr_title, base_ref_name, decided_at,
|
|
141
152
|
enqueued_at, updated_at)
|
|
142
|
-
|
|
153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.candidateKind ?? null, entry.candidatePolicyFingerprint ?? null, entry.candidateRef ?? null, entry.candidateSha ?? null, entry.candidateBasedOn ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.decidedAt ?? null, entry.enqueuedAt, entry.updatedAt);
|
|
143
154
|
this.writeEvent(entry.id, null, entry.status);
|
|
144
155
|
})();
|
|
145
156
|
}
|
|
@@ -182,17 +193,25 @@ export class SqliteStore {
|
|
|
182
193
|
sets.push("last_failed_base_sha = ?");
|
|
183
194
|
values.push(patch.lastFailedBaseSha);
|
|
184
195
|
}
|
|
185
|
-
if (patch?.
|
|
186
|
-
sets.push("
|
|
187
|
-
values.push(patch.
|
|
196
|
+
if (patch?.candidateKind !== undefined) {
|
|
197
|
+
sets.push("candidate_kind = ?");
|
|
198
|
+
values.push(patch.candidateKind);
|
|
199
|
+
}
|
|
200
|
+
if (patch?.candidatePolicyFingerprint !== undefined) {
|
|
201
|
+
sets.push("candidate_policy_fingerprint = ?");
|
|
202
|
+
values.push(patch.candidatePolicyFingerprint);
|
|
188
203
|
}
|
|
189
|
-
if (patch?.
|
|
190
|
-
sets.push("
|
|
191
|
-
values.push(patch.
|
|
204
|
+
if (patch?.candidateRef !== undefined) {
|
|
205
|
+
sets.push("candidate_ref = ?");
|
|
206
|
+
values.push(patch.candidateRef);
|
|
192
207
|
}
|
|
193
|
-
if (patch?.
|
|
194
|
-
sets.push("
|
|
195
|
-
values.push(patch.
|
|
208
|
+
if (patch?.candidateSha !== undefined) {
|
|
209
|
+
sets.push("candidate_sha = ?");
|
|
210
|
+
values.push(patch.candidateSha);
|
|
211
|
+
}
|
|
212
|
+
if (patch?.candidateBasedOn !== undefined) {
|
|
213
|
+
sets.push("candidate_based_on = ?");
|
|
214
|
+
values.push(patch.candidateBasedOn);
|
|
196
215
|
}
|
|
197
216
|
if (patch?.waitDetail !== undefined) {
|
|
198
217
|
sets.push("wait_detail = ?");
|
|
@@ -217,14 +236,6 @@ export class SqliteStore {
|
|
|
217
236
|
sets.push("post_merge_checked_at = ?");
|
|
218
237
|
values.push(patch.postMergeCheckedAt);
|
|
219
238
|
}
|
|
220
|
-
if (patch?.headPatchId !== undefined) {
|
|
221
|
-
sets.push("head_patch_id = ?");
|
|
222
|
-
values.push(patch.headPatchId);
|
|
223
|
-
}
|
|
224
|
-
if (patch?.specTreeId !== undefined) {
|
|
225
|
-
sets.push("spec_tree_id = ?");
|
|
226
|
-
values.push(patch.specTreeId);
|
|
227
|
-
}
|
|
228
239
|
this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
|
|
229
240
|
this.writeEvent(entryId, from, to, detail);
|
|
230
241
|
})();
|
|
@@ -245,35 +256,35 @@ export class SqliteStore {
|
|
|
245
256
|
head_sha = ?, status = 'queued', generation = ?,
|
|
246
257
|
ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
|
|
247
258
|
last_failed_base_sha = NULL,
|
|
248
|
-
|
|
259
|
+
candidate_kind = NULL, candidate_policy_fingerprint = NULL,
|
|
260
|
+
candidate_ref = NULL, candidate_sha = NULL, candidate_based_on = NULL,
|
|
249
261
|
wait_detail = NULL,
|
|
250
262
|
post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
|
|
251
|
-
head_patch_id = NULL, spec_tree_id = NULL,
|
|
252
263
|
updated_at = ?
|
|
253
264
|
WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
|
|
254
265
|
this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
|
|
255
266
|
})();
|
|
256
267
|
}
|
|
257
|
-
|
|
268
|
+
updateBaseRef(entryId, baseRefName, detail) {
|
|
258
269
|
this.conn.transaction(() => {
|
|
259
|
-
const current = this.conn.prepare("SELECT status,
|
|
270
|
+
const current = this.conn.prepare("SELECT status, base_ref_name FROM queue_entries WHERE id = ?").get(entryId);
|
|
260
271
|
if (!current)
|
|
261
272
|
return;
|
|
262
273
|
const from = String(current.status);
|
|
263
274
|
if (TERMINAL_STATUSES.includes(from))
|
|
264
275
|
return;
|
|
265
|
-
const
|
|
276
|
+
const currentBaseRef = current.base_ref_name === null ? null : String(current.base_ref_name);
|
|
277
|
+
if (currentBaseRef === baseRefName)
|
|
278
|
+
return;
|
|
266
279
|
this.conn.prepare(`UPDATE queue_entries SET
|
|
267
|
-
|
|
268
|
-
ci_run_id =
|
|
280
|
+
base_ref_name = ?, status = 'queued',
|
|
281
|
+
ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
|
|
269
282
|
last_failed_base_sha = NULL,
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
wait_detail = NULL,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
WHERE id = ?`).run(patch.headSha, newGen, patch.ciRunId, patch.specBranch, patch.specSha, patch.headPatchId, patch.specTreeId, isoNow(), entryId);
|
|
276
|
-
this.writeEvent(entryId, from, "validating", detail ?? `patch-id-equivalent rebuild: generation ${newGen}`);
|
|
283
|
+
candidate_kind = NULL, candidate_policy_fingerprint = NULL,
|
|
284
|
+
candidate_ref = NULL, candidate_sha = NULL, candidate_based_on = NULL,
|
|
285
|
+
wait_detail = NULL, updated_at = ?
|
|
286
|
+
WHERE id = ?`).run(baseRefName, isoNow(), entryId);
|
|
287
|
+
this.writeEvent(entryId, from, "queued", detail ?? `base ref changed to ${baseRefName ?? "(default)"}`);
|
|
277
288
|
})();
|
|
278
289
|
}
|
|
279
290
|
updatePriority(entryId, priority, detail) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CIRunner } from "../interfaces.ts";
|
|
2
|
-
import type { CIStatus } from "../types.ts";
|
|
2
|
+
import type { CIStatus, RequiredCheck } from "../types.ts";
|
|
3
3
|
/**
|
|
4
4
|
* CI runner that polls GitHub Actions via the gh CLI.
|
|
5
5
|
* triggerRun is a no-op — force-pushing the branch triggers CI automatically.
|
|
@@ -9,9 +9,13 @@ import type { CIStatus } from "../types.ts";
|
|
|
9
9
|
export declare class GitHubActionsRunner implements CIRunner {
|
|
10
10
|
private readonly repoFullName;
|
|
11
11
|
private readonly getRequiredChecks;
|
|
12
|
-
|
|
12
|
+
private readonly shouldRequireAllChecksOnEmptyRequiredSet;
|
|
13
|
+
constructor(repoFullName: string, getRequiredChecks?: () => Array<string | RequiredCheck>, shouldRequireAllChecksOnEmptyRequiredSet?: () => boolean);
|
|
13
14
|
triggerRun(_branch: string, sha: string): Promise<string>;
|
|
15
|
+
rerunRun(_runId: string, _branch: string, sha: string): Promise<string>;
|
|
14
16
|
getStatus(runId: string): Promise<CIStatus>;
|
|
17
|
+
private listCheckRuns;
|
|
18
|
+
private evaluate;
|
|
15
19
|
cancelRun(_runId: string): Promise<void>;
|
|
16
20
|
getMainStatus(baseBranch: string): Promise<CIStatus>;
|
|
17
21
|
}
|