@bridge_gpt/mcp-server 0.2.60 → 0.2.62
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 +16 -8
- package/build/commands.generated.js +3 -3
- package/build/conductor/bridge-api-client.js +353 -2
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +99 -16
- package/build/conductor/readiness.js +108 -15
- package/build/credential-store.js +156 -0
- package/build/doctor.js +94 -6
- package/build/drive-epic.js +124 -12
- package/build/epic-implementer-bin.js +13 -13
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/cli.js +9 -0
- package/build/executor/credentials.js +17 -1
- package/build/executor/http-client.js +45 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +92 -9
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/types.js +43 -0
- package/build/index.js +338 -114
- package/build/install-bridge-conductor.js +398 -6
- package/build/install-bridge.js +92 -16
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +1 -1
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +103 -32
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import { readFile, stat } from "node:fs/promises";
|
|
14
|
-
import { resolveBapiCredentials } from "../credential-store.js";
|
|
14
|
+
import { resolveBapiCredentials, resolveEpicLaunchCredential } from "../credential-store.js";
|
|
15
|
+
import { readEpicLaunchProfile, resolveEpicLaunchTarget, } from "../epic-launch-profile.js";
|
|
15
16
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
16
17
|
import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
17
18
|
import { ConductorValidationError } from "./errors.js";
|
|
@@ -63,6 +64,87 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
63
64
|
: CONDUCTOR_DEFAULT_BASE_URL;
|
|
64
65
|
return { ok: true, access: { repoName, apiKey: credResult.credentials.apiKey, baseUrl } };
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The STRONGER launch-resolution contract (BAPI-1260), used by `drive-epic` and
|
|
69
|
+
* by a `setup-epic` that was handed a launch selection. The order is the point:
|
|
70
|
+
*
|
|
71
|
+
* 1. the selected profile is read and validated (host-local, read-only);
|
|
72
|
+
* 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
|
|
73
|
+
* — conflicts refuse, and an absent target is refused, never defaulted to
|
|
74
|
+
* {@link CONDUCTOR_DEFAULT_BASE_URL};
|
|
75
|
+
* 3. the repository is decided (profile binding, then `--repo`, then the
|
|
76
|
+
* shared resolver);
|
|
77
|
+
* 4. only then is a credential looked up, coupled to that target AND that
|
|
78
|
+
* repository ({@link resolveEpicLaunchCredential}).
|
|
79
|
+
*
|
|
80
|
+
* Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
|
|
81
|
+
* is deliberately left as it is for every other client.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
84
|
+
const env = deps.env ?? process.env;
|
|
85
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
86
|
+
const homedir = deps.homedir ?? os.homedir;
|
|
87
|
+
const platform = deps.platform ?? process.platform;
|
|
88
|
+
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
89
|
+
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
90
|
+
let profile = null;
|
|
91
|
+
if (selection.profileName !== undefined) {
|
|
92
|
+
const read = await readEpicLaunchProfile(selection.profileName, {
|
|
93
|
+
env,
|
|
94
|
+
homedir,
|
|
95
|
+
readFile: readFileImpl,
|
|
96
|
+
host: (deps.hostname ?? os.hostname)(),
|
|
97
|
+
});
|
|
98
|
+
if (!read.ok)
|
|
99
|
+
return { ok: false, kind: "profile-unusable", error: read.message };
|
|
100
|
+
profile = read.profile;
|
|
101
|
+
}
|
|
102
|
+
const target = resolveEpicLaunchTarget({
|
|
103
|
+
flagBaseUrl: selection.baseUrl,
|
|
104
|
+
flagRepo: deps.repoName,
|
|
105
|
+
profile,
|
|
106
|
+
env,
|
|
107
|
+
});
|
|
108
|
+
if (!target.ok)
|
|
109
|
+
return { ok: false, kind: target.kind, error: target.message };
|
|
110
|
+
const repoName = target.repoName ||
|
|
111
|
+
deps.repoName?.trim() ||
|
|
112
|
+
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
113
|
+
if (!repoName) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
kind: "repo-missing",
|
|
117
|
+
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let cred;
|
|
121
|
+
try {
|
|
122
|
+
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, {
|
|
123
|
+
env,
|
|
124
|
+
homedir,
|
|
125
|
+
platform,
|
|
126
|
+
readFile: readFileImpl,
|
|
127
|
+
stat: statImpl,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
132
|
+
}
|
|
133
|
+
if (!cred.ok) {
|
|
134
|
+
const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
|
|
135
|
+
// The binding messages are fixed sentences naming the target KEY and the
|
|
136
|
+
// store path only; the generic store failures stay generic as before.
|
|
137
|
+
return bindingFailure
|
|
138
|
+
? { ok: false, kind: "credential-unbound", error: cred.error }
|
|
139
|
+
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
access: { repoName, apiKey: cred.apiKey, baseUrl: target.baseUrl },
|
|
144
|
+
targetSource: target.source,
|
|
145
|
+
profile,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
66
148
|
/**
|
|
67
149
|
* Build a `${baseUrl}/jira${apiPath}` URL with query params, trimming trailing
|
|
68
150
|
* slashes on the base so there is never a double slash after the host. Query
|
|
@@ -1983,12 +2065,109 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = glob
|
|
|
1983
2065
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1984
2066
|
return parsed;
|
|
1985
2067
|
}
|
|
2068
|
+
/**
|
|
2069
|
+
* BAPI-1252: the backend's closed canonical refusal vocabulary, mirrored as
|
|
2070
|
+
* string literals (the Python source is `api/models/canonical_index_state.py`;
|
|
2071
|
+
* a test pins the two together). A refusal carries exactly one of these and
|
|
2072
|
+
* nothing else — no SHA, generation, owner, or server error text.
|
|
2073
|
+
*/
|
|
2074
|
+
export const CANONICAL_REFUSAL_REASONS = [
|
|
2075
|
+
"canonical_validity_unavailable",
|
|
2076
|
+
"canonical_index_unverified",
|
|
2077
|
+
"canonical_source_missing",
|
|
2078
|
+
"canonical_generation_mismatch",
|
|
2079
|
+
"canonical_manifest_mismatch",
|
|
2080
|
+
"canonical_index_advanced",
|
|
2081
|
+
"canonical_capability_unsupported",
|
|
2082
|
+
];
|
|
2083
|
+
/** The canonical validity states the backend can report. */
|
|
2084
|
+
export const CANONICAL_VALIDITY_STATES = ["unverified", "writing", "verified"];
|
|
2085
|
+
/** The only canonical manifest versions this client accepts as evidence. */
|
|
2086
|
+
export const SUPPORTED_CANONICAL_MANIFEST_VERSIONS = ["v1"];
|
|
2087
|
+
const CANONICAL_PROVENANCE_KEYS = [
|
|
2088
|
+
"canonical_validity_state",
|
|
2089
|
+
"canonical_validity_verified",
|
|
2090
|
+
"canonical_source_commit_sha",
|
|
2091
|
+
"canonical_generation",
|
|
2092
|
+
"canonical_manifest_version",
|
|
2093
|
+
"canonical_validity_reason",
|
|
2094
|
+
];
|
|
2095
|
+
const FULL_COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
|
|
2096
|
+
const CANONICAL_GENERATION_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
2097
|
+
/**
|
|
2098
|
+
* BAPI-1252: decode a `/jira/parse-status` body's canonical provenance, failing
|
|
2099
|
+
* CLOSED. Only an explicit `verified` state, a literal `true` verified flag, a
|
|
2100
|
+
* full lowercase 40-character source SHA, a well-formed generation, a supported
|
|
2101
|
+
* manifest version, and no refusal reason decode as evidence. Everything else is
|
|
2102
|
+
* one bounded refusal:
|
|
2103
|
+
*
|
|
2104
|
+
* - all six fields absent (an older server) → `canonical_capability_unsupported`;
|
|
2105
|
+
* - an unknown state, an unknown manifest version, a malformed SHA or
|
|
2106
|
+
* generation, a non-boolean flag, or an unknown reason token →
|
|
2107
|
+
* `canonical_capability_unsupported` (the client cannot prove it, so it is not
|
|
2108
|
+
* proof);
|
|
2109
|
+
* - a known refusal reason → that reason;
|
|
2110
|
+
* - any non-verified state → `canonical_index_unverified`.
|
|
2111
|
+
*
|
|
2112
|
+
* Pure and total: it never throws and never consults lifecycle fields such as
|
|
2113
|
+
* `status` or `indexed_commit_sha`.
|
|
2114
|
+
*/
|
|
2115
|
+
export function decodeCanonicalProvenance(body) {
|
|
2116
|
+
const unsupported = { verified: false, reason: "canonical_capability_unsupported" };
|
|
2117
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
2118
|
+
return unsupported;
|
|
2119
|
+
const obj = body;
|
|
2120
|
+
const present = CANONICAL_PROVENANCE_KEYS.some((key) => key in obj && obj[key] !== undefined);
|
|
2121
|
+
if (!present)
|
|
2122
|
+
return unsupported;
|
|
2123
|
+
const reason = obj.canonical_validity_reason;
|
|
2124
|
+
if (reason !== undefined && reason !== null) {
|
|
2125
|
+
if (typeof reason !== "string")
|
|
2126
|
+
return unsupported;
|
|
2127
|
+
if (!CANONICAL_REFUSAL_REASONS.includes(reason))
|
|
2128
|
+
return unsupported;
|
|
2129
|
+
return { verified: false, reason: reason };
|
|
2130
|
+
}
|
|
2131
|
+
const flag = obj.canonical_validity_verified;
|
|
2132
|
+
if (flag !== undefined && flag !== null && typeof flag !== "boolean")
|
|
2133
|
+
return unsupported;
|
|
2134
|
+
const state = obj.canonical_validity_state;
|
|
2135
|
+
if (typeof state !== "string")
|
|
2136
|
+
return unsupported;
|
|
2137
|
+
if (!CANONICAL_VALIDITY_STATES.includes(state))
|
|
2138
|
+
return unsupported;
|
|
2139
|
+
if (state !== "verified" || flag !== true) {
|
|
2140
|
+
return { verified: false, reason: "canonical_index_unverified" };
|
|
2141
|
+
}
|
|
2142
|
+
const sha = obj.canonical_source_commit_sha;
|
|
2143
|
+
const generation = obj.canonical_generation;
|
|
2144
|
+
const version = obj.canonical_manifest_version;
|
|
2145
|
+
if (typeof sha !== "string" || !FULL_COMMIT_SHA_RE.test(sha))
|
|
2146
|
+
return unsupported;
|
|
2147
|
+
if (typeof generation !== "string" || !CANONICAL_GENERATION_RE.test(generation)) {
|
|
2148
|
+
return unsupported;
|
|
2149
|
+
}
|
|
2150
|
+
if (typeof version !== "string" ||
|
|
2151
|
+
!SUPPORTED_CANONICAL_MANIFEST_VERSIONS.includes(version)) {
|
|
2152
|
+
return unsupported;
|
|
2153
|
+
}
|
|
2154
|
+
return {
|
|
2155
|
+
verified: true,
|
|
2156
|
+
sourceCommitSha: sha,
|
|
2157
|
+
generation,
|
|
2158
|
+
manifestVersion: version,
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
1986
2161
|
/**
|
|
1987
2162
|
* GET `/jira/parse-status?repo_name=<repo>` and return the durable parse-run
|
|
1988
2163
|
* status. See {@link ParseRepositoryStatusValue} for the full state set (the
|
|
1989
2164
|
* status surface distinguishes queued/running from terminal succeeded/failed).
|
|
1990
2165
|
* Throws a sanitized {@link ConductorBridgeApiError} on any transport/auth/server
|
|
1991
2166
|
* failure. URL + auth are unchanged from the pre-BAPI-527 two-state client.
|
|
2167
|
+
*
|
|
2168
|
+
* BAPI-1252: the body is returned as the server sent it, so a payload from an
|
|
2169
|
+
* older server (no canonical fields) still decodes exactly as before. Canonical
|
|
2170
|
+
* provenance is read from it only through {@link decodeCanonicalProvenance}.
|
|
1992
2171
|
*/
|
|
1993
2172
|
export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
|
|
1994
2173
|
const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
|
|
@@ -2092,6 +2271,21 @@ export const CONDUCTOR_REVIEW_ALIGNMENT_STATUSES = new Set([
|
|
|
2092
2271
|
"not_configured",
|
|
2093
2272
|
"invalid",
|
|
2094
2273
|
]);
|
|
2274
|
+
/** The closed verdict vocabulary the parser accepts. */
|
|
2275
|
+
export const REQUIRED_CHECKS_VERIFICATIONS = new Set([
|
|
2276
|
+
"verified",
|
|
2277
|
+
"unknown_name",
|
|
2278
|
+
"unverifiable",
|
|
2279
|
+
]);
|
|
2280
|
+
/** BAPI-1260 — one mandatory-evidence verdict. Only `confirmed` ever holds. */
|
|
2281
|
+
export const ADMISSION_EVIDENCE_VERDICTS = [
|
|
2282
|
+
"confirmed",
|
|
2283
|
+
"denied",
|
|
2284
|
+
"unable_to_verify",
|
|
2285
|
+
"not_applicable",
|
|
2286
|
+
];
|
|
2287
|
+
/** The evidence schema this client renders. A newer one is shown as unknown, never as held. */
|
|
2288
|
+
export const ADMISSION_EVIDENCE_SCHEMA_VERSION = 1;
|
|
2095
2289
|
/** Upper bound of the server's capped `fresh_reconciler_count` ("two or more"). */
|
|
2096
2290
|
export const READINESS_FRESH_RECONCILER_COUNT_CAP = 2;
|
|
2097
2291
|
const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
|
|
@@ -2178,6 +2372,89 @@ function requireEnum(o, key, allowed) {
|
|
|
2178
2372
|
throw readinessShapeError();
|
|
2179
2373
|
return v;
|
|
2180
2374
|
}
|
|
2375
|
+
/** The five BAPI-1254 supervisor keys, which travel as one all-or-none group. */
|
|
2376
|
+
const REQUIRED_CHECKS_VERIFICATION_KEYS = [
|
|
2377
|
+
"required_checks_verification",
|
|
2378
|
+
"required_checks_mismatch_count",
|
|
2379
|
+
"required_checks_unverified_count",
|
|
2380
|
+
"required_checks_evidence_observed_at",
|
|
2381
|
+
"required_checks_evidence_fresh",
|
|
2382
|
+
];
|
|
2383
|
+
/**
|
|
2384
|
+
* A strict ISO-8601 instant with an EXPLICIT UTC designator (`Z` or `+00:00`).
|
|
2385
|
+
* A timezone-free or offset time is rejected rather than read in local time.
|
|
2386
|
+
*/
|
|
2387
|
+
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|\+00:00)$/;
|
|
2388
|
+
function requireNonNegativeInt(o, key) {
|
|
2389
|
+
const v = requireInt(o, key);
|
|
2390
|
+
if (v < 0)
|
|
2391
|
+
throw readinessShapeError();
|
|
2392
|
+
return v;
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Parse the BAPI-1254 required-check verification group, FAIL-CLOSED.
|
|
2396
|
+
*
|
|
2397
|
+
* All five keys ABSENT is an older server: every field becomes `null`, which
|
|
2398
|
+
* every consumer renders as "could not verify" — absence is never verification.
|
|
2399
|
+
* Any key present makes all five mandatory, and each is checked for exact type:
|
|
2400
|
+
* the verdict against its closed vocabulary, both counts as non-negative
|
|
2401
|
+
* integers, the time as an explicit-UTC instant, freshness as a real boolean.
|
|
2402
|
+
* The server's cross-field invariants are then re-applied, because a
|
|
2403
|
+
* contradictory group (a `verified` with a mismatch, a stale `unknown_name`)
|
|
2404
|
+
* is untrusted input, not a verdict to render.
|
|
2405
|
+
*/
|
|
2406
|
+
function parseRequiredChecksVerification(sup, requiredChecksCount) {
|
|
2407
|
+
const present = REQUIRED_CHECKS_VERIFICATION_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(sup, key) && sup[key] !== undefined);
|
|
2408
|
+
if (present.length === 0) {
|
|
2409
|
+
return {
|
|
2410
|
+
required_checks_verification: null,
|
|
2411
|
+
required_checks_mismatch_count: null,
|
|
2412
|
+
required_checks_unverified_count: null,
|
|
2413
|
+
required_checks_evidence_observed_at: null,
|
|
2414
|
+
required_checks_evidence_fresh: null,
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
if (present.length !== REQUIRED_CHECKS_VERIFICATION_KEYS.length)
|
|
2418
|
+
throw readinessShapeError();
|
|
2419
|
+
const verification = requireEnum(sup, "required_checks_verification", REQUIRED_CHECKS_VERIFICATIONS);
|
|
2420
|
+
const mismatch = requireNonNegativeInt(sup, "required_checks_mismatch_count");
|
|
2421
|
+
const unverified = requireNonNegativeInt(sup, "required_checks_unverified_count");
|
|
2422
|
+
const observedAt = sup.required_checks_evidence_observed_at;
|
|
2423
|
+
if (typeof observedAt !== "string" ||
|
|
2424
|
+
!UTC_TIMESTAMP_PATTERN.test(observedAt) ||
|
|
2425
|
+
Number.isNaN(Date.parse(observedAt))) {
|
|
2426
|
+
throw readinessShapeError();
|
|
2427
|
+
}
|
|
2428
|
+
const fresh = requireBool(sup, "required_checks_evidence_fresh");
|
|
2429
|
+
if (requiredChecksCount < 0 || mismatch + unverified > requiredChecksCount) {
|
|
2430
|
+
throw readinessShapeError();
|
|
2431
|
+
}
|
|
2432
|
+
if (verification === "verified" &&
|
|
2433
|
+
!(requiredChecksCount > 0 && mismatch === 0 && unverified === 0 && fresh)) {
|
|
2434
|
+
throw readinessShapeError();
|
|
2435
|
+
}
|
|
2436
|
+
if (verification === "unknown_name" && !(fresh && mismatch > 0))
|
|
2437
|
+
throw readinessShapeError();
|
|
2438
|
+
if (verification === "unverifiable" && mismatch > 0)
|
|
2439
|
+
throw readinessShapeError();
|
|
2440
|
+
return {
|
|
2441
|
+
required_checks_verification: verification,
|
|
2442
|
+
required_checks_mismatch_count: mismatch,
|
|
2443
|
+
required_checks_unverified_count: unverified,
|
|
2444
|
+
required_checks_evidence_observed_at: observedAt,
|
|
2445
|
+
required_checks_evidence_fresh: fresh,
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
/** Optional positive-integer threshold: absent/null from an older server. */
|
|
2449
|
+
function parseOptionalPositiveInt(o, key) {
|
|
2450
|
+
const v = o[key];
|
|
2451
|
+
if (v === undefined || v === null)
|
|
2452
|
+
return null;
|
|
2453
|
+
const parsed = requireInt(o, key);
|
|
2454
|
+
if (parsed <= 0)
|
|
2455
|
+
throw readinessShapeError();
|
|
2456
|
+
return parsed;
|
|
2457
|
+
}
|
|
2181
2458
|
/**
|
|
2182
2459
|
* Validate a raw readiness body into the typed response, FAIL-CLOSED.
|
|
2183
2460
|
*
|
|
@@ -2197,6 +2474,7 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2197
2474
|
const rec = requireObject(root.reconciler);
|
|
2198
2475
|
const exec = requireObject(root.executor);
|
|
2199
2476
|
const thr = requireObject(root.thresholds);
|
|
2477
|
+
const requiredChecksCount = requireInt(sup, "required_checks_count");
|
|
2200
2478
|
return {
|
|
2201
2479
|
repo_name: repoName,
|
|
2202
2480
|
supervisor: {
|
|
@@ -2208,13 +2486,14 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2208
2486
|
config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
|
|
2209
2487
|
config_created_at: requireNullableString(sup, "config_created_at"),
|
|
2210
2488
|
config_updated_at: requireNullableString(sup, "config_updated_at"),
|
|
2211
|
-
required_checks_count:
|
|
2489
|
+
required_checks_count: requiredChecksCount,
|
|
2212
2490
|
required_checks_empty: requireBool(sup, "required_checks_empty"),
|
|
2213
2491
|
auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
|
|
2214
2492
|
merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
|
|
2215
2493
|
review_policy_alignment: parseReviewPolicyAlignment(sup),
|
|
2216
2494
|
// Absent on a pre-BAPI-694 server, where no repository default can exist.
|
|
2217
2495
|
review_policy_present: sup.review_policy_present === true,
|
|
2496
|
+
...parseRequiredChecksVerification(sup, requiredChecksCount),
|
|
2218
2497
|
},
|
|
2219
2498
|
github: {
|
|
2220
2499
|
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
@@ -2244,13 +2523,85 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2244
2523
|
thresholds: {
|
|
2245
2524
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
2246
2525
|
executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
|
|
2526
|
+
required_checks_evidence_max_age_seconds: parseOptionalPositiveInt(thr, "required_checks_evidence_max_age_seconds"),
|
|
2247
2527
|
},
|
|
2248
2528
|
review_workflow: parseReviewWorkflow(root),
|
|
2249
2529
|
conductor_ci_workflow: parseConductorCiWorkflow(root),
|
|
2250
2530
|
unattended: parseUnattended(root),
|
|
2251
2531
|
bring_up: parseBringUp(root),
|
|
2252
2532
|
merge_execution: parseMergeExecution(root),
|
|
2533
|
+
...(() => {
|
|
2534
|
+
const evidence = parseAdmissionEvidence(root);
|
|
2535
|
+
return evidence === undefined ? {} : { admission_evidence: evidence };
|
|
2536
|
+
})(),
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
|
|
2540
|
+
const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
|
|
2541
|
+
/**
|
|
2542
|
+
* Parse the optional BAPI-1260 `admission_evidence` block.
|
|
2543
|
+
*
|
|
2544
|
+
* Absent → `undefined` (the key is then omitted from the parsed response).
|
|
2545
|
+
* Present → validated strictly, EXCEPT that an unrecognized verdict is coerced
|
|
2546
|
+
* to `unable_to_verify` rather than failing the whole readiness read: a newer
|
|
2547
|
+
* server's vocabulary must degrade to "not proven", never to "held" and never
|
|
2548
|
+
* to "no readiness at all". Free-text fields are bounded tokens only, so a
|
|
2549
|
+
* payload cannot smuggle prose into operator output.
|
|
2550
|
+
*/
|
|
2551
|
+
function parseAdmissionEvidence(body) {
|
|
2552
|
+
const raw = body.admission_evidence;
|
|
2553
|
+
if (raw === undefined)
|
|
2554
|
+
return undefined;
|
|
2555
|
+
if (raw === null)
|
|
2556
|
+
return null;
|
|
2557
|
+
const o = requireObject(raw);
|
|
2558
|
+
const schemaVersion = requireInt(o, "schema_version");
|
|
2559
|
+
const observedAt = o.observed_at;
|
|
2560
|
+
if (typeof observedAt !== "string")
|
|
2561
|
+
throw readinessShapeError();
|
|
2562
|
+
if (!Array.isArray(o.records) || o.records.length > 32)
|
|
2563
|
+
throw readinessShapeError();
|
|
2564
|
+
const token = (value, nullable) => {
|
|
2565
|
+
if (value === null && nullable)
|
|
2566
|
+
return null;
|
|
2567
|
+
if (typeof value !== "string" || !BOUNDED_TOKEN.test(value))
|
|
2568
|
+
throw readinessShapeError();
|
|
2569
|
+
return value;
|
|
2253
2570
|
};
|
|
2571
|
+
const records = o.records.map((entry) => {
|
|
2572
|
+
const r = requireObject(entry);
|
|
2573
|
+
const verdictRaw = r.verdict;
|
|
2574
|
+
const verdict = ADMISSION_EVIDENCE_VERDICTS.includes(verdictRaw)
|
|
2575
|
+
? verdictRaw
|
|
2576
|
+
: "unable_to_verify";
|
|
2577
|
+
const owner = r.next_action_owner;
|
|
2578
|
+
if (owner !== null && owner !== undefined && !ADMISSION_EVIDENCE_OWNERS.has(owner)) {
|
|
2579
|
+
throw readinessShapeError();
|
|
2580
|
+
}
|
|
2581
|
+
const maxAge = requireInt(r, "max_age_seconds");
|
|
2582
|
+
if (maxAge <= 0)
|
|
2583
|
+
throw readinessShapeError();
|
|
2584
|
+
const at = r.observed_at;
|
|
2585
|
+
if (at !== null && typeof at !== "string")
|
|
2586
|
+
throw readinessShapeError();
|
|
2587
|
+
return {
|
|
2588
|
+
predicate: token(r.predicate, false),
|
|
2589
|
+
verdict,
|
|
2590
|
+
observed_at: at,
|
|
2591
|
+
source: token(r.source, false),
|
|
2592
|
+
source_version: r.source_version === null || r.source_version === undefined
|
|
2593
|
+
? null
|
|
2594
|
+
: typeof r.source_version === "string" && r.source_version.length <= 80
|
|
2595
|
+
? r.source_version
|
|
2596
|
+
: (() => {
|
|
2597
|
+
throw readinessShapeError();
|
|
2598
|
+
})(),
|
|
2599
|
+
max_age_seconds: maxAge,
|
|
2600
|
+
reason: r.reason === undefined ? null : token(r.reason, true),
|
|
2601
|
+
next_action_owner: (owner ?? null),
|
|
2602
|
+
};
|
|
2603
|
+
});
|
|
2604
|
+
return { schema_version: schemaVersion, observed_at: observedAt, records };
|
|
2254
2605
|
}
|
|
2255
2606
|
/**
|
|
2256
2607
|
* Parse the optional `merge_execution` block (BAPI-1135).
|
|
@@ -47,13 +47,38 @@ export const MERGE_WORKFLOW_IDENTIFIER = "epic-implementer-merge.yml";
|
|
|
47
47
|
/** The top-level workflow key carrying the machine-readable contract version. */
|
|
48
48
|
export const MERGE_WORKFLOW_VERSION_MARKER_KEY = "x-bridge-merge-contract-version";
|
|
49
49
|
/**
|
|
50
|
-
* The
|
|
50
|
+
* The workflow contract version this CLI RENDERS and installs.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* BAPI-1259 moved it from `1` to `2` (the integration-action job and the
|
|
53
|
+
* `operation` dispatch input). Whether an INSTALLED version is usable is
|
|
54
|
+
* answered by the two closed sets below — never by "at least". A newer version
|
|
55
|
+
* is a workflow this build has never been taught to reason about, so it is NOT
|
|
56
|
+
* eligible — see {@link classifyMergeExecutionEligibility}.
|
|
55
57
|
*/
|
|
56
|
-
export const MERGE_WORKFLOW_EXPECTED_VERSION =
|
|
58
|
+
export const MERGE_WORKFLOW_EXPECTED_VERSION = 2;
|
|
59
|
+
/**
|
|
60
|
+
* Installed contract versions a CHILD merge may be dispatched to. Mirrors
|
|
61
|
+
* `MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS` in the Python contract.
|
|
62
|
+
* Version 1's child-merge job is the behavior version 2 keeps unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export const MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS = [1, 2];
|
|
65
|
+
/**
|
|
66
|
+
* Installed contract versions an INTEGRATION action may be dispatched to.
|
|
67
|
+
* Mirrors `INTEGRATION_ACTION_WORKFLOW_VERSIONS` in the Python contract.
|
|
68
|
+
*/
|
|
69
|
+
export const INTEGRATION_ACTION_WORKFLOW_VERSIONS = [2];
|
|
70
|
+
/** True when an installed marker may receive a child-merge dispatch. */
|
|
71
|
+
export function mergeWorkflowVersionSupportsChildMerge(version) {
|
|
72
|
+
return (typeof version === "number" &&
|
|
73
|
+
Number.isInteger(version) &&
|
|
74
|
+
MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS.includes(version));
|
|
75
|
+
}
|
|
76
|
+
/** True when an installed marker may receive an integration-action dispatch. */
|
|
77
|
+
export function mergeWorkflowVersionSupportsIntegrationActions(version) {
|
|
78
|
+
return (typeof version === "number" &&
|
|
79
|
+
Number.isInteger(version) &&
|
|
80
|
+
INTEGRATION_ACTION_WORKFLOW_VERSIONS.includes(version));
|
|
81
|
+
}
|
|
57
82
|
/**
|
|
58
83
|
* The single command that generates the merge workflow and opens its pull
|
|
59
84
|
* request. Every refusal, remediation, and readiness line imports this constant.
|
|
@@ -23,14 +23,15 @@
|
|
|
23
23
|
* credentials or connectivity. Collapsing them sends half of all operators to do
|
|
24
24
|
* work that changes nothing. `could_not_verify` is never treated as eligible.
|
|
25
25
|
*
|
|
26
|
-
* ##
|
|
26
|
+
* ## Known versions only, always
|
|
27
27
|
*
|
|
28
|
-
* Eligibility requires
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Eligibility requires membership in the closed set
|
|
29
|
+
* {@link MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS} (BAPI-1259: version 1
|
|
30
|
+
* and version 2 both carry the unchanged child-merge job). A NEWER version is
|
|
31
|
+
* not eligible: it is a workflow this build has never been taught to reason
|
|
32
|
+
* about, and "newer is probably fine" is a guess about a merge actuator.
|
|
32
33
|
*/
|
|
33
|
-
import {
|
|
34
|
+
import { MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS, MERGE_WORKFLOW_REMEDY_COMMAND, mergeWorkflowVersionSupportsChildMerge, } from "./merge-execution-contract.js";
|
|
34
35
|
/**
|
|
35
36
|
* Remediation for a verdict that could not be established.
|
|
36
37
|
*
|
|
@@ -107,10 +108,10 @@ export function classifyMergeExecutionEligibility(readiness) {
|
|
|
107
108
|
if (!m.merge_workflow_enabled) {
|
|
108
109
|
return ineligible("workflow_disabled", "the merge workflow is present but disabled");
|
|
109
110
|
}
|
|
110
|
-
if (m.merge_workflow_version
|
|
111
|
+
if (!mergeWorkflowVersionSupportsChildMerge(m.merge_workflow_version)) {
|
|
111
112
|
// A newer version lands here too — see the module docstring.
|
|
112
113
|
return ineligible("workflow_version_mismatch", `merge workflow version ${m.merge_workflow_version ?? "unset"} ` +
|
|
113
|
-
`(expected ${
|
|
114
|
+
`(expected one of ${MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS.join(", ")})`);
|
|
114
115
|
}
|
|
115
116
|
if (!gh.actions_write) {
|
|
116
117
|
return ineligible("app_actions_write_denied", `the GitHub App holds actions: ${gh.actions_permission_level}`);
|
|
@@ -123,7 +124,7 @@ export function classifyMergeExecutionEligibility(readiness) {
|
|
|
123
124
|
}
|
|
124
125
|
return {
|
|
125
126
|
status: "eligible",
|
|
126
|
-
detail: `merge workflow present, enabled, version ${
|
|
127
|
+
detail: `merge workflow present, enabled, version ${m.merge_workflow_version}; ` +
|
|
127
128
|
"App actions: write, workflow token write authority, workflow actor admitted",
|
|
128
129
|
};
|
|
129
130
|
}
|
|
@@ -38,7 +38,7 @@ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
|
|
|
38
38
|
import { CLAUDE_REVIEW_WORKFLOW_DRIFT_REMEDIATION, summarizeClaudeReviewWorkflowDrift, } from "../claude-review-workflow-drift.js";
|
|
39
39
|
import { describeNativeLedgerAvailability, GH_ACTIONS_MERGE_AUTH_REMEDIATION, GH_ACTIONS_MERGE_SKIPPED_DETAIL, GH_ACTIONS_MERGE_UNOBSERVABLE_REMEDIATION, } from "./doctor.js";
|
|
40
40
|
import { classifyMergeExecutionEligibility, MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION, } from "./merge-execution-eligibility.js";
|
|
41
|
-
import {
|
|
41
|
+
import { MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS, MERGE_WORKFLOW_PATH, mergeWorkflowVersionSupportsChildMerge, } from "./merge-execution-contract.js";
|
|
42
42
|
import { classifyDatabaseMigration, classifySingleTransitionAuthority, classifyWorkerPresent, } from "./bring-up-facts.js";
|
|
43
43
|
/** Operator runbook pointer surfaced beside the executor gap. */
|
|
44
44
|
export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-implementer-v2-operator-runbook.md";
|
|
@@ -80,6 +80,88 @@ export const EXECUTOR_INSTALL_SERVICE_REMEDIATION = "generate a persistent unit
|
|
|
80
80
|
export const EXECUTOR_SERVICE_START_REMEDIATION = "start the service (re-run `install conductor` and accept the start consent, or run the " +
|
|
81
81
|
`printed launchctl/systemctl command by hand) — see ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}.`;
|
|
82
82
|
// ---------------------------------------------------------------------------
|
|
83
|
+
// BAPI-1254 — the ONE required-check presentation
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* Operator-hold sentence shared by every non-verified outcome. It states what an
|
|
87
|
+
* operator must do; it does not claim this report blocks anything — activation
|
|
88
|
+
* and dispatch enforcement belong to a separate slice.
|
|
89
|
+
*/
|
|
90
|
+
const REQUIRED_CHECKS_OPERATOR_HOLD = "hold new unattended epic work until this is resolved";
|
|
91
|
+
/** Fixed, secret-free copy for each outcome. Names no check, branch, or SHA. */
|
|
92
|
+
export const REQUIRED_CHECKS_PRESENTATION_COPY = {
|
|
93
|
+
verified: {
|
|
94
|
+
detail: "Verified — every configured required check is produced by the lane workflows",
|
|
95
|
+
},
|
|
96
|
+
unknown_name: {
|
|
97
|
+
detail: "Configuration mismatch confirmed — a configured required check conflicts with the " +
|
|
98
|
+
`current lane producer contract; ${REQUIRED_CHECKS_OPERATOR_HOLD}`,
|
|
99
|
+
remediation: "repair the project-default required-check configuration so it names the check the " +
|
|
100
|
+
"lane workflow emits (see the operator runbook's patch-policy section for active runs).",
|
|
101
|
+
},
|
|
102
|
+
unverifiable: {
|
|
103
|
+
detail: "Could not verify — the configured required checks could not be matched to an " +
|
|
104
|
+
`authoritative producer; ${REQUIRED_CHECKS_OPERATOR_HOLD}`,
|
|
105
|
+
remediation: "restore required-check verification (a readable default branch with static lane " +
|
|
106
|
+
"workflows and a current Bridge API), or correct the required-check configuration.",
|
|
107
|
+
},
|
|
108
|
+
empty: {
|
|
109
|
+
detail: "Could not verify — the required-check list is EMPTY, which is unsafe: the done gate " +
|
|
110
|
+
`would pass unconditionally; ${REQUIRED_CHECKS_OPERATOR_HOLD}`,
|
|
111
|
+
remediation: "configure a non-empty required-check list naming the checks the lane workflows emit.",
|
|
112
|
+
},
|
|
113
|
+
olderServer: {
|
|
114
|
+
detail: "Could not verify — this Bridge API does not report required-check verification; " +
|
|
115
|
+
REQUIRED_CHECKS_OPERATOR_HOLD,
|
|
116
|
+
remediation: "upgrade the Bridge API deploy so it verifies required checks, or correct the " +
|
|
117
|
+
"required-check configuration; its state is unknown here, not healthy.",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Map the validated supervisor facts onto one required-check presentation.
|
|
122
|
+
*
|
|
123
|
+
* PURE, and the only place either renderer decides this row: the CLI descriptor
|
|
124
|
+
* and the install-time section both call it, so they cannot drift and neither
|
|
125
|
+
* performs a second producer verification. Only the server's `verified` verdict
|
|
126
|
+
* is satisfied. A nonempty count is never proof, an older server's absent group
|
|
127
|
+
* is `unverifiable`, and an empty list stays unsafe whatever else is reported.
|
|
128
|
+
* No remediation suggests disabling required checks.
|
|
129
|
+
*/
|
|
130
|
+
export function presentRequiredChecks(supervisor) {
|
|
131
|
+
const verdict = supervisor.required_checks_verification ?? null;
|
|
132
|
+
if (supervisor.required_checks_empty) {
|
|
133
|
+
return { outcome: "unverifiable", satisfied: false, ...REQUIRED_CHECKS_PRESENTATION_COPY.empty };
|
|
134
|
+
}
|
|
135
|
+
if (verdict === null) {
|
|
136
|
+
return {
|
|
137
|
+
outcome: "unverifiable",
|
|
138
|
+
satisfied: false,
|
|
139
|
+
...REQUIRED_CHECKS_PRESENTATION_COPY.olderServer,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (verdict === "verified") {
|
|
143
|
+
const fresh = supervisor.required_checks_evidence_fresh === true ? ", fresh evidence" : "";
|
|
144
|
+
return {
|
|
145
|
+
outcome: "verified",
|
|
146
|
+
satisfied: true,
|
|
147
|
+
detail: `${REQUIRED_CHECKS_PRESENTATION_COPY.verified.detail} ` +
|
|
148
|
+
`(${supervisor.required_checks_count} required check(s) configured${fresh})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (verdict === "unknown_name") {
|
|
152
|
+
return {
|
|
153
|
+
outcome: "unknown_name",
|
|
154
|
+
satisfied: false,
|
|
155
|
+
...REQUIRED_CHECKS_PRESENTATION_COPY.unknown_name,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
outcome: "unverifiable",
|
|
160
|
+
satisfied: false,
|
|
161
|
+
...REQUIRED_CHECKS_PRESENTATION_COPY.unverifiable,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
83
165
|
// Section builders
|
|
84
166
|
// ---------------------------------------------------------------------------
|
|
85
167
|
export function supervisorSection(readiness) {
|
|
@@ -109,20 +191,20 @@ export function supervisorSection(readiness) {
|
|
|
109
191
|
// Deliberately its OWN section, not folded into the one above: "configured but
|
|
110
192
|
// unsafe" and "not configured" drive different operator actions, and an empty
|
|
111
193
|
// required-check list makes the CI gate pass unconditionally rather than fail.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
194
|
+
//
|
|
195
|
+
// BAPI-1254: the verdict comes from the shared `presentRequiredChecks`, the
|
|
196
|
+
// same mapping the CLI descriptor uses. Only `verified` is `ok`; a confirmed
|
|
197
|
+
// mismatch and could-not-verify are both `degraded`, never `fatal`.
|
|
198
|
+
const requiredChecks = presentRequiredChecks(s);
|
|
199
|
+
sections.push({
|
|
200
|
+
id: "supervisor-required-checks",
|
|
201
|
+
label: "Supervisor required CI checks",
|
|
202
|
+
status: requiredChecks.satisfied ? "ok" : "degraded",
|
|
203
|
+
detail: requiredChecks.detail,
|
|
204
|
+
...(requiredChecks.remediation === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { remediation: requiredChecks.remediation }),
|
|
207
|
+
});
|
|
126
208
|
// BAPI-694 (R5) — its OWN section, and WARN-ONLY.
|
|
127
209
|
//
|
|
128
210
|
// `degraded`, never `fatal`: a divergence between the code_review gate's
|
|
@@ -292,7 +374,8 @@ export function mergeExecutionSections(readiness) {
|
|
|
292
374
|
? `\`${MERGE_WORKFLOW_PATH}\` present`
|
|
293
375
|
: `not eligible: \`${MERGE_WORKFLOW_PATH}\` is absent`),
|
|
294
376
|
fact("merge-execution-workflow-enabled", "Merge workflow enabled", m.merge_workflow_enabled, m.merge_workflow_enabled ? "enabled" : "not eligible: disabled or unknown"),
|
|
295
|
-
fact("merge-execution-workflow-version", "Merge workflow version", m.merge_workflow_version
|
|
377
|
+
fact("merge-execution-workflow-version", "Merge workflow version", mergeWorkflowVersionSupportsChildMerge(m.merge_workflow_version), `\`${m.merge_workflow_version ?? "unset"}\` (expected one of ` +
|
|
378
|
+
`\`${MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS.join(", ")}\`)`),
|
|
296
379
|
fact("merge-execution-app-actions-write", "GitHub App `actions: write`", readiness.github.actions_write, readiness.github.actions_probe_succeeded
|
|
297
380
|
? `actions: \`${readiness.github.actions_permission_level}\``
|
|
298
381
|
: "could not verify: the App permission could not be checked"),
|