@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.
- package/README.md +193 -16
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- 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
|
+
}
|