@bridge_gpt/mcp-server 0.2.23 → 0.2.25
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 +96 -24
- package/build/commands.generated.js +4 -4
- package/build/conductor/epic-reconcile.js +7 -1
- package/build/conductor/epic-runtime.js +5 -0
- package/build/connect-github-api.js +365 -0
- package/build/connect-github.js +415 -0
- package/build/decision-page-schema.js +34 -5
- package/build/decision-page-template.js +126 -39
- package/build/docs.generated.js +5 -0
- package/build/index.js +1872 -633
- package/build/init.js +29 -0
- package/build/install-bridge.js +739 -91
- package/build/install-doctor.js +64 -0
- package/build/pipelines.generated.js +122 -128
- package/build/readme.generated.js +1 -1
- package/build/sfcc/log-gate.js +85 -0
- package/build/sfcc/log-query.js +170 -0
- package/build/sfcc/register.js +10 -0
- package/build/sfcc/setup-status.js +33 -3
- package/build/start-tickets.js +48 -13
- package/build/version.generated.js +1 -1
- package/{CONDUCTOR.md → docs/CONDUCTOR.md} +2 -2
- package/docs/install/github-app.md +252 -0
- package/docs/install/mcp-tool-integrations.md +305 -0
- package/docs/install/sfcc-integration.md +140 -0
- package/package.json +5 -5
- package/pipelines/learn-repository.json +111 -119
- package/public/css/main.min.css +258 -65
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +233 -74
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +3 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge API access + polling primitives for the sessionless GitHub connect flow
|
|
3
|
+
* (BAPI-631).
|
|
4
|
+
*
|
|
5
|
+
* Split out from `connect-github.ts` so the standalone command and `install-bridge`
|
|
6
|
+
* share one implementation of the request contract, the lifecycle vocabulary, and the
|
|
7
|
+
* polling policy — rather than each growing its own slightly-different copy.
|
|
8
|
+
*
|
|
9
|
+
* Two rules shape everything here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Nothing secret is ever returned in a message.** Failures collapse to coarse,
|
|
12
|
+
* enumerated categories. Fetch exception text, response bodies, headers, the state
|
|
13
|
+
* nonce, the install URL, and the API key never reach a string a caller might print.
|
|
14
|
+
* 2. **A malformed success is a failure.** Responses are validated against explicit
|
|
15
|
+
* unions; a 200 whose body does not match is rejected rather than guessed at, so a
|
|
16
|
+
* server change can never be silently reinterpreted as a connection outcome.
|
|
17
|
+
*/
|
|
18
|
+
/** Statuses that end the flow — polling past one of these is pointless. */
|
|
19
|
+
const TERMINAL_STATUSES = new Set([
|
|
20
|
+
"staged",
|
|
21
|
+
"awaiting-organization-approval",
|
|
22
|
+
"connected",
|
|
23
|
+
"expired",
|
|
24
|
+
"invalid",
|
|
25
|
+
"verification-failed",
|
|
26
|
+
"no-repositories",
|
|
27
|
+
"conflict",
|
|
28
|
+
"failed",
|
|
29
|
+
]);
|
|
30
|
+
const ALL_STATUSES = new Set([
|
|
31
|
+
"waiting",
|
|
32
|
+
...TERMINAL_STATUSES,
|
|
33
|
+
]);
|
|
34
|
+
/** Per-request timeout. Generous enough for a cold server, short enough to retry. */
|
|
35
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
36
|
+
/**
|
|
37
|
+
* POST JSON to a Bridge endpoint.
|
|
38
|
+
*
|
|
39
|
+
* The state nonce travels in the BODY, never the query string: query strings land in
|
|
40
|
+
* server access logs, proxy logs, and browser history in a way request bodies do not.
|
|
41
|
+
*/
|
|
42
|
+
async function postJson(deps, path, payload) {
|
|
43
|
+
let resp;
|
|
44
|
+
try {
|
|
45
|
+
resp = await deps.fetch(`${deps.baseUrl}${path}`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"X-API-Key": deps.apiKey,
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(payload),
|
|
52
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
// Deliberately does not forward `e`: fetch exception text can echo the URL, which
|
|
57
|
+
// carries the state nonce.
|
|
58
|
+
const isTimeout = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
|
|
59
|
+
return { ok: false, kind: isTimeout ? "timeout" : "network" };
|
|
60
|
+
}
|
|
61
|
+
let body = null;
|
|
62
|
+
try {
|
|
63
|
+
body = await resp.json();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* Non-JSON body. The status still classifies the outcome. */
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
value: { status: resp.status, body, retryAfter: resp.headers.get("Retry-After") },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Map a non-2xx status to a coarse category. */
|
|
74
|
+
function classifyStatus(status) {
|
|
75
|
+
if (status === 401 || status === 403)
|
|
76
|
+
return "unauthorized";
|
|
77
|
+
if (status === 404)
|
|
78
|
+
return "not-found";
|
|
79
|
+
return "server";
|
|
80
|
+
}
|
|
81
|
+
function asRecord(value) {
|
|
82
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
83
|
+
? value
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
function asString(value) {
|
|
87
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
88
|
+
}
|
|
89
|
+
function asNullableString(value) {
|
|
90
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Retry-After
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
/**
|
|
96
|
+
* Parse a `Retry-After` header to milliseconds.
|
|
97
|
+
*
|
|
98
|
+
* Handles both RFC forms — delta-seconds and an HTTP-date — and clamps the result to
|
|
99
|
+
* `remainingMs` so a server (or a bogus far-future date) can never push a wait past the
|
|
100
|
+
* caller's own deadline. Returns null for absent/unparseable/negative values.
|
|
101
|
+
*/
|
|
102
|
+
export function parseRetryAfterMs(header, remainingMs, nowMs) {
|
|
103
|
+
if (!header)
|
|
104
|
+
return null;
|
|
105
|
+
const trimmed = header.trim();
|
|
106
|
+
if (!trimmed)
|
|
107
|
+
return null;
|
|
108
|
+
const clamp = (ms) => {
|
|
109
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
110
|
+
return null;
|
|
111
|
+
return Math.min(ms, Math.max(0, remainingMs));
|
|
112
|
+
};
|
|
113
|
+
// delta-seconds
|
|
114
|
+
if (/^\d+$/.test(trimmed)) {
|
|
115
|
+
return clamp(Number(trimmed) * 1_000);
|
|
116
|
+
}
|
|
117
|
+
// HTTP-date
|
|
118
|
+
const parsed = Date.parse(trimmed);
|
|
119
|
+
if (Number.isNaN(parsed))
|
|
120
|
+
return null;
|
|
121
|
+
return clamp(parsed - nowMs);
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Endpoints
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/** Mint a CLI-origin connection code and its install URL. */
|
|
127
|
+
export async function mintGithubConnection(deps, repoName) {
|
|
128
|
+
const res = await postJson(deps, "/setup/github/cli/connection-code", {
|
|
129
|
+
repo_name: repoName,
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok)
|
|
132
|
+
return res;
|
|
133
|
+
if (res.value.status !== 200) {
|
|
134
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
135
|
+
}
|
|
136
|
+
const body = asRecord(res.value.body);
|
|
137
|
+
const state = asString(body?.state);
|
|
138
|
+
const installUrl = asString(body?.install_url);
|
|
139
|
+
const ttlSeconds = body?.ttl_seconds;
|
|
140
|
+
if (!body || !state || !installUrl || typeof ttlSeconds !== "number") {
|
|
141
|
+
return { ok: false, kind: "malformed" };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, value: { state, installUrl, ttlSeconds } };
|
|
144
|
+
}
|
|
145
|
+
function parseCandidates(value) {
|
|
146
|
+
if (!Array.isArray(value))
|
|
147
|
+
return null;
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const raw of value) {
|
|
150
|
+
const rec = asRecord(raw);
|
|
151
|
+
const id = asString(rec?.github_repository_id);
|
|
152
|
+
const name = asString(rec?.github_repo_name);
|
|
153
|
+
// A candidate without a usable identity is a malformed response, not a candidate to
|
|
154
|
+
// silently drop — dropping it would show the user an incomplete picker.
|
|
155
|
+
if (!rec || !id || !name)
|
|
156
|
+
return null;
|
|
157
|
+
out.push({
|
|
158
|
+
github_repository_id: id,
|
|
159
|
+
github_repo_name: name,
|
|
160
|
+
github_repo_full_name: asNullableString(rec.github_repo_full_name),
|
|
161
|
+
owner: asNullableString(rec.owner),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** Read one connection code's current lifecycle state. */
|
|
167
|
+
export async function fetchGithubConnectionStatus(deps, repoName, state, nowMs = Date.now()) {
|
|
168
|
+
const res = await postJson(deps, "/setup/github/cli/status", {
|
|
169
|
+
repo_name: repoName,
|
|
170
|
+
state,
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
return res;
|
|
174
|
+
if (res.value.status !== 200) {
|
|
175
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
176
|
+
}
|
|
177
|
+
const body = asRecord(res.value.body);
|
|
178
|
+
const status = asString(body?.status);
|
|
179
|
+
if (!body || !status || !ALL_STATUSES.has(status)) {
|
|
180
|
+
return { ok: false, kind: "malformed" };
|
|
181
|
+
}
|
|
182
|
+
const candidates = parseCandidates(body.candidates ?? []);
|
|
183
|
+
if (candidates === null)
|
|
184
|
+
return { ok: false, kind: "malformed" };
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
value: {
|
|
188
|
+
status: status,
|
|
189
|
+
candidates,
|
|
190
|
+
githubRepoName: asNullableString(body.github_repo_name),
|
|
191
|
+
// A single read has no deadline of its own to clamp against; the poller applies
|
|
192
|
+
// its own bound. POLL_DEADLINE_MS is the widest a caller could honor.
|
|
193
|
+
retryAfterMs: parseRetryAfterMs(res.value.retryAfter, POLL_DEADLINE_MS, nowMs),
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Bind one chosen repository. Sends no installation id — the server owns that. */
|
|
198
|
+
export async function confirmGithubConnection(deps, repoName, state, githubRepositoryId) {
|
|
199
|
+
const res = await postJson(deps, "/setup/github/cli/confirm", {
|
|
200
|
+
repo_name: repoName,
|
|
201
|
+
state,
|
|
202
|
+
github_repository_id: githubRepositoryId,
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
return res;
|
|
206
|
+
if (res.value.status !== 200) {
|
|
207
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
208
|
+
}
|
|
209
|
+
const body = asRecord(res.value.body);
|
|
210
|
+
const name = asString(body?.github_repo_name);
|
|
211
|
+
if (!body || body.status !== "connected" || !name) {
|
|
212
|
+
return { ok: false, kind: "malformed" };
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
value: {
|
|
217
|
+
githubRepoName: name,
|
|
218
|
+
githubRepoFullName: asNullableString(body.github_repo_full_name),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Read whether GitHub is already configured for a project.
|
|
224
|
+
*
|
|
225
|
+
* Uses the existing authenticated install-manifest/capability surface — read-only, and
|
|
226
|
+
* unrelated to any in-flight connection attempt. `unavailable` is a real answer and is
|
|
227
|
+
* deliberately distinct from `unconfigured`: callers must be able to skip rather than
|
|
228
|
+
* fabricate "not configured" from a probe that simply failed.
|
|
229
|
+
*/
|
|
230
|
+
export async function fetchGithubConfigurationState(deps, repoName) {
|
|
231
|
+
let resp;
|
|
232
|
+
try {
|
|
233
|
+
resp = await deps.fetch(`${deps.baseUrl}/jira/config/install-manifest?repo_name=${encodeURIComponent(repoName)}`, {
|
|
234
|
+
headers: { "X-API-Key": deps.apiKey },
|
|
235
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "unavailable";
|
|
240
|
+
}
|
|
241
|
+
if (resp.status !== 200)
|
|
242
|
+
return "unavailable";
|
|
243
|
+
let body;
|
|
244
|
+
try {
|
|
245
|
+
body = await resp.json();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return "unavailable";
|
|
249
|
+
}
|
|
250
|
+
const integrations = asRecord(body)?.integrations;
|
|
251
|
+
if (!Array.isArray(integrations))
|
|
252
|
+
return "unavailable";
|
|
253
|
+
for (const raw of integrations) {
|
|
254
|
+
const rec = asRecord(raw);
|
|
255
|
+
if (rec?.id !== "github_app")
|
|
256
|
+
continue;
|
|
257
|
+
if (typeof rec.is_configured !== "boolean")
|
|
258
|
+
return "unavailable";
|
|
259
|
+
return rec.is_configured ? "configured" : "unconfigured";
|
|
260
|
+
}
|
|
261
|
+
// The checklist is provider-scoped: GitHub is legitimately absent for a Bitbucket
|
|
262
|
+
// project. Absent is not "unconfigured" — there is nothing here to connect.
|
|
263
|
+
return "unavailable";
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Slightly longer than the server's 15-minute code TTL, so an expiry is reported by the
|
|
267
|
+
* server as `expired` rather than guessed at by a local timeout.
|
|
268
|
+
*/
|
|
269
|
+
export const POLL_DEADLINE_MS = 15.5 * 60 * 1_000;
|
|
270
|
+
/** Back off gently: quick early (the common case is fast), then settle. */
|
|
271
|
+
const POLL_DELAYS_MS = [2_000, 3_000, 5_000];
|
|
272
|
+
const MAX_JITTER_MS = 400;
|
|
273
|
+
/**
|
|
274
|
+
* TRANSPORT faults worth retrying — the request never produced a response at all.
|
|
275
|
+
*
|
|
276
|
+
* Deliberately does NOT list "server": `postJson` only ever reports these two kinds,
|
|
277
|
+
* because any HTTP response (500 included) comes back as `ok: true` with a status. HTTP
|
|
278
|
+
* statuses are classified by {@link isRetryableStatus} instead; conflating the two is
|
|
279
|
+
* what previously let a plain 500 kill a poll.
|
|
280
|
+
*/
|
|
281
|
+
const RETRYABLE_TRANSPORT = new Set([
|
|
282
|
+
"network",
|
|
283
|
+
"timeout",
|
|
284
|
+
]);
|
|
285
|
+
/**
|
|
286
|
+
* Is this HTTP status worth retrying rather than surfacing?
|
|
287
|
+
*
|
|
288
|
+
* Every 5xx, not just 502/503: a 500 from a transient server-side hiccup (the /cli/status
|
|
289
|
+
* handler raises a bare 500 on a DB read failure) and a 504 gateway timeout are exactly
|
|
290
|
+
* as transient as a dropped packet, and the user's alternative is restarting the whole
|
|
291
|
+
* mint → browser → poll flow. 429 is retried because it is literally a request to retry.
|
|
292
|
+
* 4xx is terminal: the request itself is the problem, so repeating it cannot help.
|
|
293
|
+
*/
|
|
294
|
+
function isRetryableStatus(status) {
|
|
295
|
+
return status === 429 || status >= 500;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Poll until a terminal status, an unrecoverable failure, or the deadline.
|
|
299
|
+
*
|
|
300
|
+
* Terminal statuses return immediately — including the failure ones. Waiting out a
|
|
301
|
+
* 15-minute deadline on a code the server already called `expired` would be theatre.
|
|
302
|
+
*
|
|
303
|
+
* Transient transport faults (network blips, timeouts, 502/503) are retried, because a
|
|
304
|
+
* dropped packet is not a failed connection. `Retry-After` is honored when the server
|
|
305
|
+
* sends it, including on a 429.
|
|
306
|
+
*/
|
|
307
|
+
export async function pollGithubConnection(deps, poll, repoName, state) {
|
|
308
|
+
const started = poll.now();
|
|
309
|
+
let attempt = 0;
|
|
310
|
+
for (;;) {
|
|
311
|
+
const elapsed = poll.now() - started;
|
|
312
|
+
const remaining = POLL_DEADLINE_MS - elapsed;
|
|
313
|
+
if (remaining <= 0)
|
|
314
|
+
return { ok: false, kind: "deadline" };
|
|
315
|
+
const res = await postJson(deps, "/setup/github/cli/status", {
|
|
316
|
+
repo_name: repoName,
|
|
317
|
+
state,
|
|
318
|
+
});
|
|
319
|
+
let waitMs = null;
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
if (!RETRYABLE_TRANSPORT.has(res.kind))
|
|
322
|
+
return { ok: false, kind: res.kind };
|
|
323
|
+
}
|
|
324
|
+
else if (res.value.status === 200) {
|
|
325
|
+
const body = asRecord(res.value.body);
|
|
326
|
+
const status = asString(body?.status);
|
|
327
|
+
if (!body || !status || !ALL_STATUSES.has(status)) {
|
|
328
|
+
return { ok: false, kind: "malformed" };
|
|
329
|
+
}
|
|
330
|
+
const candidates = parseCandidates(body.candidates ?? []);
|
|
331
|
+
if (candidates === null)
|
|
332
|
+
return { ok: false, kind: "malformed" };
|
|
333
|
+
const typed = status;
|
|
334
|
+
if (TERMINAL_STATUSES.has(typed)) {
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
value: {
|
|
338
|
+
status: typed,
|
|
339
|
+
candidates,
|
|
340
|
+
githubRepoName: asNullableString(body.github_repo_name),
|
|
341
|
+
retryAfterMs: null,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// `waiting` — honor server pacing if offered.
|
|
346
|
+
waitMs = parseRetryAfterMs(res.value.retryAfter, remaining, poll.now());
|
|
347
|
+
}
|
|
348
|
+
else if (isRetryableStatus(res.value.status)) {
|
|
349
|
+
waitMs = parseRetryAfterMs(res.value.retryAfter, remaining, poll.now());
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
353
|
+
}
|
|
354
|
+
if (waitMs === null) {
|
|
355
|
+
const base = POLL_DELAYS_MS[Math.min(attempt, POLL_DELAYS_MS.length - 1)];
|
|
356
|
+
waitMs = base + Math.floor(poll.jitter() * MAX_JITTER_MS);
|
|
357
|
+
}
|
|
358
|
+
attempt += 1;
|
|
359
|
+
// Never sleep past the deadline.
|
|
360
|
+
const capped = Math.min(waitMs, Math.max(0, POLL_DEADLINE_MS - (poll.now() - started)));
|
|
361
|
+
if (capped <= 0)
|
|
362
|
+
return { ok: false, kind: "deadline" };
|
|
363
|
+
await poll.sleep(capped);
|
|
364
|
+
}
|
|
365
|
+
}
|