@bridge_gpt/mcp-server 0.2.59 → 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 +181 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +406 -4
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +86 -0
- package/build/conductor/merge-execution-eligibility.js +130 -0
- package/build/conductor/readiness-sections.js +256 -16
- package/build/conductor/readiness.js +203 -29
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +172 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +277 -15
- package/build/drive-epic.js +147 -12
- package/build/epic-implementer-bin.js +15 -15
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +40 -0
- package/build/executor/credentials.js +28 -1
- package/build/executor/env.js +60 -0
- package/build/executor/http-client.js +45 -1
- package/build/executor/index.js +21 -4
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +218 -9
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +48 -0
- package/build/index.js +479 -240
- package/build/install-bridge-conductor.js +599 -14
- package/build/install-bridge.js +448 -46
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +187 -0
- package/build/merge-workflow-drift.js +97 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +956 -0
- package/build/pipelines.generated.js +3 -3
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +263 -38
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- package/build/plan-epic-conductor-eligibility.js +0 -213
|
@@ -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
|
|
@@ -364,8 +446,15 @@ export function safeDiagnosticMessage(err, fallback) {
|
|
|
364
446
|
}
|
|
365
447
|
return fallback;
|
|
366
448
|
}
|
|
367
|
-
/**
|
|
368
|
-
|
|
449
|
+
/**
|
|
450
|
+
* GET auth headers. The API key travels ONLY in a header, never in the URL.
|
|
451
|
+
*
|
|
452
|
+
* Exported (BAPI-1133) so a caller outside this module — `setup-epic.ts`'s
|
|
453
|
+
* {@link ConductorBridgeApiAccess}-based adapter for
|
|
454
|
+
* `resolveMergeExecutionMode`'s narrow dependency shape — can build the same
|
|
455
|
+
* headers without a second copy of this one-line mapping.
|
|
456
|
+
*/
|
|
457
|
+
export function conductorGetHeaders(access) {
|
|
369
458
|
return { "X-API-Key": access.apiKey };
|
|
370
459
|
}
|
|
371
460
|
/**
|
|
@@ -1976,12 +2065,109 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = glob
|
|
|
1976
2065
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1977
2066
|
return parsed;
|
|
1978
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
|
+
}
|
|
1979
2161
|
/**
|
|
1980
2162
|
* GET `/jira/parse-status?repo_name=<repo>` and return the durable parse-run
|
|
1981
2163
|
* status. See {@link ParseRepositoryStatusValue} for the full state set (the
|
|
1982
2164
|
* status surface distinguishes queued/running from terminal succeeded/failed).
|
|
1983
2165
|
* Throws a sanitized {@link ConductorBridgeApiError} on any transport/auth/server
|
|
1984
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}.
|
|
1985
2171
|
*/
|
|
1986
2172
|
export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
|
|
1987
2173
|
const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
|
|
@@ -2085,10 +2271,27 @@ export const CONDUCTOR_REVIEW_ALIGNMENT_STATUSES = new Set([
|
|
|
2085
2271
|
"not_configured",
|
|
2086
2272
|
"invalid",
|
|
2087
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;
|
|
2088
2289
|
/** Upper bound of the server's capped `fresh_reconciler_count` ("two or more"). */
|
|
2089
2290
|
export const READINESS_FRESH_RECONCILER_COUNT_CAP = 2;
|
|
2090
2291
|
const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
|
|
2091
2292
|
const ACTIONS_LEVELS = new Set(["write", "read", "none", "unknown"]);
|
|
2293
|
+
/** The one scope the server reports for the merge-execution block (BAPI-1135). */
|
|
2294
|
+
const MERGE_EXECUTION_BRANCH_SCOPES = new Set(["default_branch"]);
|
|
2092
2295
|
function readinessShapeError() {
|
|
2093
2296
|
// Deliberately carries NO field name, value, or payload excerpt: a malformed
|
|
2094
2297
|
// body is untrusted input and its contents must not reach output.
|
|
@@ -2169,6 +2372,89 @@ function requireEnum(o, key, allowed) {
|
|
|
2169
2372
|
throw readinessShapeError();
|
|
2170
2373
|
return v;
|
|
2171
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
|
+
}
|
|
2172
2458
|
/**
|
|
2173
2459
|
* Validate a raw readiness body into the typed response, FAIL-CLOSED.
|
|
2174
2460
|
*
|
|
@@ -2188,6 +2474,7 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2188
2474
|
const rec = requireObject(root.reconciler);
|
|
2189
2475
|
const exec = requireObject(root.executor);
|
|
2190
2476
|
const thr = requireObject(root.thresholds);
|
|
2477
|
+
const requiredChecksCount = requireInt(sup, "required_checks_count");
|
|
2191
2478
|
return {
|
|
2192
2479
|
repo_name: repoName,
|
|
2193
2480
|
supervisor: {
|
|
@@ -2199,13 +2486,14 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2199
2486
|
config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
|
|
2200
2487
|
config_created_at: requireNullableString(sup, "config_created_at"),
|
|
2201
2488
|
config_updated_at: requireNullableString(sup, "config_updated_at"),
|
|
2202
|
-
required_checks_count:
|
|
2489
|
+
required_checks_count: requiredChecksCount,
|
|
2203
2490
|
required_checks_empty: requireBool(sup, "required_checks_empty"),
|
|
2204
2491
|
auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
|
|
2205
2492
|
merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
|
|
2206
2493
|
review_policy_alignment: parseReviewPolicyAlignment(sup),
|
|
2207
2494
|
// Absent on a pre-BAPI-694 server, where no repository default can exist.
|
|
2208
2495
|
review_policy_present: sup.review_policy_present === true,
|
|
2496
|
+
...parseRequiredChecksVerification(sup, requiredChecksCount),
|
|
2209
2497
|
},
|
|
2210
2498
|
github: {
|
|
2211
2499
|
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
@@ -2235,11 +2523,125 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2235
2523
|
thresholds: {
|
|
2236
2524
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
2237
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"),
|
|
2238
2527
|
},
|
|
2239
2528
|
review_workflow: parseReviewWorkflow(root),
|
|
2240
2529
|
conductor_ci_workflow: parseConductorCiWorkflow(root),
|
|
2241
2530
|
unattended: parseUnattended(root),
|
|
2242
2531
|
bring_up: parseBringUp(root),
|
|
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;
|
|
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 };
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Parse the optional `merge_execution` block (BAPI-1135).
|
|
2608
|
+
*
|
|
2609
|
+
* Same older-server rule as every optional block above: absent (or null) is
|
|
2610
|
+
* `null`, and a PRESENT block is validated strictly — every boolean through
|
|
2611
|
+
* `requireBool`, the scope through the shared enum parser, and the version as
|
|
2612
|
+
* either `null` or a non-negative integer through `requireInt`.
|
|
2613
|
+
*
|
|
2614
|
+
* A malformed PRESENT block or a malformed present FIELD throws, and is never
|
|
2615
|
+
* degraded to `null`. The distinction is load-bearing: `null` means "this server
|
|
2616
|
+
* does not report it", which is a server-version fact, while a block that claims
|
|
2617
|
+
* to carry merge eligibility and cannot be read is untrusted input. Silently
|
|
2618
|
+
* turning the second into the first would let a corrupt body masquerade as an
|
|
2619
|
+
* older deploy, and the two have different remediations.
|
|
2620
|
+
*/
|
|
2621
|
+
function parseMergeExecution(body) {
|
|
2622
|
+
const raw = body.merge_execution;
|
|
2623
|
+
if (raw === undefined || raw === null)
|
|
2624
|
+
return null;
|
|
2625
|
+
const o = requireObject(raw);
|
|
2626
|
+
const version = o.merge_workflow_version;
|
|
2627
|
+
let parsedVersion;
|
|
2628
|
+
if (version === null || version === undefined) {
|
|
2629
|
+
parsedVersion = null;
|
|
2630
|
+
}
|
|
2631
|
+
else {
|
|
2632
|
+
parsedVersion = requireInt(o, "merge_workflow_version");
|
|
2633
|
+
if (parsedVersion < 0)
|
|
2634
|
+
throw readinessShapeError();
|
|
2635
|
+
}
|
|
2636
|
+
return {
|
|
2637
|
+
branch_scope: requireEnum(o, "branch_scope", MERGE_EXECUTION_BRANCH_SCOPES),
|
|
2638
|
+
branch_protection_probe_succeeded: requireBool(o, "branch_protection_probe_succeeded"),
|
|
2639
|
+
branch_protection_actions_bot_admitted: requireBool(o, "branch_protection_actions_bot_admitted"),
|
|
2640
|
+
merge_workflow_probe_succeeded: requireBool(o, "merge_workflow_probe_succeeded"),
|
|
2641
|
+
merge_workflow_present: requireBool(o, "merge_workflow_present"),
|
|
2642
|
+
merge_workflow_enabled: requireBool(o, "merge_workflow_enabled"),
|
|
2643
|
+
merge_workflow_version: parsedVersion,
|
|
2644
|
+
merge_workflow_token_write_authority: requireBool(o, "merge_workflow_token_write_authority"),
|
|
2243
2645
|
};
|
|
2244
2646
|
}
|
|
2245
2647
|
/**
|