@bridge_gpt/mcp-server 0.2.24 → 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 +39 -19
- package/build/commands.generated.js +3 -3
- 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 +117 -35
- package/build/index.js +1124 -368
- package/build/install-bridge.js +278 -34
- package/build/install-doctor.js +64 -0
- package/build/pipelines.generated.js +122 -128
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +48 -13
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +80 -17
- package/package.json +2 -2
- 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 +187 -73
- package/public/js/main.min.js.map +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connect-github` — sessionless GitHub App connection from the terminal (BAPI-631).
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this command is that it needs no Bridge web session: a user who has
|
|
5
|
+
* a Bridge API key can connect GitHub without ever opening the setup UI. The flow is
|
|
6
|
+
*
|
|
7
|
+
* mint → browser (install on GitHub) → poll → pick a repository → confirm
|
|
8
|
+
*
|
|
9
|
+
* and the browser half authenticates to *GitHub*, not to Bridge. Bridge never asks for,
|
|
10
|
+
* accepts, echoes, or transports a GitHub credential. The only thing crossing between
|
|
11
|
+
* the browser and this process is a Bridge-issued state nonce, held in memory.
|
|
12
|
+
*
|
|
13
|
+
* Two properties are load-bearing and easy to erode:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The nonce and install URL are never printed.** The URL carries the state as a
|
|
16
|
+
* query parameter, so printing it (or letting it into an error message) would paste
|
|
17
|
+
* a live credential-equivalent into the user's scrollback.
|
|
18
|
+
* 2. **Nothing binds without an explicit human choice.** Even a single-repository
|
|
19
|
+
* installation is confirmed by hand — the server stages candidates and this command
|
|
20
|
+
* asks. There is deliberately no `--yes`.
|
|
21
|
+
*/
|
|
22
|
+
import { readFile, stat } from "fs/promises";
|
|
23
|
+
import { spawn } from "child_process";
|
|
24
|
+
import os from "os";
|
|
25
|
+
import path from "path";
|
|
26
|
+
import readline from "readline";
|
|
27
|
+
import { DEFAULT_BAPI_BASE_URL } from "./install-bridge.js";
|
|
28
|
+
import { validateRepoName } from "./bridge-config.js";
|
|
29
|
+
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
30
|
+
import { resolveBapiCredentials, } from "./credential-store.js";
|
|
31
|
+
import { POLL_DEADLINE_MS, confirmGithubConnection, mintGithubConnection, pollGithubConnection, } from "./connect-github-api.js";
|
|
32
|
+
const USAGE = `Usage: connect-github [--repo <repo_name>]
|
|
33
|
+
|
|
34
|
+
Connect a GitHub repository to a Bridge project from your terminal.
|
|
35
|
+
|
|
36
|
+
Opens the GitHub App install page in your browser, waits for you to install it,
|
|
37
|
+
then asks which repository to connect. You are never asked for a GitHub token or
|
|
38
|
+
password — you authenticate to GitHub in the browser.
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
--repo <repo_name> Bridge project to connect (inferred from this directory
|
|
42
|
+
when omitted; you will be asked to confirm).
|
|
43
|
+
--help Show this message.`;
|
|
44
|
+
/**
|
|
45
|
+
* Parse argv. Deliberately strict — an unknown flag is an error, not something to
|
|
46
|
+
* ignore. In particular there is NO `--yes` (every bind is confirmed by a human) and no
|
|
47
|
+
* `--installation-id` (the server derives it; accepting one from the caller would let an
|
|
48
|
+
* API-key holder claim an installation they do not own).
|
|
49
|
+
*/
|
|
50
|
+
export function parseConnectGithubArgs(argv) {
|
|
51
|
+
const out = { help: false };
|
|
52
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (arg === "--help" || arg === "-h") {
|
|
55
|
+
out.help = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (arg === "--repo") {
|
|
59
|
+
const value = argv[i + 1];
|
|
60
|
+
if (!value || value.startsWith("-")) {
|
|
61
|
+
return { ok: false, error: "--repo requires a value (e.g. --repo my-project)." };
|
|
62
|
+
}
|
|
63
|
+
out.repo = value;
|
|
64
|
+
i += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg.startsWith("--repo=")) {
|
|
68
|
+
const value = arg.slice("--repo=".length);
|
|
69
|
+
if (!value) {
|
|
70
|
+
return { ok: false, error: "--repo requires a value (e.g. --repo my-project)." };
|
|
71
|
+
}
|
|
72
|
+
out.repo = value;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg === "--yes" || arg === "-y") {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: "connect-github does not support --yes: connecting a repository always " +
|
|
79
|
+
"requires an explicit confirmation.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (arg === "--installation-id" || arg.startsWith("--installation-id=")) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "connect-github does not accept --installation-id: the installation is " +
|
|
86
|
+
"verified by Bridge from your browser install, not supplied by the caller.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (arg.startsWith("-")) {
|
|
90
|
+
return { ok: false, error: `Unknown option: ${arg}` };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, error: `Unexpected argument: ${arg}` };
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, value: out };
|
|
95
|
+
}
|
|
96
|
+
/** Echoed single-line prompt on stderr. */
|
|
97
|
+
function defaultPromptLine(promptText) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
100
|
+
// `rl.close()` emits `close` SYNCHRONOUSLY, so without this guard the close handler
|
|
101
|
+
// would settle the promise empty and discard a real answer. EOF must also resolve
|
|
102
|
+
// rather than deadlock a top-level await.
|
|
103
|
+
let answered = false;
|
|
104
|
+
rl.on("close", () => {
|
|
105
|
+
if (!answered)
|
|
106
|
+
resolve("");
|
|
107
|
+
});
|
|
108
|
+
rl.question(promptText, (answer) => {
|
|
109
|
+
answered = true;
|
|
110
|
+
rl.close();
|
|
111
|
+
resolve(answer.trim());
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Open *url* in the platform browser.
|
|
117
|
+
*
|
|
118
|
+
* `shell: false` is mandatory: the URL carries the state nonce, and handing it to a
|
|
119
|
+
* shell would expose it to word-splitting and metacharacter interpretation. Passing it
|
|
120
|
+
* as a single argv entry means the OS opener receives it verbatim.
|
|
121
|
+
*/
|
|
122
|
+
function defaultOpenBrowser(platform, url) {
|
|
123
|
+
const [command, args] = platform === "darwin"
|
|
124
|
+
? ["open", [url]]
|
|
125
|
+
: platform === "win32"
|
|
126
|
+
? // `start` is a cmd builtin; the empty string is its window-title argument,
|
|
127
|
+
// without which a quoted URL would be swallowed as the title.
|
|
128
|
+
["cmd", ["/c", "start", "", url]]
|
|
129
|
+
: ["xdg-open", [url]];
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
try {
|
|
132
|
+
const child = spawn(command, args, {
|
|
133
|
+
stdio: "ignore",
|
|
134
|
+
detached: false,
|
|
135
|
+
shell: false,
|
|
136
|
+
});
|
|
137
|
+
child.on("error", () => resolve(false));
|
|
138
|
+
child.on("spawn", () => resolve(true));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
resolve(false);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function createDefaultConnectGithubDeps() {
|
|
146
|
+
return {
|
|
147
|
+
env: process.env,
|
|
148
|
+
cwd: process.cwd(),
|
|
149
|
+
platform: process.platform,
|
|
150
|
+
homedir: os.homedir,
|
|
151
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
152
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
153
|
+
stat: async (filePath) => {
|
|
154
|
+
const s = await stat(filePath);
|
|
155
|
+
return { mode: s.mode };
|
|
156
|
+
},
|
|
157
|
+
fetch: globalThis.fetch,
|
|
158
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
159
|
+
now: () => Date.now(),
|
|
160
|
+
jitter: () => Math.random(),
|
|
161
|
+
promptLine: defaultPromptLine,
|
|
162
|
+
openBrowser: (url) => defaultOpenBrowser(process.platform, url),
|
|
163
|
+
stdout: (message) => process.stdout.write(`${message}\n`),
|
|
164
|
+
stderr: (message) => process.stderr.write(`${message}\n`),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the Bridge project name: explicit `--repo`, then repository identity from the
|
|
169
|
+
* environment / project-local config (confirmed interactively, since an inferred name is
|
|
170
|
+
* a guess and connecting the wrong project is not cheap to undo).
|
|
171
|
+
*/
|
|
172
|
+
export async function resolveConnectGithubRepoName(args, deps) {
|
|
173
|
+
if (args.repo) {
|
|
174
|
+
const validated = validateRepoName(args.repo);
|
|
175
|
+
return validated.ok ? { ok: true, value: validated.value } : { ok: false, error: validated.error };
|
|
176
|
+
}
|
|
177
|
+
let inferred = await resolveStartTicketsRepoName({
|
|
178
|
+
env: deps.env,
|
|
179
|
+
cwd: deps.cwd,
|
|
180
|
+
readFile: deps.readFile,
|
|
181
|
+
});
|
|
182
|
+
if (!inferred) {
|
|
183
|
+
const validated = validateRepoName(path.basename(deps.cwd));
|
|
184
|
+
if (validated.ok)
|
|
185
|
+
inferred = validated.value;
|
|
186
|
+
}
|
|
187
|
+
if (!inferred) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: "Could not determine the Bridge project. Pass --repo <repo_name>.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const answer = (await deps.promptLine(`Bridge project [${inferred}]: `)).trim();
|
|
194
|
+
const chosen = answer.length > 0 ? answer : inferred;
|
|
195
|
+
const validated = validateRepoName(chosen);
|
|
196
|
+
return validated.ok ? { ok: true, value: validated.value } : { ok: false, error: validated.error };
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Browser handoff
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Hand the install URL to the browser.
|
|
203
|
+
*
|
|
204
|
+
* On failure this returns a FIXED message and never falls back to printing the URL —
|
|
205
|
+
* that URL contains the state nonce, and a copy-paste fallback would defeat the reason
|
|
206
|
+
* the nonce is kept in memory in the first place.
|
|
207
|
+
*/
|
|
208
|
+
export async function openGithubInstallPage(deps, installUrl) {
|
|
209
|
+
const opened = await deps.openBrowser(installUrl);
|
|
210
|
+
if (opened)
|
|
211
|
+
return { ok: true };
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: "Could not open your browser automatically. Re-run this command from a desktop " +
|
|
215
|
+
"session with a browser available.",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Flow
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
const STEPS = [
|
|
222
|
+
"Connect GitHub",
|
|
223
|
+
"Complete GitHub in browser",
|
|
224
|
+
"Verify connection",
|
|
225
|
+
"Choose repository",
|
|
226
|
+
"Confirm connection",
|
|
227
|
+
];
|
|
228
|
+
function renderStep(deps, index) {
|
|
229
|
+
deps.stderr(`[${index + 1}/${STEPS.length}] ${STEPS[index]}`);
|
|
230
|
+
}
|
|
231
|
+
/** Display identity for a candidate: prefer the unambiguous owner/repo form. */
|
|
232
|
+
function candidateLabel(c) {
|
|
233
|
+
if (c.github_repo_full_name)
|
|
234
|
+
return c.github_repo_full_name;
|
|
235
|
+
return c.owner ? `${c.owner}/${c.github_repo_name}` : c.github_repo_name;
|
|
236
|
+
}
|
|
237
|
+
/** One remediation line per terminal non-success outcome. Never any upstream detail. */
|
|
238
|
+
const OUTCOME_MESSAGES = {
|
|
239
|
+
expired: "The connection request expired before GitHub reported back. Run connect-github again.",
|
|
240
|
+
invalid: "This connection request is no longer valid. Run connect-github again.",
|
|
241
|
+
"verification-failed": "Bridge could not verify the GitHub installation. Run connect-github again.",
|
|
242
|
+
"no-repositories": "The GitHub App installation did not include any repositories Bridge can access. " +
|
|
243
|
+
"Re-run connect-github and grant access to at least one repository.",
|
|
244
|
+
conflict: "This GitHub installation or project is already connected to a different Bridge " +
|
|
245
|
+
"account or repository. Contact support if that is unexpected.",
|
|
246
|
+
failed: "The GitHub connection did not complete. Run connect-github again.",
|
|
247
|
+
};
|
|
248
|
+
const FAILURE_MESSAGES = {
|
|
249
|
+
network: "Could not reach Bridge API. Check your network, then run connect-github again.",
|
|
250
|
+
timeout: "Bridge API did not respond in time. Run connect-github again.",
|
|
251
|
+
unauthorized: "Bridge rejected your API key for this project. Re-run install-bridge with a current key.",
|
|
252
|
+
"not-found": "Bridge does not recognize this project. Check --repo matches your Bridge project name.",
|
|
253
|
+
server: "Bridge API returned an error. Run connect-github again shortly.",
|
|
254
|
+
malformed: "Bridge API returned an unexpected response. Run connect-github again shortly.",
|
|
255
|
+
deadline: "Timed out waiting for GitHub. If you completed the install, run connect-github again " +
|
|
256
|
+
"to pick up the connection.",
|
|
257
|
+
};
|
|
258
|
+
/** The no-connection-made framing, used for every unsuccessful terminal path. */
|
|
259
|
+
function reportNoConnection(deps, detail) {
|
|
260
|
+
deps.stderr("");
|
|
261
|
+
deps.stderr("No GitHub connection was made.");
|
|
262
|
+
deps.stderr(detail);
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
export async function runGithubConnectionFlow(deps, api, repoName) {
|
|
266
|
+
renderStep(deps, 0);
|
|
267
|
+
const minted = await mintGithubConnection(api, repoName);
|
|
268
|
+
if (!minted.ok) {
|
|
269
|
+
return reportNoConnection(deps, FAILURE_MESSAGES[minted.kind]);
|
|
270
|
+
}
|
|
271
|
+
renderStep(deps, 1);
|
|
272
|
+
deps.stderr("Opening GitHub…");
|
|
273
|
+
// The URL goes straight from memory to the opener — never through a log line.
|
|
274
|
+
const opened = await openGithubInstallPage(deps, minted.value.installUrl);
|
|
275
|
+
if (!opened.ok) {
|
|
276
|
+
return reportNoConnection(deps, opened.error);
|
|
277
|
+
}
|
|
278
|
+
renderStep(deps, 2);
|
|
279
|
+
// floor, not round: the deadline carries ~30s of slack past the server's 15-minute
|
|
280
|
+
// code TTL purely so the SERVER reports the expiry rather than us guessing at it.
|
|
281
|
+
// Advertising that slack as "~16 minutes" would overstate how long the code lives.
|
|
282
|
+
const minutes = Math.floor(POLL_DEADLINE_MS / 60_000);
|
|
283
|
+
deps.stderr(`Waiting for GitHub installation… (up to ~${minutes} minutes)`);
|
|
284
|
+
const pollDeps = { sleep: deps.sleep, now: deps.now, jitter: deps.jitter };
|
|
285
|
+
const started = deps.now();
|
|
286
|
+
const polled = await pollGithubConnection(api, pollDeps, repoName, minted.value.state);
|
|
287
|
+
if (!polled.ok) {
|
|
288
|
+
return reportNoConnection(deps, FAILURE_MESSAGES[polled.kind]);
|
|
289
|
+
}
|
|
290
|
+
const elapsedSec = Math.max(0, Math.round((deps.now() - started) / 1_000));
|
|
291
|
+
deps.stderr(`Waited ${elapsedSec}s.`);
|
|
292
|
+
const result = polled.value;
|
|
293
|
+
if (result.status === "awaiting-organization-approval") {
|
|
294
|
+
deps.stderr("");
|
|
295
|
+
deps.stderr("No GitHub connection was made.");
|
|
296
|
+
deps.stderr("Your request to install the GitHub App was sent to an organization owner for approval.");
|
|
297
|
+
deps.stderr("That approval happens on GitHub and does not return here, so this command cannot " +
|
|
298
|
+
"wait for it.");
|
|
299
|
+
deps.stderr("Once an owner approves the install, finish the connection with the manual steps in " +
|
|
300
|
+
"the GitHub App setup guide (docs/install/github-app.md).");
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
if (result.status === "connected") {
|
|
304
|
+
// Already bound (e.g. a re-run against a finished handshake).
|
|
305
|
+
deps.stdout(`Connected ${result.githubRepoName ?? repoName}.`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
if (result.status !== "staged") {
|
|
309
|
+
return reportNoConnection(deps, OUTCOME_MESSAGES[result.status] ?? OUTCOME_MESSAGES.failed);
|
|
310
|
+
}
|
|
311
|
+
const candidates = result.candidates;
|
|
312
|
+
if (candidates.length === 0) {
|
|
313
|
+
return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
|
|
314
|
+
}
|
|
315
|
+
renderStep(deps, 3);
|
|
316
|
+
let selected = null;
|
|
317
|
+
if (candidates.length === 1) {
|
|
318
|
+
const only = candidates[0];
|
|
319
|
+
// Even with one option: show the full identity and require a yes. The user is
|
|
320
|
+
// authorizing a binding, not acknowledging a notice.
|
|
321
|
+
const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
|
|
322
|
+
.trim()
|
|
323
|
+
.toLowerCase();
|
|
324
|
+
if (answer !== "y" && answer !== "yes") {
|
|
325
|
+
deps.stderr("");
|
|
326
|
+
deps.stderr("No GitHub connection was made. Nothing was changed.");
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
selected = only;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
deps.stderr("");
|
|
333
|
+
deps.stderr("Your GitHub installation includes multiple repositories:");
|
|
334
|
+
candidates.forEach((c, i) => {
|
|
335
|
+
deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${candidateLabel(c)}`);
|
|
336
|
+
});
|
|
337
|
+
deps.stderr("");
|
|
338
|
+
// No default: a stray Enter must not bind anything.
|
|
339
|
+
const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
|
|
340
|
+
const index = Number(answer);
|
|
341
|
+
if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > candidates.length) {
|
|
342
|
+
deps.stderr("");
|
|
343
|
+
deps.stderr("No GitHub connection was made. No repository was selected.");
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
selected = candidates[index - 1];
|
|
347
|
+
deps.stderr(`Selected ${candidateLabel(selected)}.`);
|
|
348
|
+
}
|
|
349
|
+
renderStep(deps, 4);
|
|
350
|
+
const confirmed = await confirmGithubConnection(api, repoName, minted.value.state, selected.github_repository_id);
|
|
351
|
+
if (!confirmed.ok) {
|
|
352
|
+
return reportNoConnection(deps, FAILURE_MESSAGES[confirmed.kind]);
|
|
353
|
+
}
|
|
354
|
+
deps.stdout(`Connected ${confirmed.value.githubRepoFullName ?? confirmed.value.githubRepoName}.`);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Process boundary
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
/**
|
|
361
|
+
* Entry point. Catches everything and always resolves to a numeric exit code — an
|
|
362
|
+
* unhandled rejection here would surface a stack trace that could contain the install
|
|
363
|
+
* URL (and therefore the nonce).
|
|
364
|
+
*/
|
|
365
|
+
export async function runConnectGithubCli(argv, injected) {
|
|
366
|
+
const deps = injected ?? createDefaultConnectGithubDeps();
|
|
367
|
+
try {
|
|
368
|
+
const parsed = parseConnectGithubArgs(argv);
|
|
369
|
+
if (!parsed.ok) {
|
|
370
|
+
deps.stderr(parsed.error);
|
|
371
|
+
deps.stderr("");
|
|
372
|
+
deps.stderr(USAGE);
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
if (parsed.value.help) {
|
|
376
|
+
deps.stdout(USAGE);
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
// Checked BEFORE minting: this flow always ends in a human choice, so a
|
|
380
|
+
// non-interactive run can only ever strand a code it can never confirm.
|
|
381
|
+
if (!deps.isTTY) {
|
|
382
|
+
deps.stderr("connect-github needs an interactive terminal: it asks you to confirm which " +
|
|
383
|
+
"repository to connect. Run it directly in your terminal.");
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const repo = await resolveConnectGithubRepoName(parsed.value, deps);
|
|
387
|
+
if (!repo.ok) {
|
|
388
|
+
deps.stderr(repo.error);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
const cred = await resolveBapiCredentials(repo.value, {
|
|
392
|
+
env: deps.env,
|
|
393
|
+
homedir: deps.homedir,
|
|
394
|
+
platform: deps.platform,
|
|
395
|
+
readFile: deps.readFile,
|
|
396
|
+
stat: deps.stat,
|
|
397
|
+
stderr: () => { },
|
|
398
|
+
});
|
|
399
|
+
if (!cred.ok) {
|
|
400
|
+
deps.stderr(cred.error);
|
|
401
|
+
return 1;
|
|
402
|
+
}
|
|
403
|
+
const api = {
|
|
404
|
+
fetch: deps.fetch,
|
|
405
|
+
baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
|
|
406
|
+
apiKey: cred.credentials.apiKey,
|
|
407
|
+
};
|
|
408
|
+
return await runGithubConnectionFlow(deps, api, repo.value);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Never forward the caught value: it may carry the install URL or the nonce.
|
|
412
|
+
deps.stderr("No GitHub connection was made. An unexpected error occurred.");
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -111,10 +111,34 @@ export const SystemGoalNfrSchema = z.object({
|
|
|
111
111
|
.enum(["confirmed", "assumed", "open"])
|
|
112
112
|
.describe("confirmed = explicitly stated or observable in code; assumed = low-risk and reversible default; open = unresolved (also surface as an actionable_items card)."),
|
|
113
113
|
});
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
114
|
+
// A single acceptance criterion: what the system must do, stated so it can be
|
|
115
|
+
// verified. Criteria are the input to implementation options, not a summary of
|
|
116
|
+
// them — solutioning is expected to flow downstream from this list. `status` uses
|
|
117
|
+
// the same confirmed/assumed/open rubric as NFRs, and `id` is the per-criterion
|
|
118
|
+
// JSON output key (data-ac-id) in the rendered page, so it must be unique.
|
|
119
|
+
export const AcceptanceCriterionSchema = z.object({
|
|
120
|
+
id: z
|
|
121
|
+
.string()
|
|
122
|
+
.min(1)
|
|
123
|
+
.regex(/^[A-Za-z0-9_-]+$/, "id must contain only letters, digits, hyphens, or underscores")
|
|
124
|
+
.describe("Stable per-criterion id, e.g. AC-1. Becomes the captured-feedback JSON key."),
|
|
125
|
+
criterion: z
|
|
126
|
+
.string()
|
|
127
|
+
.min(1)
|
|
128
|
+
.describe("What the system must do, stated concretely enough to be verified."),
|
|
129
|
+
verification: z
|
|
130
|
+
.string()
|
|
131
|
+
.min(1)
|
|
132
|
+
.describe("How we would confirm this criterion is met. Required — a criterion with no way to check it is not yet a criterion."),
|
|
133
|
+
status: z
|
|
134
|
+
.enum(["confirmed", "assumed", "open"])
|
|
135
|
+
.describe("confirmed = explicitly stated or observable in code; assumed = low-risk and reversible default; open = unresolved."),
|
|
136
|
+
});
|
|
137
|
+
// System-goals panel for the pre_ticket_planning artifact. Captures the business
|
|
138
|
+
// goal, the desired end-state, how the system must behave to complete its task,
|
|
139
|
+
// the acceptance criteria (what it must do), and the classified NFR list (the
|
|
140
|
+
// standards it must meet). The three prose fields are display-only; acceptance
|
|
141
|
+
// criteria and NFRs additionally collect a per-item stance from the reader.
|
|
118
142
|
export const SystemGoalsSchema = z.object({
|
|
119
143
|
business_goal: z.string().min(1).describe("The business goal this work serves."),
|
|
120
144
|
desired_end_state: z.string().min(1).describe("The end-state the system should reach."),
|
|
@@ -122,6 +146,11 @@ export const SystemGoalsSchema = z.object({
|
|
|
122
146
|
.string()
|
|
123
147
|
.min(1)
|
|
124
148
|
.describe("How the system must behave / complete its task (quality attributes in prose)."),
|
|
149
|
+
acceptance_criteria: z
|
|
150
|
+
.array(AcceptanceCriterionSchema)
|
|
151
|
+
.optional()
|
|
152
|
+
.default([])
|
|
153
|
+
.describe("What the system must do, as verifiable criteria. Implementation options should be derived from these rather than the reverse."),
|
|
125
154
|
nfrs: z.array(SystemGoalNfrSchema).optional().default([]),
|
|
126
155
|
});
|
|
127
156
|
// Read-only recommended implementation order for epic-planning surfaces. Hard
|
|
@@ -153,7 +182,7 @@ export const DecisionPageInputShape = {
|
|
|
153
182
|
.optional()
|
|
154
183
|
.default("review_decisions")
|
|
155
184
|
.describe('Which flavor of page to render. "review_decisions" (default) is the ticket-review decision-capture page and is unaffected by the planning fields. "pre_ticket_planning" additionally renders the read-only system_goals and implementation_order sections for pre-ticket epic/task framing.'),
|
|
156
|
-
system_goals: SystemGoalsSchema.optional().describe("pre_ticket_planning only:
|
|
185
|
+
system_goals: SystemGoalsSchema.optional().describe("pre_ticket_planning only: business goal, desired end-state, system behavior, acceptance criteria (what the system must do), and classified NFRs (the standards it must meet). Acceptance criteria and NFRs render with per-item stance controls. Unresolved (open) NFRs should ALSO be passed as actionable_items so the human can decide them."),
|
|
157
186
|
implementation_order: z
|
|
158
187
|
.array(ImplementationOrderItemSchema)
|
|
159
188
|
.optional()
|