@bridge_gpt/mcp-server 0.2.37 → 0.2.39

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.
Files changed (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -0,0 +1,562 @@
1
+ /**
2
+ * merge_pull_request — the fail-closed, server-decided protected merge tool (BAPI-799).
3
+ *
4
+ * The agent asks; the SERVER decides. This module holds no VCS credential, spawns
5
+ * no `gh` subprocess, and implements no merge of its own. It resolves the
6
+ * authoritative required-check set, constructs the deterministic action key, POSTs
7
+ * the protected root-mounted route, and renders the server's decision as one
8
+ * compact JSON text envelope. Every branch — including every error branch —
9
+ * resolves to that envelope; nothing throws through the MCP transport.
10
+ *
11
+ * WHY A FIXED ENVELOPE
12
+ * --------------------
13
+ * A merge is the one operation whose outcome an agent must never guess at. The
14
+ * envelope therefore always opens with the same six keys in the same order —
15
+ * `merged`, `outcome`, `reason`, `retry_hint`, `evaluated_head_sha`, `pr_number` —
16
+ * so a caller can branch on `merged` without parsing prose, and can always tell
17
+ * WHICH pull request and WHICH commit the answer is about. `merged: true` is the
18
+ * ONLY success; `dry_run`, `pending_approval`, and `lease_held` are non-progress
19
+ * outcomes that read as "nothing happened", not as "not yet, keep waiting".
20
+ *
21
+ * DIAGNOSTIC ALLOWLIST
22
+ * --------------------
23
+ * Only `actual_head_sha`, `ci_summary`, `paths`, `hint`, and `http_status` may
24
+ * follow the six stable fields, and each is copied from an authoritative server
25
+ * location. Raw ledger events, provider payloads, request headers, credentials,
26
+ * the action key, worker claims, and reviewer claims are never serialized here.
27
+ *
28
+ * REVIEW PRE-CHECK IS ADVISORY, NOT PROOF (BAPI-800)
29
+ * --------------------------------------------------
30
+ * When the configured done gate carries a `review_state` condition, this module
31
+ * reads the review-status route and evaluates the condition BEFORE dispatching
32
+ * the merge — so an obviously unreviewed PR is refused without a round trip.
33
+ * That check is advisory in the precise sense that matters: requiring the
34
+ * route's `detail.head_sha` to equal `expected_head_sha` is a PRECONDITION, not
35
+ * evidence that the review was *of* that head.
36
+ *
37
+ * The fields that do prove head binding — `sticky_reviewed_sha`, and the
38
+ * head-matching native decision/approval pair — are server-only and are not in
39
+ * the route's `detail`. So a legacy approved comment carrying no `Reviewed-SHA`
40
+ * line passes here and is then refused by the server as
41
+ * `refused` / `review_not_approved`. That sequence is EXPECTED, not a bug: the
42
+ * server holds the proof, and its answer is surfaced verbatim.
43
+ *
44
+ * FROZEN-TREE BOUNDARY
45
+ * --------------------
46
+ * This module lives OUTSIDE `conductor/` and consumes it only through pure,
47
+ * I/O-free seams: `git-ci-types.js`, `done-gate.js`, and `merge-identity.js`. The
48
+ * action key and gate identity are imported rather than reimplemented, because a
49
+ * second copy that drifts by one character produces a 409 the caller cannot act on.
50
+ */
51
+ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, } from "./conductor/git-ci-types.js";
52
+ import { evaluateReviewCondition, normalizeReviewSnapshot, parseDoneGateConfig, } from "./conductor/done-gate.js";
53
+ import { buildGateIdentity, makeMergeActionKey } from "./conductor/merge-identity.js";
54
+ /**
55
+ * The exact remediation for a `dry_run`. Fixed text, because a caller that has to
56
+ * infer WHICH setting to flip from a reason code guesses wrong.
57
+ */
58
+ const DRY_RUN_HINT = "set auto_merge_enabled=true on the project default via PUT /jira/epic-runs/supervisor-config/defaults/";
59
+ /**
60
+ * Guidance for a post-dispatch transport failure. The action key makes the request
61
+ * idempotent, so repeating it verbatim is safe and is the ONLY safe move: the merge
62
+ * may or may not have landed, and this side cannot tell which.
63
+ */
64
+ const UNKNOWN_HINT = "the request was sent but its outcome was not observed; repeat this call with identical arguments — the server's action key makes it idempotent";
65
+ /** The one required-check resolution failure this handler ever reports. */
66
+ const REQUIRED_CHECKS_EMPTY = "required_checks_empty";
67
+ /** Server `failed` reasons whose remedy is genuinely "wait and repeat". */
68
+ const CI_NOT_GREEN_REASON = "ci_not_green";
69
+ /**
70
+ * The one review refusal the SERVER can issue that is worth waiting on: a
71
+ * verdict for this head may still be posted. Its siblings —
72
+ * `review_unavailable`, `review_source_unsupported`, `review_config_invalid` —
73
+ * fall through to `needs_human` below, because no amount of repeating the call
74
+ * moves an unreadable, unsupported, or malformed review configuration.
75
+ */
76
+ const REVIEW_NOT_APPROVED_REASON = "review_not_approved";
77
+ // ---------------------------------------------------------------------------
78
+ // Envelope construction
79
+ // ---------------------------------------------------------------------------
80
+ /** Wrap a JSON envelope as the single MCP text content item. */
81
+ function text(envelope) {
82
+ return { content: [{ type: "text", text: JSON.stringify(envelope) }] };
83
+ }
84
+ /**
85
+ * Build the envelope with the six stable fields FIRST, in fixed order, then only
86
+ * the approved diagnostics that are actually present. Insertion order is the
87
+ * serialized key order, and callers pin it — so this is the only place an envelope
88
+ * is ever constructed.
89
+ */
90
+ function envelope(merged, outcome, reason, retryHint, evaluatedHeadSha, prNumber, diagnostics = {}) {
91
+ const result = {
92
+ merged,
93
+ outcome,
94
+ reason,
95
+ retry_hint: retryHint,
96
+ evaluated_head_sha: evaluatedHeadSha,
97
+ pr_number: prNumber,
98
+ };
99
+ if (diagnostics.actual_head_sha !== undefined)
100
+ result.actual_head_sha = diagnostics.actual_head_sha;
101
+ if (diagnostics.ci_summary !== undefined)
102
+ result.ci_summary = diagnostics.ci_summary;
103
+ if (diagnostics.paths !== undefined)
104
+ result.paths = diagnostics.paths;
105
+ if (diagnostics.hint !== undefined)
106
+ result.hint = diagnostics.hint;
107
+ if (diagnostics.http_status !== undefined)
108
+ result.http_status = diagnostics.http_status;
109
+ return result;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Input validation
113
+ // ---------------------------------------------------------------------------
114
+ const SHA_RE = /^[0-9a-fA-F]{40}$/;
115
+ /** Narrow an unknown to a plain (non-array, non-null) object. */
116
+ function isPlainObject(value) {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
118
+ }
119
+ /**
120
+ * Validate the two inputs BEFORE any URL, header, or fetch operation.
121
+ *
122
+ * The MCP registration's Zod schema already rejects both cases at the protocol
123
+ * boundary, so this is defense in depth for a direct call — but it runs first
124
+ * regardless, because "reject before dispatch" is the only ordering in which an
125
+ * invalid PR number cannot become a real request against SOME pull request.
126
+ *
127
+ * Returns the reason for rejection, or `null` when both inputs are usable.
128
+ */
129
+ function validateInputs(prNumber, expectedHeadSha) {
130
+ if (typeof prNumber !== "number" ||
131
+ !Number.isSafeInteger(prNumber) ||
132
+ prNumber <= 0) {
133
+ return "invalid_pr_number";
134
+ }
135
+ if (typeof expectedHeadSha !== "string" || !SHA_RE.test(expectedHeadSha)) {
136
+ return "invalid_expected_head_sha";
137
+ }
138
+ return null;
139
+ }
140
+ /**
141
+ * Read a response body as JSON without throwing. A body that is absent, empty, or
142
+ * not JSON yields `undefined` and is handled as an unusable payload by the caller.
143
+ */
144
+ async function readJson(resp) {
145
+ try {
146
+ const body = await resp.text();
147
+ if (body.trim().length === 0)
148
+ return undefined;
149
+ return JSON.parse(body);
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
155
+ /**
156
+ * Normalize, de-duplicate, and order a candidate check-name list. Response order is
157
+ * preserved because it is the order the authoritative source declared, and the
158
+ * server recomputes the same action key over the same sequence.
159
+ */
160
+ function normalizeCheckNames(raw) {
161
+ const out = [];
162
+ const seen = new Set();
163
+ for (const candidate of raw) {
164
+ const name = normalizeCheckName(candidate);
165
+ if (name === null || seen.has(name))
166
+ continue;
167
+ seen.add(name);
168
+ out.push(name);
169
+ }
170
+ return out;
171
+ }
172
+ /**
173
+ * Resolve the required checks from the ONE authoritative source, in priority order.
174
+ *
175
+ * The effective supervisor defaults win whenever they carry a `done_gate_config` at
176
+ * all. A config that is PRESENT but malformed is still authoritative — it resolves
177
+ * to no checks and the merge is refused. Falling back to the CI resolver there
178
+ * would be the worst possible behavior: it would silently substitute a discovered
179
+ * check set for the one an operator deliberately configured and got wrong.
180
+ *
181
+ * Returns `null` when the authoritative source could not be read at all, which the
182
+ * caller treats exactly like "resolved no checks" — fail closed, never dispatch.
183
+ */
184
+ async function resolveRequiredChecks(deps, expectedHeadSha) {
185
+ const fetchImpl = deps.fetchImpl ?? fetch;
186
+ // URL and header construction sit OUTSIDE the try on purpose. A failure there is
187
+ // a broken dependency, not an unreadable config, and it belongs to the handler's
188
+ // outer containment boundary — reporting it as "resolved no checks" would
189
+ // describe the wrong thing. Only the request itself failing means unreadable.
190
+ const defaultsUrl = deps.buildGetUrl("/epic-runs/supervisor-setup/defaults/", {
191
+ repo_name: deps.repoName,
192
+ });
193
+ const defaultsHeaders = await deps.getHeaders();
194
+ let defaultsResp;
195
+ try {
196
+ defaultsResp = await fetchImpl(defaultsUrl, { headers: defaultsHeaders });
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ if (!defaultsResp.ok) {
202
+ // Consume and normalize the body through the shared handler so no raw upstream
203
+ // text is retained; the status alone drives the fail-closed decision.
204
+ await deps.handleResponse(defaultsResp).catch(() => "");
205
+ return null;
206
+ }
207
+ const defaults = await readJson(defaultsResp);
208
+ if (!isPlainObject(defaults))
209
+ return null;
210
+ // Presence semantics: the defaults response ALWAYS carries the key (the Pydantic
211
+ // model serializes it as null when unset), so "explicitly present" means present
212
+ // and non-null. Only that state is authoritative.
213
+ const rawGateConfig = defaults.done_gate_config;
214
+ if (rawGateConfig !== undefined && rawGateConfig !== null) {
215
+ const parsed = parseDoneGateConfig(rawGateConfig);
216
+ if (!parsed.enabled || !parsed.valid) {
217
+ return { checks: [], configHash: null, reviewCondition: null };
218
+ }
219
+ const condition = parsed.conditions.find((c) => c.type === REQUIRED_CI_CHECKS_GREEN);
220
+ if (condition === undefined || condition.type !== REQUIRED_CI_CHECKS_GREEN) {
221
+ return { checks: [], configHash: null, reviewCondition: null };
222
+ }
223
+ // Same parse, same moment: the review condition is read out of the result
224
+ // that resolved the checks rather than re-fetched.
225
+ const review = parsed.conditions.find((c) => c.type === REVIEW_STATE);
226
+ const reviewCondition = review !== undefined && review.type === REVIEW_STATE ? review : null;
227
+ // Parser-provided order and hash are preserved verbatim — the server recomputes
228
+ // the action key over exactly these values.
229
+ return {
230
+ checks: [...condition.required_checks],
231
+ configHash: parsed.config_hash,
232
+ reviewCondition,
233
+ };
234
+ }
235
+ // Fallback: discover the checks through the CI resolver. `config_hash` is null
236
+ // here because no configured gate produced this set.
237
+ const resolverUrl = deps.buildUrl("/resolve-ci-checks");
238
+ const resolverHeaders = await deps.getPostHeaders();
239
+ let resolverResp;
240
+ try {
241
+ resolverResp = await fetchImpl(resolverUrl, {
242
+ method: "POST",
243
+ headers: resolverHeaders,
244
+ body: JSON.stringify({
245
+ repo_name: deps.repoName,
246
+ commit_ref: expectedHeadSha,
247
+ }),
248
+ });
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ if (!resolverResp.ok) {
254
+ await deps.handleResponse(resolverResp).catch(() => "");
255
+ return null;
256
+ }
257
+ const resolved = await readJson(resolverResp);
258
+ if (!isPlainObject(resolved))
259
+ return null;
260
+ const detail = resolved.detail;
261
+ if (!isPlainObject(detail))
262
+ return { checks: [], configHash: null, reviewCondition: null };
263
+ const rawChecks = detail.checks;
264
+ if (!Array.isArray(rawChecks))
265
+ return { checks: [], configHash: null, reviewCondition: null };
266
+ // STRICTLY `true`. A missing or truthy-but-not-boolean `required` is not a
267
+ // declaration that the check gates a merge, and treating it as one would let an
268
+ // informational check block — or, worse, let a mis-typed one silently pass.
269
+ const requiredNames = rawChecks
270
+ .filter((entry) => isPlainObject(entry) && entry.required === true)
271
+ .map((entry) => entry.name);
272
+ // The resolver discovers CI checks only; it knows nothing about review policy,
273
+ // so this path is never review-gated.
274
+ return { checks: normalizeCheckNames(requiredNames), configHash: null, reviewCondition: null };
275
+ }
276
+ // ---------------------------------------------------------------------------
277
+ // Review pre-check (BAPI-800)
278
+ // ---------------------------------------------------------------------------
279
+ /** The review sources this pre-check can evaluate against the route's `detail`. */
280
+ const SUPPORTED_REVIEW_SOURCES = new Set(["verdict_protocol", "native_review_decision"]);
281
+ const REVIEW_UNAVAILABLE_REASON = "review_unavailable";
282
+ const REVIEW_SOURCE_UNSUPPORTED_REASON = "review_source_unsupported";
283
+ const HEAD_SHA_DRIFT_REASON = "head_sha_drift";
284
+ /**
285
+ * Evaluate the configured `review_state` condition before the merge is sent.
286
+ *
287
+ * Returns a refusal envelope, or `null` meaning "nothing objected — continue".
288
+ * Every failure direction is contained here and every one of them is a refusal:
289
+ * a review this side cannot read, cannot parse, or cannot evaluate is not a
290
+ * review that approved anything.
291
+ *
292
+ * `min_approvals` and `combination` are refused OUTRIGHT rather than approximated
293
+ * with a weaker rule. The server refuses them too, for the same reason: silently
294
+ * evaluating a different condition than the operator configured is worse than
295
+ * declining to evaluate at all.
296
+ */
297
+ async function precheckReviewCondition(deps, condition, prNumber, expectedHeadSha) {
298
+ if (!SUPPORTED_REVIEW_SOURCES.has(condition.source)) {
299
+ return envelope(false, "review_source_unsupported", REVIEW_SOURCE_UNSUPPORTED_REASON, "needs_human", expectedHeadSha, prNumber);
300
+ }
301
+ const unavailable = () => envelope(false, "review_unavailable", REVIEW_UNAVAILABLE_REASON, "needs_human", expectedHeadSha, prNumber);
302
+ // URL and header construction sit OUTSIDE the try, matching the required-check
303
+ // resolver: a failure there is a broken dependency, not an unreadable review,
304
+ // and it belongs to the handler's outer containment boundary. The repository
305
+ // name comes from the server's environment and is query-encoded, so a name
306
+ // carrying a slash or a space cannot reshape the path.
307
+ const reviewUrl = deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/reviews/status`) +
308
+ `?repo_name=${encodeURIComponent(deps.repoName)}`;
309
+ const reviewHeaders = await deps.getHeaders();
310
+ let reviewResp;
311
+ try {
312
+ reviewResp = await (deps.fetchImpl ?? fetch)(reviewUrl, { headers: reviewHeaders });
313
+ }
314
+ catch {
315
+ return unavailable();
316
+ }
317
+ if (!reviewResp.ok) {
318
+ await deps.handleResponse(reviewResp).catch(() => "");
319
+ return unavailable();
320
+ }
321
+ // `normalizeReviewSnapshot` returns null for `available: false` and for any
322
+ // body it cannot read as a review snapshot — the same answer, because both
323
+ // mean the same thing here.
324
+ const snapshot = normalizeReviewSnapshot(await readJson(reviewResp));
325
+ if (snapshot === null)
326
+ return unavailable();
327
+ // Head equality is checked BEFORE the evaluator, and takes precedence over an
328
+ // otherwise-approving verdict: a review of a different commit says nothing
329
+ // about this one. The evaluator never compares heads, which is exactly why
330
+ // this check cannot be folded into it.
331
+ if (snapshot.head_sha !== expectedHeadSha) {
332
+ const diagnostics = {};
333
+ if (typeof snapshot.head_sha === "string" && snapshot.head_sha.length > 0) {
334
+ diagnostics.actual_head_sha = snapshot.head_sha;
335
+ }
336
+ return envelope(false, "refused", HEAD_SHA_DRIFT_REASON, "needs_human", expectedHeadSha, prNumber, diagnostics);
337
+ }
338
+ const evaluation = evaluateReviewCondition(condition, snapshot);
339
+ if (!evaluation.passed) {
340
+ // A wait, not a park: a verdict for THIS head may still be posted. The
341
+ // evaluator's own reason is forwarded verbatim rather than flattened, so the
342
+ // caller can tell "no verdict yet" from "changes requested".
343
+ return envelope(false, "review_not_approved", evaluation.reason, "retry_later", expectedHeadSha, prNumber);
344
+ }
345
+ return null;
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // Server-response interpretation
349
+ // ---------------------------------------------------------------------------
350
+ /**
351
+ * Pull the approved diagnostics out of the merge response's ledger events.
352
+ *
353
+ * The authoritative location is the FAILED event's
354
+ * `details.guard_outcomes` bundle, which the Python service builds through its
355
+ * secret-scrubbing evidence builder. Nothing else from the event — not its type,
356
+ * its action key, or the rest of the bundle — is read or forwarded.
357
+ */
358
+ function extractDiagnostics(body) {
359
+ const diagnostics = {};
360
+ const events = body.ledger_events;
361
+ if (!Array.isArray(events))
362
+ return diagnostics;
363
+ for (const event of events) {
364
+ if (!isPlainObject(event) || event.status !== "failed")
365
+ continue;
366
+ const details = event.details;
367
+ if (!isPlainObject(details))
368
+ continue;
369
+ const guard = isPlainObject(details.guard_outcomes) ? details.guard_outcomes : {};
370
+ if (diagnostics.actual_head_sha === undefined && typeof guard.actual_head_sha === "string") {
371
+ diagnostics.actual_head_sha = guard.actual_head_sha;
372
+ }
373
+ if (diagnostics.ci_summary === undefined) {
374
+ const summary = isPlainObject(guard.ci_summary)
375
+ ? guard.ci_summary
376
+ : isPlainObject(details.ci_summary)
377
+ ? details.ci_summary
378
+ : undefined;
379
+ if (summary !== undefined)
380
+ diagnostics.ci_summary = summary;
381
+ }
382
+ if (diagnostics.paths === undefined && Array.isArray(guard.paths)) {
383
+ diagnostics.paths = guard.paths.filter((p) => typeof p === "string");
384
+ }
385
+ }
386
+ return diagnostics;
387
+ }
388
+ /**
389
+ * True when the authoritative CI summary shows at least one required check that is
390
+ * REPORTING and has not completed yet.
391
+ *
392
+ * This is the whole difference between "wait" and "act": a running check will
393
+ * settle on its own, while a check that completed red will still be red on the next
394
+ * identical call.
395
+ *
396
+ * A check the provider has not reported at all (`present: false`) does NOT count as
397
+ * pending. Absence is not evidence that work is in flight — it is equally
398
+ * consistent with a check that will never appear, and treating it as pending would
399
+ * send the caller into an unbounded retry loop over a condition no retry can move.
400
+ * Absent or malformed summary data reads the same way, for the same reason.
401
+ */
402
+ function hasIncompleteRequiredCheck(ciSummary) {
403
+ if (!isPlainObject(ciSummary))
404
+ return false;
405
+ const checks = ciSummary.checks;
406
+ if (!Array.isArray(checks))
407
+ return false;
408
+ return checks.some((check) => isPlainObject(check) && check.complete !== true && check.present !== false);
409
+ }
410
+ /** Retry hint for a server `failed` reason. */
411
+ function retryHintForFailure(reason, ciSummary) {
412
+ if (reason === CI_NOT_GREEN_REASON) {
413
+ return hasIncompleteRequiredCheck(ciSummary) ? "retry_later" : "needs_human";
414
+ }
415
+ if (reason === REVIEW_NOT_APPROVED_REASON)
416
+ return "retry_later";
417
+ return "needs_human";
418
+ }
419
+ /**
420
+ * Map one parsed 200-level merge response onto the envelope.
421
+ *
422
+ * `merged: true` is reachable from exactly one branch: `terminal === true` AND
423
+ * `status === "succeeded"` AND a recognized success reason. Every other shape,
424
+ * including a succeeded-but-not-terminal payload and any unrecognized status, is a
425
+ * non-success envelope. A merge tool that guesses generously about a malformed
426
+ * payload is worse than one that refuses.
427
+ */
428
+ function interpretMergeResponse(body, expectedHeadSha, prNumber) {
429
+ const malformed = () => envelope(false, "error", "malformed_merge_response", "needs_human", expectedHeadSha, prNumber);
430
+ if (!isPlainObject(body))
431
+ return malformed();
432
+ const status = body.status;
433
+ const reason = typeof body.reason === "string" ? body.reason : null;
434
+ if (typeof status !== "string")
435
+ return malformed();
436
+ if (status === "succeeded") {
437
+ if (body.terminal !== true)
438
+ return malformed();
439
+ if (reason === "already_merged") {
440
+ return envelope(true, "already_merged", reason, null, expectedHeadSha, prNumber);
441
+ }
442
+ if (reason === "merged") {
443
+ return envelope(true, "merged", reason, null, expectedHeadSha, prNumber);
444
+ }
445
+ return malformed();
446
+ }
447
+ if (status === "dry_run") {
448
+ return envelope(false, "dry_run", reason, "needs_human", expectedHeadSha, prNumber, {
449
+ hint: DRY_RUN_HINT,
450
+ });
451
+ }
452
+ if (status === "pending_approval") {
453
+ return envelope(false, "pending_approval", reason, "needs_human", expectedHeadSha, prNumber);
454
+ }
455
+ if (status === "lease_held") {
456
+ return envelope(false, "lease_held", reason, "retry_later", expectedHeadSha, prNumber);
457
+ }
458
+ if (status === "failed") {
459
+ if (reason === null)
460
+ return malformed();
461
+ const diagnostics = extractDiagnostics(body);
462
+ return envelope(false, "refused", reason, retryHintForFailure(reason, diagnostics.ci_summary), expectedHeadSha, prNumber, diagnostics);
463
+ }
464
+ return malformed();
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // Handler
468
+ // ---------------------------------------------------------------------------
469
+ /**
470
+ * Ask the Bridge API to merge a pull request, and render its decision.
471
+ *
472
+ * The outer `try` is a containment boundary, not error handling: an exception that
473
+ * escaped here would surface to the agent as an MCP transport failure with no
474
+ * envelope at all, and a caller facing a merge tool that "errored" cannot tell
475
+ * whether the merge happened. Every path returns an envelope instead.
476
+ */
477
+ export async function mergePullRequestHandler(deps, args) {
478
+ // Read the identity fields defensively: `args` is a runtime value and this
479
+ // handler must echo the caller's own PR/SHA back even when they are unusable.
480
+ const rawPr = args?.pr_number;
481
+ const rawSha = args?.expected_head_sha;
482
+ const echoedSha = typeof rawSha === "string" ? rawSha : null;
483
+ const echoedPr = typeof rawPr === "number" ? rawPr : null;
484
+ try {
485
+ // (1) Reject invalid input before ANY dependency that could perform I/O.
486
+ // `error` is reused deliberately rather than minting a new outcome: the
487
+ // vocabulary above is closed, and an unusable argument is not a merge decision.
488
+ const invalid = validateInputs(rawPr, rawSha);
489
+ if (invalid !== null) {
490
+ return text(envelope(false, "error", invalid, "needs_human", echoedSha, echoedPr));
491
+ }
492
+ const prNumber = rawPr;
493
+ const expectedHeadSha = rawSha;
494
+ // (2) Resolve the authoritative required checks. This stage completes fully
495
+ // before the review pre-check below, so an unresolvable CI gate refuses
496
+ // without ever asking a review provider about a merge that could not have
497
+ // happened anyway.
498
+ const resolution = await resolveRequiredChecks(deps, expectedHeadSha);
499
+ if (resolution === null || resolution.checks.length === 0) {
500
+ // Nothing authoritative said which checks gate this merge, so no merge URL,
501
+ // no merge headers, and no request are constructed. A vacuous "all zero
502
+ // required checks are green" is the one CI pass that must never be possible.
503
+ return text(envelope(false, "gate_unresolved", REQUIRED_CHECKS_EMPTY, "needs_human", expectedHeadSha, prNumber));
504
+ }
505
+ // (2b) Review pre-check (BAPI-800). It runs only for a configured gate that
506
+ // carries a `review_state` condition, and it returns BEFORE the pre-POST
507
+ // section below — so a refusal here constructs no merge URL, no merge
508
+ // headers, no action key, and sends no merge request.
509
+ if (resolution.reviewCondition !== null) {
510
+ const refusal = await precheckReviewCondition(deps, resolution.reviewCondition, prNumber, expectedHeadSha);
511
+ if (refusal !== null)
512
+ return text(refusal);
513
+ }
514
+ // --- pre-POST section ---------------------------------------------------
515
+ const gateIdentity = buildGateIdentity(DEFAULT_GATE_NAME, resolution.configHash);
516
+ const actionKey = makeMergeActionKey(deps.repoName, prNumber, expectedHeadSha, gateIdentity);
517
+ // The body is built from server-side values only. No caller-supplied field is
518
+ // spread in, so an extra runtime `repo_name` argument cannot retarget the merge.
519
+ const mergeBody = {
520
+ repo_name: deps.repoName,
521
+ expected_head_sha: expectedHeadSha,
522
+ gate: {
523
+ name: DEFAULT_GATE_NAME,
524
+ config_hash: resolution.configHash,
525
+ required_checks: resolution.checks,
526
+ },
527
+ action_key: actionKey,
528
+ };
529
+ // (3) POST the protected root-mounted route. Wrapped on its own so a rejection
530
+ // AFTER dispatch is reported as genuinely unknown rather than as a failure.
531
+ let mergeResp;
532
+ try {
533
+ mergeResp = await (deps.fetchImpl ?? fetch)(deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/merge`), {
534
+ method: "POST",
535
+ headers: await deps.getPostHeaders(),
536
+ body: JSON.stringify(mergeBody),
537
+ });
538
+ }
539
+ catch {
540
+ return text(envelope(false, "unknown", "merge_request_not_observed", "retry_later", expectedHeadSha, prNumber, {
541
+ hint: UNKNOWN_HINT,
542
+ }));
543
+ }
544
+ if (!mergeResp.ok) {
545
+ // Normalize and consume the body through the shared handler, then report the
546
+ // status only — raw upstream error text is never forwarded to the agent.
547
+ await deps.handleResponse(mergeResp).catch(() => "");
548
+ if (mergeResp.status === 409) {
549
+ return text(envelope(false, "action_key_mismatch", "action_key_mismatch", "needs_human", expectedHeadSha, prNumber));
550
+ }
551
+ return text(envelope(false, "error", "merge_request_failed", "needs_human", expectedHeadSha, prNumber, {
552
+ http_status: mergeResp.status,
553
+ }));
554
+ }
555
+ return text(interpretMergeResponse(await readJson(mergeResp), expectedHeadSha, prNumber));
556
+ }
557
+ catch {
558
+ // Containment: never throw through the MCP transport, and never serialize the
559
+ // exception — its text is unbounded and may carry a URL or header value.
560
+ return text(envelope(false, "error", "handler_error", "needs_human", echoedSha, echoedPr));
561
+ }
562
+ }