@bridge_gpt/mcp-server 0.2.62 → 0.2.66
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 +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- 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 +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -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 +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- 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/status.js +4 -0
- package/build/plane/supervisor.js +146 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- package/pipelines/implement-ticket.json +1 -1
|
@@ -16,11 +16,33 @@ import { readEpicLaunchProfile, resolveEpicLaunchTarget, } from "../epic-launch-
|
|
|
16
16
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
17
17
|
import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
18
18
|
import { ConductorValidationError } from "./errors.js";
|
|
19
|
+
import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
|
|
19
20
|
import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
|
|
21
|
+
import { safeEpicLaunchTargetUrl } from "../epic-launch-diagnostic.js";
|
|
22
|
+
import { validateHttpBaseUrl } from "../base-url.js";
|
|
23
|
+
import { isCiResumeRefusalCode } from "./repair-contract.js";
|
|
20
24
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
21
25
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
22
26
|
/** Default per-request timeout for conductor Bridge API calls. */
|
|
23
27
|
export const CONDUCTOR_FETCH_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** Keep an explicit readiness target from borrowing a key bound to another ambient endpoint. */
|
|
29
|
+
export function readinessEndpointEnvironments(env, baseUrl) {
|
|
30
|
+
if (baseUrl === undefined)
|
|
31
|
+
return { reportEnv: env, resolverEnv: env };
|
|
32
|
+
const ambientBase = env.BAPI_BASE_URL ? validateHttpBaseUrl(env.BAPI_BASE_URL) : null;
|
|
33
|
+
const ambientMatchesOverride = ambientBase?.ok && ambientBase.baseUrl === baseUrl;
|
|
34
|
+
return {
|
|
35
|
+
reportEnv: { ...env, BAPI_BASE_URL: baseUrl, BAPI_EPIC_LAUNCH_PROFILE: undefined,
|
|
36
|
+
BAPI_API_KEY: ambientMatchesOverride ? env.BAPI_API_KEY : undefined },
|
|
37
|
+
resolverEnv: {
|
|
38
|
+
...env,
|
|
39
|
+
BAPI_EPIC_LAUNCH_PROFILE: undefined,
|
|
40
|
+
// Never manufacture an ambient key's endpoint pairing. A stored key
|
|
41
|
+
// still passes its own binding check against the explicit target.
|
|
42
|
+
BAPI_BASE_URL: ambientMatchesOverride ? baseUrl : undefined,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
24
46
|
/**
|
|
25
47
|
* Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
|
|
26
48
|
* resolver, and the shared credential store. Never throws and never embeds the
|
|
@@ -70,12 +92,15 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
70
92
|
*
|
|
71
93
|
* 1. the selected profile is read and validated (host-local, read-only);
|
|
72
94
|
* 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
|
|
73
|
-
* — conflicts refuse
|
|
74
|
-
* {@link CONDUCTOR_DEFAULT_BASE_URL};
|
|
95
|
+
* — conflicts refuse. With none of them set, the target is
|
|
96
|
+
* {@link CONDUCTOR_DEFAULT_BASE_URL} (BAPI-1272);
|
|
75
97
|
* 3. the repository is decided (profile binding, then `--repo`, then the
|
|
76
98
|
* shared resolver);
|
|
77
99
|
* 4. only then is a credential looked up, coupled to that target AND that
|
|
78
|
-
* repository ({@link resolveEpicLaunchCredential}).
|
|
100
|
+
* repository ({@link resolveEpicLaunchCredential}). A stored key with no
|
|
101
|
+
* binding is accepted only when the target is production; a key bound to
|
|
102
|
+
* another endpoint, or an ambient key without a matching `BAPI_BASE_URL`,
|
|
103
|
+
* is still refused.
|
|
79
104
|
*
|
|
80
105
|
* Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
|
|
81
106
|
* is deliberately left as it is for every other client.
|
|
@@ -87,9 +112,11 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
87
112
|
const platform = deps.platform ?? process.platform;
|
|
88
113
|
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
89
114
|
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
115
|
+
const environmentProfile = env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
|
|
116
|
+
const profileName = selection.profileName ?? (environmentProfile || undefined);
|
|
90
117
|
let profile = null;
|
|
91
|
-
if (
|
|
92
|
-
const read = await readEpicLaunchProfile(
|
|
118
|
+
if (profileName !== undefined) {
|
|
119
|
+
const read = await readEpicLaunchProfile(profileName, {
|
|
93
120
|
env,
|
|
94
121
|
homedir,
|
|
95
122
|
readFile: readFileImpl,
|
|
@@ -103,10 +130,13 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
103
130
|
flagBaseUrl: selection.baseUrl,
|
|
104
131
|
flagRepo: deps.repoName,
|
|
105
132
|
profile,
|
|
133
|
+
profileSource: selection.profileName !== undefined ? "profile" : profile ? "profile-env" : undefined,
|
|
106
134
|
env,
|
|
135
|
+
defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
|
|
107
136
|
});
|
|
108
137
|
if (!target.ok)
|
|
109
138
|
return { ok: false, kind: target.kind, error: target.message };
|
|
139
|
+
const reportableTarget = safeEpicLaunchTargetUrl(target.baseUrl) ? { baseUrl: target.baseUrl } : {};
|
|
110
140
|
const repoName = target.repoName ||
|
|
111
141
|
deps.repoName?.trim() ||
|
|
112
142
|
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
@@ -115,28 +145,23 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
115
145
|
ok: false,
|
|
116
146
|
kind: "repo-missing",
|
|
117
147
|
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
148
|
+
...reportableTarget,
|
|
118
149
|
};
|
|
119
150
|
}
|
|
120
151
|
let cred;
|
|
121
152
|
try {
|
|
122
|
-
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, {
|
|
123
|
-
env,
|
|
124
|
-
homedir,
|
|
125
|
-
platform,
|
|
126
|
-
readFile: readFileImpl,
|
|
127
|
-
stat: statImpl,
|
|
128
|
-
});
|
|
153
|
+
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
|
|
129
154
|
}
|
|
130
155
|
catch {
|
|
131
|
-
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
156
|
+
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials", ...reportableTarget };
|
|
132
157
|
}
|
|
133
158
|
if (!cred.ok) {
|
|
134
159
|
const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
|
|
135
160
|
// The binding messages are fixed sentences naming the target KEY and the
|
|
136
161
|
// store path only; the generic store failures stay generic as before.
|
|
137
162
|
return bindingFailure
|
|
138
|
-
? { ok: false, kind: "credential-unbound", error: cred.error }
|
|
139
|
-
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
|
|
163
|
+
? { ok: false, kind: "credential-unbound", error: cred.error, ...reportableTarget }
|
|
164
|
+
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable", ...reportableTarget };
|
|
140
165
|
}
|
|
141
166
|
return {
|
|
142
167
|
ok: true,
|
|
@@ -750,6 +775,18 @@ export async function remediateEpicTicket(access, request, fetchImpl = globalThi
|
|
|
750
775
|
throw err;
|
|
751
776
|
}
|
|
752
777
|
}
|
|
778
|
+
/**
|
|
779
|
+
* BAPI-1310 (E1) — the server's refusal codes for the unexplained review-head
|
|
780
|
+
* stop. Each is an HTTP 409 that leaves the ticket parked.
|
|
781
|
+
*/
|
|
782
|
+
export const REVIEW_HEAD_REFUSAL_CODES = [
|
|
783
|
+
"REVIEW_HEAD_ADOPTION_REQUIRED",
|
|
784
|
+
"REVIEW_HEAD_EPISODE_CONFLICT",
|
|
785
|
+
"REVIEW_HEAD_STATE_INVALID",
|
|
786
|
+
"REVIEW_HEAD_BINDING_CHANGED",
|
|
787
|
+
"REVIEW_HEAD_LIVE_WORK",
|
|
788
|
+
"REVIEW_HEAD_UNAVAILABLE",
|
|
789
|
+
];
|
|
753
790
|
// ---------------------------------------------------------------------------
|
|
754
791
|
// Local boundary validators (reject before any fetch call)
|
|
755
792
|
// ---------------------------------------------------------------------------
|
|
@@ -1251,6 +1288,108 @@ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.f
|
|
|
1251
1288
|
});
|
|
1252
1289
|
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1253
1290
|
}
|
|
1291
|
+
const TYPED_CONTINUATION_STATUSES = new Set([200, 201, 409, 503]);
|
|
1292
|
+
const SAFE_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
1293
|
+
function continuationChildPath(epicRunId, ticketKey) {
|
|
1294
|
+
return `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}`;
|
|
1295
|
+
}
|
|
1296
|
+
function safeDetailErrorCode(body) {
|
|
1297
|
+
if (body === null || typeof body !== "object")
|
|
1298
|
+
return null;
|
|
1299
|
+
const detail = body.detail;
|
|
1300
|
+
if (detail === null || typeof detail !== "object")
|
|
1301
|
+
return null;
|
|
1302
|
+
const code = detail.error_code;
|
|
1303
|
+
return typeof code === "string" && SAFE_ERROR_CODE_RE.test(code) ? code : null;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* POST `.../tickets/{ticket_key}/continue` exactly once and classify the answer.
|
|
1307
|
+
*
|
|
1308
|
+
* Never throws for an HTTP or transport outcome — every one of them is returned
|
|
1309
|
+
* as a {@link ContinuationPostOutcome}, so no caller can accidentally turn a
|
|
1310
|
+
* timeout into "refused" by catching an exception. Never falls back to unpark.
|
|
1311
|
+
* The API key travels only in the header; the body carries no credential and no
|
|
1312
|
+
* caller-asserted authority.
|
|
1313
|
+
*/
|
|
1314
|
+
export async function postContinueParkedChild(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
1315
|
+
const b = request.bindings;
|
|
1316
|
+
requireNonEmptyString(b.epicRunId);
|
|
1317
|
+
requireNonEmptyString(b.ticketKey);
|
|
1318
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(b.epicRunId, b.ticketKey)}/continue`);
|
|
1319
|
+
const body = JSON.stringify({
|
|
1320
|
+
repo_name: b.repoName,
|
|
1321
|
+
request_id: request.requestId,
|
|
1322
|
+
parked_gate: b.parkedGate,
|
|
1323
|
+
expected_row_version: b.expectedRowVersion,
|
|
1324
|
+
expected_head_sha: b.expectedHeadSha,
|
|
1325
|
+
source_park_generation: b.sourceParkGeneration,
|
|
1326
|
+
request_fingerprint: request.requestFingerprint,
|
|
1327
|
+
...(request.reason ? { reason: request.reason } : {}),
|
|
1328
|
+
});
|
|
1329
|
+
const controller = new AbortController();
|
|
1330
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1331
|
+
try {
|
|
1332
|
+
let resp;
|
|
1333
|
+
try {
|
|
1334
|
+
resp = await fetchImpl(url, {
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
headers: conductorPostHeaders(access),
|
|
1337
|
+
body,
|
|
1338
|
+
signal: controller.signal,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
1343
|
+
}
|
|
1344
|
+
let parsed;
|
|
1345
|
+
let parsedOk = true;
|
|
1346
|
+
try {
|
|
1347
|
+
parsed = await resp.json();
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
parsedOk = false;
|
|
1351
|
+
}
|
|
1352
|
+
if (TYPED_CONTINUATION_STATUSES.has(resp.status) && parsedOk) {
|
|
1353
|
+
try {
|
|
1354
|
+
return { kind: "result", status: resp.status, result: parseContinueParkedChildResult(parsed) };
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
// Fall through: an untyped body on these statuses is classified below.
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
1361
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
1362
|
+
}
|
|
1363
|
+
if (resp.status >= 500) {
|
|
1364
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
1365
|
+
}
|
|
1366
|
+
// A 4xx without a typed body is an authoritative pre-mutation rejection.
|
|
1367
|
+
return {
|
|
1368
|
+
kind: "refused_http",
|
|
1369
|
+
status: resp.status,
|
|
1370
|
+
errorCode: parsedOk ? safeDetailErrorCode(parsed) : null,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
finally {
|
|
1374
|
+
clearTimeout(timer);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* GET `.../tickets/{ticket_key}/continuation` — the child's current episode and,
|
|
1379
|
+
* with `requestId`, that request's receipt. Read-only; bounded by the standard
|
|
1380
|
+
* conductor timeout. Throws {@link ConductorBridgeApiError} on transport or HTTP
|
|
1381
|
+
* failure and {@link ContinuationParseError} on a malformed body.
|
|
1382
|
+
*/
|
|
1383
|
+
export async function fetchChildContinuationStatus(access, query, fetchImpl = globalThis.fetch) {
|
|
1384
|
+
requireNonEmptyString(query.epicRunId);
|
|
1385
|
+
requireNonEmptyString(query.ticketKey);
|
|
1386
|
+
const params = { repo_name: access.repoName };
|
|
1387
|
+
if (query.requestId)
|
|
1388
|
+
params.request_id = query.requestId;
|
|
1389
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(query.epicRunId, query.ticketKey)}/continuation`, params);
|
|
1390
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1391
|
+
return parseContinuationStatusResult(parsed);
|
|
1392
|
+
}
|
|
1254
1393
|
/**
|
|
1255
1394
|
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
|
|
1256
1395
|
* — the recovery lane for a human/external push that drifted the PR head off the
|
|
@@ -1365,6 +1504,9 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1365
1504
|
// The server's refusal explanation is the operator-facing message here.
|
|
1366
1505
|
{ readErrorField: true });
|
|
1367
1506
|
}
|
|
1507
|
+
function withGuard(body, guard) {
|
|
1508
|
+
return guard === undefined ? body : { ...body, guard };
|
|
1509
|
+
}
|
|
1368
1510
|
/**
|
|
1369
1511
|
* POST `/jira/epic-runs/runs/{epic_run_id}/scope-reparse` — re-drive the run's
|
|
1370
1512
|
* OWN index scope through the retry-reparse lifecycle. Body: `{repo_name,
|
|
@@ -1374,7 +1516,7 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1374
1516
|
export async function scopeReparse(access, request, fetchImpl = globalThis.fetch) {
|
|
1375
1517
|
requireNonEmptyString(request.epicRunId);
|
|
1376
1518
|
requireNonEmptyString(request.scopeId);
|
|
1377
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, { scope_id: request.scopeId }, fetchImpl);
|
|
1519
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
|
|
1378
1520
|
return parseScopeReparseResult(parsed);
|
|
1379
1521
|
}
|
|
1380
1522
|
/**
|
|
@@ -1387,7 +1529,7 @@ export async function dispatchRemediation(access, request, fetchImpl = globalThi
|
|
|
1387
1529
|
requireNonEmptyString(request.epicRunId);
|
|
1388
1530
|
requireNonEmptyString(request.ticketKey);
|
|
1389
1531
|
requireNonEmptyString(request.context);
|
|
1390
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, { context: request.context }, fetchImpl);
|
|
1532
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, withGuard({ context: request.context }, request.guard), fetchImpl);
|
|
1391
1533
|
return parseDispatchRemediationResult(parsed);
|
|
1392
1534
|
}
|
|
1393
1535
|
/**
|
|
@@ -1441,8 +1583,13 @@ const REPAIR_TOKEN_RE = /^[a-z0-9_.:-]{1,120}$/;
|
|
|
1441
1583
|
const POLICY_DIGEST_TOKEN_RE = /^policy:[0-9a-f]{12}$/;
|
|
1442
1584
|
/** A dotted policy-path NAME. Never a value. */
|
|
1443
1585
|
const POLICY_PATH_RE = /^[A-Za-z0-9_-]{1,100}(?:\.[A-Za-z0-9_-]{1,100}){0,15}$/;
|
|
1444
|
-
/**
|
|
1445
|
-
|
|
1586
|
+
/**
|
|
1587
|
+
* `resume-run`'s closed PRIOR-state vocabulary. `blocked` is the qualifying
|
|
1588
|
+
* integration block the run-addressed route may lift; it is never a resulting state.
|
|
1589
|
+
*/
|
|
1590
|
+
const RESUME_RUN_BEFORE_STATES = ["blocked", "paused", "quarantined", "none"];
|
|
1591
|
+
/** `resume-run`'s closed RESULTING-state vocabulary: a resume always leaves no control. */
|
|
1592
|
+
const RESUME_RUN_AFTER_STATES = ["none"];
|
|
1446
1593
|
function requireOnlyRepairKeys(p, extraKeys) {
|
|
1447
1594
|
for (const key of Object.keys(p)) {
|
|
1448
1595
|
if (!REPAIR_BASE_KEYS.includes(key) && !extraKeys.includes(key)) {
|
|
@@ -1509,18 +1656,29 @@ function parseResumeRunResult(parsed) {
|
|
|
1509
1656
|
requireOnlyRepairKeys(p, []);
|
|
1510
1657
|
const before = p["before"];
|
|
1511
1658
|
const after = p["after"];
|
|
1659
|
+
const committed = p["committed"];
|
|
1660
|
+
const eventRecorded = p["event_recorded"];
|
|
1512
1661
|
if (typeof before !== "string" ||
|
|
1513
|
-
!
|
|
1662
|
+
!RESUME_RUN_BEFORE_STATES.includes(before) ||
|
|
1514
1663
|
typeof after !== "string" ||
|
|
1515
|
-
!
|
|
1664
|
+
!RESUME_RUN_AFTER_STATES.includes(after)) {
|
|
1665
|
+
throw new ConductorBridgeApiError("server");
|
|
1666
|
+
}
|
|
1667
|
+
// Exactly two families: an applied resume (`blocked`/`paused`/`quarantined` →
|
|
1668
|
+
// `none`, committed, either audit outcome) or the already-current no-op
|
|
1669
|
+
// (`none` → `none`, nothing committed, no event). Any other combination is
|
|
1670
|
+
// unconfirmed, never coerced into a result.
|
|
1671
|
+
const applied = before !== "none" && committed;
|
|
1672
|
+
const noOp = before === "none" && !committed && !eventRecorded;
|
|
1673
|
+
if (!applied && !noOp) {
|
|
1516
1674
|
throw new ConductorBridgeApiError("server");
|
|
1517
1675
|
}
|
|
1518
1676
|
return {
|
|
1519
1677
|
epic_run_id: p["epic_run_id"],
|
|
1520
1678
|
before,
|
|
1521
1679
|
after,
|
|
1522
|
-
committed
|
|
1523
|
-
event_recorded:
|
|
1680
|
+
committed,
|
|
1681
|
+
event_recorded: eventRecorded,
|
|
1524
1682
|
};
|
|
1525
1683
|
}
|
|
1526
1684
|
/**
|
|
@@ -1533,7 +1691,7 @@ function parseResumeRunResult(parsed) {
|
|
|
1533
1691
|
export async function scopeCatchUp(access, request, fetchImpl = globalThis.fetch) {
|
|
1534
1692
|
requireNonEmptyString(request.epicRunId);
|
|
1535
1693
|
requireNonEmptyString(request.scopeId);
|
|
1536
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, { scope_id: request.scopeId }, fetchImpl);
|
|
1694
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
|
|
1537
1695
|
return parseScopeCatchUpResult(parsed);
|
|
1538
1696
|
}
|
|
1539
1697
|
/**
|
|
@@ -1553,7 +1711,7 @@ export async function patchPolicy(access, request, fetchImpl = globalThis.fetch)
|
|
|
1553
1711
|
Object.keys(patch).length === 0) {
|
|
1554
1712
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1555
1713
|
}
|
|
1556
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, { merge_patch: patch }, fetchImpl);
|
|
1714
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, withGuard({ merge_patch: patch }, request.guard), fetchImpl);
|
|
1557
1715
|
return parsePatchPolicyResult(parsed);
|
|
1558
1716
|
}
|
|
1559
1717
|
/**
|
|
@@ -1566,7 +1724,7 @@ export async function resumeRun(access, request, fetchImpl = globalThis.fetch) {
|
|
|
1566
1724
|
if (typeof request.clearQuarantine !== "boolean") {
|
|
1567
1725
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1568
1726
|
}
|
|
1569
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, { clear_quarantine: request.clearQuarantine }, fetchImpl);
|
|
1727
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, withGuard({ clear_quarantine: request.clearQuarantine }, request.guard), fetchImpl);
|
|
1570
1728
|
return parseResumeRunResult(parsed);
|
|
1571
1729
|
}
|
|
1572
1730
|
/**
|
|
@@ -2333,6 +2491,20 @@ function requireNullableString(o, key) {
|
|
|
2333
2491
|
throw readinessShapeError();
|
|
2334
2492
|
return v;
|
|
2335
2493
|
}
|
|
2494
|
+
/**
|
|
2495
|
+
* BAPI-1279 — the executor coverage fields. Each is omitted when the server did
|
|
2496
|
+
* not send it (an older deploy), and a present non-boolean fails CLOSED to
|
|
2497
|
+
* `false`: coverage that cannot be read is coverage that is not confirmed.
|
|
2498
|
+
*/
|
|
2499
|
+
function parseOptionalExecutorCoverage(o) {
|
|
2500
|
+
const out = {};
|
|
2501
|
+
for (const key of ["process_heartbeat_fresh", "repo_wide_executor_live"]) {
|
|
2502
|
+
if (!(key in o))
|
|
2503
|
+
continue;
|
|
2504
|
+
out[key] = o[key] === true;
|
|
2505
|
+
}
|
|
2506
|
+
return out;
|
|
2507
|
+
}
|
|
2336
2508
|
function requireNullableBool(o, key) {
|
|
2337
2509
|
const v = o[key];
|
|
2338
2510
|
if (v === null || v === undefined)
|
|
@@ -2519,6 +2691,7 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2519
2691
|
last_seen_at: requireNullableString(exec, "last_seen_at"),
|
|
2520
2692
|
last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
|
|
2521
2693
|
ready: requireNullableBool(exec, "ready"),
|
|
2694
|
+
...parseOptionalExecutorCoverage(exec),
|
|
2522
2695
|
},
|
|
2523
2696
|
thresholds: {
|
|
2524
2697
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
@@ -2534,8 +2707,29 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2534
2707
|
const evidence = parseAdmissionEvidence(root);
|
|
2535
2708
|
return evidence === undefined ? {} : { admission_evidence: evidence };
|
|
2536
2709
|
})(),
|
|
2710
|
+
...(() => {
|
|
2711
|
+
const diagnostics = parseReadinessDiagnostics(root);
|
|
2712
|
+
return diagnostics === undefined ? {} : { diagnostics };
|
|
2713
|
+
})(),
|
|
2537
2714
|
};
|
|
2538
2715
|
}
|
|
2716
|
+
function parseReadinessDiagnostics(body) {
|
|
2717
|
+
if (body.diagnostics === undefined || body.diagnostics === null)
|
|
2718
|
+
return undefined;
|
|
2719
|
+
if (!Array.isArray(body.diagnostics) || body.diagnostics.length > 64)
|
|
2720
|
+
throw readinessShapeError();
|
|
2721
|
+
return body.diagnostics.map((entry) => {
|
|
2722
|
+
const item = requireObject(entry);
|
|
2723
|
+
if (typeof item.id !== "string" || !/^[a-z][a-z0-9_.-]{0,79}$/.test(item.id))
|
|
2724
|
+
throw readinessShapeError();
|
|
2725
|
+
const severity = item.severity === "mandatory" || item.severity === "advisory" ? item.severity : null;
|
|
2726
|
+
const applicability = item.applicability === "applicable" || item.applicability === "inapplicable"
|
|
2727
|
+
? item.applicability : null;
|
|
2728
|
+
const verdict = item.verdict === "passed" || item.verdict === "failed" || item.verdict === "unknown"
|
|
2729
|
+
? item.verdict : "unknown";
|
|
2730
|
+
return { id: item.id, severity, applicability, verdict };
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2539
2733
|
const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
|
|
2540
2734
|
const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
|
|
2541
2735
|
/**
|
|
@@ -2704,8 +2898,9 @@ function parseConductorCiWorkflow(body) {
|
|
|
2704
2898
|
*
|
|
2705
2899
|
* `notify_webhook_default_verified` is the one tri-state: absent is coerced to
|
|
2706
2900
|
* `null` rather than to `false`, because "not verified here" and "verified as
|
|
2707
|
-
* broken" are different statements and only one of them is evidence.
|
|
2708
|
-
*
|
|
2901
|
+
* broken" are different statements and only one of them is evidence.
|
|
2902
|
+
* `escalation_email_available` (BAPI-1274) is lenient: anything but `true` is
|
|
2903
|
+
* `false`. Every other field is a strict boolean and a wrong type throws.
|
|
2709
2904
|
*/
|
|
2710
2905
|
function parseUnattended(body) {
|
|
2711
2906
|
const raw = body.unattended;
|
|
@@ -2719,6 +2914,10 @@ function parseUnattended(body) {
|
|
|
2719
2914
|
? null
|
|
2720
2915
|
: requireNullableBool(o, "notify_webhook_default_verified"),
|
|
2721
2916
|
repository_readiness_confirmed: requireBool(o, "repository_readiness_confirmed"),
|
|
2917
|
+
// BAPI-1274 — optional and lenient: absent, null, or any non-boolean
|
|
2918
|
+
// normalizes to `false` rather than throwing, because a server older than
|
|
2919
|
+
// BAPI-1274 omits it and must keep today's behavior.
|
|
2920
|
+
escalation_email_available: o.escalation_email_available === true,
|
|
2722
2921
|
};
|
|
2723
2922
|
}
|
|
2724
2923
|
/**
|
|
@@ -2735,6 +2934,15 @@ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fet
|
|
|
2735
2934
|
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
2736
2935
|
return parseConductorReadinessResponse(body);
|
|
2737
2936
|
}
|
|
2937
|
+
const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
|
|
2938
|
+
function optionalRowOutcome(o, key) {
|
|
2939
|
+
const v = o[key];
|
|
2940
|
+
if (v === undefined || v === null)
|
|
2941
|
+
return null;
|
|
2942
|
+
if (typeof v !== "string" || !ROW_OUTCOMES.has(v))
|
|
2943
|
+
throw readinessShapeError();
|
|
2944
|
+
return v;
|
|
2945
|
+
}
|
|
2738
2946
|
/** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
|
|
2739
2947
|
export function parseConductorSupervisorBootstrapResponse(body) {
|
|
2740
2948
|
const o = requireObject(body);
|
|
@@ -2749,6 +2957,8 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2749
2957
|
repo_name: repoName,
|
|
2750
2958
|
setup_written: requireBool(o, "setup_written"),
|
|
2751
2959
|
config_written: requireBool(o, "config_written"),
|
|
2960
|
+
setup_outcome: optionalRowOutcome(o, "setup_outcome"),
|
|
2961
|
+
config_outcome: optionalRowOutcome(o, "config_outcome"),
|
|
2752
2962
|
setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
|
|
2753
2963
|
config_source: requireEnum(o, "config_source", READINESS_SOURCES),
|
|
2754
2964
|
setup_created_at: requireNullableString(o, "setup_created_at"),
|
|
@@ -2762,14 +2972,337 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2762
2972
|
/**
|
|
2763
2973
|
* POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
|
|
2764
2974
|
*
|
|
2765
|
-
*
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2975
|
+
* Since BAPI-694 it may also carry the optional repository-default
|
|
2976
|
+
* `review_policy`; omitting that field keeps the stored default. Since BAPI-1288
|
|
2977
|
+
* a relaxation is refused with 409 unless `approval` carries the exact token a
|
|
2978
|
+
* {@link previewConductorSupervisorBootstrap} of the SAME request returned.
|
|
2979
|
+
* A 409 is never retried here — see {@link classifySupervisorBootstrapFailure}.
|
|
2768
2980
|
*/
|
|
2769
2981
|
export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
|
|
2770
2982
|
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
|
|
2771
2983
|
repo_name: access.repoName,
|
|
2772
2984
|
});
|
|
2773
|
-
const body = await
|
|
2985
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
2774
2986
|
return parseConductorSupervisorBootstrapResponse(body);
|
|
2775
2987
|
}
|
|
2988
|
+
// ---------------------------------------------------------------------------
|
|
2989
|
+
// BAPI-1288 — preview of a protected project-default change
|
|
2990
|
+
// ---------------------------------------------------------------------------
|
|
2991
|
+
/** Closed vocabularies of a server-authored gate-change effect. */
|
|
2992
|
+
const GATE_CHANGE_REQUIREMENTS = new Set([
|
|
2993
|
+
"done_gate",
|
|
2994
|
+
"required_ci_checks",
|
|
2995
|
+
"done_gate_review",
|
|
2996
|
+
"review_policy_default",
|
|
2997
|
+
]);
|
|
2998
|
+
const GATE_CHANGE_KINDS = new Set(["removed", "weakened", "replaced"]);
|
|
2999
|
+
const GATE_OBLIGATIONS = new Set([
|
|
3000
|
+
"ci_check_green",
|
|
3001
|
+
"sticky_verdict_approved",
|
|
3002
|
+
"native_review_approved",
|
|
3003
|
+
"approval_minimum",
|
|
3004
|
+
"verdictless_review_parks",
|
|
3005
|
+
]);
|
|
3006
|
+
const CHECK_REF_RE = /^ci-[0-9a-f]{12}$/;
|
|
3007
|
+
/** Bounds the client enforces on server text before it may be displayed. */
|
|
3008
|
+
const MAX_EFFECTS = 16;
|
|
3009
|
+
const MAX_DESCRIPTION_LENGTH = 300;
|
|
3010
|
+
const MAX_CHECK_REFS = 50;
|
|
3011
|
+
const MAX_APPROVAL_TOKEN_LENGTH = 1024;
|
|
3012
|
+
function requireObligationList(o, key) {
|
|
3013
|
+
const v = o[key];
|
|
3014
|
+
if (!Array.isArray(v) || v.length > GATE_OBLIGATIONS.size)
|
|
3015
|
+
throw readinessShapeError();
|
|
3016
|
+
for (const item of v) {
|
|
3017
|
+
if (typeof item !== "string" || !GATE_OBLIGATIONS.has(item))
|
|
3018
|
+
throw readinessShapeError();
|
|
3019
|
+
}
|
|
3020
|
+
return v;
|
|
3021
|
+
}
|
|
3022
|
+
function parseGateChangeEffect(raw) {
|
|
3023
|
+
const o = requireObject(raw);
|
|
3024
|
+
const refs = o.removed_ci_check_refs;
|
|
3025
|
+
if (!Array.isArray(refs) ||
|
|
3026
|
+
refs.length > MAX_CHECK_REFS ||
|
|
3027
|
+
refs.some((r) => typeof r !== "string" || !CHECK_REF_RE.test(r))) {
|
|
3028
|
+
throw readinessShapeError();
|
|
3029
|
+
}
|
|
3030
|
+
const description = o.description;
|
|
3031
|
+
if (typeof description !== "string" ||
|
|
3032
|
+
description.length === 0 ||
|
|
3033
|
+
description.length > MAX_DESCRIPTION_LENGTH ||
|
|
3034
|
+
/[\u0000-\u001F\u007F]/.test(description)) {
|
|
3035
|
+
throw readinessShapeError();
|
|
3036
|
+
}
|
|
3037
|
+
const count = requireInt(o, "removed_ci_check_count");
|
|
3038
|
+
if (count < 0)
|
|
3039
|
+
throw readinessShapeError();
|
|
3040
|
+
return {
|
|
3041
|
+
row: requireEnum(o, "row", new Set(["setup", "config"])),
|
|
3042
|
+
requirement: requireEnum(o, "requirement", GATE_CHANGE_REQUIREMENTS),
|
|
3043
|
+
change: requireEnum(o, "change", GATE_CHANGE_KINDS),
|
|
3044
|
+
removed_obligations: requireObligationList(o, "removed_obligations"),
|
|
3045
|
+
replacement_obligations: requireObligationList(o, "replacement_obligations"),
|
|
3046
|
+
removed_ci_check_count: count,
|
|
3047
|
+
removed_ci_check_refs: refs,
|
|
3048
|
+
stored_min_approvals: requireNullableInt(o, "stored_min_approvals"),
|
|
3049
|
+
proposed_min_approvals: requireNullableInt(o, "proposed_min_approvals"),
|
|
3050
|
+
description,
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Validate a raw preview body, fail-closed.
|
|
3055
|
+
*
|
|
3056
|
+
* Every field is checked against the server's closed contract; anything else —
|
|
3057
|
+
* an unknown outcome, an effect category this client does not know, an
|
|
3058
|
+
* over-long or control-character description, a token without
|
|
3059
|
+
* `approval_required`, a writer or repository that does not match the request —
|
|
3060
|
+
* is a shape error, so an unbounded server string can never reach the operator's
|
|
3061
|
+
* terminal as if it were a vetted description.
|
|
3062
|
+
*/
|
|
3063
|
+
export function parseConductorSupervisorBootstrapPreview(body, expectedRepoName) {
|
|
3064
|
+
const o = requireObject(body);
|
|
3065
|
+
if (o.repo_name !== expectedRepoName)
|
|
3066
|
+
throw readinessShapeError();
|
|
3067
|
+
if (o.writer !== "bootstrap")
|
|
3068
|
+
throw readinessShapeError();
|
|
3069
|
+
const names = o.changed_field_names;
|
|
3070
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || n.length > 100)) {
|
|
3071
|
+
throw readinessShapeError();
|
|
3072
|
+
}
|
|
3073
|
+
const effectsRaw = o.effects;
|
|
3074
|
+
if (!Array.isArray(effectsRaw) || effectsRaw.length > MAX_EFFECTS)
|
|
3075
|
+
throw readinessShapeError();
|
|
3076
|
+
const effects = effectsRaw.map(parseGateChangeEffect);
|
|
3077
|
+
const approvalRequired = requireBool(o, "approval_required");
|
|
3078
|
+
const token = requireNullableString(o, "approval_token");
|
|
3079
|
+
if (approvalRequired !== effects.length > 0)
|
|
3080
|
+
throw readinessShapeError();
|
|
3081
|
+
if (approvalRequired !== (token !== null))
|
|
3082
|
+
throw readinessShapeError();
|
|
3083
|
+
if (token !== null && (token.length === 0 || token.length > MAX_APPROVAL_TOKEN_LENGTH)) {
|
|
3084
|
+
throw readinessShapeError();
|
|
3085
|
+
}
|
|
3086
|
+
return {
|
|
3087
|
+
repo_name: expectedRepoName,
|
|
3088
|
+
writer: "bootstrap",
|
|
3089
|
+
setup_outcome: requireEnum(o, "setup_outcome", ROW_OUTCOMES),
|
|
3090
|
+
config_outcome: requireEnum(o, "config_outcome", ROW_OUTCOMES),
|
|
3091
|
+
changed_field_names: names,
|
|
3092
|
+
approval_required: approvalRequired,
|
|
3093
|
+
effects,
|
|
3094
|
+
approval_token: token,
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* POST `/jira/epic-runs/supervisor-bootstrap/preview?repo_name=<repo>` — what a
|
|
3099
|
+
* bootstrap with `request` WOULD do. Read-only on the server.
|
|
3100
|
+
*
|
|
3101
|
+
* Send the same body to {@link bootstrapConductorSupervisorDefaults} afterwards
|
|
3102
|
+
* (plus `approval` when the preview required it); any difference makes the
|
|
3103
|
+
* server's token check fail with a fresh-preview conflict.
|
|
3104
|
+
*/
|
|
3105
|
+
export async function previewConductorSupervisorBootstrap(access, request, fetchImpl) {
|
|
3106
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap/preview`, { repo_name: access.repoName });
|
|
3107
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
3108
|
+
return parseConductorSupervisorBootstrapPreview(body, access.repoName);
|
|
3109
|
+
}
|
|
3110
|
+
const GATE_CHANGE_CODE_TO_KIND = Object.freeze({
|
|
3111
|
+
APPROVAL_REQUIRED: "approval-required",
|
|
3112
|
+
STALE_PREVIEW: "stale-preview",
|
|
3113
|
+
UNKNOWN_STORED_STATE: "unknown-stored-state",
|
|
3114
|
+
NON_RELAXABLE: "non-relaxable",
|
|
3115
|
+
INVALID_PROPOSAL: "invalid-proposal",
|
|
3116
|
+
STATE_UNAVAILABLE: "state-unavailable",
|
|
3117
|
+
});
|
|
3118
|
+
export function classifySupervisorBootstrapFailure(err) {
|
|
3119
|
+
if (!(err instanceof ConductorBridgeApiError))
|
|
3120
|
+
return "network";
|
|
3121
|
+
const mapped = err.errorCode ? GATE_CHANGE_CODE_TO_KIND[err.errorCode] : undefined;
|
|
3122
|
+
if (mapped)
|
|
3123
|
+
return mapped;
|
|
3124
|
+
if (err.kind === "invalid-input")
|
|
3125
|
+
return "unsupported-protocol";
|
|
3126
|
+
if (err.kind === "unauthorized")
|
|
3127
|
+
return "unauthorized";
|
|
3128
|
+
if (err.kind === "timeout")
|
|
3129
|
+
return "timeout";
|
|
3130
|
+
if (err.kind === "network")
|
|
3131
|
+
return "network";
|
|
3132
|
+
if (err.status === 404 || err.status === 405 || err.status === 422) {
|
|
3133
|
+
return "unsupported-protocol";
|
|
3134
|
+
}
|
|
3135
|
+
if (err.kind === "server")
|
|
3136
|
+
return "server";
|
|
3137
|
+
return "rejected";
|
|
3138
|
+
}
|
|
3139
|
+
const CI_STOP_REASONS = new Set(["wait_expired", "verdict_unavailable", "repair_exhausted"]);
|
|
3140
|
+
const RESUME_CI_AUDIT_WARNING_MAX = 200;
|
|
3141
|
+
function isPlainRecord(value) {
|
|
3142
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3143
|
+
}
|
|
3144
|
+
function isNonNegativeInt(value) {
|
|
3145
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3146
|
+
}
|
|
3147
|
+
function nullableNonNegativeInt(value) {
|
|
3148
|
+
return value === null || value === undefined || isNonNegativeInt(value);
|
|
3149
|
+
}
|
|
3150
|
+
function nullableString(value) {
|
|
3151
|
+
return value === null || value === undefined || typeof value === "string";
|
|
3152
|
+
}
|
|
3153
|
+
function parseCiResumeOperation(value) {
|
|
3154
|
+
if (!isPlainRecord(value))
|
|
3155
|
+
throw new ConductorBridgeApiError("server");
|
|
3156
|
+
const v = value;
|
|
3157
|
+
if (typeof v.resumed_at !== "string" ||
|
|
3158
|
+
!CI_STOP_REASONS.has(v.stop_reason) ||
|
|
3159
|
+
!isNonNegativeInt(v.park_generation) ||
|
|
3160
|
+
!isNonNegativeInt(v.resulting_park_generation) ||
|
|
3161
|
+
v.resulting_park_generation !== v.park_generation + 1 ||
|
|
3162
|
+
v.resumed_gate !== "ci" ||
|
|
3163
|
+
v.ci_gate_status !== "in_progress" ||
|
|
3164
|
+
v.allowance_effect !== "repair_allowances_preserved" ||
|
|
3165
|
+
!isNonNegativeInt(v.preserved_attempts) ||
|
|
3166
|
+
!nullableNonNegativeInt(v.preserved_ci_infra_rerun_attempts_used) ||
|
|
3167
|
+
!nullableNonNegativeInt(v.preserved_entry_attempts) ||
|
|
3168
|
+
!nullableString(v.deadline_at) ||
|
|
3169
|
+
!(v.deadline_source === null || v.deadline_source === undefined ||
|
|
3170
|
+
v.deadline_source === "original_gate" || v.deadline_source === "post_repair") ||
|
|
3171
|
+
// The three fixed consequences are part of the contract, not decoration: a
|
|
3172
|
+
// response claiming an extended deadline, a created job, or a CI pass is not
|
|
3173
|
+
// a resume-ci success and is refused as malformed.
|
|
3174
|
+
v.deadline_extended !== false ||
|
|
3175
|
+
v.jobs_created !== 0 ||
|
|
3176
|
+
v.ci_passed_by_action !== false) {
|
|
3177
|
+
throw new ConductorBridgeApiError("server");
|
|
3178
|
+
}
|
|
3179
|
+
return {
|
|
3180
|
+
resumed_at: v.resumed_at,
|
|
3181
|
+
stop_reason: v.stop_reason,
|
|
3182
|
+
park_generation: v.park_generation,
|
|
3183
|
+
resulting_park_generation: v.resulting_park_generation,
|
|
3184
|
+
resumed_gate: "ci",
|
|
3185
|
+
ci_gate_status: "in_progress",
|
|
3186
|
+
allowance_effect: "repair_allowances_preserved",
|
|
3187
|
+
preserved_attempts: v.preserved_attempts,
|
|
3188
|
+
preserved_ci_infra_rerun_attempts_used: (v.preserved_ci_infra_rerun_attempts_used ?? null),
|
|
3189
|
+
preserved_entry_attempts: (v.preserved_entry_attempts ?? null),
|
|
3190
|
+
deadline_at: (v.deadline_at ?? null),
|
|
3191
|
+
deadline_source: (v.deadline_source ?? null),
|
|
3192
|
+
deadline_extended: false,
|
|
3193
|
+
jobs_created: 0,
|
|
3194
|
+
ci_passed_by_action: false,
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
function parseCiResumeCurrentState(value) {
|
|
3198
|
+
if (!isPlainRecord(value))
|
|
3199
|
+
throw new ConductorBridgeApiError("server");
|
|
3200
|
+
const v = value;
|
|
3201
|
+
if (!nullableString(v.ticket_status) ||
|
|
3202
|
+
!nullableString(v.current_gate) ||
|
|
3203
|
+
!nullableString(v.ci_gate_status) ||
|
|
3204
|
+
typeof v.held_by_run_control !== "boolean") {
|
|
3205
|
+
throw new ConductorBridgeApiError("server");
|
|
3206
|
+
}
|
|
3207
|
+
return {
|
|
3208
|
+
ticket_status: (v.ticket_status ?? null),
|
|
3209
|
+
current_gate: (v.current_gate ?? null),
|
|
3210
|
+
ci_gate_status: (v.ci_gate_status ?? null),
|
|
3211
|
+
held_by_run_control: v.held_by_run_control,
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* Strictly parse a `resume-ci` success body. Anything short of the full
|
|
3216
|
+
* contract — an unknown outcome, a missing history or current-state block, a
|
|
3217
|
+
* non-boolean audit flag, an over-long warning — throws, and the caller treats
|
|
3218
|
+
* that as an uncertain outcome, never as success.
|
|
3219
|
+
*/
|
|
3220
|
+
export function parseResumeCiResult(value) {
|
|
3221
|
+
if (!isPlainRecord(value))
|
|
3222
|
+
throw new ConductorBridgeApiError("server");
|
|
3223
|
+
const v = value;
|
|
3224
|
+
if (typeof v.epic_run_id !== "string" || !v.epic_run_id ||
|
|
3225
|
+
typeof v.ticket_key !== "string" || !v.ticket_key ||
|
|
3226
|
+
(v.outcome !== "committed" && v.outcome !== "replayed") ||
|
|
3227
|
+
typeof v.audit_recorded !== "boolean" ||
|
|
3228
|
+
!(v.audit_warning === null || v.audit_warning === undefined ||
|
|
3229
|
+
(typeof v.audit_warning === "string" && v.audit_warning.length <= RESUME_CI_AUDIT_WARNING_MAX))) {
|
|
3230
|
+
throw new ConductorBridgeApiError("server");
|
|
3231
|
+
}
|
|
3232
|
+
return {
|
|
3233
|
+
epic_run_id: v.epic_run_id,
|
|
3234
|
+
ticket_key: v.ticket_key,
|
|
3235
|
+
outcome: v.outcome,
|
|
3236
|
+
operation: parseCiResumeOperation(v.operation),
|
|
3237
|
+
current: parseCiResumeCurrentState(v.current),
|
|
3238
|
+
audit_recorded: v.audit_recorded,
|
|
3239
|
+
audit_warning: (v.audit_warning ?? null),
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/resume-ci`.
|
|
3244
|
+
*
|
|
3245
|
+
* Sends the pinned operation key and stop binding only in the authenticated
|
|
3246
|
+
* body. Never throws for an HTTP or transport outcome: every response is
|
|
3247
|
+
* classified into a {@link ResumeCiPostOutcome}, so the caller — not this
|
|
3248
|
+
* function — decides whether an uncertain outcome is retried with the SAME
|
|
3249
|
+
* operation.
|
|
3250
|
+
*/
|
|
3251
|
+
export async function resumeCiTicket(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
3252
|
+
requireNonEmptyString(request.epicRunId);
|
|
3253
|
+
requireNonEmptyString(request.ticketKey);
|
|
3254
|
+
requireNonEmptyString(request.operationKey);
|
|
3255
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
3256
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(request.epicRunId, request.ticketKey)}/resume-ci`);
|
|
3257
|
+
const body = JSON.stringify({
|
|
3258
|
+
repo_name: access.repoName,
|
|
3259
|
+
expected_row_version: request.expectedRowVersion,
|
|
3260
|
+
operation_key: request.operationKey,
|
|
3261
|
+
stop_binding: request.stopBinding,
|
|
3262
|
+
});
|
|
3263
|
+
const controller = new AbortController();
|
|
3264
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3265
|
+
try {
|
|
3266
|
+
let resp;
|
|
3267
|
+
try {
|
|
3268
|
+
resp = await fetchImpl(url, {
|
|
3269
|
+
method: "POST",
|
|
3270
|
+
headers: conductorPostHeaders(access),
|
|
3271
|
+
body,
|
|
3272
|
+
signal: controller.signal,
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
catch {
|
|
3276
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
3277
|
+
}
|
|
3278
|
+
let parsed;
|
|
3279
|
+
let parsedOk = true;
|
|
3280
|
+
try {
|
|
3281
|
+
parsed = await resp.json();
|
|
3282
|
+
}
|
|
3283
|
+
catch {
|
|
3284
|
+
parsedOk = false;
|
|
3285
|
+
}
|
|
3286
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
3287
|
+
if (!parsedOk)
|
|
3288
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
3289
|
+
try {
|
|
3290
|
+
return { kind: "result", result: parseResumeCiResult(parsed) };
|
|
3291
|
+
}
|
|
3292
|
+
catch {
|
|
3293
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
const errorCode = parsedOk ? safeDetailErrorCode(parsed) : null;
|
|
3297
|
+
if (errorCode !== null && isCiResumeRefusalCode(errorCode)) {
|
|
3298
|
+
return { kind: "refused", status: resp.status, errorCode };
|
|
3299
|
+
}
|
|
3300
|
+
if (resp.status >= 500) {
|
|
3301
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
3302
|
+
}
|
|
3303
|
+
return { kind: "rejected", status: resp.status, errorCode };
|
|
3304
|
+
}
|
|
3305
|
+
finally {
|
|
3306
|
+
clearTimeout(timer);
|
|
3307
|
+
}
|
|
3308
|
+
}
|