@bridge_gpt/mcp-server 0.2.34 → 0.2.37
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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notifications` — read-only CLI for the Epic Conductor durable notification
|
|
3
|
+
* sink (BAPI-755).
|
|
4
|
+
*
|
|
5
|
+
* npx -y @bridge_gpt/mcp-server notifications --repo <repo> --run <epic-key|run-id> [--follow]
|
|
6
|
+
*
|
|
7
|
+
* Resolves an epic key or a concrete run id to its canonical epic run via the
|
|
8
|
+
* EXISTING run-read endpoint (`fetchEpicRunState`, shared with `setup-epic`),
|
|
9
|
+
* then fetches `GET /jira/epic-runs/runs/{epic_run_id}/notifications` — a new
|
|
10
|
+
* BAPI-755 endpoint built in parallel on the Python side. This module treats
|
|
11
|
+
* that endpoint's response shape as a fixed, given contract and validates every
|
|
12
|
+
* field defensively rather than casting it.
|
|
13
|
+
*
|
|
14
|
+
* Strictly read-only and side-effect-free: no mutation, no MCP server startup.
|
|
15
|
+
* Without `--follow` it prints the latest page once and exits. With `--follow`
|
|
16
|
+
* it polls forever (until interrupted) at the same ~60s cadence as the
|
|
17
|
+
* server's own `epic_conductor_notification_sweep` job
|
|
18
|
+
* (`api/library/scheduled_jobs.py`), draining any full page immediately so a
|
|
19
|
+
* backlog is caught up fast before returning to normal cadence.
|
|
20
|
+
*
|
|
21
|
+
* Credentials are resolved ONLY through the shared conductor access seam
|
|
22
|
+
* ({@link resolveConductorBridgeApiAccess}, which itself resolves ONLY through
|
|
23
|
+
* `resolveBapiCredentials`) — never read directly from the environment here,
|
|
24
|
+
* and never obtained by spawning a subprocess. The API key travels only in the
|
|
25
|
+
* `X-API-Key` request header, matching the existing conductor Bridge API
|
|
26
|
+
* client convention; it never appears in a URL, in stdout, in stderr, or in any
|
|
27
|
+
* thrown/error text.
|
|
28
|
+
*
|
|
29
|
+
* Every notification data line goes to stdout (so a script can safely parse
|
|
30
|
+
* it); every other line — progress, "no data yet", warnings, errors — goes to
|
|
31
|
+
* stderr. This module deliberately renders in monochrome: no shared ANSI/color
|
|
32
|
+
* helper exists elsewhere in this codebase for CLI output (the one bespoke use,
|
|
33
|
+
* `review-tickets.ts`'s fail-loud alert box, is a one-off inline escape
|
|
34
|
+
* sequence, not a reusable convention), so per this ticket's guidance this CLI
|
|
35
|
+
* does not invent a new styling system.
|
|
36
|
+
*/
|
|
37
|
+
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
38
|
+
import os from "node:os";
|
|
39
|
+
import { ConductorBridgeApiError, fetchConductorJsonWithTimeout, fetchEpicRunState, resolveConductorBridgeApiAccess, safeDiagnosticMessage, buildConductorJiraUrl, CONDUCTOR_FETCH_TIMEOUT_MS, } from "./conductor/bridge-api-client.js";
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Tunables (injectable — never a bare hardcoded setTimeout/limit downstream)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/**
|
|
44
|
+
* Follow-mode poll cadence, matching the server's own notification sweep
|
|
45
|
+
* (`epic_conductor_notification_sweep`, a 60s `IntervalTrigger` in
|
|
46
|
+
* `api/library/scheduled_jobs.py`). Overridable via
|
|
47
|
+
* {@link NotificationsCliDeps.pollIntervalMs} for tests.
|
|
48
|
+
*/
|
|
49
|
+
export const NOTIFICATIONS_FOLLOW_POLL_INTERVAL_MS = 60_000;
|
|
50
|
+
/**
|
|
51
|
+
* Page size requested on every fetch (both the one-shot non-follow fetch and
|
|
52
|
+
* every follow-mode poll). A page that comes back at exactly this size is
|
|
53
|
+
* treated as "more may be waiting" and drained immediately, without a poll
|
|
54
|
+
* delay; a page smaller than this is "caught up". Overridable via
|
|
55
|
+
* {@link NotificationsCliDeps.pageLimit} for tests.
|
|
56
|
+
*/
|
|
57
|
+
export const NOTIFICATIONS_DEFAULT_PAGE_LIMIT = 200;
|
|
58
|
+
function isSafeInteger(value) {
|
|
59
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
60
|
+
}
|
|
61
|
+
function isValidTimestamp(value) {
|
|
62
|
+
return typeof value === "string" && value.trim() !== "" && !Number.isNaN(Date.parse(value));
|
|
63
|
+
}
|
|
64
|
+
/** A nullable/absent field must be `null`/absent, or a non-empty string. */
|
|
65
|
+
function readNullableNonEmptyString(record, key) {
|
|
66
|
+
const raw = record[key];
|
|
67
|
+
if (raw === undefined || raw === null)
|
|
68
|
+
return { ok: true, value: null };
|
|
69
|
+
return typeof raw === "string" && raw.trim() !== "" ? { ok: true, value: raw } : { ok: false };
|
|
70
|
+
}
|
|
71
|
+
/** A nullable/absent field must be `null`/absent, or a positive safe integer. */
|
|
72
|
+
function readNullablePositiveInt(record, key) {
|
|
73
|
+
const raw = record[key];
|
|
74
|
+
if (raw === undefined || raw === null)
|
|
75
|
+
return { ok: true, value: null };
|
|
76
|
+
return isSafeInteger(raw) && raw > 0 ? { ok: true, value: raw } : { ok: false };
|
|
77
|
+
}
|
|
78
|
+
/** A nullable/absent field must be `null`/absent, or a valid timestamp string. */
|
|
79
|
+
function readNullableTimestamp(record, key) {
|
|
80
|
+
const raw = record[key];
|
|
81
|
+
if (raw === undefined || raw === null)
|
|
82
|
+
return { ok: true, value: null };
|
|
83
|
+
return isValidTimestamp(raw) ? { ok: true, value: raw } : { ok: false };
|
|
84
|
+
}
|
|
85
|
+
/** Validate one raw notification item. Returns `null` for any malformed shape. */
|
|
86
|
+
function validateNotification(raw) {
|
|
87
|
+
if (!raw || typeof raw !== "object")
|
|
88
|
+
return null;
|
|
89
|
+
const r = raw;
|
|
90
|
+
if (!isSafeInteger(r.id) || r.id <= 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (typeof r.kind !== "string" || r.kind.trim() === "")
|
|
93
|
+
return null;
|
|
94
|
+
if (!isValidTimestamp(r.created_at))
|
|
95
|
+
return null;
|
|
96
|
+
if (!isSafeInteger(r.attempt_count) || r.attempt_count < 0)
|
|
97
|
+
return null;
|
|
98
|
+
const ticketKey = readNullableNonEmptyString(r, "ticket_key");
|
|
99
|
+
if (!ticketKey.ok)
|
|
100
|
+
return null;
|
|
101
|
+
const gate = readNullableNonEmptyString(r, "gate");
|
|
102
|
+
if (!gate.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const reason = readNullableNonEmptyString(r, "reason");
|
|
105
|
+
if (!reason.ok)
|
|
106
|
+
return null;
|
|
107
|
+
const headSha = readNullableNonEmptyString(r, "head_sha");
|
|
108
|
+
if (!headSha.ok)
|
|
109
|
+
return null;
|
|
110
|
+
const prNumber = readNullablePositiveInt(r, "pr_number");
|
|
111
|
+
if (!prNumber.ok)
|
|
112
|
+
return null;
|
|
113
|
+
const sentAt = readNullableTimestamp(r, "sent_at");
|
|
114
|
+
if (!sentAt.ok)
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
id: r.id,
|
|
118
|
+
kind: r.kind,
|
|
119
|
+
ticket_key: ticketKey.value,
|
|
120
|
+
gate: gate.value,
|
|
121
|
+
reason: reason.value,
|
|
122
|
+
pr_number: prNumber.value,
|
|
123
|
+
head_sha: headSha.value,
|
|
124
|
+
attempt_count: r.attempt_count,
|
|
125
|
+
sent_at: sentAt.value,
|
|
126
|
+
created_at: r.created_at,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Validate the whole `{ notifications: [...] }` envelope. Never casts blindly. */
|
|
130
|
+
function validateNotificationsResponse(parsed) {
|
|
131
|
+
if (!parsed || typeof parsed !== "object") {
|
|
132
|
+
return { ok: false, error: "malformed notifications response: expected a JSON object" };
|
|
133
|
+
}
|
|
134
|
+
const list = parsed.notifications;
|
|
135
|
+
if (!Array.isArray(list)) {
|
|
136
|
+
return { ok: false, error: "malformed notifications response: 'notifications' is not an array" };
|
|
137
|
+
}
|
|
138
|
+
const notifications = [];
|
|
139
|
+
for (let i = 0; i < list.length; i++) {
|
|
140
|
+
const n = validateNotification(list[i]);
|
|
141
|
+
if (!n) {
|
|
142
|
+
return { ok: false, error: `malformed notifications response: invalid entry at index ${i}` };
|
|
143
|
+
}
|
|
144
|
+
notifications.push(n);
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, notifications };
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Sanitized error classification (never a raw body/URL/API key)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
/**
|
|
152
|
+
* Classify a thrown fetch error as recoverable (network hiccup, timeout, 5xx,
|
|
153
|
+
* or 429 — worth a follow-mode retry) or terminal (auth failure, 404, or any
|
|
154
|
+
* other non-2xx — never retried). Always returns a bounded, secret-free
|
|
155
|
+
* message via the shared {@link safeDiagnosticMessage} formatter.
|
|
156
|
+
*/
|
|
157
|
+
function describeConductorFetchError(err) {
|
|
158
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
159
|
+
const recoverable = err.kind === "network" ||
|
|
160
|
+
err.kind === "timeout" ||
|
|
161
|
+
err.kind === "server" ||
|
|
162
|
+
(err.kind === "http" && err.status === 429);
|
|
163
|
+
return { recoverable, message: safeDiagnosticMessage(err, "Bridge API request failed") };
|
|
164
|
+
}
|
|
165
|
+
return { recoverable: false, message: "unexpected error contacting the Bridge API" };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve `epicKeyOrRunId` to a canonical {@link ResolvedNotificationRun} via
|
|
169
|
+
* the SAME run-read endpoint `setup-epic` already uses ({@link fetchEpicRunState},
|
|
170
|
+
* `GET /jira/epic-runs/runs/{identifier}/state?repo_name=...` — the server
|
|
171
|
+
* accepts either the epic key or the concrete run UUID as `{identifier}`).
|
|
172
|
+
*
|
|
173
|
+
* `fetchEpicRunState` itself casts the parsed JSON to `EpicRunState` without
|
|
174
|
+
* validation; this function does NOT trust that cast — it re-validates the
|
|
175
|
+
* three fields it actually needs (`epic_run_id`, `epic_key`, `repo_name`) as
|
|
176
|
+
* non-empty strings, and rejects a response whose `repo_name` disagrees with
|
|
177
|
+
* the repo the caller authenticated against, before returning anything.
|
|
178
|
+
*/
|
|
179
|
+
export async function resolveNotificationRun(access, epicKeyOrRunId, fetchImpl = globalThis.fetch) {
|
|
180
|
+
let state;
|
|
181
|
+
try {
|
|
182
|
+
state = await fetchEpicRunState(access, epicKeyOrRunId, fetchImpl);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const described = describeConductorFetchError(err);
|
|
186
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': ${described.message}` };
|
|
187
|
+
}
|
|
188
|
+
if (!state || typeof state !== "object") {
|
|
189
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': malformed response` };
|
|
190
|
+
}
|
|
191
|
+
const run = state.epic_run;
|
|
192
|
+
if (!run || typeof run !== "object") {
|
|
193
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': response is missing epic_run` };
|
|
194
|
+
}
|
|
195
|
+
const r = run;
|
|
196
|
+
const epicRunId = r.epic_run_id;
|
|
197
|
+
const epicKey = r.epic_key;
|
|
198
|
+
const repoName = r.repo_name;
|
|
199
|
+
if (typeof epicRunId !== "string" || epicRunId.trim() === "") {
|
|
200
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid epic_run_id` };
|
|
201
|
+
}
|
|
202
|
+
if (typeof epicKey !== "string" || epicKey.trim() === "") {
|
|
203
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid epic_key` };
|
|
204
|
+
}
|
|
205
|
+
if (typeof repoName !== "string" || repoName.trim() === "") {
|
|
206
|
+
return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid repo_name` };
|
|
207
|
+
}
|
|
208
|
+
if (repoName !== access.repoName) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `could not resolve run '${epicKeyOrRunId}': it belongs to a different repository`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, run: { epicRunId, epicKey, repoName } };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* `GET /jira/epic-runs/runs/{epicRunId}/notifications?repo_name=...`
|
|
218
|
+
* (`&after_id=...` only when a cursor is supplied, `&limit=...` always).
|
|
219
|
+
* URL-encodes the run id; sends the API key ONLY as the `X-API-Key` header,
|
|
220
|
+
* matching the existing conductor Bridge API client convention — never in the
|
|
221
|
+
* URL. Validates the parsed response defensively via
|
|
222
|
+
* {@link validateNotificationsResponse} rather than casting it.
|
|
223
|
+
*/
|
|
224
|
+
export async function fetchNotificationsPage(access, epicRunId, options, fetchImpl = globalThis.fetch) {
|
|
225
|
+
const params = {
|
|
226
|
+
repo_name: access.repoName,
|
|
227
|
+
limit: String(options.limit),
|
|
228
|
+
};
|
|
229
|
+
if (options.afterId !== undefined) {
|
|
230
|
+
params.after_id = String(options.afterId);
|
|
231
|
+
}
|
|
232
|
+
const url = buildConductorJiraUrl(access.baseUrl, `/epic-runs/runs/${encodeURIComponent(epicRunId)}/notifications`, params);
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = await fetchConductorJsonWithTimeout(url, { "X-API-Key": access.apiKey }, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const described = describeConductorFetchError(err);
|
|
239
|
+
return { ok: false, recoverable: described.recoverable, error: described.message };
|
|
240
|
+
}
|
|
241
|
+
const validated = validateNotificationsResponse(parsed);
|
|
242
|
+
if (!validated.ok) {
|
|
243
|
+
return { ok: false, recoverable: false, error: validated.error };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, notifications: validated.notifications };
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Line formatting
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
/**
|
|
251
|
+
* Bound on the rendered `reason=` segment. The server already bounds stored
|
|
252
|
+
* reason text (BAPI-755 backend); this is a client-side belt-and-suspenders
|
|
253
|
+
* cap so a single notification line can never grow unboundedly even if that
|
|
254
|
+
* server-side bound changes. ~200 chars keeps one line legible in a terminal.
|
|
255
|
+
*/
|
|
256
|
+
const REASON_MAX_LENGTH = 200;
|
|
257
|
+
/** Collapse embedded newlines/control chars to single spaces and bound the length. */
|
|
258
|
+
function normalizeReasonText(raw) {
|
|
259
|
+
// Control characters (\x00-\x1f, \x7f) become a single space -- this
|
|
260
|
+
// covers \n, \r, \t and anything else that could break a single terminal
|
|
261
|
+
// line -- then any remaining run of whitespace collapses to one space.
|
|
262
|
+
// eslint-disable-next-line no-control-regex -- deliberately matching control chars
|
|
263
|
+
const collapsed = raw.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
264
|
+
return collapsed.length > REASON_MAX_LENGTH ? `${collapsed.slice(0, REASON_MAX_LENGTH)}…` : collapsed;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Render one stable one-line grammar:
|
|
268
|
+
* `created_at | kind | ticket=... | gate=... | pr=... | sha=... | reason=...`
|
|
269
|
+
* `created_at` and `kind` are always first and always present. Every other
|
|
270
|
+
* segment is OMITTED (never rendered as `ticket=null`/`ticket=undefined`) when
|
|
271
|
+
* the corresponding field is absent.
|
|
272
|
+
*/
|
|
273
|
+
export function formatNotificationLine(notification) {
|
|
274
|
+
const segments = [notification.created_at, notification.kind];
|
|
275
|
+
if (notification.ticket_key)
|
|
276
|
+
segments.push(`ticket=${notification.ticket_key}`);
|
|
277
|
+
if (notification.gate)
|
|
278
|
+
segments.push(`gate=${notification.gate}`);
|
|
279
|
+
if (notification.pr_number !== null)
|
|
280
|
+
segments.push(`pr=${notification.pr_number}`);
|
|
281
|
+
if (notification.head_sha)
|
|
282
|
+
segments.push(`sha=${notification.head_sha}`);
|
|
283
|
+
if (notification.reason)
|
|
284
|
+
segments.push(`reason=${normalizeReasonText(notification.reason)}`);
|
|
285
|
+
return segments.join(" | ");
|
|
286
|
+
}
|
|
287
|
+
/** User-facing usage text for `notifications`, including `-h`/`--help`. */
|
|
288
|
+
export function getNotificationsUsage() {
|
|
289
|
+
return [
|
|
290
|
+
"Usage: mcp-server notifications --repo <repo> --run <epic-key|run-id> [--follow]",
|
|
291
|
+
"",
|
|
292
|
+
"Read-only: prints an epic run's durable notification feed (BAPI-755), one line",
|
|
293
|
+
"per notification, oldest first. <epic-key|run-id> is resolved to the canonical",
|
|
294
|
+
"epic run via the existing run-read endpoint, exactly like setup-epic does.",
|
|
295
|
+
"",
|
|
296
|
+
"Required:",
|
|
297
|
+
" --repo <name> Repo name",
|
|
298
|
+
" --run <epic-key|id> Epic key (e.g. BAPI-405) or a concrete run id",
|
|
299
|
+
"",
|
|
300
|
+
"Options:",
|
|
301
|
+
" --follow Keep polling for new notifications (~60s cadence,",
|
|
302
|
+
" catching up immediately on any backlog) and print",
|
|
303
|
+
" each new one as it arrives, until interrupted",
|
|
304
|
+
" (Ctrl-C). Without it, prints the latest page once",
|
|
305
|
+
" and exits.",
|
|
306
|
+
" -h, --help Show this help",
|
|
307
|
+
"",
|
|
308
|
+
"Each line has the form:",
|
|
309
|
+
" <created_at> | <kind> | ticket=... | gate=... | pr=... | sha=... | reason=...",
|
|
310
|
+
"Every ticket=/gate=/pr=/sha=/reason= segment is OMITTED when the",
|
|
311
|
+
"corresponding field is absent — never rendered as 'null' or 'undefined'.",
|
|
312
|
+
"",
|
|
313
|
+
"All notification data is written to stdout (safe to pipe/parse); progress",
|
|
314
|
+
"and diagnostic text is written to stderr.",
|
|
315
|
+
].join("\n");
|
|
316
|
+
}
|
|
317
|
+
const NOTIFICATIONS_KNOWN_FLAGS = new Set(["--repo", "--run", "--follow"]);
|
|
318
|
+
/**
|
|
319
|
+
* Parse `notifications` argv. Requires non-blank `--repo` and `--run`, accepts
|
|
320
|
+
* an optional `--follow` boolean flag (and its `--help`), and REJECTS unknown
|
|
321
|
+
* flags, duplicate flags, a flag with a missing/blank value, and any
|
|
322
|
+
* positional (non-flag) argument. Mirrors the flag-parsing shape already used
|
|
323
|
+
* by `parseMcpInvokeArgs` (`mcp-invoke.ts`): `--flag value` or `--flag=value`,
|
|
324
|
+
* duplicate detection per flag, missing-value detection before the flag is
|
|
325
|
+
* consumed.
|
|
326
|
+
*/
|
|
327
|
+
export function parseNotificationsArgs(argv) {
|
|
328
|
+
let repo;
|
|
329
|
+
let run;
|
|
330
|
+
let follow = false;
|
|
331
|
+
for (let i = 0; i < argv.length; i++) {
|
|
332
|
+
const token = argv[i];
|
|
333
|
+
if (token === "-h" || token === "--help") {
|
|
334
|
+
return { ok: true, help: true };
|
|
335
|
+
}
|
|
336
|
+
let flag = token;
|
|
337
|
+
let value;
|
|
338
|
+
const eq = token.indexOf("=");
|
|
339
|
+
if (token.startsWith("--") && eq !== -1) {
|
|
340
|
+
flag = token.slice(0, eq);
|
|
341
|
+
value = token.slice(eq + 1);
|
|
342
|
+
}
|
|
343
|
+
if (!NOTIFICATIONS_KNOWN_FLAGS.has(flag)) {
|
|
344
|
+
if (token.startsWith("-")) {
|
|
345
|
+
return { ok: false, error: `Unknown flag: ${flag}` };
|
|
346
|
+
}
|
|
347
|
+
return { ok: false, error: `Unexpected positional argument: ${token}` };
|
|
348
|
+
}
|
|
349
|
+
if (flag === "--follow") {
|
|
350
|
+
if (value !== undefined) {
|
|
351
|
+
return { ok: false, error: "--follow does not take a value" };
|
|
352
|
+
}
|
|
353
|
+
if (follow) {
|
|
354
|
+
return { ok: false, error: "Duplicate --follow flag" };
|
|
355
|
+
}
|
|
356
|
+
follow = true;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (value === undefined) {
|
|
360
|
+
const next = argv[i + 1];
|
|
361
|
+
if (next === undefined || next.startsWith("-")) {
|
|
362
|
+
return { ok: false, error: `Missing value for ${flag}` };
|
|
363
|
+
}
|
|
364
|
+
value = next;
|
|
365
|
+
i++;
|
|
366
|
+
}
|
|
367
|
+
const trimmed = value.trim();
|
|
368
|
+
if (trimmed === "") {
|
|
369
|
+
return { ok: false, error: `${flag} requires a non-blank value` };
|
|
370
|
+
}
|
|
371
|
+
if (flag === "--repo") {
|
|
372
|
+
if (repo !== undefined)
|
|
373
|
+
return { ok: false, error: "Duplicate --repo flag" };
|
|
374
|
+
repo = trimmed;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
if (run !== undefined)
|
|
378
|
+
return { ok: false, error: "Duplicate --run flag" };
|
|
379
|
+
run = trimmed;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (repo === undefined)
|
|
383
|
+
return { ok: false, error: "Missing required --repo flag" };
|
|
384
|
+
if (run === undefined)
|
|
385
|
+
return { ok: false, error: "Missing required --run flag" };
|
|
386
|
+
return { ok: true, args: { repo, run, follow } };
|
|
387
|
+
}
|
|
388
|
+
export function createDefaultNotificationsCliDeps() {
|
|
389
|
+
return {
|
|
390
|
+
env: process.env,
|
|
391
|
+
cwd: process.cwd(),
|
|
392
|
+
platform: process.platform,
|
|
393
|
+
homedir: os.homedir,
|
|
394
|
+
readFile: (p) => fsReadFile(p, "utf-8"),
|
|
395
|
+
stat: (p) => fsStat(p),
|
|
396
|
+
fetch: globalThis.fetch,
|
|
397
|
+
stdout: (line) => {
|
|
398
|
+
process.stdout.write(`${line}\n`);
|
|
399
|
+
},
|
|
400
|
+
stderr: (line) => {
|
|
401
|
+
process.stderr.write(`${line}\n`);
|
|
402
|
+
},
|
|
403
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
404
|
+
now: () => new Date(),
|
|
405
|
+
isTTY: Boolean(process.stderr.isTTY),
|
|
406
|
+
onSignal: (signal, handler) => {
|
|
407
|
+
process.on(signal, handler);
|
|
408
|
+
return () => {
|
|
409
|
+
process.off(signal, handler);
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
pollIntervalMs: NOTIFICATIONS_FOLLOW_POLL_INTERVAL_MS,
|
|
413
|
+
pageLimit: NOTIFICATIONS_DEFAULT_PAGE_LIMIT,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** Print each notification whose id advances `cursor`; returns the new cursor. */
|
|
417
|
+
function emitNewRows(page, cursor, stdout) {
|
|
418
|
+
let next = cursor;
|
|
419
|
+
for (const n of page) {
|
|
420
|
+
if (next !== undefined && n.id <= next)
|
|
421
|
+
continue;
|
|
422
|
+
stdout(formatNotificationLine(n));
|
|
423
|
+
next = next === undefined ? n.id : Math.max(next, n.id);
|
|
424
|
+
}
|
|
425
|
+
return next;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* CLI entry for the read-only `notifications` subcommand. Returns a process
|
|
429
|
+
* exit code (never calls `process.exit` itself — the caller in `index.ts`
|
|
430
|
+
* assigns the return value to `process.exitCode`, matching every other
|
|
431
|
+
* subcommand here).
|
|
432
|
+
*
|
|
433
|
+
* Non-follow: fetches the latest (no-cursor) page once, prints it in
|
|
434
|
+
* ascending id order, exits 0. An empty result is reported on stderr, naming
|
|
435
|
+
* the resolved run and pointing at `--follow` — never implying data loss.
|
|
436
|
+
*
|
|
437
|
+
* Follow: repeatedly fetches (cursor-advancing), printing only new rows.
|
|
438
|
+
* A page that comes back at `pageLimit` is drained immediately (no sleep)
|
|
439
|
+
* until a page comes back under the limit, then normal `pollIntervalMs`
|
|
440
|
+
* cadence resumes. A RECOVERABLE fetch failure (network/timeout/5xx/429) is a
|
|
441
|
+
* stderr warning that keeps the last confirmed cursor and retries after the
|
|
442
|
+
* normal wait; a TERMINAL failure (401/403/404/malformed response) exits 1
|
|
443
|
+
* immediately with no further polling. A clean interruption (SIGINT/SIGTERM)
|
|
444
|
+
* exits 0 with no stack trace.
|
|
445
|
+
*/
|
|
446
|
+
export async function runNotificationsCli(argv, overrides = {}) {
|
|
447
|
+
const deps = { ...createDefaultNotificationsCliDeps(), ...overrides };
|
|
448
|
+
const parsed = parseNotificationsArgs(argv);
|
|
449
|
+
if (!parsed.ok) {
|
|
450
|
+
deps.stderr(`Error: ${parsed.error}`);
|
|
451
|
+
deps.stderr("");
|
|
452
|
+
deps.stderr(getNotificationsUsage());
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
// Narrowing on a single `in` check (rather than the prior compound
|
|
456
|
+
// `"help" in parsed && parsed.help`) so TypeScript can actually exclude the
|
|
457
|
+
// `{ ok: true; help: true }` branch below — a compound `&&` condition's
|
|
458
|
+
// negation isn't narrowed the same way by control-flow analysis.
|
|
459
|
+
if ("help" in parsed) {
|
|
460
|
+
deps.stdout(getNotificationsUsage());
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
const { repo, run, follow } = parsed.args;
|
|
464
|
+
const accessResult = await resolveConductorBridgeApiAccess({
|
|
465
|
+
env: deps.env,
|
|
466
|
+
cwd: deps.cwd,
|
|
467
|
+
homedir: deps.homedir,
|
|
468
|
+
platform: deps.platform,
|
|
469
|
+
readFile: deps.readFile,
|
|
470
|
+
stat: deps.stat,
|
|
471
|
+
repoName: repo,
|
|
472
|
+
});
|
|
473
|
+
if (!accessResult.ok) {
|
|
474
|
+
deps.stderr(`Error: cannot reach the Bridge API: ${accessResult.error}`);
|
|
475
|
+
return 1;
|
|
476
|
+
}
|
|
477
|
+
const access = accessResult.access;
|
|
478
|
+
const fetchImpl = deps.fetch;
|
|
479
|
+
if (deps.isTTY)
|
|
480
|
+
deps.stderr("Checking...");
|
|
481
|
+
const resolved = await resolveNotificationRun(access, run, fetchImpl);
|
|
482
|
+
if (!resolved.ok) {
|
|
483
|
+
deps.stderr(`Error: ${resolved.error}`);
|
|
484
|
+
return 1;
|
|
485
|
+
}
|
|
486
|
+
const { epicRunId, epicKey } = resolved.run;
|
|
487
|
+
if (!follow) {
|
|
488
|
+
const page = await fetchNotificationsPage(access, epicRunId, { limit: deps.pageLimit }, fetchImpl);
|
|
489
|
+
if (!page.ok) {
|
|
490
|
+
deps.stderr(`Error: could not fetch notifications for ${epicKey} (${epicRunId}): ${page.error}`);
|
|
491
|
+
return 1;
|
|
492
|
+
}
|
|
493
|
+
if (page.notifications.length === 0) {
|
|
494
|
+
deps.stderr(`No notifications yet for ${epicKey} (${epicRunId}). Re-run with --follow to wait for future ones.`);
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
emitNewRows(page.notifications, undefined, deps.stdout);
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
// --- Follow mode -----------------------------------------------------
|
|
501
|
+
let stopped = false;
|
|
502
|
+
let resolveStop;
|
|
503
|
+
const stopPromise = new Promise((resolve) => {
|
|
504
|
+
resolveStop = resolve;
|
|
505
|
+
});
|
|
506
|
+
const requestStop = () => {
|
|
507
|
+
if (!stopped) {
|
|
508
|
+
stopped = true;
|
|
509
|
+
resolveStop?.();
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const disposers = [deps.onSignal("SIGINT", requestStop), deps.onSignal("SIGTERM", requestStop)];
|
|
513
|
+
try {
|
|
514
|
+
let cursor;
|
|
515
|
+
let announcedWaiting = false;
|
|
516
|
+
while (!stopped) {
|
|
517
|
+
// Drain loop: keep fetching immediately (no sleep) while pages come
|
|
518
|
+
// back full — catches up on a backlog fast before returning to the
|
|
519
|
+
// normal poll cadence.
|
|
520
|
+
for (;;) {
|
|
521
|
+
const page = await fetchNotificationsPage(access, epicRunId, { afterId: cursor, limit: deps.pageLimit }, fetchImpl);
|
|
522
|
+
if (!page.ok) {
|
|
523
|
+
if (!page.recoverable) {
|
|
524
|
+
deps.stderr(`Error: notifications fetch failed for ${epicKey} (${epicRunId}): ${page.error}`);
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
deps.stderr(`Warning: notifications fetch failed, retrying: ${page.error}`);
|
|
528
|
+
break; // Exit the drain loop; the outer loop waits, then retries.
|
|
529
|
+
}
|
|
530
|
+
if (page.notifications.length === 0) {
|
|
531
|
+
if (cursor === undefined && !announcedWaiting) {
|
|
532
|
+
deps.stderr(`No notifications yet for ${epicKey} (${epicRunId}) — waiting (--follow).`);
|
|
533
|
+
announcedWaiting = true;
|
|
534
|
+
}
|
|
535
|
+
break; // Nothing to drain.
|
|
536
|
+
}
|
|
537
|
+
cursor = emitNewRows(page.notifications, cursor, deps.stdout);
|
|
538
|
+
if (page.notifications.length < deps.pageLimit)
|
|
539
|
+
break; // Caught up.
|
|
540
|
+
if (stopped)
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
if (stopped)
|
|
544
|
+
break;
|
|
545
|
+
await Promise.race([deps.sleep(deps.pollIntervalMs), stopPromise]);
|
|
546
|
+
}
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
for (const dispose of disposers)
|
|
551
|
+
dispose();
|
|
552
|
+
}
|
|
553
|
+
}
|