@bridge_gpt/mcp-server 0.2.1 → 0.2.3
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 +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +558 -63
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +3 -0
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +683 -82
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +18 -6
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +16 -8
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript-side Bridge API access layer for the conductor producer (BAPI-395).
|
|
3
|
+
*
|
|
4
|
+
* The producer reads per-repo gate config and polls CI through the EXISTING
|
|
5
|
+
* Bridge API HTTP surface — it never opens a Postgres connection from TypeScript.
|
|
6
|
+
* Credential and repo identity resolution reuse the shared
|
|
7
|
+
* {@link resolveBapiCredentials} / {@link resolveStartTicketsRepoName} boundaries
|
|
8
|
+
* so the `bapi:<repo>` target can never drift. All errors are sanitized: a thrown
|
|
9
|
+
* {@link ConductorBridgeApiError} carries only a coarse `kind`, an optional HTTP
|
|
10
|
+
* status, and generic text — never a response body, header, token, or API key.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { readFile, stat } from "node:fs/promises";
|
|
14
|
+
import { resolveBapiCredentials } from "../credential-store.js";
|
|
15
|
+
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
16
|
+
import { DONE_GATE_CONFIG_FIELD, normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
17
|
+
import { ConductorValidationError } from "./errors.js";
|
|
18
|
+
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
19
|
+
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
20
|
+
/** Default per-request timeout for conductor Bridge API calls. */
|
|
21
|
+
export const CONDUCTOR_FETCH_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
|
|
24
|
+
* resolver, and the shared credential store. Never throws and never embeds the
|
|
25
|
+
* secret value in any failure text. No Postgres/DB module is imported here.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
28
|
+
const env = deps.env ?? process.env;
|
|
29
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
30
|
+
const homedir = deps.homedir ?? os.homedir;
|
|
31
|
+
const platform = deps.platform ?? process.platform;
|
|
32
|
+
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
33
|
+
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
34
|
+
const repoName = await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl });
|
|
35
|
+
if (!repoName) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
kind: "repo-missing",
|
|
39
|
+
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
let credResult;
|
|
43
|
+
try {
|
|
44
|
+
credResult = await resolveBapiCredentials(repoName, {
|
|
45
|
+
env,
|
|
46
|
+
homedir,
|
|
47
|
+
platform,
|
|
48
|
+
readFile: readFileImpl,
|
|
49
|
+
stat: statImpl,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
54
|
+
}
|
|
55
|
+
if (!credResult.ok) {
|
|
56
|
+
return { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
|
|
57
|
+
}
|
|
58
|
+
const baseUrlRaw = env.BAPI_BASE_URL;
|
|
59
|
+
const baseUrl = typeof baseUrlRaw === "string" && baseUrlRaw.trim().length > 0
|
|
60
|
+
? baseUrlRaw.trim()
|
|
61
|
+
: CONDUCTOR_DEFAULT_BASE_URL;
|
|
62
|
+
return { ok: true, access: { repoName, apiKey: credResult.credentials.apiKey, baseUrl } };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a `${baseUrl}/jira${apiPath}` URL with query params, trimming trailing
|
|
66
|
+
* slashes on the base so there is never a double slash after the host. Query
|
|
67
|
+
* values are URL-encoded by {@link URL}.
|
|
68
|
+
*/
|
|
69
|
+
export function buildConductorJiraUrl(baseUrl, apiPath, params = {}) {
|
|
70
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
71
|
+
const url = new URL(`${trimmed}/jira${apiPath}`);
|
|
72
|
+
for (const [k, v] of Object.entries(params)) {
|
|
73
|
+
url.searchParams.set(k, v);
|
|
74
|
+
}
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
/** Sanitized HTTP error for conductor Bridge API calls — never leaks secrets. */
|
|
78
|
+
export class ConductorBridgeApiError extends Error {
|
|
79
|
+
kind;
|
|
80
|
+
status;
|
|
81
|
+
constructor(kind, status) {
|
|
82
|
+
super(`Conductor Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
|
|
83
|
+
this.name = "ConductorBridgeApiError";
|
|
84
|
+
this.kind = kind;
|
|
85
|
+
this.status = status;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** GET auth headers. The API key travels ONLY in a header, never in the URL. */
|
|
89
|
+
function conductorGetHeaders(access) {
|
|
90
|
+
return { "X-API-Key": access.apiKey };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* GET JSON with an `AbortController` timeout. Throws a sanitized
|
|
94
|
+
* {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
|
|
95
|
+
* non-2xx response — never including the response body, headers, or API key. The
|
|
96
|
+
* timer is always cleared.
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = fetch) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
let resp;
|
|
103
|
+
try {
|
|
104
|
+
resp = await fetchImpl(url, { headers, signal: controller.signal });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Aborts (timeout), DNS/connection failures, and other fetch exceptions.
|
|
108
|
+
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
109
|
+
}
|
|
110
|
+
if (!resp.ok) {
|
|
111
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
112
|
+
throw new ConductorBridgeApiError("unauthorized", resp.status);
|
|
113
|
+
}
|
|
114
|
+
if (resp.status >= 500) {
|
|
115
|
+
throw new ConductorBridgeApiError("server", resp.status);
|
|
116
|
+
}
|
|
117
|
+
throw new ConductorBridgeApiError("http", resp.status);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return await resp.json();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new ConductorBridgeApiError("network");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* GET `/jira/config-field/{fieldName}?repo_name=<repo>` and return the raw
|
|
132
|
+
* `value` payload (or `undefined` when the envelope lacks a `value`). The field
|
|
133
|
+
* name is URL-encoded; the repo scope is always included.
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchConductorConfigField(access, fieldName, fetchImpl = fetch) {
|
|
136
|
+
const url = buildConductorJiraUrl(access.baseUrl, `/config-field/${encodeURIComponent(fieldName)}`, {
|
|
137
|
+
repo_name: access.repoName,
|
|
138
|
+
});
|
|
139
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
140
|
+
if (body && typeof body === "object" && "value" in body) {
|
|
141
|
+
return body.value;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/** Fetch the per-repo `conductor_done_gate` config value (raw, unparsed). */
|
|
146
|
+
export function fetchDoneGateConfigField(access, fetchImpl = fetch) {
|
|
147
|
+
return fetchConductorConfigField(access, DONE_GATE_CONFIG_FIELD, fetchImpl);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Poll CI checks for a commit through the existing `/poll-ci-checks` endpoint.
|
|
151
|
+
* Validates `commitRef` with {@link normalizeSha} and rejects BEFORE issuing the
|
|
152
|
+
* request when it is not a valid SHA. Returns the endpoint response verbatim —
|
|
153
|
+
* the client never invents `pr_number`/`head_sha` fields.
|
|
154
|
+
*/
|
|
155
|
+
export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch) {
|
|
156
|
+
const sha = normalizeSha(commitRef);
|
|
157
|
+
if (sha === null) {
|
|
158
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
159
|
+
}
|
|
160
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/poll-ci-checks", {
|
|
161
|
+
repo_name: access.repoName,
|
|
162
|
+
commit_ref: sha,
|
|
163
|
+
});
|
|
164
|
+
return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Conductor C6 (BAPI-398): protected VCS merge POST client
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
/**
|
|
170
|
+
* Build a `${baseUrl}${apiPath}` URL for non-`/jira` API routes (e.g. the
|
|
171
|
+
* protected `/vcs/...` merge endpoint), trimming trailing slashes on the base.
|
|
172
|
+
* Distinct from {@link buildConductorJiraUrl}, which forces a `/jira` prefix.
|
|
173
|
+
*/
|
|
174
|
+
export function buildConductorVcsUrl(baseUrl, apiPath) {
|
|
175
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
176
|
+
const path = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
|
|
177
|
+
return new URL(`${trimmed}${path}`).toString();
|
|
178
|
+
}
|
|
179
|
+
/** POST auth + content headers. The API key travels ONLY in a header. */
|
|
180
|
+
function conductorPostHeaders(access) {
|
|
181
|
+
return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* POST JSON with an `AbortController` timeout, mirroring
|
|
185
|
+
* {@link fetchConductorJsonWithTimeout}. Throws a sanitized
|
|
186
|
+
* {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
|
|
187
|
+
* non-2xx response — never including the response body, headers, API key, or
|
|
188
|
+
* request body in the error. The timer is always cleared.
|
|
189
|
+
*/
|
|
190
|
+
export async function fetchConductorJsonPostWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
193
|
+
try {
|
|
194
|
+
let resp;
|
|
195
|
+
try {
|
|
196
|
+
resp = await fetchImpl(url, { method: "POST", headers, body, signal: controller.signal });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
200
|
+
}
|
|
201
|
+
if (!resp.ok) {
|
|
202
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
203
|
+
throw new ConductorBridgeApiError("unauthorized", resp.status);
|
|
204
|
+
}
|
|
205
|
+
if (resp.status >= 500) {
|
|
206
|
+
throw new ConductorBridgeApiError("server", resp.status);
|
|
207
|
+
}
|
|
208
|
+
throw new ConductorBridgeApiError("http", resp.status);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return await resp.json();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new ConductorBridgeApiError("network");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Call the protected `POST /vcs/pull-requests/{pr_number}/merge` endpoint. The PR
|
|
223
|
+
* number lives ONLY in the path; the API key travels ONLY in headers; the body
|
|
224
|
+
* carries no branch name or provider token. PR number and head SHA are validated
|
|
225
|
+
* before the request is issued. Throws a sanitized {@link ConductorBridgeApiError}
|
|
226
|
+
* on any failure.
|
|
227
|
+
*/
|
|
228
|
+
export async function mergePullRequestForGate(access, request, fetchImpl = fetch) {
|
|
229
|
+
const pr = normalizePrNumber(request.pr_number);
|
|
230
|
+
const sha = normalizeSha(request.expected_head_sha);
|
|
231
|
+
if (pr === null || sha === null || !request.action_key || !request.repo_name) {
|
|
232
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
233
|
+
}
|
|
234
|
+
const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/merge`);
|
|
235
|
+
// The body deliberately omits pr_number (it is in the path) and never includes
|
|
236
|
+
// a branch name or provider token; extra fields would be rejected server-side.
|
|
237
|
+
const body = JSON.stringify({
|
|
238
|
+
repo_name: request.repo_name,
|
|
239
|
+
expected_head_sha: sha,
|
|
240
|
+
gate: request.gate,
|
|
241
|
+
action_key: request.action_key,
|
|
242
|
+
...(request.gate_event ? { gate_event: request.gate_event } : {}),
|
|
243
|
+
});
|
|
244
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
245
|
+
return parsed;
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Local boundary validators (reject before any fetch call)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
function requireNonEmptyString(value) {
|
|
251
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
252
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function requirePositiveSafeInteger(value) {
|
|
256
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
257
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function requireNonNegativeSafeInteger(value) {
|
|
261
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
262
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function requireNoSlashPathSegment(value) {
|
|
266
|
+
if (value.includes("/")) {
|
|
267
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const EPIC_TICKET_STATUS_VALUES = [
|
|
271
|
+
"planned", "ready", "dispatched", "running", "blocked", "abandoned", "done",
|
|
272
|
+
];
|
|
273
|
+
function requireEpicTicketStatusValue(value) {
|
|
274
|
+
if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
|
|
275
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const EPIC_DISPATCH_TRANSITION_STATUSES = ["run_spawned", "terminal"];
|
|
279
|
+
function requireEpicDispatchTransitionStatus(value) {
|
|
280
|
+
if (typeof value !== "string" || !EPIC_DISPATCH_TRANSITION_STATUSES.includes(value)) {
|
|
281
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Epic Run route constants and path helpers
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// TODO(epic-run-store): confirm final protected route prefix remains /jira/epic-runs against the sibling durable-store routes.
|
|
288
|
+
const EPIC_RUNS_API_PREFIX = "/epic-runs";
|
|
289
|
+
function epicRunApiPath(epicKey) {
|
|
290
|
+
return `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}`;
|
|
291
|
+
}
|
|
292
|
+
function epicDispatchTransitionApiPath(dispatchKey, nextStatus) {
|
|
293
|
+
if (nextStatus === "run_spawned") {
|
|
294
|
+
return `/epic-runs/dispatch/${encodeURIComponent(dispatchKey)}/run-spawned`;
|
|
295
|
+
}
|
|
296
|
+
return `/epic-runs/dispatch/${encodeURIComponent(dispatchKey)}/terminal`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Build the canonical dispatch idempotency key in lock-step with the server-side
|
|
300
|
+
* `build_dispatch_key` Python helper.
|
|
301
|
+
*
|
|
302
|
+
* Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`.
|
|
303
|
+
*
|
|
304
|
+
* // TODO(epic-run-store): the epic component maps to the server-side epic_run_id component used by build_dispatch_key; confirm naming if the sibling renames it at integration.
|
|
305
|
+
*/
|
|
306
|
+
export function buildEpicDispatchKey(epicKey, ticketKey, planVersion) {
|
|
307
|
+
requireNonEmptyString(epicKey);
|
|
308
|
+
requireNonEmptyString(ticketKey);
|
|
309
|
+
requireNonNegativeSafeInteger(planVersion);
|
|
310
|
+
return `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Epic supervision lease claim/renew
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
function parseEpicSupervisionLeaseResult(parsed) {
|
|
316
|
+
if (!parsed || typeof parsed !== "object") {
|
|
317
|
+
throw new ConductorBridgeApiError("server");
|
|
318
|
+
}
|
|
319
|
+
const p = parsed;
|
|
320
|
+
const row = p["row"];
|
|
321
|
+
if (p["claimed"] === true && row && typeof row === "object") {
|
|
322
|
+
return { ok: true, kind: "acquired-or-renewed", row: row };
|
|
323
|
+
}
|
|
324
|
+
if (p["claimed"] === false && p["reason"] === "lease_held" && row && typeof row === "object") {
|
|
325
|
+
return { ok: false, kind: "held-by-other", reason: "lease_held", row: row };
|
|
326
|
+
}
|
|
327
|
+
if (p["claimed"] === false && p["reason"] === "terminal" && row && typeof row === "object") {
|
|
328
|
+
return { ok: false, kind: "terminal", reason: "terminal", row: row };
|
|
329
|
+
}
|
|
330
|
+
throw new ConductorBridgeApiError("server");
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* POST to the epic supervision lease endpoint. Returns a typed result distinguishing
|
|
334
|
+
* acquired/renewed from held-by-other and terminal so the control loop can decide
|
|
335
|
+
* whether to act or exit as an observer. Validates inputs before the request.
|
|
336
|
+
*/
|
|
337
|
+
export async function claimEpicSupervisionLease(access, request, fetchImpl = fetch) {
|
|
338
|
+
requireNonEmptyString(request.epicKey);
|
|
339
|
+
requireNonEmptyString(request.leaseOwner);
|
|
340
|
+
requirePositiveSafeInteger(request.ttlSeconds);
|
|
341
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/lease/claim`);
|
|
342
|
+
const body = JSON.stringify({
|
|
343
|
+
repo_name: access.repoName,
|
|
344
|
+
lease_owner: request.leaseOwner,
|
|
345
|
+
ttl_seconds: request.ttlSeconds,
|
|
346
|
+
});
|
|
347
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
348
|
+
return parseEpicSupervisionLeaseResult(parsed);
|
|
349
|
+
}
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Epic desired + observed state read
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
/**
|
|
354
|
+
* GET the durable epic run state (epic record + per-ticket statuses + dispatches).
|
|
355
|
+
* Returns the server payload verbatim — no ready-set computation, plan-hash
|
|
356
|
+
* assertion, or derived control-loop fields are added.
|
|
357
|
+
*/
|
|
358
|
+
export async function fetchEpicRunState(access, epicKey, fetchImpl = fetch) {
|
|
359
|
+
requireNonEmptyString(epicKey);
|
|
360
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/state`, { repo_name: access.repoName });
|
|
361
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
362
|
+
return parsed;
|
|
363
|
+
}
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Per-ticket CAS status advancement
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
function parseAdvanceEpicTicketStatusResult(parsed) {
|
|
368
|
+
if (!parsed || typeof parsed !== "object") {
|
|
369
|
+
throw new ConductorBridgeApiError("server");
|
|
370
|
+
}
|
|
371
|
+
const p = parsed;
|
|
372
|
+
// Structured CAS conflict envelope
|
|
373
|
+
if (p["ok"] === false && p["kind"] === "cas-conflict") {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
kind: "cas-conflict",
|
|
377
|
+
...(typeof p["current_row_version"] === "number"
|
|
378
|
+
? { current_row_version: p["current_row_version"] }
|
|
379
|
+
: {}),
|
|
380
|
+
...(p["ticket_status"] && typeof p["ticket_status"] === "object"
|
|
381
|
+
? { ticket_status: p["ticket_status"] }
|
|
382
|
+
: {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
// Structured success envelope
|
|
386
|
+
if (p["ok"] === true && p["ticket_status"] && typeof p["ticket_status"] === "object") {
|
|
387
|
+
return { ok: true, ticket_status: p["ticket_status"] };
|
|
388
|
+
}
|
|
389
|
+
// Bare sibling success shape (EpicTicketStatusResponse): has ticket_key, status, row_version
|
|
390
|
+
if ("ticket_key" in p && "status" in p && "row_version" in p) {
|
|
391
|
+
return { ok: true, ticket_status: parsed };
|
|
392
|
+
}
|
|
393
|
+
throw new ConductorBridgeApiError("server");
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* POST to the per-ticket CAS status endpoint. Surfaces CAS conflicts as a distinct
|
|
397
|
+
* non-throwing outcome so the control loop can re-read rather than crashing.
|
|
398
|
+
* Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
|
|
399
|
+
*
|
|
400
|
+
* // TODO(epic-run-store): ticket requires POST but sibling currently declares PATCH /runs/{epic_run_id}/tickets/{ticket_key}; reconcile HTTP method at integration.
|
|
401
|
+
*/
|
|
402
|
+
export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch) {
|
|
403
|
+
requireNonEmptyString(request.epicKey);
|
|
404
|
+
requireNonEmptyString(request.ticketKey);
|
|
405
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
406
|
+
requireNonNegativeSafeInteger(request.planVersion);
|
|
407
|
+
requireEpicTicketStatusValue(request.nextStatus);
|
|
408
|
+
if (request.dispatchRunId !== undefined) {
|
|
409
|
+
requireNonEmptyString(request.dispatchRunId);
|
|
410
|
+
}
|
|
411
|
+
// TODO(epic-run-store): ticket requires POST; sibling is currently PATCH — reconcile at integration
|
|
412
|
+
const CAS_ENDPOINT_PATH = `${epicRunApiPath(request.epicKey)}/tickets/${encodeURIComponent(request.ticketKey)}`;
|
|
413
|
+
const url = buildConductorJiraUrl(access.baseUrl, CAS_ENDPOINT_PATH);
|
|
414
|
+
const body = JSON.stringify({
|
|
415
|
+
repo_name: access.repoName,
|
|
416
|
+
status: request.nextStatus,
|
|
417
|
+
plan_version: request.planVersion,
|
|
418
|
+
expected_row_version: request.expectedRowVersion,
|
|
419
|
+
...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
|
|
420
|
+
});
|
|
421
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
422
|
+
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Idempotently seed an epic ticket status row via POST to the per-epic tickets
|
|
426
|
+
* endpoint. The backend uses ON CONFLICT DO NOTHING so repeated seeding across
|
|
427
|
+
* ticks and re-plans is safe. Throws a sanitized {@link ConductorBridgeApiError}
|
|
428
|
+
* on any transport/auth/server error.
|
|
429
|
+
*/
|
|
430
|
+
export async function createEpicTicketStatus(access, request, fetchImpl = fetch) {
|
|
431
|
+
requireNonEmptyString(request.epicKey);
|
|
432
|
+
requireNonEmptyString(request.ticketKey);
|
|
433
|
+
requireEpicTicketStatusValue(request.status);
|
|
434
|
+
requireNonNegativeSafeInteger(request.planVersion);
|
|
435
|
+
if (request.dispatchRunId !== undefined) {
|
|
436
|
+
requireNonEmptyString(request.dispatchRunId);
|
|
437
|
+
}
|
|
438
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/tickets`);
|
|
439
|
+
const body = JSON.stringify({
|
|
440
|
+
repo_name: access.repoName,
|
|
441
|
+
ticket_key: request.ticketKey,
|
|
442
|
+
status: request.status,
|
|
443
|
+
plan_version: request.planVersion,
|
|
444
|
+
...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
|
|
445
|
+
});
|
|
446
|
+
await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
447
|
+
// fetchConductorJsonPostWithTimeout already throws on 4xx/5xx;
|
|
448
|
+
// the 201 response body is not needed (we return void).
|
|
449
|
+
}
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Idempotent dispatch recording
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
function parseEpicDispatchResult(parsed) {
|
|
454
|
+
if (!parsed || typeof parsed !== "object") {
|
|
455
|
+
throw new ConductorBridgeApiError("server");
|
|
456
|
+
}
|
|
457
|
+
const p = parsed;
|
|
458
|
+
const row = p["row"];
|
|
459
|
+
if (!row || typeof row !== "object") {
|
|
460
|
+
throw new ConductorBridgeApiError("server");
|
|
461
|
+
}
|
|
462
|
+
const dispatch = row;
|
|
463
|
+
if (p["claimed"] === true) {
|
|
464
|
+
return { ok: true, kind: "claimed", dispatch };
|
|
465
|
+
}
|
|
466
|
+
if (p["claimed"] === false) {
|
|
467
|
+
const reason = p["reason"];
|
|
468
|
+
if (reason === "already_spawned") {
|
|
469
|
+
return { ok: true, kind: "already-spawned", dispatch };
|
|
470
|
+
}
|
|
471
|
+
if (reason === "already_exists") {
|
|
472
|
+
return { ok: true, kind: "already-exists", dispatch };
|
|
473
|
+
}
|
|
474
|
+
if (reason === "terminal") {
|
|
475
|
+
return { ok: true, kind: "terminal", terminal: true, dispatch };
|
|
476
|
+
}
|
|
477
|
+
if (reason === "lease_held") {
|
|
478
|
+
return { ok: false, kind: "lease-held", dispatch };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
throw new ConductorBridgeApiError("server");
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* POST to the dispatch claim endpoint. Treats dispatch creation as idempotent:
|
|
485
|
+
* re-recording the same `dispatch_key` returns the existing dispatch as a normal
|
|
486
|
+
* outcome so a crashed-then-retried tick never double-dispatches. The dispatch key
|
|
487
|
+
* is composed exclusively via {@link buildEpicDispatchKey}.
|
|
488
|
+
*/
|
|
489
|
+
export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
|
|
490
|
+
requireNonEmptyString(request.epicKey);
|
|
491
|
+
requireNonEmptyString(request.ticketKey);
|
|
492
|
+
requireNonEmptyString(request.leaseOwner);
|
|
493
|
+
requireNonNegativeSafeInteger(request.planVersion);
|
|
494
|
+
requirePositiveSafeInteger(request.ttlSeconds);
|
|
495
|
+
const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion);
|
|
496
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/dispatch/claim`);
|
|
497
|
+
const body = JSON.stringify({
|
|
498
|
+
repo_name: access.repoName,
|
|
499
|
+
epic_run_id: request.epicKey,
|
|
500
|
+
ticket_key: request.ticketKey,
|
|
501
|
+
plan_version: request.planVersion,
|
|
502
|
+
lease_owner: request.leaseOwner,
|
|
503
|
+
ttl_seconds: request.ttlSeconds,
|
|
504
|
+
dispatch_key: dispatchKey,
|
|
505
|
+
});
|
|
506
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
507
|
+
return parseEpicDispatchResult(parsed);
|
|
508
|
+
}
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Dispatch state transitions
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
/**
|
|
513
|
+
* POST to the appropriate dispatch transition endpoint (`run-spawned` or `terminal`).
|
|
514
|
+
* `run_spawned` requires a non-empty `runId`; `terminal` omits it even if supplied.
|
|
515
|
+
* Dispatch keys containing `/` are rejected before the request because transition
|
|
516
|
+
* endpoints embed the key as a URL path segment.
|
|
517
|
+
*/
|
|
518
|
+
export async function transitionEpicDispatch(access, request, fetchImpl = fetch) {
|
|
519
|
+
requireNonEmptyString(request.dispatchKey);
|
|
520
|
+
requireNoSlashPathSegment(request.dispatchKey);
|
|
521
|
+
requireEpicDispatchTransitionStatus(request.nextStatus);
|
|
522
|
+
if (request.nextStatus === "run_spawned") {
|
|
523
|
+
requireNonEmptyString(request.runId);
|
|
524
|
+
}
|
|
525
|
+
const path = epicDispatchTransitionApiPath(request.dispatchKey, request.nextStatus);
|
|
526
|
+
const url = buildConductorJiraUrl(access.baseUrl, path);
|
|
527
|
+
const body = request.nextStatus === "run_spawned"
|
|
528
|
+
? JSON.stringify({ repo_name: access.repoName, run_id: request.runId })
|
|
529
|
+
: JSON.stringify({ repo_name: access.repoName });
|
|
530
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
531
|
+
return parsed;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* POST the immutable plan blob to the durable-store endpoint. The blob is
|
|
535
|
+
* written once per `(epic_run_id, plan_version)` and never mutated — a
|
|
536
|
+
* re-plan must use a strictly higher `plan_version`. The API key travels ONLY
|
|
537
|
+
* in the `X-API-Key` header, never in the URL. Throws a sanitized
|
|
538
|
+
* {@link ConductorBridgeApiError} on any transport/auth/server error.
|
|
539
|
+
*/
|
|
540
|
+
export async function storeEpicPlan(access, request, fetchImpl = fetch) {
|
|
541
|
+
requireNonEmptyString(request.epicKey);
|
|
542
|
+
requirePositiveSafeInteger(request.planVersion);
|
|
543
|
+
requireNonEmptyString(request.planHash);
|
|
544
|
+
const blobVersion = request.planBlob.plan_version;
|
|
545
|
+
if (blobVersion !== undefined && blobVersion !== request.planVersion) {
|
|
546
|
+
throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
|
|
547
|
+
}
|
|
548
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan`);
|
|
549
|
+
const body = JSON.stringify({
|
|
550
|
+
repo_name: access.repoName,
|
|
551
|
+
plan_version: request.planVersion,
|
|
552
|
+
plan_blob: request.planBlob,
|
|
553
|
+
plan_hash: request.planHash,
|
|
554
|
+
});
|
|
555
|
+
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Atomically approve a plan version. Bumps `epic_runs.approved_plan_hash` to
|
|
559
|
+
* the named version's `plan_hash` in a single CAS write — never a
|
|
560
|
+
* read-then-write race. HTTP 409 (Conflict) is trapped and returned as a
|
|
561
|
+
* structured `{ ok: false, kind: "conflict" }` value (stale version or
|
|
562
|
+
* monotonic constraint violation). Other errors throw a sanitized
|
|
563
|
+
* {@link ConductorBridgeApiError}.
|
|
564
|
+
*/
|
|
565
|
+
export async function approveEpicPlan(access, request, fetchImpl = fetch) {
|
|
566
|
+
requireNonEmptyString(request.epicKey);
|
|
567
|
+
requirePositiveSafeInteger(request.planVersion);
|
|
568
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/approve-plan`);
|
|
569
|
+
const body = JSON.stringify({
|
|
570
|
+
repo_name: access.repoName,
|
|
571
|
+
plan_version: request.planVersion,
|
|
572
|
+
});
|
|
573
|
+
try {
|
|
574
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
575
|
+
return parsed;
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
if (error instanceof ConductorBridgeApiError && error.status === 409) {
|
|
579
|
+
// ConductorBridgeApiError does not expose the response body, so server-provided
|
|
580
|
+
// conflict subtypes are not accessible here. "superseded" is used as a safe default.
|
|
581
|
+
return { ok: false, kind: "conflict", reason: "superseded" };
|
|
582
|
+
}
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* GET the stored plan blob for a specific version or the currently-approved
|
|
588
|
+
* version. Pass `"approved"` for `planVersion` to retrieve the approved blob.
|
|
589
|
+
* Returns the response as a {@link GetEpicPlanResponse}. The API key travels
|
|
590
|
+
* ONLY in the `X-API-Key` header, never in the URL.
|
|
591
|
+
*/
|
|
592
|
+
export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetch) {
|
|
593
|
+
requireNonEmptyString(epicKey);
|
|
594
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/plan`, { repo_name: access.repoName, plan_version: String(planVersion) });
|
|
595
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
596
|
+
return parsed;
|
|
597
|
+
}
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Parse pipeline helpers (BAPI-415)
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
/**
|
|
602
|
+
* GET `/jira/parse-status?repo_name=<repo>` and return the live parse lock
|
|
603
|
+
* status. Only two states are possible: `"in_progress"` (the async parse job
|
|
604
|
+
* holds the lock) and `"idle"` (no lock is held). Throws a sanitized
|
|
605
|
+
* {@link ConductorBridgeApiError} on any transport/auth/server failure.
|
|
606
|
+
*/
|
|
607
|
+
export async function fetchParseStatus(access, fetchImpl = fetch) {
|
|
608
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
|
|
609
|
+
repo_name: access.repoName,
|
|
610
|
+
});
|
|
611
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
612
|
+
return body;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* POST `/jira/parse-repository` to trigger an async repository parse. The
|
|
616
|
+
* endpoint is idempotent (coalesces concurrent requests via
|
|
617
|
+
* `mark_parse_repository_pending`) so duplicate triggers from re-ticking are
|
|
618
|
+
* safe. Returns the response envelope verbatim. Throws a sanitized
|
|
619
|
+
* {@link ConductorBridgeApiError} on any transport/auth/server failure.
|
|
620
|
+
*/
|
|
621
|
+
export async function triggerRepositoryParse(access, fetchImpl = fetch) {
|
|
622
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/parse-repository");
|
|
623
|
+
const body = JSON.stringify({ repo_name: access.repoName });
|
|
624
|
+
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
625
|
+
}
|