@bridge_gpt/mcp-server 0.2.24 → 0.2.26
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 +98 -28
- package/build/agents.generated.js +1 -1
- package/build/bridge-api-urls.js +31 -0
- package/build/commands.generated.js +5 -5
- package/build/conductor/epic-reconcile.js +7 -1
- package/build/conductor/epic-runtime.js +5 -0
- package/build/conductor-bundle-artifacts.js +802 -0
- package/build/conductor-bundle-cli.js +256 -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 +117 -35
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +148 -1
- package/build/env-flags.js +31 -0
- package/build/index.js +3467 -498
- package/build/init.js +7 -3
- package/build/install-bridge.js +624 -38
- package/build/install-doctor.js +64 -0
- package/build/mcp-host-config.js +521 -0
- package/build/mcp-host-targets.js +194 -0
- package/build/mcp-install-state.js +175 -0
- package/build/pipelines.generated.js +127 -132
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +166 -18
- package/build/tool-surface-gating.js +396 -0
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +80 -17
- package/docs/install/mcp-tool-integrations.md +2 -2
- 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 +188 -92
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-634: thin CLI handler exposing the deterministic conductor-bundle helpers
|
|
3
|
+
* to `emit-conductor-bundle.md`.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the instruction ships **compiled** to consuming repositories
|
|
6
|
+
* inside `build/pipelines.generated.js`. `mcp_server/src/` does not exist there,
|
|
7
|
+
* so an instruction that told the agent to "call `validateConductorBundleInputs`"
|
|
8
|
+
* would be referencing a path only this repository has. This subcommand is the
|
|
9
|
+
* reachable surface, mirroring how `store-and-approve-epic-plan.md` invokes
|
|
10
|
+
* `setup-epic` rather than hand-rolling HTTP calls.
|
|
11
|
+
*
|
|
12
|
+
* It holds no policy of its own: it parses argv, delegates to
|
|
13
|
+
* `conductor-bundle-artifacts.ts`, and prints a JSON envelope. All I/O is behind
|
|
14
|
+
* an injectable seam so tests never touch a real disk or construct a server.
|
|
15
|
+
*/
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { finalizeEpicPlanSidecar, serializeSiblingTicketManifest, validateConductorBundleInputs, writeJsonAtomically, } from "./conductor-bundle-artifacts.js";
|
|
18
|
+
const USAGE = [
|
|
19
|
+
"Usage: emit-conductor-bundle <validate|finalize> --input <file> [--docs-dir <dir>] [--json]",
|
|
20
|
+
"",
|
|
21
|
+
" validate Validate identities, the node->ticket mapping, manifest agreement,",
|
|
22
|
+
" and path containment. Writes nothing.",
|
|
23
|
+
" finalize Validate, then finalize epic-plan.dag.json with real keys and",
|
|
24
|
+
" per-node touched_files, and write the sibling-ticket manifest.",
|
|
25
|
+
"",
|
|
26
|
+
"Options:",
|
|
27
|
+
" --input <file> JSON document with epic_key, epic_slug, mappings,",
|
|
28
|
+
" decomposition_fingerprint, and (for finalize)",
|
|
29
|
+
" touched_files_by_key.",
|
|
30
|
+
" --docs-dir <dir> Docs directory (default: $BAPI_DOCS_DIR, else docs/tmp).",
|
|
31
|
+
" --json Emit a machine-readable result on stdout.",
|
|
32
|
+
" -h, --help Show this help.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
/** Read a flag's value, rejecting a missing or flag-shaped value. */
|
|
35
|
+
function takeValue(argv, index, flag) {
|
|
36
|
+
const value = argv[index + 1];
|
|
37
|
+
if (value === undefined || value.startsWith("-")) {
|
|
38
|
+
throw new Error(`Flag "${flag}" requires a value.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/** Parse argv into options, or throw with an actionable message. */
|
|
43
|
+
export function parseConductorBundleArgs(argv) {
|
|
44
|
+
const mode = argv[0];
|
|
45
|
+
if (mode === "-h" || mode === "--help" || mode === undefined) {
|
|
46
|
+
return { mode: "validate", inputFile: "", json: false, help: true };
|
|
47
|
+
}
|
|
48
|
+
if (mode !== "validate" && mode !== "finalize") {
|
|
49
|
+
throw new Error(`Unknown subcommand "${mode}". Expected "validate" or "finalize".`);
|
|
50
|
+
}
|
|
51
|
+
let inputFile;
|
|
52
|
+
let docsDir;
|
|
53
|
+
let json = false;
|
|
54
|
+
let help = false;
|
|
55
|
+
for (let i = 1; i < argv.length; i++) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
switch (arg) {
|
|
58
|
+
case "--input":
|
|
59
|
+
inputFile = takeValue(argv, i, "--input");
|
|
60
|
+
i++;
|
|
61
|
+
break;
|
|
62
|
+
case "--docs-dir":
|
|
63
|
+
docsDir = takeValue(argv, i, "--docs-dir");
|
|
64
|
+
i++;
|
|
65
|
+
break;
|
|
66
|
+
case "--json":
|
|
67
|
+
json = true;
|
|
68
|
+
break;
|
|
69
|
+
case "-h":
|
|
70
|
+
case "--help":
|
|
71
|
+
help = true;
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
throw new Error(`Unknown argument "${arg}".`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (help)
|
|
78
|
+
return { mode, inputFile: inputFile ?? "", docsDir, json, help: true };
|
|
79
|
+
if (!inputFile)
|
|
80
|
+
throw new Error('Flag "--input" is required.');
|
|
81
|
+
return { mode, inputFile, docsDir, json, help: false };
|
|
82
|
+
}
|
|
83
|
+
/** Parse JSON, converting a syntax error into an actionable message. */
|
|
84
|
+
async function readJson(filePath, fs, label) {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await fs.readFile(filePath);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
throw new Error(`${label} at ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Read an optional JSON file, returning null when it does not exist. */
|
|
100
|
+
async function readOptionalJson(filePath, fs, label) {
|
|
101
|
+
try {
|
|
102
|
+
await fs.readFile(filePath);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return readJson(filePath, fs, label);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Run the `emit-conductor-bundle` subcommand. Returns a process exit code; never
|
|
111
|
+
* throws for an expected validation failure.
|
|
112
|
+
*/
|
|
113
|
+
export async function runConductorBundleCli(argv, overrides = {}) {
|
|
114
|
+
const deps = {
|
|
115
|
+
env: process.env,
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
fs: createDefaultBundleFs(),
|
|
118
|
+
log: (m) => console.log(m),
|
|
119
|
+
// Diagnostics go to stderr so `--json` keeps stdout a single result object.
|
|
120
|
+
errorLog: (m) => console.error(m),
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
let opts;
|
|
124
|
+
try {
|
|
125
|
+
opts = parseConductorBundleArgs(argv);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
deps.errorLog(err instanceof Error ? err.message : String(err));
|
|
129
|
+
deps.errorLog(USAGE);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
if (opts.help) {
|
|
133
|
+
deps.log(USAGE);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const emitFailure = (error) => {
|
|
137
|
+
if (opts.json)
|
|
138
|
+
deps.log(JSON.stringify({ ok: false, error }));
|
|
139
|
+
else
|
|
140
|
+
deps.errorLog(`Error: ${error}`);
|
|
141
|
+
return 1;
|
|
142
|
+
};
|
|
143
|
+
const docsDir = path.resolve(deps.cwd, opts.docsDir ?? deps.env.BAPI_DOCS_DIR ?? path.join("docs", "tmp"));
|
|
144
|
+
let input;
|
|
145
|
+
try {
|
|
146
|
+
input = (await readJson(path.resolve(deps.cwd, opts.inputFile), deps.fs, "--input document"));
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return emitFailure(err instanceof Error ? err.message : String(err));
|
|
150
|
+
}
|
|
151
|
+
if (!input || typeof input !== "object") {
|
|
152
|
+
return emitFailure("--input document must be a JSON object.");
|
|
153
|
+
}
|
|
154
|
+
const epicDir = path.resolve(docsDir, "epic-plans", String(input.epic_slug));
|
|
155
|
+
const sidecarPath = path.join(epicDir, "epic-plan.dag.json");
|
|
156
|
+
const manifestPath = path.join(epicDir, "sibling-ticket-manifest.json");
|
|
157
|
+
let sidecar;
|
|
158
|
+
let existingManifest;
|
|
159
|
+
try {
|
|
160
|
+
sidecar = await readJson(sidecarPath, deps.fs, "epic-plan.dag.json");
|
|
161
|
+
existingManifest = await readOptionalJson(manifestPath, deps.fs, "sibling-ticket-manifest.json");
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return emitFailure(err instanceof Error ? err.message : String(err));
|
|
165
|
+
}
|
|
166
|
+
const validation = await validateConductorBundleInputs({
|
|
167
|
+
epic_key: input.epic_key,
|
|
168
|
+
epic_slug: input.epic_slug,
|
|
169
|
+
docs_dir: docsDir,
|
|
170
|
+
mappings: input.mappings,
|
|
171
|
+
sidecar,
|
|
172
|
+
existing_manifest: existingManifest ?? undefined,
|
|
173
|
+
decomposition_fingerprint: input.decomposition_fingerprint,
|
|
174
|
+
}, deps.fs);
|
|
175
|
+
if (!validation.ok)
|
|
176
|
+
return emitFailure(validation.error);
|
|
177
|
+
if (opts.mode === "validate") {
|
|
178
|
+
const result = {
|
|
179
|
+
ok: true,
|
|
180
|
+
mode: "validate",
|
|
181
|
+
epic_key: validation.value.epic_key,
|
|
182
|
+
plan_version: validation.value.plan_version,
|
|
183
|
+
mapped_nodes: validation.value.mappings.length,
|
|
184
|
+
sidecar_path: validation.value.sidecar_path,
|
|
185
|
+
};
|
|
186
|
+
if (opts.json)
|
|
187
|
+
deps.log(JSON.stringify(result));
|
|
188
|
+
else {
|
|
189
|
+
deps.log(`Validated ${result.mapped_nodes} node mapping(s) for ${result.epic_key} ` +
|
|
190
|
+
`(plan v${result.plan_version}). Nothing was written.`);
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
// ---- finalize ----------------------------------------------------------
|
|
195
|
+
const nodeKeyMap = {};
|
|
196
|
+
for (const m of validation.value.mappings) {
|
|
197
|
+
nodeKeyMap[m.plan_node_id] = m.ticket_key;
|
|
198
|
+
}
|
|
199
|
+
const finalized = finalizeEpicPlanSidecar({
|
|
200
|
+
sidecar,
|
|
201
|
+
node_key_map: nodeKeyMap,
|
|
202
|
+
touched_files_by_key: input.touched_files_by_key ?? {},
|
|
203
|
+
plan_version_already_stored: input.plan_version_already_stored === true,
|
|
204
|
+
});
|
|
205
|
+
if (!finalized.ok)
|
|
206
|
+
return emitFailure(finalized.error);
|
|
207
|
+
const sidecarWrite = await writeJsonAtomically(validation.value.sidecar_path, finalized.value, deps.fs);
|
|
208
|
+
if (!sidecarWrite.ok)
|
|
209
|
+
return emitFailure(sidecarWrite.error);
|
|
210
|
+
const manifest = serializeSiblingTicketManifest({
|
|
211
|
+
schema_version: 1,
|
|
212
|
+
epic_key: validation.value.epic_key,
|
|
213
|
+
epic_slug: validation.value.epic_slug,
|
|
214
|
+
plan_version: validation.value.plan_version,
|
|
215
|
+
decomposition_fingerprint: input.decomposition_fingerprint,
|
|
216
|
+
finalized_fingerprint: null,
|
|
217
|
+
run_phase: input.run_phase ?? "staged",
|
|
218
|
+
mappings: validation.value.mappings.map((m) => ({
|
|
219
|
+
plan_node_id: m.plan_node_id,
|
|
220
|
+
ticket_key: m.ticket_key,
|
|
221
|
+
exploration_path: m.exploration_path,
|
|
222
|
+
draft_path: m.draft_path,
|
|
223
|
+
})),
|
|
224
|
+
decisions: [],
|
|
225
|
+
completed_mutations: [],
|
|
226
|
+
});
|
|
227
|
+
const manifestWrite = await writeJsonAtomically(validation.value.manifest_path, manifest, deps.fs);
|
|
228
|
+
if (!manifestWrite.ok)
|
|
229
|
+
return emitFailure(manifestWrite.error);
|
|
230
|
+
const result = {
|
|
231
|
+
ok: true,
|
|
232
|
+
mode: "finalize",
|
|
233
|
+
epic_key: validation.value.epic_key,
|
|
234
|
+
plan_version: validation.value.plan_version,
|
|
235
|
+
sidecar_path: validation.value.sidecar_path,
|
|
236
|
+
manifest_path: validation.value.manifest_path,
|
|
237
|
+
ticket_keys: validation.value.mappings.map((m) => m.ticket_key),
|
|
238
|
+
};
|
|
239
|
+
if (opts.json)
|
|
240
|
+
deps.log(JSON.stringify(result));
|
|
241
|
+
else {
|
|
242
|
+
deps.log(`Finalized ${result.sidecar_path} with ${result.ticket_keys.length} real key(s) ` +
|
|
243
|
+
`and per-node touched_files. Manifest: ${result.manifest_path}`);
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
/** Real filesystem seam, loaded lazily so importing this module stays cheap. */
|
|
248
|
+
function createDefaultBundleFs() {
|
|
249
|
+
return {
|
|
250
|
+
readFile: async (p) => (await import("node:fs/promises")).readFile(p, "utf-8"),
|
|
251
|
+
writeFile: async (p, data) => (await import("node:fs/promises")).writeFile(p, data, "utf-8"),
|
|
252
|
+
rename: async (from, to) => (await import("node:fs/promises")).rename(from, to),
|
|
253
|
+
unlink: async (p) => (await import("node:fs/promises")).unlink(p),
|
|
254
|
+
realpath: async (p) => (await import("node:fs/promises")).realpath(p),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|