@bridge_gpt/mcp-server 0.2.60 → 0.2.65
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 +58 -11
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +8 -7
- package/build/conductor/bridge-api-client.js +678 -14
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +104 -20
- package/build/conductor/readiness.js +129 -25
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +194 -0
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +161 -7
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1318 -111
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +464 -0
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +48 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +19 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +117 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1386 -35
- package/build/executor/job-types.js +10 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +246 -1
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +55 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +772 -954
- package/build/install-bridge-conductor.js +622 -40
- package/build/install-bridge.js +92 -16
- package/build/merge-pull-request.js +1 -1
- 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 +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +148 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +130 -48
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -11,10 +11,12 @@
|
|
|
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";
|
|
19
|
+
import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
|
|
18
20
|
import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
|
|
19
21
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
20
22
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
@@ -63,6 +65,85 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
63
65
|
: CONDUCTOR_DEFAULT_BASE_URL;
|
|
64
66
|
return { ok: true, access: { repoName, apiKey: credResult.credentials.apiKey, baseUrl } };
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The STRONGER launch-resolution contract (BAPI-1260), used by `drive-epic` and
|
|
70
|
+
* by a `setup-epic` that was handed a launch selection. The order is the point:
|
|
71
|
+
*
|
|
72
|
+
* 1. the selected profile is read and validated (host-local, read-only);
|
|
73
|
+
* 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
|
|
74
|
+
* — conflicts refuse. With none of them set, the target is
|
|
75
|
+
* {@link CONDUCTOR_DEFAULT_BASE_URL} (BAPI-1272);
|
|
76
|
+
* 3. the repository is decided (profile binding, then `--repo`, then the
|
|
77
|
+
* shared resolver);
|
|
78
|
+
* 4. only then is a credential looked up, coupled to that target AND that
|
|
79
|
+
* repository ({@link resolveEpicLaunchCredential}). A stored key with no
|
|
80
|
+
* binding is accepted only when the target is production; a key bound to
|
|
81
|
+
* another endpoint, or an ambient key without a matching `BAPI_BASE_URL`,
|
|
82
|
+
* is still refused.
|
|
83
|
+
*
|
|
84
|
+
* Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
|
|
85
|
+
* is deliberately left as it is for every other client.
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
88
|
+
const env = deps.env ?? process.env;
|
|
89
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
90
|
+
const homedir = deps.homedir ?? os.homedir;
|
|
91
|
+
const platform = deps.platform ?? process.platform;
|
|
92
|
+
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
93
|
+
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
94
|
+
let profile = null;
|
|
95
|
+
if (selection.profileName !== undefined) {
|
|
96
|
+
const read = await readEpicLaunchProfile(selection.profileName, {
|
|
97
|
+
env,
|
|
98
|
+
homedir,
|
|
99
|
+
readFile: readFileImpl,
|
|
100
|
+
host: (deps.hostname ?? os.hostname)(),
|
|
101
|
+
});
|
|
102
|
+
if (!read.ok)
|
|
103
|
+
return { ok: false, kind: "profile-unusable", error: read.message };
|
|
104
|
+
profile = read.profile;
|
|
105
|
+
}
|
|
106
|
+
const target = resolveEpicLaunchTarget({
|
|
107
|
+
flagBaseUrl: selection.baseUrl,
|
|
108
|
+
flagRepo: deps.repoName,
|
|
109
|
+
profile,
|
|
110
|
+
env,
|
|
111
|
+
defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
|
|
112
|
+
});
|
|
113
|
+
if (!target.ok)
|
|
114
|
+
return { ok: false, kind: target.kind, error: target.message };
|
|
115
|
+
const repoName = target.repoName ||
|
|
116
|
+
deps.repoName?.trim() ||
|
|
117
|
+
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
118
|
+
if (!repoName) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
kind: "repo-missing",
|
|
122
|
+
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let cred;
|
|
126
|
+
try {
|
|
127
|
+
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
131
|
+
}
|
|
132
|
+
if (!cred.ok) {
|
|
133
|
+
const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
|
|
134
|
+
// The binding messages are fixed sentences naming the target KEY and the
|
|
135
|
+
// store path only; the generic store failures stay generic as before.
|
|
136
|
+
return bindingFailure
|
|
137
|
+
? { ok: false, kind: "credential-unbound", error: cred.error }
|
|
138
|
+
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
access: { repoName, apiKey: cred.apiKey, baseUrl: target.baseUrl },
|
|
143
|
+
targetSource: target.source,
|
|
144
|
+
profile,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
66
147
|
/**
|
|
67
148
|
* Build a `${baseUrl}/jira${apiPath}` URL with query params, trimming trailing
|
|
68
149
|
* slashes on the base so there is never a double slash after the host. Query
|
|
@@ -668,6 +749,18 @@ export async function remediateEpicTicket(access, request, fetchImpl = globalThi
|
|
|
668
749
|
throw err;
|
|
669
750
|
}
|
|
670
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* BAPI-1310 (E1) — the server's refusal codes for the unexplained review-head
|
|
754
|
+
* stop. Each is an HTTP 409 that leaves the ticket parked.
|
|
755
|
+
*/
|
|
756
|
+
export const REVIEW_HEAD_REFUSAL_CODES = [
|
|
757
|
+
"REVIEW_HEAD_ADOPTION_REQUIRED",
|
|
758
|
+
"REVIEW_HEAD_EPISODE_CONFLICT",
|
|
759
|
+
"REVIEW_HEAD_STATE_INVALID",
|
|
760
|
+
"REVIEW_HEAD_BINDING_CHANGED",
|
|
761
|
+
"REVIEW_HEAD_LIVE_WORK",
|
|
762
|
+
"REVIEW_HEAD_UNAVAILABLE",
|
|
763
|
+
];
|
|
671
764
|
// ---------------------------------------------------------------------------
|
|
672
765
|
// Local boundary validators (reject before any fetch call)
|
|
673
766
|
// ---------------------------------------------------------------------------
|
|
@@ -1169,6 +1262,108 @@ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.f
|
|
|
1169
1262
|
});
|
|
1170
1263
|
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1171
1264
|
}
|
|
1265
|
+
const TYPED_CONTINUATION_STATUSES = new Set([200, 201, 409, 503]);
|
|
1266
|
+
const SAFE_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
1267
|
+
function continuationChildPath(epicRunId, ticketKey) {
|
|
1268
|
+
return `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}`;
|
|
1269
|
+
}
|
|
1270
|
+
function safeDetailErrorCode(body) {
|
|
1271
|
+
if (body === null || typeof body !== "object")
|
|
1272
|
+
return null;
|
|
1273
|
+
const detail = body.detail;
|
|
1274
|
+
if (detail === null || typeof detail !== "object")
|
|
1275
|
+
return null;
|
|
1276
|
+
const code = detail.error_code;
|
|
1277
|
+
return typeof code === "string" && SAFE_ERROR_CODE_RE.test(code) ? code : null;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* POST `.../tickets/{ticket_key}/continue` exactly once and classify the answer.
|
|
1281
|
+
*
|
|
1282
|
+
* Never throws for an HTTP or transport outcome — every one of them is returned
|
|
1283
|
+
* as a {@link ContinuationPostOutcome}, so no caller can accidentally turn a
|
|
1284
|
+
* timeout into "refused" by catching an exception. Never falls back to unpark.
|
|
1285
|
+
* The API key travels only in the header; the body carries no credential and no
|
|
1286
|
+
* caller-asserted authority.
|
|
1287
|
+
*/
|
|
1288
|
+
export async function postContinueParkedChild(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
1289
|
+
const b = request.bindings;
|
|
1290
|
+
requireNonEmptyString(b.epicRunId);
|
|
1291
|
+
requireNonEmptyString(b.ticketKey);
|
|
1292
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(b.epicRunId, b.ticketKey)}/continue`);
|
|
1293
|
+
const body = JSON.stringify({
|
|
1294
|
+
repo_name: b.repoName,
|
|
1295
|
+
request_id: request.requestId,
|
|
1296
|
+
parked_gate: b.parkedGate,
|
|
1297
|
+
expected_row_version: b.expectedRowVersion,
|
|
1298
|
+
expected_head_sha: b.expectedHeadSha,
|
|
1299
|
+
source_park_generation: b.sourceParkGeneration,
|
|
1300
|
+
request_fingerprint: request.requestFingerprint,
|
|
1301
|
+
...(request.reason ? { reason: request.reason } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
const controller = new AbortController();
|
|
1304
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1305
|
+
try {
|
|
1306
|
+
let resp;
|
|
1307
|
+
try {
|
|
1308
|
+
resp = await fetchImpl(url, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: conductorPostHeaders(access),
|
|
1311
|
+
body,
|
|
1312
|
+
signal: controller.signal,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
catch {
|
|
1316
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
1317
|
+
}
|
|
1318
|
+
let parsed;
|
|
1319
|
+
let parsedOk = true;
|
|
1320
|
+
try {
|
|
1321
|
+
parsed = await resp.json();
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
parsedOk = false;
|
|
1325
|
+
}
|
|
1326
|
+
if (TYPED_CONTINUATION_STATUSES.has(resp.status) && parsedOk) {
|
|
1327
|
+
try {
|
|
1328
|
+
return { kind: "result", status: resp.status, result: parseContinueParkedChildResult(parsed) };
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
// Fall through: an untyped body on these statuses is classified below.
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
1335
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
1336
|
+
}
|
|
1337
|
+
if (resp.status >= 500) {
|
|
1338
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
1339
|
+
}
|
|
1340
|
+
// A 4xx without a typed body is an authoritative pre-mutation rejection.
|
|
1341
|
+
return {
|
|
1342
|
+
kind: "refused_http",
|
|
1343
|
+
status: resp.status,
|
|
1344
|
+
errorCode: parsedOk ? safeDetailErrorCode(parsed) : null,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
clearTimeout(timer);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* GET `.../tickets/{ticket_key}/continuation` — the child's current episode and,
|
|
1353
|
+
* with `requestId`, that request's receipt. Read-only; bounded by the standard
|
|
1354
|
+
* conductor timeout. Throws {@link ConductorBridgeApiError} on transport or HTTP
|
|
1355
|
+
* failure and {@link ContinuationParseError} on a malformed body.
|
|
1356
|
+
*/
|
|
1357
|
+
export async function fetchChildContinuationStatus(access, query, fetchImpl = globalThis.fetch) {
|
|
1358
|
+
requireNonEmptyString(query.epicRunId);
|
|
1359
|
+
requireNonEmptyString(query.ticketKey);
|
|
1360
|
+
const params = { repo_name: access.repoName };
|
|
1361
|
+
if (query.requestId)
|
|
1362
|
+
params.request_id = query.requestId;
|
|
1363
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(query.epicRunId, query.ticketKey)}/continuation`, params);
|
|
1364
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1365
|
+
return parseContinuationStatusResult(parsed);
|
|
1366
|
+
}
|
|
1172
1367
|
/**
|
|
1173
1368
|
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
|
|
1174
1369
|
* — the recovery lane for a human/external push that drifted the PR head off the
|
|
@@ -1359,8 +1554,13 @@ const REPAIR_TOKEN_RE = /^[a-z0-9_.:-]{1,120}$/;
|
|
|
1359
1554
|
const POLICY_DIGEST_TOKEN_RE = /^policy:[0-9a-f]{12}$/;
|
|
1360
1555
|
/** A dotted policy-path NAME. Never a value. */
|
|
1361
1556
|
const POLICY_PATH_RE = /^[A-Za-z0-9_-]{1,100}(?:\.[A-Za-z0-9_-]{1,100}){0,15}$/;
|
|
1362
|
-
/**
|
|
1363
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* `resume-run`'s closed PRIOR-state vocabulary. `blocked` is the qualifying
|
|
1559
|
+
* integration block the run-addressed route may lift; it is never a resulting state.
|
|
1560
|
+
*/
|
|
1561
|
+
const RESUME_RUN_BEFORE_STATES = ["blocked", "paused", "quarantined", "none"];
|
|
1562
|
+
/** `resume-run`'s closed RESULTING-state vocabulary: a resume always leaves no control. */
|
|
1563
|
+
const RESUME_RUN_AFTER_STATES = ["none"];
|
|
1364
1564
|
function requireOnlyRepairKeys(p, extraKeys) {
|
|
1365
1565
|
for (const key of Object.keys(p)) {
|
|
1366
1566
|
if (!REPAIR_BASE_KEYS.includes(key) && !extraKeys.includes(key)) {
|
|
@@ -1427,18 +1627,29 @@ function parseResumeRunResult(parsed) {
|
|
|
1427
1627
|
requireOnlyRepairKeys(p, []);
|
|
1428
1628
|
const before = p["before"];
|
|
1429
1629
|
const after = p["after"];
|
|
1630
|
+
const committed = p["committed"];
|
|
1631
|
+
const eventRecorded = p["event_recorded"];
|
|
1430
1632
|
if (typeof before !== "string" ||
|
|
1431
|
-
!
|
|
1633
|
+
!RESUME_RUN_BEFORE_STATES.includes(before) ||
|
|
1432
1634
|
typeof after !== "string" ||
|
|
1433
|
-
!
|
|
1635
|
+
!RESUME_RUN_AFTER_STATES.includes(after)) {
|
|
1636
|
+
throw new ConductorBridgeApiError("server");
|
|
1637
|
+
}
|
|
1638
|
+
// Exactly two families: an applied resume (`blocked`/`paused`/`quarantined` →
|
|
1639
|
+
// `none`, committed, either audit outcome) or the already-current no-op
|
|
1640
|
+
// (`none` → `none`, nothing committed, no event). Any other combination is
|
|
1641
|
+
// unconfirmed, never coerced into a result.
|
|
1642
|
+
const applied = before !== "none" && committed;
|
|
1643
|
+
const noOp = before === "none" && !committed && !eventRecorded;
|
|
1644
|
+
if (!applied && !noOp) {
|
|
1434
1645
|
throw new ConductorBridgeApiError("server");
|
|
1435
1646
|
}
|
|
1436
1647
|
return {
|
|
1437
1648
|
epic_run_id: p["epic_run_id"],
|
|
1438
1649
|
before,
|
|
1439
1650
|
after,
|
|
1440
|
-
committed
|
|
1441
|
-
event_recorded:
|
|
1651
|
+
committed,
|
|
1652
|
+
event_recorded: eventRecorded,
|
|
1442
1653
|
};
|
|
1443
1654
|
}
|
|
1444
1655
|
/**
|
|
@@ -1983,12 +2194,109 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = glob
|
|
|
1983
2194
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1984
2195
|
return parsed;
|
|
1985
2196
|
}
|
|
2197
|
+
/**
|
|
2198
|
+
* BAPI-1252: the backend's closed canonical refusal vocabulary, mirrored as
|
|
2199
|
+
* string literals (the Python source is `api/models/canonical_index_state.py`;
|
|
2200
|
+
* a test pins the two together). A refusal carries exactly one of these and
|
|
2201
|
+
* nothing else — no SHA, generation, owner, or server error text.
|
|
2202
|
+
*/
|
|
2203
|
+
export const CANONICAL_REFUSAL_REASONS = [
|
|
2204
|
+
"canonical_validity_unavailable",
|
|
2205
|
+
"canonical_index_unverified",
|
|
2206
|
+
"canonical_source_missing",
|
|
2207
|
+
"canonical_generation_mismatch",
|
|
2208
|
+
"canonical_manifest_mismatch",
|
|
2209
|
+
"canonical_index_advanced",
|
|
2210
|
+
"canonical_capability_unsupported",
|
|
2211
|
+
];
|
|
2212
|
+
/** The canonical validity states the backend can report. */
|
|
2213
|
+
export const CANONICAL_VALIDITY_STATES = ["unverified", "writing", "verified"];
|
|
2214
|
+
/** The only canonical manifest versions this client accepts as evidence. */
|
|
2215
|
+
export const SUPPORTED_CANONICAL_MANIFEST_VERSIONS = ["v1"];
|
|
2216
|
+
const CANONICAL_PROVENANCE_KEYS = [
|
|
2217
|
+
"canonical_validity_state",
|
|
2218
|
+
"canonical_validity_verified",
|
|
2219
|
+
"canonical_source_commit_sha",
|
|
2220
|
+
"canonical_generation",
|
|
2221
|
+
"canonical_manifest_version",
|
|
2222
|
+
"canonical_validity_reason",
|
|
2223
|
+
];
|
|
2224
|
+
const FULL_COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
|
|
2225
|
+
const CANONICAL_GENERATION_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
2226
|
+
/**
|
|
2227
|
+
* BAPI-1252: decode a `/jira/parse-status` body's canonical provenance, failing
|
|
2228
|
+
* CLOSED. Only an explicit `verified` state, a literal `true` verified flag, a
|
|
2229
|
+
* full lowercase 40-character source SHA, a well-formed generation, a supported
|
|
2230
|
+
* manifest version, and no refusal reason decode as evidence. Everything else is
|
|
2231
|
+
* one bounded refusal:
|
|
2232
|
+
*
|
|
2233
|
+
* - all six fields absent (an older server) → `canonical_capability_unsupported`;
|
|
2234
|
+
* - an unknown state, an unknown manifest version, a malformed SHA or
|
|
2235
|
+
* generation, a non-boolean flag, or an unknown reason token →
|
|
2236
|
+
* `canonical_capability_unsupported` (the client cannot prove it, so it is not
|
|
2237
|
+
* proof);
|
|
2238
|
+
* - a known refusal reason → that reason;
|
|
2239
|
+
* - any non-verified state → `canonical_index_unverified`.
|
|
2240
|
+
*
|
|
2241
|
+
* Pure and total: it never throws and never consults lifecycle fields such as
|
|
2242
|
+
* `status` or `indexed_commit_sha`.
|
|
2243
|
+
*/
|
|
2244
|
+
export function decodeCanonicalProvenance(body) {
|
|
2245
|
+
const unsupported = { verified: false, reason: "canonical_capability_unsupported" };
|
|
2246
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
2247
|
+
return unsupported;
|
|
2248
|
+
const obj = body;
|
|
2249
|
+
const present = CANONICAL_PROVENANCE_KEYS.some((key) => key in obj && obj[key] !== undefined);
|
|
2250
|
+
if (!present)
|
|
2251
|
+
return unsupported;
|
|
2252
|
+
const reason = obj.canonical_validity_reason;
|
|
2253
|
+
if (reason !== undefined && reason !== null) {
|
|
2254
|
+
if (typeof reason !== "string")
|
|
2255
|
+
return unsupported;
|
|
2256
|
+
if (!CANONICAL_REFUSAL_REASONS.includes(reason))
|
|
2257
|
+
return unsupported;
|
|
2258
|
+
return { verified: false, reason: reason };
|
|
2259
|
+
}
|
|
2260
|
+
const flag = obj.canonical_validity_verified;
|
|
2261
|
+
if (flag !== undefined && flag !== null && typeof flag !== "boolean")
|
|
2262
|
+
return unsupported;
|
|
2263
|
+
const state = obj.canonical_validity_state;
|
|
2264
|
+
if (typeof state !== "string")
|
|
2265
|
+
return unsupported;
|
|
2266
|
+
if (!CANONICAL_VALIDITY_STATES.includes(state))
|
|
2267
|
+
return unsupported;
|
|
2268
|
+
if (state !== "verified" || flag !== true) {
|
|
2269
|
+
return { verified: false, reason: "canonical_index_unverified" };
|
|
2270
|
+
}
|
|
2271
|
+
const sha = obj.canonical_source_commit_sha;
|
|
2272
|
+
const generation = obj.canonical_generation;
|
|
2273
|
+
const version = obj.canonical_manifest_version;
|
|
2274
|
+
if (typeof sha !== "string" || !FULL_COMMIT_SHA_RE.test(sha))
|
|
2275
|
+
return unsupported;
|
|
2276
|
+
if (typeof generation !== "string" || !CANONICAL_GENERATION_RE.test(generation)) {
|
|
2277
|
+
return unsupported;
|
|
2278
|
+
}
|
|
2279
|
+
if (typeof version !== "string" ||
|
|
2280
|
+
!SUPPORTED_CANONICAL_MANIFEST_VERSIONS.includes(version)) {
|
|
2281
|
+
return unsupported;
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
verified: true,
|
|
2285
|
+
sourceCommitSha: sha,
|
|
2286
|
+
generation,
|
|
2287
|
+
manifestVersion: version,
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
1986
2290
|
/**
|
|
1987
2291
|
* GET `/jira/parse-status?repo_name=<repo>` and return the durable parse-run
|
|
1988
2292
|
* status. See {@link ParseRepositoryStatusValue} for the full state set (the
|
|
1989
2293
|
* status surface distinguishes queued/running from terminal succeeded/failed).
|
|
1990
2294
|
* Throws a sanitized {@link ConductorBridgeApiError} on any transport/auth/server
|
|
1991
2295
|
* failure. URL + auth are unchanged from the pre-BAPI-527 two-state client.
|
|
2296
|
+
*
|
|
2297
|
+
* BAPI-1252: the body is returned as the server sent it, so a payload from an
|
|
2298
|
+
* older server (no canonical fields) still decodes exactly as before. Canonical
|
|
2299
|
+
* provenance is read from it only through {@link decodeCanonicalProvenance}.
|
|
1992
2300
|
*/
|
|
1993
2301
|
export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
|
|
1994
2302
|
const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
|
|
@@ -2092,6 +2400,21 @@ export const CONDUCTOR_REVIEW_ALIGNMENT_STATUSES = new Set([
|
|
|
2092
2400
|
"not_configured",
|
|
2093
2401
|
"invalid",
|
|
2094
2402
|
]);
|
|
2403
|
+
/** The closed verdict vocabulary the parser accepts. */
|
|
2404
|
+
export const REQUIRED_CHECKS_VERIFICATIONS = new Set([
|
|
2405
|
+
"verified",
|
|
2406
|
+
"unknown_name",
|
|
2407
|
+
"unverifiable",
|
|
2408
|
+
]);
|
|
2409
|
+
/** BAPI-1260 — one mandatory-evidence verdict. Only `confirmed` ever holds. */
|
|
2410
|
+
export const ADMISSION_EVIDENCE_VERDICTS = [
|
|
2411
|
+
"confirmed",
|
|
2412
|
+
"denied",
|
|
2413
|
+
"unable_to_verify",
|
|
2414
|
+
"not_applicable",
|
|
2415
|
+
];
|
|
2416
|
+
/** The evidence schema this client renders. A newer one is shown as unknown, never as held. */
|
|
2417
|
+
export const ADMISSION_EVIDENCE_SCHEMA_VERSION = 1;
|
|
2095
2418
|
/** Upper bound of the server's capped `fresh_reconciler_count` ("two or more"). */
|
|
2096
2419
|
export const READINESS_FRESH_RECONCILER_COUNT_CAP = 2;
|
|
2097
2420
|
const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
|
|
@@ -2139,6 +2462,20 @@ function requireNullableString(o, key) {
|
|
|
2139
2462
|
throw readinessShapeError();
|
|
2140
2463
|
return v;
|
|
2141
2464
|
}
|
|
2465
|
+
/**
|
|
2466
|
+
* BAPI-1279 — the executor coverage fields. Each is omitted when the server did
|
|
2467
|
+
* not send it (an older deploy), and a present non-boolean fails CLOSED to
|
|
2468
|
+
* `false`: coverage that cannot be read is coverage that is not confirmed.
|
|
2469
|
+
*/
|
|
2470
|
+
function parseOptionalExecutorCoverage(o) {
|
|
2471
|
+
const out = {};
|
|
2472
|
+
for (const key of ["process_heartbeat_fresh", "repo_wide_executor_live"]) {
|
|
2473
|
+
if (!(key in o))
|
|
2474
|
+
continue;
|
|
2475
|
+
out[key] = o[key] === true;
|
|
2476
|
+
}
|
|
2477
|
+
return out;
|
|
2478
|
+
}
|
|
2142
2479
|
function requireNullableBool(o, key) {
|
|
2143
2480
|
const v = o[key];
|
|
2144
2481
|
if (v === null || v === undefined)
|
|
@@ -2178,6 +2515,89 @@ function requireEnum(o, key, allowed) {
|
|
|
2178
2515
|
throw readinessShapeError();
|
|
2179
2516
|
return v;
|
|
2180
2517
|
}
|
|
2518
|
+
/** The five BAPI-1254 supervisor keys, which travel as one all-or-none group. */
|
|
2519
|
+
const REQUIRED_CHECKS_VERIFICATION_KEYS = [
|
|
2520
|
+
"required_checks_verification",
|
|
2521
|
+
"required_checks_mismatch_count",
|
|
2522
|
+
"required_checks_unverified_count",
|
|
2523
|
+
"required_checks_evidence_observed_at",
|
|
2524
|
+
"required_checks_evidence_fresh",
|
|
2525
|
+
];
|
|
2526
|
+
/**
|
|
2527
|
+
* A strict ISO-8601 instant with an EXPLICIT UTC designator (`Z` or `+00:00`).
|
|
2528
|
+
* A timezone-free or offset time is rejected rather than read in local time.
|
|
2529
|
+
*/
|
|
2530
|
+
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|\+00:00)$/;
|
|
2531
|
+
function requireNonNegativeInt(o, key) {
|
|
2532
|
+
const v = requireInt(o, key);
|
|
2533
|
+
if (v < 0)
|
|
2534
|
+
throw readinessShapeError();
|
|
2535
|
+
return v;
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Parse the BAPI-1254 required-check verification group, FAIL-CLOSED.
|
|
2539
|
+
*
|
|
2540
|
+
* All five keys ABSENT is an older server: every field becomes `null`, which
|
|
2541
|
+
* every consumer renders as "could not verify" — absence is never verification.
|
|
2542
|
+
* Any key present makes all five mandatory, and each is checked for exact type:
|
|
2543
|
+
* the verdict against its closed vocabulary, both counts as non-negative
|
|
2544
|
+
* integers, the time as an explicit-UTC instant, freshness as a real boolean.
|
|
2545
|
+
* The server's cross-field invariants are then re-applied, because a
|
|
2546
|
+
* contradictory group (a `verified` with a mismatch, a stale `unknown_name`)
|
|
2547
|
+
* is untrusted input, not a verdict to render.
|
|
2548
|
+
*/
|
|
2549
|
+
function parseRequiredChecksVerification(sup, requiredChecksCount) {
|
|
2550
|
+
const present = REQUIRED_CHECKS_VERIFICATION_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(sup, key) && sup[key] !== undefined);
|
|
2551
|
+
if (present.length === 0) {
|
|
2552
|
+
return {
|
|
2553
|
+
required_checks_verification: null,
|
|
2554
|
+
required_checks_mismatch_count: null,
|
|
2555
|
+
required_checks_unverified_count: null,
|
|
2556
|
+
required_checks_evidence_observed_at: null,
|
|
2557
|
+
required_checks_evidence_fresh: null,
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
if (present.length !== REQUIRED_CHECKS_VERIFICATION_KEYS.length)
|
|
2561
|
+
throw readinessShapeError();
|
|
2562
|
+
const verification = requireEnum(sup, "required_checks_verification", REQUIRED_CHECKS_VERIFICATIONS);
|
|
2563
|
+
const mismatch = requireNonNegativeInt(sup, "required_checks_mismatch_count");
|
|
2564
|
+
const unverified = requireNonNegativeInt(sup, "required_checks_unverified_count");
|
|
2565
|
+
const observedAt = sup.required_checks_evidence_observed_at;
|
|
2566
|
+
if (typeof observedAt !== "string" ||
|
|
2567
|
+
!UTC_TIMESTAMP_PATTERN.test(observedAt) ||
|
|
2568
|
+
Number.isNaN(Date.parse(observedAt))) {
|
|
2569
|
+
throw readinessShapeError();
|
|
2570
|
+
}
|
|
2571
|
+
const fresh = requireBool(sup, "required_checks_evidence_fresh");
|
|
2572
|
+
if (requiredChecksCount < 0 || mismatch + unverified > requiredChecksCount) {
|
|
2573
|
+
throw readinessShapeError();
|
|
2574
|
+
}
|
|
2575
|
+
if (verification === "verified" &&
|
|
2576
|
+
!(requiredChecksCount > 0 && mismatch === 0 && unverified === 0 && fresh)) {
|
|
2577
|
+
throw readinessShapeError();
|
|
2578
|
+
}
|
|
2579
|
+
if (verification === "unknown_name" && !(fresh && mismatch > 0))
|
|
2580
|
+
throw readinessShapeError();
|
|
2581
|
+
if (verification === "unverifiable" && mismatch > 0)
|
|
2582
|
+
throw readinessShapeError();
|
|
2583
|
+
return {
|
|
2584
|
+
required_checks_verification: verification,
|
|
2585
|
+
required_checks_mismatch_count: mismatch,
|
|
2586
|
+
required_checks_unverified_count: unverified,
|
|
2587
|
+
required_checks_evidence_observed_at: observedAt,
|
|
2588
|
+
required_checks_evidence_fresh: fresh,
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
/** Optional positive-integer threshold: absent/null from an older server. */
|
|
2592
|
+
function parseOptionalPositiveInt(o, key) {
|
|
2593
|
+
const v = o[key];
|
|
2594
|
+
if (v === undefined || v === null)
|
|
2595
|
+
return null;
|
|
2596
|
+
const parsed = requireInt(o, key);
|
|
2597
|
+
if (parsed <= 0)
|
|
2598
|
+
throw readinessShapeError();
|
|
2599
|
+
return parsed;
|
|
2600
|
+
}
|
|
2181
2601
|
/**
|
|
2182
2602
|
* Validate a raw readiness body into the typed response, FAIL-CLOSED.
|
|
2183
2603
|
*
|
|
@@ -2197,6 +2617,7 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2197
2617
|
const rec = requireObject(root.reconciler);
|
|
2198
2618
|
const exec = requireObject(root.executor);
|
|
2199
2619
|
const thr = requireObject(root.thresholds);
|
|
2620
|
+
const requiredChecksCount = requireInt(sup, "required_checks_count");
|
|
2200
2621
|
return {
|
|
2201
2622
|
repo_name: repoName,
|
|
2202
2623
|
supervisor: {
|
|
@@ -2208,13 +2629,14 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2208
2629
|
config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
|
|
2209
2630
|
config_created_at: requireNullableString(sup, "config_created_at"),
|
|
2210
2631
|
config_updated_at: requireNullableString(sup, "config_updated_at"),
|
|
2211
|
-
required_checks_count:
|
|
2632
|
+
required_checks_count: requiredChecksCount,
|
|
2212
2633
|
required_checks_empty: requireBool(sup, "required_checks_empty"),
|
|
2213
2634
|
auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
|
|
2214
2635
|
merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
|
|
2215
2636
|
review_policy_alignment: parseReviewPolicyAlignment(sup),
|
|
2216
2637
|
// Absent on a pre-BAPI-694 server, where no repository default can exist.
|
|
2217
2638
|
review_policy_present: sup.review_policy_present === true,
|
|
2639
|
+
...parseRequiredChecksVerification(sup, requiredChecksCount),
|
|
2218
2640
|
},
|
|
2219
2641
|
github: {
|
|
2220
2642
|
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
@@ -2240,18 +2662,91 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2240
2662
|
last_seen_at: requireNullableString(exec, "last_seen_at"),
|
|
2241
2663
|
last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
|
|
2242
2664
|
ready: requireNullableBool(exec, "ready"),
|
|
2665
|
+
...parseOptionalExecutorCoverage(exec),
|
|
2243
2666
|
},
|
|
2244
2667
|
thresholds: {
|
|
2245
2668
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
2246
2669
|
executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
|
|
2670
|
+
required_checks_evidence_max_age_seconds: parseOptionalPositiveInt(thr, "required_checks_evidence_max_age_seconds"),
|
|
2247
2671
|
},
|
|
2248
2672
|
review_workflow: parseReviewWorkflow(root),
|
|
2249
2673
|
conductor_ci_workflow: parseConductorCiWorkflow(root),
|
|
2250
2674
|
unattended: parseUnattended(root),
|
|
2251
2675
|
bring_up: parseBringUp(root),
|
|
2252
2676
|
merge_execution: parseMergeExecution(root),
|
|
2677
|
+
...(() => {
|
|
2678
|
+
const evidence = parseAdmissionEvidence(root);
|
|
2679
|
+
return evidence === undefined ? {} : { admission_evidence: evidence };
|
|
2680
|
+
})(),
|
|
2253
2681
|
};
|
|
2254
2682
|
}
|
|
2683
|
+
const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
|
|
2684
|
+
const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
|
|
2685
|
+
/**
|
|
2686
|
+
* Parse the optional BAPI-1260 `admission_evidence` block.
|
|
2687
|
+
*
|
|
2688
|
+
* Absent → `undefined` (the key is then omitted from the parsed response).
|
|
2689
|
+
* Present → validated strictly, EXCEPT that an unrecognized verdict is coerced
|
|
2690
|
+
* to `unable_to_verify` rather than failing the whole readiness read: a newer
|
|
2691
|
+
* server's vocabulary must degrade to "not proven", never to "held" and never
|
|
2692
|
+
* to "no readiness at all". Free-text fields are bounded tokens only, so a
|
|
2693
|
+
* payload cannot smuggle prose into operator output.
|
|
2694
|
+
*/
|
|
2695
|
+
function parseAdmissionEvidence(body) {
|
|
2696
|
+
const raw = body.admission_evidence;
|
|
2697
|
+
if (raw === undefined)
|
|
2698
|
+
return undefined;
|
|
2699
|
+
if (raw === null)
|
|
2700
|
+
return null;
|
|
2701
|
+
const o = requireObject(raw);
|
|
2702
|
+
const schemaVersion = requireInt(o, "schema_version");
|
|
2703
|
+
const observedAt = o.observed_at;
|
|
2704
|
+
if (typeof observedAt !== "string")
|
|
2705
|
+
throw readinessShapeError();
|
|
2706
|
+
if (!Array.isArray(o.records) || o.records.length > 32)
|
|
2707
|
+
throw readinessShapeError();
|
|
2708
|
+
const token = (value, nullable) => {
|
|
2709
|
+
if (value === null && nullable)
|
|
2710
|
+
return null;
|
|
2711
|
+
if (typeof value !== "string" || !BOUNDED_TOKEN.test(value))
|
|
2712
|
+
throw readinessShapeError();
|
|
2713
|
+
return value;
|
|
2714
|
+
};
|
|
2715
|
+
const records = o.records.map((entry) => {
|
|
2716
|
+
const r = requireObject(entry);
|
|
2717
|
+
const verdictRaw = r.verdict;
|
|
2718
|
+
const verdict = ADMISSION_EVIDENCE_VERDICTS.includes(verdictRaw)
|
|
2719
|
+
? verdictRaw
|
|
2720
|
+
: "unable_to_verify";
|
|
2721
|
+
const owner = r.next_action_owner;
|
|
2722
|
+
if (owner !== null && owner !== undefined && !ADMISSION_EVIDENCE_OWNERS.has(owner)) {
|
|
2723
|
+
throw readinessShapeError();
|
|
2724
|
+
}
|
|
2725
|
+
const maxAge = requireInt(r, "max_age_seconds");
|
|
2726
|
+
if (maxAge <= 0)
|
|
2727
|
+
throw readinessShapeError();
|
|
2728
|
+
const at = r.observed_at;
|
|
2729
|
+
if (at !== null && typeof at !== "string")
|
|
2730
|
+
throw readinessShapeError();
|
|
2731
|
+
return {
|
|
2732
|
+
predicate: token(r.predicate, false),
|
|
2733
|
+
verdict,
|
|
2734
|
+
observed_at: at,
|
|
2735
|
+
source: token(r.source, false),
|
|
2736
|
+
source_version: r.source_version === null || r.source_version === undefined
|
|
2737
|
+
? null
|
|
2738
|
+
: typeof r.source_version === "string" && r.source_version.length <= 80
|
|
2739
|
+
? r.source_version
|
|
2740
|
+
: (() => {
|
|
2741
|
+
throw readinessShapeError();
|
|
2742
|
+
})(),
|
|
2743
|
+
max_age_seconds: maxAge,
|
|
2744
|
+
reason: r.reason === undefined ? null : token(r.reason, true),
|
|
2745
|
+
next_action_owner: (owner ?? null),
|
|
2746
|
+
};
|
|
2747
|
+
});
|
|
2748
|
+
return { schema_version: schemaVersion, observed_at: observedAt, records };
|
|
2749
|
+
}
|
|
2255
2750
|
/**
|
|
2256
2751
|
* Parse the optional `merge_execution` block (BAPI-1135).
|
|
2257
2752
|
*
|
|
@@ -2353,8 +2848,9 @@ function parseConductorCiWorkflow(body) {
|
|
|
2353
2848
|
*
|
|
2354
2849
|
* `notify_webhook_default_verified` is the one tri-state: absent is coerced to
|
|
2355
2850
|
* `null` rather than to `false`, because "not verified here" and "verified as
|
|
2356
|
-
* broken" are different statements and only one of them is evidence.
|
|
2357
|
-
*
|
|
2851
|
+
* broken" are different statements and only one of them is evidence.
|
|
2852
|
+
* `escalation_email_available` (BAPI-1274) is lenient: anything but `true` is
|
|
2853
|
+
* `false`. Every other field is a strict boolean and a wrong type throws.
|
|
2358
2854
|
*/
|
|
2359
2855
|
function parseUnattended(body) {
|
|
2360
2856
|
const raw = body.unattended;
|
|
@@ -2368,6 +2864,10 @@ function parseUnattended(body) {
|
|
|
2368
2864
|
? null
|
|
2369
2865
|
: requireNullableBool(o, "notify_webhook_default_verified"),
|
|
2370
2866
|
repository_readiness_confirmed: requireBool(o, "repository_readiness_confirmed"),
|
|
2867
|
+
// BAPI-1274 — optional and lenient: absent, null, or any non-boolean
|
|
2868
|
+
// normalizes to `false` rather than throwing, because a server older than
|
|
2869
|
+
// BAPI-1274 omits it and must keep today's behavior.
|
|
2870
|
+
escalation_email_available: o.escalation_email_available === true,
|
|
2371
2871
|
};
|
|
2372
2872
|
}
|
|
2373
2873
|
/**
|
|
@@ -2384,6 +2884,15 @@ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fet
|
|
|
2384
2884
|
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
2385
2885
|
return parseConductorReadinessResponse(body);
|
|
2386
2886
|
}
|
|
2887
|
+
const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
|
|
2888
|
+
function optionalRowOutcome(o, key) {
|
|
2889
|
+
const v = o[key];
|
|
2890
|
+
if (v === undefined || v === null)
|
|
2891
|
+
return null;
|
|
2892
|
+
if (typeof v !== "string" || !ROW_OUTCOMES.has(v))
|
|
2893
|
+
throw readinessShapeError();
|
|
2894
|
+
return v;
|
|
2895
|
+
}
|
|
2387
2896
|
/** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
|
|
2388
2897
|
export function parseConductorSupervisorBootstrapResponse(body) {
|
|
2389
2898
|
const o = requireObject(body);
|
|
@@ -2398,6 +2907,8 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2398
2907
|
repo_name: repoName,
|
|
2399
2908
|
setup_written: requireBool(o, "setup_written"),
|
|
2400
2909
|
config_written: requireBool(o, "config_written"),
|
|
2910
|
+
setup_outcome: optionalRowOutcome(o, "setup_outcome"),
|
|
2911
|
+
config_outcome: optionalRowOutcome(o, "config_outcome"),
|
|
2401
2912
|
setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
|
|
2402
2913
|
config_source: requireEnum(o, "config_source", READINESS_SOURCES),
|
|
2403
2914
|
setup_created_at: requireNullableString(o, "setup_created_at"),
|
|
@@ -2411,14 +2922,167 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2411
2922
|
/**
|
|
2412
2923
|
* POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
|
|
2413
2924
|
*
|
|
2414
|
-
*
|
|
2415
|
-
*
|
|
2416
|
-
*
|
|
2925
|
+
* Since BAPI-694 it may also carry the optional repository-default
|
|
2926
|
+
* `review_policy`; omitting that field keeps the stored default. Since BAPI-1288
|
|
2927
|
+
* a relaxation is refused with 409 unless `approval` carries the exact token a
|
|
2928
|
+
* {@link previewConductorSupervisorBootstrap} of the SAME request returned.
|
|
2929
|
+
* A 409 is never retried here — see {@link classifySupervisorBootstrapFailure}.
|
|
2417
2930
|
*/
|
|
2418
2931
|
export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
|
|
2419
2932
|
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
|
|
2420
2933
|
repo_name: access.repoName,
|
|
2421
2934
|
});
|
|
2422
|
-
const body = await
|
|
2935
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
2423
2936
|
return parseConductorSupervisorBootstrapResponse(body);
|
|
2424
2937
|
}
|
|
2938
|
+
// ---------------------------------------------------------------------------
|
|
2939
|
+
// BAPI-1288 — preview of a protected project-default change
|
|
2940
|
+
// ---------------------------------------------------------------------------
|
|
2941
|
+
/** Closed vocabularies of a server-authored gate-change effect. */
|
|
2942
|
+
const GATE_CHANGE_REQUIREMENTS = new Set([
|
|
2943
|
+
"done_gate",
|
|
2944
|
+
"required_ci_checks",
|
|
2945
|
+
"done_gate_review",
|
|
2946
|
+
"review_policy_default",
|
|
2947
|
+
]);
|
|
2948
|
+
const GATE_CHANGE_KINDS = new Set(["removed", "weakened", "replaced"]);
|
|
2949
|
+
const GATE_OBLIGATIONS = new Set([
|
|
2950
|
+
"ci_check_green",
|
|
2951
|
+
"sticky_verdict_approved",
|
|
2952
|
+
"native_review_approved",
|
|
2953
|
+
"approval_minimum",
|
|
2954
|
+
"verdictless_review_parks",
|
|
2955
|
+
]);
|
|
2956
|
+
const CHECK_REF_RE = /^ci-[0-9a-f]{12}$/;
|
|
2957
|
+
/** Bounds the client enforces on server text before it may be displayed. */
|
|
2958
|
+
const MAX_EFFECTS = 16;
|
|
2959
|
+
const MAX_DESCRIPTION_LENGTH = 300;
|
|
2960
|
+
const MAX_CHECK_REFS = 50;
|
|
2961
|
+
const MAX_APPROVAL_TOKEN_LENGTH = 1024;
|
|
2962
|
+
function requireObligationList(o, key) {
|
|
2963
|
+
const v = o[key];
|
|
2964
|
+
if (!Array.isArray(v) || v.length > GATE_OBLIGATIONS.size)
|
|
2965
|
+
throw readinessShapeError();
|
|
2966
|
+
for (const item of v) {
|
|
2967
|
+
if (typeof item !== "string" || !GATE_OBLIGATIONS.has(item))
|
|
2968
|
+
throw readinessShapeError();
|
|
2969
|
+
}
|
|
2970
|
+
return v;
|
|
2971
|
+
}
|
|
2972
|
+
function parseGateChangeEffect(raw) {
|
|
2973
|
+
const o = requireObject(raw);
|
|
2974
|
+
const refs = o.removed_ci_check_refs;
|
|
2975
|
+
if (!Array.isArray(refs) ||
|
|
2976
|
+
refs.length > MAX_CHECK_REFS ||
|
|
2977
|
+
refs.some((r) => typeof r !== "string" || !CHECK_REF_RE.test(r))) {
|
|
2978
|
+
throw readinessShapeError();
|
|
2979
|
+
}
|
|
2980
|
+
const description = o.description;
|
|
2981
|
+
if (typeof description !== "string" ||
|
|
2982
|
+
description.length === 0 ||
|
|
2983
|
+
description.length > MAX_DESCRIPTION_LENGTH ||
|
|
2984
|
+
/[\u0000-\u001F\u007F]/.test(description)) {
|
|
2985
|
+
throw readinessShapeError();
|
|
2986
|
+
}
|
|
2987
|
+
const count = requireInt(o, "removed_ci_check_count");
|
|
2988
|
+
if (count < 0)
|
|
2989
|
+
throw readinessShapeError();
|
|
2990
|
+
return {
|
|
2991
|
+
row: requireEnum(o, "row", new Set(["setup", "config"])),
|
|
2992
|
+
requirement: requireEnum(o, "requirement", GATE_CHANGE_REQUIREMENTS),
|
|
2993
|
+
change: requireEnum(o, "change", GATE_CHANGE_KINDS),
|
|
2994
|
+
removed_obligations: requireObligationList(o, "removed_obligations"),
|
|
2995
|
+
replacement_obligations: requireObligationList(o, "replacement_obligations"),
|
|
2996
|
+
removed_ci_check_count: count,
|
|
2997
|
+
removed_ci_check_refs: refs,
|
|
2998
|
+
stored_min_approvals: requireNullableInt(o, "stored_min_approvals"),
|
|
2999
|
+
proposed_min_approvals: requireNullableInt(o, "proposed_min_approvals"),
|
|
3000
|
+
description,
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Validate a raw preview body, fail-closed.
|
|
3005
|
+
*
|
|
3006
|
+
* Every field is checked against the server's closed contract; anything else —
|
|
3007
|
+
* an unknown outcome, an effect category this client does not know, an
|
|
3008
|
+
* over-long or control-character description, a token without
|
|
3009
|
+
* `approval_required`, a writer or repository that does not match the request —
|
|
3010
|
+
* is a shape error, so an unbounded server string can never reach the operator's
|
|
3011
|
+
* terminal as if it were a vetted description.
|
|
3012
|
+
*/
|
|
3013
|
+
export function parseConductorSupervisorBootstrapPreview(body, expectedRepoName) {
|
|
3014
|
+
const o = requireObject(body);
|
|
3015
|
+
if (o.repo_name !== expectedRepoName)
|
|
3016
|
+
throw readinessShapeError();
|
|
3017
|
+
if (o.writer !== "bootstrap")
|
|
3018
|
+
throw readinessShapeError();
|
|
3019
|
+
const names = o.changed_field_names;
|
|
3020
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || n.length > 100)) {
|
|
3021
|
+
throw readinessShapeError();
|
|
3022
|
+
}
|
|
3023
|
+
const effectsRaw = o.effects;
|
|
3024
|
+
if (!Array.isArray(effectsRaw) || effectsRaw.length > MAX_EFFECTS)
|
|
3025
|
+
throw readinessShapeError();
|
|
3026
|
+
const effects = effectsRaw.map(parseGateChangeEffect);
|
|
3027
|
+
const approvalRequired = requireBool(o, "approval_required");
|
|
3028
|
+
const token = requireNullableString(o, "approval_token");
|
|
3029
|
+
if (approvalRequired !== effects.length > 0)
|
|
3030
|
+
throw readinessShapeError();
|
|
3031
|
+
if (approvalRequired !== (token !== null))
|
|
3032
|
+
throw readinessShapeError();
|
|
3033
|
+
if (token !== null && (token.length === 0 || token.length > MAX_APPROVAL_TOKEN_LENGTH)) {
|
|
3034
|
+
throw readinessShapeError();
|
|
3035
|
+
}
|
|
3036
|
+
return {
|
|
3037
|
+
repo_name: expectedRepoName,
|
|
3038
|
+
writer: "bootstrap",
|
|
3039
|
+
setup_outcome: requireEnum(o, "setup_outcome", ROW_OUTCOMES),
|
|
3040
|
+
config_outcome: requireEnum(o, "config_outcome", ROW_OUTCOMES),
|
|
3041
|
+
changed_field_names: names,
|
|
3042
|
+
approval_required: approvalRequired,
|
|
3043
|
+
effects,
|
|
3044
|
+
approval_token: token,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* POST `/jira/epic-runs/supervisor-bootstrap/preview?repo_name=<repo>` — what a
|
|
3049
|
+
* bootstrap with `request` WOULD do. Read-only on the server.
|
|
3050
|
+
*
|
|
3051
|
+
* Send the same body to {@link bootstrapConductorSupervisorDefaults} afterwards
|
|
3052
|
+
* (plus `approval` when the preview required it); any difference makes the
|
|
3053
|
+
* server's token check fail with a fresh-preview conflict.
|
|
3054
|
+
*/
|
|
3055
|
+
export async function previewConductorSupervisorBootstrap(access, request, fetchImpl) {
|
|
3056
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap/preview`, { repo_name: access.repoName });
|
|
3057
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
3058
|
+
return parseConductorSupervisorBootstrapPreview(body, access.repoName);
|
|
3059
|
+
}
|
|
3060
|
+
const GATE_CHANGE_CODE_TO_KIND = Object.freeze({
|
|
3061
|
+
APPROVAL_REQUIRED: "approval-required",
|
|
3062
|
+
STALE_PREVIEW: "stale-preview",
|
|
3063
|
+
UNKNOWN_STORED_STATE: "unknown-stored-state",
|
|
3064
|
+
NON_RELAXABLE: "non-relaxable",
|
|
3065
|
+
INVALID_PROPOSAL: "invalid-proposal",
|
|
3066
|
+
STATE_UNAVAILABLE: "state-unavailable",
|
|
3067
|
+
});
|
|
3068
|
+
export function classifySupervisorBootstrapFailure(err) {
|
|
3069
|
+
if (!(err instanceof ConductorBridgeApiError))
|
|
3070
|
+
return "network";
|
|
3071
|
+
const mapped = err.errorCode ? GATE_CHANGE_CODE_TO_KIND[err.errorCode] : undefined;
|
|
3072
|
+
if (mapped)
|
|
3073
|
+
return mapped;
|
|
3074
|
+
if (err.kind === "invalid-input")
|
|
3075
|
+
return "unsupported-protocol";
|
|
3076
|
+
if (err.kind === "unauthorized")
|
|
3077
|
+
return "unauthorized";
|
|
3078
|
+
if (err.kind === "timeout")
|
|
3079
|
+
return "timeout";
|
|
3080
|
+
if (err.kind === "network")
|
|
3081
|
+
return "network";
|
|
3082
|
+
if (err.status === 404 || err.status === 405 || err.status === 422) {
|
|
3083
|
+
return "unsupported-protocol";
|
|
3084
|
+
}
|
|
3085
|
+
if (err.kind === "server")
|
|
3086
|
+
return "server";
|
|
3087
|
+
return "rejected";
|
|
3088
|
+
}
|