@bridge_gpt/mcp-server 0.2.69 → 0.2.70
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 +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { READINESS_PREREQUISITE_BY_ID } from "../readiness-prerequisites.generated.js";
|
|
2
|
+
import { READINESS_PREREQUISITE_FIXES } from "../printed-remedy-commands.js";
|
|
1
3
|
/**
|
|
2
4
|
* TypeScript-side Bridge API access layer for the conductor producer (BAPI-395).
|
|
3
5
|
*
|
|
@@ -193,6 +195,31 @@ const CONDUCTOR_BRIDGE_API_ERROR_KINDS = [
|
|
|
193
195
|
];
|
|
194
196
|
/** Max length of the sanitized body preview embedded in error diagnostics. */
|
|
195
197
|
const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
|
|
198
|
+
/** BAPI-1455 — the stable readiness refusal code for a proven-missing local schema object. */
|
|
199
|
+
export const SCHEMA_INCOMPATIBLE_ERROR_CODE = "SCHEMA_INCOMPATIBLE";
|
|
200
|
+
/** BAPI-1455 — the bound for that refusal's server-authored explanation. */
|
|
201
|
+
const SCHEMA_REFUSAL_PREVIEW_MAX = 2000;
|
|
202
|
+
/** Whether a readiness read failed because the local schema is proven incompatible. */
|
|
203
|
+
export function isSchemaIncompatibleError(err) {
|
|
204
|
+
return err instanceof ConductorBridgeApiError && err.errorCode === SCHEMA_INCOMPATIBLE_ERROR_CODE;
|
|
205
|
+
}
|
|
206
|
+
/** BAPI-1455 — the fixed repair route. Never `alembic stamp`, never a downgrade. */
|
|
207
|
+
export const SCHEMA_INCOMPATIBLE_REPAIR_ROUTE = "Write a reviewed, forward-only Alembic migration that restores the missing objects, " +
|
|
208
|
+
"or rebuild the local database only with the owner's explicit consent.";
|
|
209
|
+
/**
|
|
210
|
+
* The operator lines for a `SCHEMA_INCOMPATIBLE` readiness refusal. The server's
|
|
211
|
+
* explanation (applied stamp and missing objects, from its maintained list) is
|
|
212
|
+
* shown when present; the heading and the repair route are fixed text.
|
|
213
|
+
*/
|
|
214
|
+
export function formatSchemaIncompatibleRefusal(subject, err) {
|
|
215
|
+
return [
|
|
216
|
+
`${subject}: REFUSED (schema-incompatible) — Local schema incompatible. ` +
|
|
217
|
+
"Launch-critical schema objects are missing, so no run was created.",
|
|
218
|
+
...(err.bodyPreview ? [` ${err.bodyPreview}`] : []),
|
|
219
|
+
` Repair: ${SCHEMA_INCOMPATIBLE_REPAIR_ROUTE}`,
|
|
220
|
+
" A migration stamp is not proof of schema; `alembic stamp` cannot restore missing DDL.",
|
|
221
|
+
];
|
|
222
|
+
}
|
|
196
223
|
/**
|
|
197
224
|
* Redact obvious secret-shaped tokens from a diagnostic preview string so a
|
|
198
225
|
* backend message that happens to echo a token/header never reaches a log line.
|
|
@@ -243,11 +270,9 @@ function stripWebhookUrlsDeep(value, depth = 0) {
|
|
|
243
270
|
return out;
|
|
244
271
|
}
|
|
245
272
|
/** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
|
|
246
|
-
function boundedErrorPreview(text) {
|
|
273
|
+
function boundedErrorPreview(text, max = CONDUCTOR_ERROR_PREVIEW_MAX) {
|
|
247
274
|
const redacted = redactErrorPreview(text).replace(/\s+/g, " ").trim();
|
|
248
|
-
return redacted.length >
|
|
249
|
-
? `${redacted.slice(0, CONDUCTOR_ERROR_PREVIEW_MAX)}…`
|
|
250
|
-
: redacted;
|
|
275
|
+
return redacted.length > max ? `${redacted.slice(0, max)}…` : redacted;
|
|
251
276
|
}
|
|
252
277
|
/** Max number of FastAPI `loc` components rendered into a diagnostic path. */
|
|
253
278
|
const CONDUCTOR_VALIDATION_LOC_MAX_PARTS = 8;
|
|
@@ -369,7 +394,9 @@ async function readSanitizedErrorDiagnostics(resp, headers = {}, options = {}) {
|
|
|
369
394
|
try {
|
|
370
395
|
const parsed = stripWebhookUrlsDeep(await resp.json());
|
|
371
396
|
const diagnostics = extractSanitizedErrorDiagnostics(parsed);
|
|
372
|
-
|
|
397
|
+
const codeAllowed = options.readErrorFieldOnlyFor === undefined ||
|
|
398
|
+
(diagnostics.errorCode !== undefined && options.readErrorFieldOnlyFor.includes(diagnostics.errorCode));
|
|
399
|
+
if (options.readErrorField && codeAllowed && !diagnostics.bodyPreview) {
|
|
373
400
|
// BAPI-1154: opt-in only. The route layer's `_http_error` envelope carries
|
|
374
401
|
// its server-authored explanation in `detail.error`, which the shared
|
|
375
402
|
// extractor deliberately does not read (pinned by its own tests). The
|
|
@@ -382,7 +409,7 @@ async function readSanitizedErrorDiagnostics(resp, headers = {}, options = {}) {
|
|
|
382
409
|
? detail["error"]
|
|
383
410
|
: undefined;
|
|
384
411
|
if (typeof error === "string" && error.trim()) {
|
|
385
|
-
diagnostics.bodyPreview = boundedErrorPreview(error.trim());
|
|
412
|
+
diagnostics.bodyPreview = boundedErrorPreview(error.trim(), options.errorFieldPreviewMax);
|
|
386
413
|
}
|
|
387
414
|
}
|
|
388
415
|
const secrets = Object.entries(headers)
|
|
@@ -488,7 +515,7 @@ export function conductorGetHeaders(access) {
|
|
|
488
515
|
* non-2xx response — never including the response body, headers, or API key. The
|
|
489
516
|
* timer is always cleared.
|
|
490
517
|
*/
|
|
491
|
-
export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = globalThis.fetch) {
|
|
518
|
+
export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = globalThis.fetch, errorOptions = {}) {
|
|
492
519
|
const controller = new AbortController();
|
|
493
520
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
494
521
|
try {
|
|
@@ -501,7 +528,7 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
|
|
|
501
528
|
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
502
529
|
}
|
|
503
530
|
if (!resp.ok) {
|
|
504
|
-
const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
|
|
531
|
+
const diagnostics = await readSanitizedErrorDiagnostics(resp, headers, errorOptions);
|
|
505
532
|
if (resp.status === 401 || resp.status === 403) {
|
|
506
533
|
throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
|
|
507
534
|
}
|
|
@@ -1037,6 +1064,41 @@ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch)
|
|
|
1037
1064
|
}
|
|
1038
1065
|
return [];
|
|
1039
1066
|
}
|
|
1067
|
+
/** Every status after which a run can still do work. */
|
|
1068
|
+
const NON_TERMINAL_EPIC_RUN_STATUSES = ["planning", "pending_approval", "active", "blocked"];
|
|
1069
|
+
const NON_TERMINAL_EPIC_RUN_PAGE_LIMIT = 100;
|
|
1070
|
+
/**
|
|
1071
|
+
* The ids of every non-terminal epic run the server holds for the access's
|
|
1072
|
+
* repository (BAPI-1496 plane cleanup). `null` unless the answer is CONCLUSIVE:
|
|
1073
|
+
* any failed read, malformed body, or full page (which may hide more runs)
|
|
1074
|
+
* returns `null`, never a shorter list. Never throws.
|
|
1075
|
+
*/
|
|
1076
|
+
export async function listNonTerminalEpicRunIds(access, fetchImpl = globalThis.fetch) {
|
|
1077
|
+
const ids = [];
|
|
1078
|
+
try {
|
|
1079
|
+
for (const status of NON_TERMINAL_EPIC_RUN_STATUSES) {
|
|
1080
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`, {
|
|
1081
|
+
repo_name: access.repoName,
|
|
1082
|
+
status,
|
|
1083
|
+
limit: String(NON_TERMINAL_EPIC_RUN_PAGE_LIMIT),
|
|
1084
|
+
});
|
|
1085
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1086
|
+
const runs = parsed && typeof parsed === "object" ? parsed.runs : undefined;
|
|
1087
|
+
if (!Array.isArray(runs) || runs.length >= NON_TERMINAL_EPIC_RUN_PAGE_LIMIT)
|
|
1088
|
+
return null;
|
|
1089
|
+
for (const run of runs) {
|
|
1090
|
+
const id = run && typeof run === "object" ? run.epic_run_id : undefined;
|
|
1091
|
+
if (typeof id !== "string" || id.length === 0)
|
|
1092
|
+
return null;
|
|
1093
|
+
ids.push(id);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
return ids;
|
|
1101
|
+
}
|
|
1040
1102
|
/**
|
|
1041
1103
|
* POST `/jira/epic-runs/runs` to create the durable epic run row.
|
|
1042
1104
|
*
|
|
@@ -1327,6 +1389,7 @@ export async function postContinueParkedChild(access, request, fetchImpl = globa
|
|
|
1327
1389
|
request_fingerprint: request.requestFingerprint,
|
|
1328
1390
|
...(request.reason ? { reason: request.reason } : {}),
|
|
1329
1391
|
...(request.workerExitProof ? { worker_exit_proof: request.workerExitProof } : {}),
|
|
1392
|
+
...(request.driverContext !== undefined ? { driver_context: request.driverContext } : {}),
|
|
1330
1393
|
});
|
|
1331
1394
|
const controller = new AbortController();
|
|
1332
1395
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -1416,9 +1479,15 @@ export async function postGrantContinuation(access, request, fetchImpl = globalT
|
|
|
1416
1479
|
typeof body.replayed === "boolean" &&
|
|
1417
1480
|
grant && typeof grant === "object" &&
|
|
1418
1481
|
grant.request_id === request.requestId) {
|
|
1482
|
+
const fields = body;
|
|
1483
|
+
const replayed = fields.replayed;
|
|
1419
1484
|
return {
|
|
1420
1485
|
kind: "result", requestId: request.requestId,
|
|
1421
|
-
replayed
|
|
1486
|
+
replayed,
|
|
1487
|
+
committed: typeof fields.committed === "boolean" ? fields.committed : !replayed,
|
|
1488
|
+
sourceParkGeneration: typeof fields.source_park_generation === "number"
|
|
1489
|
+
? fields.source_park_generation : null,
|
|
1490
|
+
gate: typeof fields.gate === "string" ? fields.gate : null,
|
|
1422
1491
|
};
|
|
1423
1492
|
}
|
|
1424
1493
|
if (response.status >= 500 || response.ok) {
|
|
@@ -1584,6 +1653,75 @@ export async function dispatchRemediation(access, request, fetchImpl = globalThi
|
|
|
1584
1653
|
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, withGuard({ context: request.context }, request.guard), fetchImpl);
|
|
1585
1654
|
return parseDispatchRemediationResult(parsed);
|
|
1586
1655
|
}
|
|
1656
|
+
function parseHandoffResolutionRecord(value) {
|
|
1657
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1658
|
+
throw new ConductorBridgeApiError("server");
|
|
1659
|
+
}
|
|
1660
|
+
const r = value;
|
|
1661
|
+
const steps = r["steps"];
|
|
1662
|
+
if (!Array.isArray(steps) ||
|
|
1663
|
+
!steps.every((step) => typeof step === "number" && Number.isSafeInteger(step) && step > 0) ||
|
|
1664
|
+
typeof r["binding_attempt_id"] !== "string" ||
|
|
1665
|
+
typeof r["evidence_revision"] !== "number" ||
|
|
1666
|
+
typeof r["epic_head_sha"] !== "string" ||
|
|
1667
|
+
typeof r["evidence_ref"] !== "string" ||
|
|
1668
|
+
typeof r["actor"] !== "string" ||
|
|
1669
|
+
(r["kind"] !== "verified" && r["kind"] !== "risk_accepted") ||
|
|
1670
|
+
typeof r["reason"] !== "string" ||
|
|
1671
|
+
typeof r["at"] !== "string") {
|
|
1672
|
+
throw new ConductorBridgeApiError("server");
|
|
1673
|
+
}
|
|
1674
|
+
return {
|
|
1675
|
+
steps: steps,
|
|
1676
|
+
binding_attempt_id: r["binding_attempt_id"],
|
|
1677
|
+
evidence_revision: r["evidence_revision"],
|
|
1678
|
+
epic_head_sha: r["epic_head_sha"],
|
|
1679
|
+
evidence_ref: r["evidence_ref"],
|
|
1680
|
+
actor: r["actor"],
|
|
1681
|
+
kind: r["kind"],
|
|
1682
|
+
reason: r["reason"],
|
|
1683
|
+
at: r["at"],
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
function parseResolveHandoffResult(parsed) {
|
|
1687
|
+
const p = parseRepairBase(parsed);
|
|
1688
|
+
const remaining = p["remaining_unresolved"];
|
|
1689
|
+
if (typeof p["ticket_key"] !== "string" ||
|
|
1690
|
+
!Array.isArray(p["resolutions"]) ||
|
|
1691
|
+
typeof remaining !== "number" ||
|
|
1692
|
+
!Number.isSafeInteger(remaining) ||
|
|
1693
|
+
remaining < 0) {
|
|
1694
|
+
throw new ConductorBridgeApiError("server");
|
|
1695
|
+
}
|
|
1696
|
+
return {
|
|
1697
|
+
epic_run_id: p["epic_run_id"],
|
|
1698
|
+
ticket_key: p["ticket_key"],
|
|
1699
|
+
committed: p["committed"],
|
|
1700
|
+
event_recorded: p["event_recorded"],
|
|
1701
|
+
resolutions: p["resolutions"].map(parseHandoffResolutionRecord),
|
|
1702
|
+
remaining_unresolved: remaining,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/resolve-handoff`
|
|
1707
|
+
* (BAPI-1494) — body `{repo_name, steps, kind, evidence_ref, reason}` and nothing
|
|
1708
|
+
* else. There is no actor field: the SERVER records the verified request actor.
|
|
1709
|
+
*/
|
|
1710
|
+
export async function resolveHandoff(access, request, fetchImpl = globalThis.fetch) {
|
|
1711
|
+
requireNonEmptyString(request.epicRunId);
|
|
1712
|
+
requireNonEmptyString(request.ticketKey);
|
|
1713
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/resolve-handoff`, {
|
|
1714
|
+
steps: request.steps,
|
|
1715
|
+
kind: request.kind,
|
|
1716
|
+
evidence_ref: request.evidenceRef,
|
|
1717
|
+
reason: request.reason,
|
|
1718
|
+
}, fetchImpl);
|
|
1719
|
+
return parseResolveHandoffResult(parsed);
|
|
1720
|
+
}
|
|
1721
|
+
/** The synthetic code an old server's missing escalate route is reported under. */
|
|
1722
|
+
export const ESCALATE_ROUTE_UNAVAILABLE_CODE = "ESCALATE_ROUTE_UNAVAILABLE";
|
|
1723
|
+
/** The fixed operator text for a server that has no `escalate` route. */
|
|
1724
|
+
export const ESCALATE_ROUTE_UNAVAILABLE_MESSAGE = "escalate is not available on this server; the Bridge API server needs an upgrade before using it";
|
|
1587
1725
|
export async function escalateTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
1588
1726
|
requireNonEmptyString(request.epicRunId);
|
|
1589
1727
|
requireNonEmptyString(request.ticketKey);
|
|
@@ -1591,7 +1729,22 @@ export async function escalateTicket(access, request, fetchImpl = globalThis.fet
|
|
|
1591
1729
|
requireNonEmptyString(request.actionNeeded);
|
|
1592
1730
|
if (request.actionNeeded.length > 300)
|
|
1593
1731
|
throw new ConductorBridgeApiError("server");
|
|
1594
|
-
|
|
1732
|
+
let result;
|
|
1733
|
+
try {
|
|
1734
|
+
result = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/escalate`, { cause: request.cause, action_needed: request.actionNeeded }, fetchImpl);
|
|
1735
|
+
}
|
|
1736
|
+
catch (err) {
|
|
1737
|
+
// An untyped 404/405 means the server predates the route; a server-coded
|
|
1738
|
+
// refusal keeps its own code and reason.
|
|
1739
|
+
if (err instanceof ConductorBridgeApiError && !err.errorCode &&
|
|
1740
|
+
(err.status === 404 || err.status === 405)) {
|
|
1741
|
+
throw new ConductorBridgeApiError("http", err.status, {
|
|
1742
|
+
errorCode: ESCALATE_ROUTE_UNAVAILABLE_CODE,
|
|
1743
|
+
bodyPreview: ESCALATE_ROUTE_UNAVAILABLE_MESSAGE,
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
throw err;
|
|
1747
|
+
}
|
|
1595
1748
|
const value = result;
|
|
1596
1749
|
if (!value || !["created", "deduplicated", "suppressed"].includes(value.status) ||
|
|
1597
1750
|
!Number.isSafeInteger(value.notification_id) || value.notification_id <= 0 ||
|
|
@@ -2259,6 +2412,149 @@ function safeStringArray(value) {
|
|
|
2259
2412
|
return [];
|
|
2260
2413
|
return value.filter((v) => typeof v === "string" && v !== "");
|
|
2261
2414
|
}
|
|
2415
|
+
// ---------------------------------------------------------------------------
|
|
2416
|
+
// BAPI-1510 — already-merged child detection and skipped-child audit clients
|
|
2417
|
+
// ---------------------------------------------------------------------------
|
|
2418
|
+
export const CHILD_MERGE_CLASSIFICATIONS = ["done", "probably_done", "not_done", "unknown"];
|
|
2419
|
+
export const CHILD_MERGE_EVIDENCE_SOURCES = ["delivery_binding", "feature_branch", "none"];
|
|
2420
|
+
export const CHILD_MERGE_ANCESTRY_VALUES = ["covered", "behind", "unknown", "not_checked"];
|
|
2421
|
+
export const CHILD_MERGE_REASONS = [
|
|
2422
|
+
"merged_on_cut_commit",
|
|
2423
|
+
"merged_without_delivery_record",
|
|
2424
|
+
"merged_after_index",
|
|
2425
|
+
"ancestry_unknown",
|
|
2426
|
+
"no_merged_pr_found",
|
|
2427
|
+
"binding_ambiguous",
|
|
2428
|
+
"provider_unavailable",
|
|
2429
|
+
"binding_read_failed",
|
|
2430
|
+
"merge_sha_unavailable",
|
|
2431
|
+
"ancestry_unavailable_for_provider",
|
|
2432
|
+
"deadline_exceeded",
|
|
2433
|
+
];
|
|
2434
|
+
const FULL_COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
2435
|
+
function isOneOf(allowed, value) {
|
|
2436
|
+
return typeof value === "string" && allowed.includes(value);
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* POST `/jira/epic-runs/child-merge-status`: read-only classification of plan
|
|
2440
|
+
* children against one indexed cut commit. Creates no run, event, or charge.
|
|
2441
|
+
*
|
|
2442
|
+
* The response is validated strictly: a missing, duplicated, or reordered child,
|
|
2443
|
+
* an out-of-vocabulary value, a malformed PR number or SHA, or an echoed cut that
|
|
2444
|
+
* differs from the requested one is a protocol error. A malformed body must never
|
|
2445
|
+
* reach plan trimming.
|
|
2446
|
+
*/
|
|
2447
|
+
export async function classifyChildMergeStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
2448
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/child-merge-status`);
|
|
2449
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify({
|
|
2450
|
+
repo_name: access.repoName,
|
|
2451
|
+
epic_key: request.epicKey,
|
|
2452
|
+
child_keys: [...request.childKeys],
|
|
2453
|
+
cut_commit_sha: request.cutCommitSha,
|
|
2454
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
2455
|
+
return parseChildMergeStatusResponse(parsed, request);
|
|
2456
|
+
}
|
|
2457
|
+
/** Strict parse of the classification body; throws a sanitized error on any deviation. */
|
|
2458
|
+
export function parseChildMergeStatusResponse(parsed, request) {
|
|
2459
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2460
|
+
throw new ConductorBridgeApiError("server");
|
|
2461
|
+
}
|
|
2462
|
+
const p = parsed;
|
|
2463
|
+
const cut = p["cut_commit_sha"];
|
|
2464
|
+
const children = p["children"];
|
|
2465
|
+
if (typeof cut !== "string" || cut !== request.cutCommitSha || !Array.isArray(children)) {
|
|
2466
|
+
throw new ConductorBridgeApiError("server");
|
|
2467
|
+
}
|
|
2468
|
+
if (children.length !== request.childKeys.length) {
|
|
2469
|
+
throw new ConductorBridgeApiError("server");
|
|
2470
|
+
}
|
|
2471
|
+
const seen = new Set();
|
|
2472
|
+
const results = [];
|
|
2473
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
2474
|
+
const raw = children[i];
|
|
2475
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2476
|
+
throw new ConductorBridgeApiError("server");
|
|
2477
|
+
}
|
|
2478
|
+
const c = raw;
|
|
2479
|
+
const key = c["ticket_key"];
|
|
2480
|
+
if (typeof key !== "string" || key !== request.childKeys[i] || seen.has(key)) {
|
|
2481
|
+
throw new ConductorBridgeApiError("server");
|
|
2482
|
+
}
|
|
2483
|
+
seen.add(key);
|
|
2484
|
+
const pr = c["pr_number"];
|
|
2485
|
+
const sha = c["merge_commit_sha"];
|
|
2486
|
+
if (!isOneOf(CHILD_MERGE_CLASSIFICATIONS, c["classification"]) ||
|
|
2487
|
+
!isOneOf(CHILD_MERGE_EVIDENCE_SOURCES, c["evidence_source"]) ||
|
|
2488
|
+
!isOneOf(CHILD_MERGE_ANCESTRY_VALUES, c["ancestry"]) ||
|
|
2489
|
+
!isOneOf(CHILD_MERGE_REASONS, c["reason"]) ||
|
|
2490
|
+
!(pr === null || (typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0)) ||
|
|
2491
|
+
!(sha === null || (typeof sha === "string" && FULL_COMMIT_SHA_PATTERN.test(sha)))) {
|
|
2492
|
+
throw new ConductorBridgeApiError("server");
|
|
2493
|
+
}
|
|
2494
|
+
results.push({
|
|
2495
|
+
ticket_key: key,
|
|
2496
|
+
classification: c["classification"],
|
|
2497
|
+
evidence_source: c["evidence_source"],
|
|
2498
|
+
pr_number: pr,
|
|
2499
|
+
merge_commit_sha: sha,
|
|
2500
|
+
ancestry: c["ancestry"],
|
|
2501
|
+
reason: c["reason"],
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
return { cut_commit_sha: cut, children: results };
|
|
2505
|
+
}
|
|
2506
|
+
export const SKIPPED_CHILD_OUTCOMES = ["recorded", "already_recorded", "refused"];
|
|
2507
|
+
export const SKIPPED_CHILD_REFUSAL_REASONS = [
|
|
2508
|
+
"not_skippable",
|
|
2509
|
+
"child_present_in_plan",
|
|
2510
|
+
"run_context_unavailable",
|
|
2511
|
+
];
|
|
2512
|
+
/**
|
|
2513
|
+
* POST `/jira/epic-runs/{epic_run_id}/skipped-children`: audit-only record of
|
|
2514
|
+
* children a newly created run left out. The body carries repository identity,
|
|
2515
|
+
* child keys, and confirmation booleans only; the server derives every fact.
|
|
2516
|
+
*/
|
|
2517
|
+
export async function recordSkippedChildren(access, epicRunId, children, fetchImpl = globalThis.fetch) {
|
|
2518
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/${encodeURIComponent(epicRunId)}/skipped-children`);
|
|
2519
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify({
|
|
2520
|
+
repo_name: access.repoName,
|
|
2521
|
+
children: children.map((c) => ({
|
|
2522
|
+
ticket_key: c.ticketKey,
|
|
2523
|
+
operator_confirmed: c.operatorConfirmed,
|
|
2524
|
+
})),
|
|
2525
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
2526
|
+
return parseRecordSkippedChildrenResponse(parsed, children.map((c) => c.ticketKey));
|
|
2527
|
+
}
|
|
2528
|
+
/** Strict parse of the recording body; throws a sanitized error on any deviation. */
|
|
2529
|
+
export function parseRecordSkippedChildrenResponse(parsed, expectedKeys) {
|
|
2530
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2531
|
+
throw new ConductorBridgeApiError("server");
|
|
2532
|
+
}
|
|
2533
|
+
const results = parsed["results"];
|
|
2534
|
+
if (!Array.isArray(results) || results.length !== expectedKeys.length) {
|
|
2535
|
+
throw new ConductorBridgeApiError("server");
|
|
2536
|
+
}
|
|
2537
|
+
const out = [];
|
|
2538
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
2539
|
+
const raw = results[i];
|
|
2540
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2541
|
+
throw new ConductorBridgeApiError("server");
|
|
2542
|
+
}
|
|
2543
|
+
const r = raw;
|
|
2544
|
+
const reason = r["reason"];
|
|
2545
|
+
if (r["ticket_key"] !== expectedKeys[i] ||
|
|
2546
|
+
!isOneOf(SKIPPED_CHILD_OUTCOMES, r["outcome"]) ||
|
|
2547
|
+
!(reason === null || reason === undefined || isOneOf(SKIPPED_CHILD_REFUSAL_REASONS, reason))) {
|
|
2548
|
+
throw new ConductorBridgeApiError("server");
|
|
2549
|
+
}
|
|
2550
|
+
out.push({
|
|
2551
|
+
ticket_key: expectedKeys[i],
|
|
2552
|
+
outcome: r["outcome"],
|
|
2553
|
+
reason: (reason ?? null),
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
return { results: out };
|
|
2557
|
+
}
|
|
2262
2558
|
/** Non-negative safe integer, or 0 for anything else (absent field included). */
|
|
2263
2559
|
function safeCount(value) {
|
|
2264
2560
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
@@ -2615,6 +2911,40 @@ export const REQUIRED_CHECKS_VERIFICATIONS = new Set([
|
|
|
2615
2911
|
"unknown_name",
|
|
2616
2912
|
"unverifiable",
|
|
2617
2913
|
]);
|
|
2914
|
+
export function parseReadinessPrerequisites(raw) {
|
|
2915
|
+
if (!Array.isArray(raw))
|
|
2916
|
+
return undefined;
|
|
2917
|
+
const seen = new Set();
|
|
2918
|
+
const result = [];
|
|
2919
|
+
for (const item of raw) {
|
|
2920
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item))
|
|
2921
|
+
return undefined;
|
|
2922
|
+
const r = item;
|
|
2923
|
+
const d = typeof r.id === 'string' ? READINESS_PREREQUISITE_BY_ID.get(r.id) : undefined;
|
|
2924
|
+
if (!d || seen.has(d.id) || Object.keys(r).sort().join(',') !== 'check_status,fix,id,impact,label,scope' || r.label !== d.label || r.scope !== d.scope ||
|
|
2925
|
+
!['met', 'not_met', 'unknown', 'not_applicable'].includes(String(r.check_status)) || !['blocking', 'warning', 'info'].includes(String(r.impact)) ||
|
|
2926
|
+
typeof r.fix !== 'string' || !Object.values(READINESS_PREREQUISITE_FIXES).includes(r.fix) ||
|
|
2927
|
+
(r.check_status === 'unknown' && r.impact === 'blocking'))
|
|
2928
|
+
return undefined;
|
|
2929
|
+
seen.add(d.id);
|
|
2930
|
+
result.push(r);
|
|
2931
|
+
}
|
|
2932
|
+
return result;
|
|
2933
|
+
}
|
|
2934
|
+
export const ESCALATION_CHANNELS = ["webhook", "email", "none", "unknown"];
|
|
2935
|
+
export const WEBHOOK_LAST_DELIVERIES = ["failed", "handed_off", "queued", "unknown"];
|
|
2936
|
+
/** BAPI-1452 — the three supported merge execution modes, as the server reports them. */
|
|
2937
|
+
export const RESOLVED_MERGE_EXECUTION_MODES = ["local", "machine_token", "github_actions"];
|
|
2938
|
+
/**
|
|
2939
|
+
* BAPI-1452 — a provider repository identity: exactly one `/`, each side
|
|
2940
|
+
* starting with an alphanumeric and bounded, no whitespace, control
|
|
2941
|
+
* characters, URL syntax, or path traversal.
|
|
2942
|
+
*/
|
|
2943
|
+
const PROVIDER_REPOSITORY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
2944
|
+
/** True when `value` is a well-formed `owner/name` provider identity (BAPI-1452). */
|
|
2945
|
+
export function isProviderRepositoryIdentity(value) {
|
|
2946
|
+
return typeof value === "string" && PROVIDER_REPOSITORY_PATTERN.test(value);
|
|
2947
|
+
}
|
|
2618
2948
|
/** BAPI-1260 — one mandatory-evidence verdict. Only `confirmed` ever holds. */
|
|
2619
2949
|
export const ADMISSION_EVIDENCE_VERDICTS = [
|
|
2620
2950
|
"confirmed",
|
|
@@ -2892,6 +3222,9 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2892
3222
|
const thr = requireObject(root.thresholds);
|
|
2893
3223
|
const requiredChecksCount = requireInt(sup, "required_checks_count");
|
|
2894
3224
|
return {
|
|
3225
|
+
...(typeof root.is_production === 'boolean' ? { is_production: root.is_production } : {}),
|
|
3226
|
+
...(root.is_production === undefined || typeof root.is_production === 'boolean'
|
|
3227
|
+
? { prerequisites: parseReadinessPrerequisites(root.prerequisites) } : {}),
|
|
2895
3228
|
repo_name: repoName,
|
|
2896
3229
|
supervisor: {
|
|
2897
3230
|
setup_present: requireBool(sup, "setup_present"),
|
|
@@ -2966,8 +3299,51 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2966
3299
|
const diagnostics = parseReadinessDiagnostics(root);
|
|
2967
3300
|
return diagnostics === undefined ? {} : { diagnostics };
|
|
2968
3301
|
})(),
|
|
3302
|
+
...parseLaunchMergeFacts(root),
|
|
3303
|
+
...parseEscalationObservations(root),
|
|
2969
3304
|
};
|
|
2970
3305
|
}
|
|
3306
|
+
/**
|
|
3307
|
+
* BAPI-1495 — the closed escalation observations. Absent stays absent (an older
|
|
3308
|
+
* server). A present value outside the closed set is dropped rather than rejecting
|
|
3309
|
+
* the read: the wording seam reads a dropped field as unknown.
|
|
3310
|
+
*/
|
|
3311
|
+
function parseEscalationObservations(body) {
|
|
3312
|
+
const out = {};
|
|
3313
|
+
if (ESCALATION_CHANNELS.includes(body.escalation_channel)) {
|
|
3314
|
+
out.escalation_channel = body.escalation_channel;
|
|
3315
|
+
}
|
|
3316
|
+
if (body.webhook_last_delivery === null) {
|
|
3317
|
+
out.webhook_last_delivery = null;
|
|
3318
|
+
}
|
|
3319
|
+
else if (WEBHOOK_LAST_DELIVERIES.includes(body.webhook_last_delivery)) {
|
|
3320
|
+
out.webhook_last_delivery = body.webhook_last_delivery;
|
|
3321
|
+
}
|
|
3322
|
+
return out;
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* BAPI-1452 — parse the two additive launch facts.
|
|
3326
|
+
*
|
|
3327
|
+
* Absent keys stay absent (an older server). A PRESENT value that is not a
|
|
3328
|
+
* supported mode, or not a well-formed `owner/name`, becomes `null` rather
|
|
3329
|
+
* than rejecting the whole readiness read: these facts only feed a launch
|
|
3330
|
+
* warning, and losing every other readiness fact over one malformed additive
|
|
3331
|
+
* field would be the wrong trade. `null` is never read as a usable default.
|
|
3332
|
+
*/
|
|
3333
|
+
function parseLaunchMergeFacts(body) {
|
|
3334
|
+
const out = {};
|
|
3335
|
+
if (Object.prototype.hasOwnProperty.call(body, "resolved_merge_execution_mode")) {
|
|
3336
|
+
const mode = body.resolved_merge_execution_mode;
|
|
3337
|
+
out.resolved_merge_execution_mode = RESOLVED_MERGE_EXECUTION_MODES.includes(mode)
|
|
3338
|
+
? mode
|
|
3339
|
+
: null;
|
|
3340
|
+
}
|
|
3341
|
+
if (Object.prototype.hasOwnProperty.call(body, "provider_repository")) {
|
|
3342
|
+
const repository = body.provider_repository;
|
|
3343
|
+
out.provider_repository = isProviderRepositoryIdentity(repository) ? repository : null;
|
|
3344
|
+
}
|
|
3345
|
+
return out;
|
|
3346
|
+
}
|
|
2971
3347
|
function parseReadinessDiagnostics(body) {
|
|
2972
3348
|
if (body.diagnostics === undefined || body.diagnostics === null)
|
|
2973
3349
|
return undefined;
|
|
@@ -3246,6 +3622,7 @@ export function parseEpicBranchCiReadiness(body) {
|
|
|
3246
3622
|
});
|
|
3247
3623
|
return {
|
|
3248
3624
|
effective_review_mode: enumOrNull(o.effective_review_mode, EFFECTIVE_REVIEW_MODES),
|
|
3625
|
+
effective_review_mode_scope: o.effective_review_mode_scope === "run" ? "run" : "repository",
|
|
3249
3626
|
decision,
|
|
3250
3627
|
evidence,
|
|
3251
3628
|
no_ci_warning: requireBool(o, "no_ci_warning"),
|
|
@@ -3380,20 +3757,96 @@ function parseUnattended(body) {
|
|
|
3380
3757
|
escalation_email_available: o.escalation_email_available === true,
|
|
3381
3758
|
};
|
|
3382
3759
|
}
|
|
3383
|
-
|
|
3384
|
-
* GET `/jira/epic-runs/conductor-readiness?repo_name=<repo>`.
|
|
3385
|
-
*
|
|
3386
|
-
* The API key travels ONLY in `X-API-Key`; `repo_name` is URL-encoded into the
|
|
3387
|
-
* query string because the endpoint scopes on it. Fails closed on transport,
|
|
3388
|
-
* auth, and shape errors alike.
|
|
3389
|
-
*/
|
|
3390
|
-
export async function fetchConductorReadiness(access, fetchImpl = globalThis.fetch) {
|
|
3760
|
+
export async function fetchConductorReadiness(access, fetchImpl = globalThis.fetch, options = {}) {
|
|
3391
3761
|
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/conductor-readiness`, {
|
|
3392
3762
|
repo_name: access.repoName,
|
|
3763
|
+
...(options.epicRunId ? { epic_run_id: options.epicRunId } : {}),
|
|
3764
|
+
});
|
|
3765
|
+
// BAPI-1455 — a `SCHEMA_INCOMPATIBLE` refusal explains itself in
|
|
3766
|
+
// `detail.error` (the applied stamp, the missing objects, the repair route),
|
|
3767
|
+
// so that field is read, with room for the object names.
|
|
3768
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, {
|
|
3769
|
+
readErrorField: true,
|
|
3770
|
+
errorFieldPreviewMax: SCHEMA_REFUSAL_PREVIEW_MAX,
|
|
3771
|
+
readErrorFieldOnlyFor: [SCHEMA_INCOMPATIBLE_ERROR_CODE],
|
|
3393
3772
|
});
|
|
3394
|
-
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
3395
3773
|
return parseConductorReadinessResponse(body);
|
|
3396
3774
|
}
|
|
3775
|
+
/** The answer for every failure: an older server, a non-200, a timeout, a bad body. */
|
|
3776
|
+
export const REPOSITORY_USAGE_ADVISORY_UNAVAILABLE = Object.freeze({
|
|
3777
|
+
state: "unavailable",
|
|
3778
|
+
utilization_percent: null,
|
|
3779
|
+
status: null,
|
|
3780
|
+
observed_at: null,
|
|
3781
|
+
resets_at: null,
|
|
3782
|
+
stale: null,
|
|
3783
|
+
});
|
|
3784
|
+
/** Same size as the plane freshness probes' bound, so no launch read waits longer. */
|
|
3785
|
+
export const REPOSITORY_USAGE_ADVISORY_TIMEOUT_MS = 30_000;
|
|
3786
|
+
const USAGE_ADVISORY_STATES = new Set([
|
|
3787
|
+
"observed",
|
|
3788
|
+
"below_warning_threshold",
|
|
3789
|
+
"absent",
|
|
3790
|
+
"unavailable",
|
|
3791
|
+
]);
|
|
3792
|
+
const USAGE_ADVISORY_FIELD_MAX = 64;
|
|
3793
|
+
function boundedUsageString(value) {
|
|
3794
|
+
if (value === undefined || value === null)
|
|
3795
|
+
return null;
|
|
3796
|
+
if (typeof value !== "string" || value.length > USAGE_ADVISORY_FIELD_MAX)
|
|
3797
|
+
return undefined;
|
|
3798
|
+
return value;
|
|
3799
|
+
}
|
|
3800
|
+
/** Validate a usage-advisory body. `null` when any field is out of contract. */
|
|
3801
|
+
export function parseRepositoryUsageAdvisory(body) {
|
|
3802
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
3803
|
+
return null;
|
|
3804
|
+
const o = body;
|
|
3805
|
+
if (typeof o.state !== "string" || !USAGE_ADVISORY_STATES.has(o.state))
|
|
3806
|
+
return null;
|
|
3807
|
+
const percent = o.utilization_percent;
|
|
3808
|
+
let utilization = null;
|
|
3809
|
+
if (percent !== undefined && percent !== null) {
|
|
3810
|
+
if (typeof percent !== "number" || !Number.isInteger(percent) || percent < 0 || percent > 100)
|
|
3811
|
+
return null;
|
|
3812
|
+
utilization = percent;
|
|
3813
|
+
}
|
|
3814
|
+
const status = boundedUsageString(o.status);
|
|
3815
|
+
const observedAt = boundedUsageString(o.observed_at);
|
|
3816
|
+
const resetsAt = boundedUsageString(o.resets_at);
|
|
3817
|
+
if (status === undefined || observedAt === undefined || resetsAt === undefined)
|
|
3818
|
+
return null;
|
|
3819
|
+
if (observedAt !== null && Number.isNaN(Date.parse(observedAt)))
|
|
3820
|
+
return null;
|
|
3821
|
+
let stale = null;
|
|
3822
|
+
if (o.stale !== undefined && o.stale !== null) {
|
|
3823
|
+
if (typeof o.stale !== "boolean")
|
|
3824
|
+
return null;
|
|
3825
|
+
stale = o.stale;
|
|
3826
|
+
}
|
|
3827
|
+
const state = o.state;
|
|
3828
|
+
if (state === "observed" && (utilization === null || status === null))
|
|
3829
|
+
return null;
|
|
3830
|
+
return { state, utilization_percent: utilization, status, observed_at: observedAt, resets_at: resetsAt, stale };
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* GET `/jira/epic-runs/usage-advisory?repo_name=<repo>`. NEVER throws: a 404
|
|
3834
|
+
* from an older server, any other non-200, a transport failure, a timeout, or a
|
|
3835
|
+
* malformed body is {@link REPOSITORY_USAGE_ADVISORY_UNAVAILABLE}. The launch
|
|
3836
|
+
* preview turns that into a warning row; it can never change a launch decision.
|
|
3837
|
+
*/
|
|
3838
|
+
export async function fetchRepositoryUsageAdvisory(access, fetchImpl = globalThis.fetch) {
|
|
3839
|
+
try {
|
|
3840
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/usage-advisory`, {
|
|
3841
|
+
repo_name: access.repoName,
|
|
3842
|
+
});
|
|
3843
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), REPOSITORY_USAGE_ADVISORY_TIMEOUT_MS, fetchImpl);
|
|
3844
|
+
return parseRepositoryUsageAdvisory(body) ?? REPOSITORY_USAGE_ADVISORY_UNAVAILABLE;
|
|
3845
|
+
}
|
|
3846
|
+
catch {
|
|
3847
|
+
return REPOSITORY_USAGE_ADVISORY_UNAVAILABLE;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3397
3850
|
const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
|
|
3398
3851
|
function optionalRowOutcome(o, key) {
|
|
3399
3852
|
const v = o[key];
|
|
@@ -3766,6 +4219,126 @@ export async function resumeCiTicket(access, request, fetchImpl = globalThis.fet
|
|
|
3766
4219
|
clearTimeout(timer);
|
|
3767
4220
|
}
|
|
3768
4221
|
}
|
|
4222
|
+
/** The closed refusal table of `regenerate-plan`; `CONFLICT` is the stale row version. */
|
|
4223
|
+
export const REGENERATE_PLAN_REFUSAL_CODES = [
|
|
4224
|
+
"PLAN_REGEN_NOT_ELIGIBLE",
|
|
4225
|
+
"PLAN_REGEN_OPERATION_CONFLICT",
|
|
4226
|
+
"PLAN_REGEN_ANSWERED_CONFLICT",
|
|
4227
|
+
"PLAN_REGEN_STALE_PARK",
|
|
4228
|
+
"PLAN_REGEN_ALLOWANCE_SPENT",
|
|
4229
|
+
"PLAN_REGEN_ROUTE_NOT_ALLOWED",
|
|
4230
|
+
"PLAN_REGEN_RUN_NOT_ACTIVE",
|
|
4231
|
+
"PLAN_REGEN_BUSY",
|
|
4232
|
+
"PLAN_REGEN_SCHEMA_UNAVAILABLE",
|
|
4233
|
+
"SPEC_SNAPSHOT_UNAVAILABLE",
|
|
4234
|
+
"TICKET_NOT_IN_RUN",
|
|
4235
|
+
"NOT_FOUND",
|
|
4236
|
+
"CONFLICT",
|
|
4237
|
+
];
|
|
4238
|
+
/** True when `code` is one of the closed `regenerate-plan` refusal codes. */
|
|
4239
|
+
export function isRegeneratePlanRefusalCode(code) {
|
|
4240
|
+
return REGENERATE_PLAN_REFUSAL_CODES.includes(code);
|
|
4241
|
+
}
|
|
4242
|
+
/** Strictly parse a `regenerate-plan` success body; anything short of the contract throws. */
|
|
4243
|
+
export function parseRegeneratePlanResult(value) {
|
|
4244
|
+
if (!isPlainRecord(value))
|
|
4245
|
+
throw new ConductorBridgeApiError("server");
|
|
4246
|
+
const v = value;
|
|
4247
|
+
if (typeof v.epic_run_id !== "string" || !v.epic_run_id ||
|
|
4248
|
+
typeof v.ticket_key !== "string" || !v.ticket_key ||
|
|
4249
|
+
(v.outcome !== "committed" && v.outcome !== "replayed" && v.outcome !== "answered_replay") ||
|
|
4250
|
+
(v.accepted_route !== "test" && v.accepted_route !== "implementation") ||
|
|
4251
|
+
!isNonNegativeInt(v.answered_park_generation) ||
|
|
4252
|
+
!isNonNegativeInt(v.row_version) ||
|
|
4253
|
+
!nullableString(v.ticket_status) ||
|
|
4254
|
+
v.plan_ready !== false) {
|
|
4255
|
+
throw new ConductorBridgeApiError("server");
|
|
4256
|
+
}
|
|
4257
|
+
return {
|
|
4258
|
+
epic_run_id: v.epic_run_id,
|
|
4259
|
+
ticket_key: v.ticket_key,
|
|
4260
|
+
outcome: v.outcome,
|
|
4261
|
+
accepted_route: v.accepted_route,
|
|
4262
|
+
answered_park_generation: v.answered_park_generation,
|
|
4263
|
+
row_version: v.row_version,
|
|
4264
|
+
ticket_status: (v.ticket_status ?? null),
|
|
4265
|
+
plan_ready: false,
|
|
4266
|
+
};
|
|
4267
|
+
}
|
|
4268
|
+
/**
|
|
4269
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/regenerate-plan`.
|
|
4270
|
+
*
|
|
4271
|
+
* Sends the pinned operation key and park generation only in the authenticated
|
|
4272
|
+
* body. Never throws for an HTTP or transport outcome: every response is
|
|
4273
|
+
* classified into a {@link RegeneratePlanPostOutcome}, so the caller — not this
|
|
4274
|
+
* function — decides whether an uncertain outcome is retried with the SAME
|
|
4275
|
+
* operation. It never falls back to another mutation.
|
|
4276
|
+
*/
|
|
4277
|
+
export async function regeneratePlanTicket(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
4278
|
+
requireNonEmptyString(request.epicRunId);
|
|
4279
|
+
requireNonEmptyString(request.ticketKey);
|
|
4280
|
+
requireNonEmptyString(request.operationKey);
|
|
4281
|
+
requireNonEmptyString(request.reason);
|
|
4282
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
4283
|
+
requireNonNegativeSafeInteger(request.parkGeneration);
|
|
4284
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(request.epicRunId, request.ticketKey)}/regenerate-plan`);
|
|
4285
|
+
const body = JSON.stringify({
|
|
4286
|
+
repo_name: access.repoName,
|
|
4287
|
+
route: request.route,
|
|
4288
|
+
reason: request.reason,
|
|
4289
|
+
expected_row_version: request.expectedRowVersion,
|
|
4290
|
+
park_generation: request.parkGeneration,
|
|
4291
|
+
operation_key: request.operationKey,
|
|
4292
|
+
});
|
|
4293
|
+
const controller = new AbortController();
|
|
4294
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4295
|
+
try {
|
|
4296
|
+
let resp;
|
|
4297
|
+
try {
|
|
4298
|
+
resp = await fetchImpl(url, {
|
|
4299
|
+
method: "POST",
|
|
4300
|
+
headers: conductorPostHeaders(access),
|
|
4301
|
+
body,
|
|
4302
|
+
signal: controller.signal,
|
|
4303
|
+
});
|
|
4304
|
+
}
|
|
4305
|
+
catch {
|
|
4306
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
4307
|
+
}
|
|
4308
|
+
let parsed;
|
|
4309
|
+
let parsedOk = true;
|
|
4310
|
+
try {
|
|
4311
|
+
parsed = await resp.json();
|
|
4312
|
+
}
|
|
4313
|
+
catch {
|
|
4314
|
+
parsedOk = false;
|
|
4315
|
+
}
|
|
4316
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
4317
|
+
if (!parsedOk)
|
|
4318
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
4319
|
+
try {
|
|
4320
|
+
return { kind: "result", result: parseRegeneratePlanResult(parsed) };
|
|
4321
|
+
}
|
|
4322
|
+
catch {
|
|
4323
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
const errorCode = parsedOk ? safeDetailErrorCode(parsed) : null;
|
|
4327
|
+
if (errorCode !== null && isRegeneratePlanRefusalCode(errorCode)) {
|
|
4328
|
+
return { kind: "refused", status: resp.status, errorCode };
|
|
4329
|
+
}
|
|
4330
|
+
if (errorCode === null && (resp.status === 404 || resp.status === 405)) {
|
|
4331
|
+
return { kind: "unsupported_server", status: resp.status };
|
|
4332
|
+
}
|
|
4333
|
+
if (resp.status >= 500) {
|
|
4334
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
4335
|
+
}
|
|
4336
|
+
return { kind: "rejected", status: resp.status, errorCode };
|
|
4337
|
+
}
|
|
4338
|
+
finally {
|
|
4339
|
+
clearTimeout(timer);
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
3769
4342
|
// ---------------------------------------------------------------------------
|
|
3770
4343
|
// BAPI-1391 — gate profile, provider history, capability probe, qualification
|
|
3771
4344
|
// ---------------------------------------------------------------------------
|
|
@@ -4162,3 +4735,9 @@ export async function fetchConductorGateQualificationJob(access, jobId, fetchImp
|
|
|
4162
4735
|
const body = await fetchConductorJsonWithTimeout(gateProfileUrl(access, `qualification/${encodeURIComponent(String(jobId))}/`), conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
4163
4736
|
return parseConductorGateQualificationJob(body, access.repoName, jobId);
|
|
4164
4737
|
}
|
|
4738
|
+
/** Authenticated read-only preview of the same effective policy sent at creation. */
|
|
4739
|
+
export async function previewLaunchReadiness(access, policyJson, fetchImpl = globalThis.fetch) {
|
|
4740
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/launch-readiness`);
|
|
4741
|
+
const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify({ repo_name: access.repoName, policy_json: policyJson }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
4742
|
+
return parseConductorReadinessResponse(body);
|
|
4743
|
+
}
|