@bridge_gpt/mcp-server 0.2.19 → 0.2.21
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 +6 -3
- package/build/agents.generated.js +1 -1
- package/build/commands.generated.js +4 -3
- package/build/conductor/local-merge.js +458 -95
- package/build/estimate-epic.js +84 -0
- package/build/executor/job-runner.js +151 -17
- package/build/executor/merge-job.js +84 -10
- package/build/executor/worker-finalization.js +98 -18
- package/build/index.js +1843 -401
- package/build/pipelines.generated.js +16 -20
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +15 -5
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1583 -117
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +2792 -449
- package/public/js/main.min.js.map +1 -1
|
@@ -9,47 +9,149 @@
|
|
|
9
9
|
*
|
|
10
10
|
* It mirrors the backend merge guard's decision order — approval precondition →
|
|
11
11
|
* re-read PR head (drift / not-open guard) → revalidate required CI for the EXACT
|
|
12
|
-
* head SHA
|
|
13
|
-
*
|
|
12
|
+
* expected head SHA, waiting briefly for transient post-remediation CI to turn
|
|
13
|
+
* green (BAPI-566 Bug B) → re-read PR head once more (close the CI-wait race) →
|
|
14
|
+
* provider merge — and returns the SAME `merge.*` ledger events the backend route
|
|
15
|
+
* returns, so the rest of {@link processGateMetMerge} is unchanged. `ci_not_green`
|
|
16
|
+
* is a bounded, pre-provider-merge failure and is NEVER a merge conflict; merge
|
|
17
|
+
* conflicts are still detected only after a `gh pr merge` attempt fails.
|
|
18
|
+
*
|
|
19
|
+
* BAPI-572 hardening:
|
|
20
|
+
* - The default command runner is now ASYNC (`spawn`-based, replacing the old
|
|
21
|
+
* synchronous runner) and abort-aware, so a slow/hung `gh` never blocks the
|
|
22
|
+
* Node event loop — this lets the executor keep heartbeating while a local
|
|
23
|
+
* merge is in flight.
|
|
24
|
+
* - "Already merged at the expected head" is treated as SUCCESS on ANY read
|
|
25
|
+
* (initial, post-CI, and after an ambiguous `gh` failure), not `pr_not_open`.
|
|
26
|
+
* - Every AMBIGUOUS `gh` failure (view/merge timeout, view failure/unparseable,
|
|
27
|
+
* CI poll failure, generic non-conflict merge failure) re-checks the merged
|
|
28
|
+
* state before reporting failure, so a successful-but-slow merge that GitHub
|
|
29
|
+
* already accepted is never lost as a false failure.
|
|
14
30
|
*
|
|
15
31
|
* Subprocess safety: argument arrays (never a shell string), a validated numeric
|
|
16
32
|
* PR number, a merge method from a fixed allowlist, `GH_PROMPT_DISABLED` to avoid
|
|
17
33
|
* interactive hangs, and no token/secret ever logged. Default OFF everywhere.
|
|
18
34
|
*/
|
|
19
|
-
import {
|
|
20
|
-
import { pollCiChecksForCommit, } from "./bridge-api-client.js";
|
|
35
|
+
import { spawn } from "child_process";
|
|
36
|
+
import { pollCiChecksForCommit, ConductorBridgeApiError, safeDiagnosticMessage, } from "./bridge-api-client.js";
|
|
21
37
|
import { isLikelyGhMergeConflictOutput, isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
22
38
|
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
39
|
+
/**
|
|
40
|
+
* `gh pr view` field set for a merged-state / head-drift read. Includes
|
|
41
|
+
* `mergeCommit` so an already-merged PR can be detected (and its merge commit
|
|
42
|
+
* SHA recorded) directly from the same read that guards head drift.
|
|
43
|
+
*/
|
|
44
|
+
const PR_STATE_JSON = "headRefOid,state,mergeCommit";
|
|
45
|
+
/**
|
|
46
|
+
* Broader `gh pr view` field set used only for the post-merge-failure re-read:
|
|
47
|
+
* it must detect BOTH "already merged at the expected head" and a merge conflict
|
|
48
|
+
* (`mergeable` / `mergeStateStatus`) from a single read.
|
|
49
|
+
*/
|
|
50
|
+
const PR_STATE_MERGEABILITY_JSON = "headRefOid,state,mergeCommit,mergeable,mergeStateStatus";
|
|
23
51
|
/**
|
|
24
52
|
* Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
|
|
25
|
-
* stateless process
|
|
26
|
-
*
|
|
27
|
-
* the
|
|
28
|
-
*
|
|
53
|
+
* stateless process — a `gh` call that hangs (network stall, an auth prompt that
|
|
54
|
+
* slips past GH_PROMPT_DISABLED) must not wedge the tick. The async runner kills
|
|
55
|
+
* the child at 60s and returns a `timedOut` result the executor maps to a
|
|
56
|
+
* distinct `*_timeout` reason (and then re-checks the merged state, since a
|
|
57
|
+
* killed `gh pr merge` may still have landed at GitHub).
|
|
29
58
|
*/
|
|
30
59
|
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
60
|
+
/**
|
|
61
|
+
* Bounded wait for the exact expected head's required CI to turn green before a
|
|
62
|
+
* provider merge (BAPI-566 Bug B). A code_review remediation that pushes a new
|
|
63
|
+
* head can advance to `merging` on the same tick its CI is still running; without
|
|
64
|
+
* this wait the very first merge attempt races that CI and fails `ci_not_green`,
|
|
65
|
+
* stalling the run until the 45-min watchdog. The wait is a total wall-clock cap
|
|
66
|
+
* across polls; `ci_not_green` is only returned once it expires. Kept short so a
|
|
67
|
+
* genuinely red head fails fast rather than tying up the tick.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_CI_WAIT_TIMEOUT_MS = 180_000;
|
|
70
|
+
const DEFAULT_CI_WAIT_POLL_INTERVAL_MS = 5_000;
|
|
71
|
+
/** Real-timer sleep; overridable via the `sleep` dep for deterministic tests. */
|
|
72
|
+
function defaultSleep(ms) {
|
|
73
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
74
|
+
}
|
|
75
|
+
/** Coerce an untrusted wait option to a bounded non-negative integer of ms. */
|
|
76
|
+
function sanitizeWaitMs(value, fallback) {
|
|
77
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
78
|
+
? Math.floor(value)
|
|
79
|
+
: fallback;
|
|
80
|
+
}
|
|
31
81
|
/** Coerce an untrusted method value to a safe allowlisted method (default squash). */
|
|
32
82
|
export function resolveLocalMergeMethod(value) {
|
|
33
83
|
return typeof value === "string" && MERGE_METHODS.has(value)
|
|
34
84
|
? value
|
|
35
85
|
: "squash";
|
|
36
86
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Async, abort-aware default command runner. Uses `spawn` (never a synchronous
|
|
89
|
+
* runner) so a slow/hung `gh` does not block the Node event loop — the heartbeat
|
|
90
|
+
* loop keeps running while a local merge is in flight. Enforces the per-command
|
|
91
|
+
* wall-clock timeout by killing the child (`timedOut: true`) and also kills the
|
|
92
|
+
* child when the injected abort signal fires. Never builds a shell string and
|
|
93
|
+
* never logs raw stdout/stderr.
|
|
94
|
+
*/
|
|
95
|
+
async function defaultRunCommand(cmd, args, env, signal) {
|
|
96
|
+
return await new Promise((resolve) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
let timedOut = false;
|
|
99
|
+
let stdout = "";
|
|
100
|
+
let stderr = "";
|
|
101
|
+
const child = spawn(cmd, args, { env: { ...process.env, ...env } });
|
|
102
|
+
const killChild = () => {
|
|
103
|
+
try {
|
|
104
|
+
child.kill("SIGKILL");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* the process may already be gone */
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
timedOut = true;
|
|
112
|
+
killChild();
|
|
113
|
+
}, DEFAULT_COMMAND_TIMEOUT_MS);
|
|
114
|
+
const onAbort = () => {
|
|
115
|
+
timedOut = true;
|
|
116
|
+
killChild();
|
|
117
|
+
};
|
|
118
|
+
const finish = (result) => {
|
|
119
|
+
if (settled)
|
|
120
|
+
return;
|
|
121
|
+
settled = true;
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
if (signal)
|
|
124
|
+
signal.removeEventListener("abort", onAbort);
|
|
125
|
+
resolve(result);
|
|
126
|
+
};
|
|
127
|
+
if (signal) {
|
|
128
|
+
if (signal.aborted)
|
|
129
|
+
onAbort();
|
|
130
|
+
else
|
|
131
|
+
signal.addEventListener("abort", onAbort);
|
|
132
|
+
}
|
|
133
|
+
child.stdout?.setEncoding("utf8");
|
|
134
|
+
child.stderr?.setEncoding("utf8");
|
|
135
|
+
child.stdout?.on("data", (chunk) => {
|
|
136
|
+
stdout += chunk;
|
|
137
|
+
});
|
|
138
|
+
child.stderr?.on("data", (chunk) => {
|
|
139
|
+
stderr += chunk;
|
|
140
|
+
});
|
|
141
|
+
child.on("error", () => {
|
|
142
|
+
// Spawn/exec error (e.g. `gh` not found): surface as a non-zero, non-timeout
|
|
143
|
+
// failure so the executor's ambiguous-failure verification still runs.
|
|
144
|
+
finish({ status: null, stdout, stderr, timedOut });
|
|
145
|
+
});
|
|
146
|
+
child.on("close", (code, sig) => {
|
|
147
|
+
finish({
|
|
148
|
+
status: code,
|
|
149
|
+
stdout,
|
|
150
|
+
stderr,
|
|
151
|
+
timedOut: timedOut || sig === "SIGKILL" || sig === "SIGTERM",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
42
154
|
});
|
|
43
|
-
// On timeout spawnSync kills the child (status: null, signal: "SIGTERM") and
|
|
44
|
-
// sets `error.code === "ETIMEDOUT"`. Surface that as a first-class flag.
|
|
45
|
-
const timedOut = result.error?.code === "ETIMEDOUT" ||
|
|
46
|
-
result.signal === "SIGTERM";
|
|
47
|
-
return {
|
|
48
|
-
status: result.status,
|
|
49
|
-
stdout: result.stdout ?? "",
|
|
50
|
-
stderr: result.stderr ?? "",
|
|
51
|
-
timedOut,
|
|
52
|
-
};
|
|
53
155
|
}
|
|
54
156
|
function buildResponse(request, status, reason, terminal, ledgerEvents) {
|
|
55
157
|
return {
|
|
@@ -105,6 +207,171 @@ export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
|
105
207
|
};
|
|
106
208
|
return requiredChecks.every((name) => isGreen(byName.get(name)));
|
|
107
209
|
}
|
|
210
|
+
/** Extract `mergeCommit.oid` from a parsed `gh pr view` object when present. */
|
|
211
|
+
function extractMergeCommitOid(parsed) {
|
|
212
|
+
const mc = parsed?.mergeCommit;
|
|
213
|
+
if (mc && typeof mc === "object" && typeof mc.oid === "string") {
|
|
214
|
+
return mc.oid;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True only when the PR is MERGED (case-insensitive) at the EXACT expected head
|
|
220
|
+
* SHA (case-insensitive). This is the "already merged at expected head" signal
|
|
221
|
+
* that must be reported as SUCCESS on any attempt, never `pr_not_open`.
|
|
222
|
+
*/
|
|
223
|
+
function isMergedAtExpectedHead(state, headOid, expectedSha) {
|
|
224
|
+
return (typeof state === "string" &&
|
|
225
|
+
state.toUpperCase() === "MERGED" &&
|
|
226
|
+
typeof headOid === "string" &&
|
|
227
|
+
headOid.toLowerCase() === expectedSha.toLowerCase());
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Read the PR's `headRefOid` + `state` + `mergeCommit` via `gh pr view`. Extracted
|
|
231
|
+
* so the head-drift / not-open / already-merged guard can run BOTH before the CI
|
|
232
|
+
* wait and again right before provider merge (BAPI-566 Bug B: the head can drift
|
|
233
|
+
* while we wait for CI). Returns the raw parsed values; the caller applies the
|
|
234
|
+
* MERGED-at-head / OPEN / exact-head checks.
|
|
235
|
+
*/
|
|
236
|
+
async function readPrMergeState(run, ghEnv, pr, json = PR_STATE_JSON) {
|
|
237
|
+
const view = await run("gh", ["pr", "view", String(pr), "--json", json], ghEnv);
|
|
238
|
+
if (view.timedOut)
|
|
239
|
+
return { ok: false, reason: "gh_pr_view_timeout" };
|
|
240
|
+
if (view.status !== 0)
|
|
241
|
+
return { ok: false, reason: "gh_pr_view_failed" };
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(view.stdout);
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
headOid: parsed.headRefOid,
|
|
247
|
+
state: parsed.state,
|
|
248
|
+
mergeCommitOid: extractMergeCommitOid(parsed),
|
|
249
|
+
raw: parsed,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { ok: false, reason: "gh_pr_view_unparseable" };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Build the SUCCESS response for a PR that is already merged at the expected
|
|
258
|
+
* head — used by the initial read short-circuit, the post-CI read short-circuit,
|
|
259
|
+
* and the post-ambiguous-failure verification. Emits a single `merge.succeeded`
|
|
260
|
+
* ledger event carrying `already_merged: true` and, when known, the merge commit
|
|
261
|
+
* SHA (so `buildMergeJobResult` populates `commit_sha`/`head_sha`).
|
|
262
|
+
*/
|
|
263
|
+
function buildAlreadyMergedResponse(request, baseDetails, mergeCommitSha) {
|
|
264
|
+
const details = {
|
|
265
|
+
...baseDetails,
|
|
266
|
+
already_merged: true,
|
|
267
|
+
...(mergeCommitSha ? { merge_commit_sha: mergeCommitSha } : {}),
|
|
268
|
+
};
|
|
269
|
+
return buildResponse(request, "succeeded", null, true, [
|
|
270
|
+
{ type: "merge.succeeded", status: "succeeded", details },
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Extract secret-free poll-failure detail from a thrown error. Returns `undefined`
|
|
275
|
+
* for anything that is not a {@link ConductorBridgeApiError} with a poll-relevant
|
|
276
|
+
* `kind` (so unknown/non-bridge throws keep the legacy `ci_poll_failed` routing and
|
|
277
|
+
* never persist raw exception text). The `diagnostic` is bounded and redacted via the
|
|
278
|
+
* established {@link safeDiagnosticMessage} helper.
|
|
279
|
+
*/
|
|
280
|
+
function ciPollFailureDetailFromError(err) {
|
|
281
|
+
if (!(err instanceof ConductorBridgeApiError))
|
|
282
|
+
return undefined;
|
|
283
|
+
const kind = err.kind;
|
|
284
|
+
if (kind !== "timeout" &&
|
|
285
|
+
kind !== "network" &&
|
|
286
|
+
kind !== "unauthorized" &&
|
|
287
|
+
kind !== "server" &&
|
|
288
|
+
kind !== "http") {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
const detail = {
|
|
292
|
+
kind,
|
|
293
|
+
diagnostic: safeDiagnosticMessage(err, "ci_poll_failed"),
|
|
294
|
+
};
|
|
295
|
+
if (typeof err.status === "number" && Number.isInteger(err.status)) {
|
|
296
|
+
detail.status = err.status;
|
|
297
|
+
}
|
|
298
|
+
return detail;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Map preserved poll-failure detail to a stable, distinct merge failure reason.
|
|
302
|
+
* Missing/unknown detail collapses to the legacy `ci_poll_failed`; an `http` kind
|
|
303
|
+
* with a usable integer status yields `ci_poll_http_<status>`, otherwise `ci_poll_http`.
|
|
304
|
+
*/
|
|
305
|
+
function ciPollFailureReasonFromDetail(detail) {
|
|
306
|
+
if (!detail)
|
|
307
|
+
return "ci_poll_failed";
|
|
308
|
+
switch (detail.kind) {
|
|
309
|
+
case "timeout":
|
|
310
|
+
return "ci_poll_timeout";
|
|
311
|
+
case "network":
|
|
312
|
+
return "ci_poll_network";
|
|
313
|
+
case "unauthorized":
|
|
314
|
+
return "ci_poll_unauthorized";
|
|
315
|
+
case "server":
|
|
316
|
+
return "ci_poll_server";
|
|
317
|
+
case "http":
|
|
318
|
+
return typeof detail.status === "number" && Number.isInteger(detail.status)
|
|
319
|
+
? `ci_poll_http_${detail.status}`
|
|
320
|
+
: "ci_poll_http";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* A CI-wait failure reason is an AMBIGUOUS poll read (a merge GitHub may already
|
|
325
|
+
* have accepted, masked by a flaky poll) when it shares the `ci_poll_` prefix —
|
|
326
|
+
* including the legacy `ci_poll_failed`. Such reasons must re-verify merged state
|
|
327
|
+
* before reporting failure. `ci_not_green` and `merge_aborted` are deterministic
|
|
328
|
+
* and never re-verify.
|
|
329
|
+
*/
|
|
330
|
+
function isAmbiguousCiPollFailureReason(reason) {
|
|
331
|
+
return reason.startsWith("ci_poll_");
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Poll required CI for the EXACT expected head, immediately and then on an
|
|
335
|
+
* interval, until every required check is green or the total wall-clock budget
|
|
336
|
+
* expires (BAPI-566 Bug B). If a poll throws, returns a distinct `ci_poll_*` reason
|
|
337
|
+
* derived from the thrown {@link ConductorBridgeApiError} kind/status (BAPI-577 Part A;
|
|
338
|
+
* legacy/unknown throws still yield `ci_poll_failed`), preserving the secret-free
|
|
339
|
+
* detail on the result. `ci_not_green` is returned only after the budget is exhausted.
|
|
340
|
+
* Never polls the branch name or PR head alias — only `expectedSha`.
|
|
341
|
+
*
|
|
342
|
+
* BAPI-572 abort-awareness: the CI-wait loop is the longest-running,
|
|
343
|
+
* non-`gh`-subprocess phase of a merge. It must short-circuit with `merge_aborted`
|
|
344
|
+
* as soon as the injected abort signal fires — otherwise a merge job the executor
|
|
345
|
+
* already reported timed-out/abandoned could keep polling for its full
|
|
346
|
+
* `ciWaitTimeoutMs` budget and then fire a REAL `gh pr merge` minutes later.
|
|
347
|
+
*/
|
|
348
|
+
async function waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredChecks, timeoutMs, pollIntervalMs, sleep, now, signal) {
|
|
349
|
+
const start = now();
|
|
350
|
+
for (;;) {
|
|
351
|
+
if (signal?.aborted)
|
|
352
|
+
return { ok: false, reason: "merge_aborted" };
|
|
353
|
+
let pollResponse;
|
|
354
|
+
try {
|
|
355
|
+
pollResponse = await pollCi(access, expectedSha);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
// Preserve the caught bridge poll error's coarse kind/status instead of
|
|
359
|
+
// flattening every failure to an opaque `ci_poll_failed` (BAPI-577 Part A).
|
|
360
|
+
// Unknown/non-bridge throws yield `undefined` detail → legacy `ci_poll_failed`.
|
|
361
|
+
const pollFailure = ciPollFailureDetailFromError(err);
|
|
362
|
+
return { ok: false, reason: ciPollFailureReasonFromDetail(pollFailure), pollFailure };
|
|
363
|
+
}
|
|
364
|
+
if (allRequiredChecksGreen(pollResponse, requiredChecks))
|
|
365
|
+
return { ok: true };
|
|
366
|
+
if (now() - start >= timeoutMs)
|
|
367
|
+
return { ok: false, reason: "ci_not_green" };
|
|
368
|
+
await sleep(pollIntervalMs);
|
|
369
|
+
// Re-check after the sleep so an abort during the interval stops the loop
|
|
370
|
+
// before the next poll (and, critically, before any provider merge).
|
|
371
|
+
if (signal?.aborted)
|
|
372
|
+
return { ok: false, reason: "merge_aborted" };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
108
375
|
/**
|
|
109
376
|
* Build a `(access, request) => Promise<ConductorMergeResponse>` that performs the
|
|
110
377
|
* merge locally via `gh`. Drop-in replacement for `mergePullRequestForGate` used
|
|
@@ -112,8 +379,19 @@ export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
|
112
379
|
*/
|
|
113
380
|
export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
114
381
|
const method = resolveLocalMergeMethod(options.method);
|
|
115
|
-
const
|
|
382
|
+
const rawRun = deps.runCommand ?? defaultRunCommand;
|
|
383
|
+
// Always await the runner: the production default is async (a hung `gh` would
|
|
384
|
+
// otherwise block the event loop and starve the executor heartbeat), and
|
|
385
|
+
// injected synchronous test runners are still awaited transparently. The abort
|
|
386
|
+
// signal is threaded to the default runner so an overall merge timeout can
|
|
387
|
+
// cancel in-flight subprocess work.
|
|
388
|
+
const run = (cmd, args, env) => Promise.resolve(rawRun(cmd, args, env, deps.signal));
|
|
116
389
|
const pollCi = deps.pollCi ?? pollCiChecksForCommit;
|
|
390
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
391
|
+
const now = deps.now ?? (() => Date.now());
|
|
392
|
+
const signal = deps.signal;
|
|
393
|
+
const ciWaitTimeoutMs = sanitizeWaitMs(options.ciWaitTimeoutMs, DEFAULT_CI_WAIT_TIMEOUT_MS);
|
|
394
|
+
const ciWaitPollIntervalMs = sanitizeWaitMs(options.ciWaitPollIntervalMs, DEFAULT_CI_WAIT_POLL_INTERVAL_MS);
|
|
117
395
|
// Non-interactive + no-noise env for gh; the caller's env (incl. any token)
|
|
118
396
|
// is overlaid first, then the safety pins.
|
|
119
397
|
const ghEnv = {
|
|
@@ -133,9 +411,47 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
133
411
|
merge_method: method,
|
|
134
412
|
executor: "local",
|
|
135
413
|
};
|
|
136
|
-
const fail = (reason) =>
|
|
137
|
-
|
|
138
|
-
|
|
414
|
+
const fail = (reason, pollFailure) => {
|
|
415
|
+
// Attach the secret-free poll-error detail (kind/status/bounded diagnostic)
|
|
416
|
+
// to the persisted failure so the executor_jobs row is diagnosable without
|
|
417
|
+
// reproducing. Never includes raw body/headers/key (see CiPollFailureDetail).
|
|
418
|
+
const details = pollFailure
|
|
419
|
+
? {
|
|
420
|
+
...baseDetails,
|
|
421
|
+
poll_error: {
|
|
422
|
+
kind: pollFailure.kind,
|
|
423
|
+
...(typeof pollFailure.status === "number" ? { status: pollFailure.status } : {}),
|
|
424
|
+
diagnostic: pollFailure.diagnostic,
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
: baseDetails;
|
|
428
|
+
return buildResponse(request, "failed", reason, false, [
|
|
429
|
+
{ type: "merge.failed", status: "failed", reason, details },
|
|
430
|
+
]);
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Re-check the merged state after an AMBIGUOUS `gh` failure (a `gh` timeout,
|
|
434
|
+
* an unparseable/failed view, a CI poll failure, or a generic non-conflict
|
|
435
|
+
* merge failure). Performs ONE fresh `gh pr view` and returns the already-
|
|
436
|
+
* merged SUCCESS response when GitHub shows the PR merged at the expected
|
|
437
|
+
* head; returns `null` when the read fails, is unparseable, is not merged, or
|
|
438
|
+
* is merged at a different head (so the caller keeps the original failure).
|
|
439
|
+
*/
|
|
440
|
+
const verifyMergedStateAfterAmbiguousFailure = async () => {
|
|
441
|
+
const read = await readPrMergeState(run, ghEnv, pr);
|
|
442
|
+
if (!read.ok)
|
|
443
|
+
return null;
|
|
444
|
+
if (isMergedAtExpectedHead(read.state, read.headOid, expectedSha)) {
|
|
445
|
+
return buildAlreadyMergedResponse(request, baseDetails, read.mergeCommitOid);
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
};
|
|
449
|
+
// 0. Abort precondition — if the overall merge timeout / ownership abandonment
|
|
450
|
+
// already fired before this flow reached a decision point, do NOT take any
|
|
451
|
+
// effectful action (never fire a provider merge for a job the executor has
|
|
452
|
+
// already reported failed/abandoned). `merge_aborted` is a bounded retryable.
|
|
453
|
+
if (signal?.aborted)
|
|
454
|
+
return fail("merge_aborted");
|
|
139
455
|
// 1. Approval precondition — never merge when approval is required.
|
|
140
456
|
if (options.approvalRequired) {
|
|
141
457
|
return buildResponse(request, "pending_approval", "local_merge_approval_required", false, [
|
|
@@ -147,84 +463,115 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
147
463
|
},
|
|
148
464
|
]);
|
|
149
465
|
}
|
|
150
|
-
// 2. Re-read PR head + open
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
headOid = parsed.headRefOid;
|
|
161
|
-
state = parsed.state;
|
|
466
|
+
// 2. Re-read PR head + open/merged state (head-drift / closed / already-merged
|
|
467
|
+
// guard). An already-merged PR at the expected head short-circuits to
|
|
468
|
+
// success on ANY attempt. An ambiguous view failure re-verifies the merged
|
|
469
|
+
// state before failing.
|
|
470
|
+
const firstRead = await readPrMergeState(run, ghEnv, pr);
|
|
471
|
+
if (!firstRead.ok) {
|
|
472
|
+
const verified = await verifyMergedStateAfterAmbiguousFailure();
|
|
473
|
+
if (verified)
|
|
474
|
+
return verified;
|
|
475
|
+
return fail(firstRead.reason);
|
|
162
476
|
}
|
|
163
|
-
|
|
164
|
-
return
|
|
477
|
+
if (isMergedAtExpectedHead(firstRead.state, firstRead.headOid, expectedSha)) {
|
|
478
|
+
return buildAlreadyMergedResponse(request, baseDetails, firstRead.mergeCommitOid);
|
|
165
479
|
}
|
|
166
|
-
if (typeof
|
|
167
|
-
return fail("pr_not_open");
|
|
168
|
-
if (typeof headOid !== "string" || headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
480
|
+
if (typeof firstRead.headOid !== "string" || firstRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
169
481
|
return fail("head_drift");
|
|
170
482
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
483
|
+
if (typeof firstRead.state === "string" && firstRead.state.toUpperCase() !== "OPEN") {
|
|
484
|
+
return fail("pr_not_open");
|
|
485
|
+
}
|
|
486
|
+
// 3. Revalidate required CI green for the EXACT expected head, waiting briefly
|
|
487
|
+
// for transient post-remediation CI to turn green before failing ci_not_green.
|
|
488
|
+
const ciWait = await waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredChecks, ciWaitTimeoutMs, ciWaitPollIntervalMs, sleep, now, signal);
|
|
489
|
+
if (!ciWait.ok) {
|
|
490
|
+
// Any `ci_poll_*` reason (the legacy `ci_poll_failed` plus the distinct
|
|
491
|
+
// BAPI-577 kinds) is an AMBIGUOUS infrastructure read failure — a merge that
|
|
492
|
+
// GitHub already accepted could be masked by a flaky poll. Re-verify the
|
|
493
|
+
// merged state before reporting. ci_not_green and merge_aborted are
|
|
494
|
+
// deterministic (CI is red / the job was aborted); they never verify and
|
|
495
|
+
// never proceed to a provider merge.
|
|
496
|
+
if (isAmbiguousCiPollFailureReason(ciWait.reason)) {
|
|
497
|
+
const verified = await verifyMergedStateAfterAmbiguousFailure();
|
|
498
|
+
if (verified)
|
|
499
|
+
return verified;
|
|
500
|
+
}
|
|
501
|
+
return fail(ciWait.reason, ciWait.pollFailure);
|
|
502
|
+
}
|
|
503
|
+
// 3b. Re-read PR head/state after the CI wait — the head can drift, the PR can
|
|
504
|
+
// close, or the merge can already have landed while we waited for CI.
|
|
505
|
+
const secondRead = await readPrMergeState(run, ghEnv, pr);
|
|
506
|
+
if (!secondRead.ok) {
|
|
507
|
+
const verified = await verifyMergedStateAfterAmbiguousFailure();
|
|
508
|
+
if (verified)
|
|
509
|
+
return verified;
|
|
510
|
+
return fail(secondRead.reason);
|
|
511
|
+
}
|
|
512
|
+
if (isMergedAtExpectedHead(secondRead.state, secondRead.headOid, expectedSha)) {
|
|
513
|
+
return buildAlreadyMergedResponse(request, baseDetails, secondRead.mergeCommitOid);
|
|
175
514
|
}
|
|
176
|
-
|
|
177
|
-
return fail("
|
|
515
|
+
if (typeof secondRead.headOid !== "string" || secondRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
516
|
+
return fail("head_drift");
|
|
517
|
+
}
|
|
518
|
+
if (typeof secondRead.state === "string" && secondRead.state.toUpperCase() !== "OPEN") {
|
|
519
|
+
return fail("pr_not_open");
|
|
178
520
|
}
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
521
|
+
// 4. Provider merge. Final abort guard: if the overall timeout / ownership
|
|
522
|
+
// abandonment fired during the CI wait or the post-CI re-read, do NOT fire
|
|
523
|
+
// the real `gh pr merge` — the executor has already reported this job.
|
|
524
|
+
if (signal?.aborted)
|
|
525
|
+
return fail("merge_aborted");
|
|
526
|
+
const merge = await run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
|
|
183
527
|
if (merge.status !== 0) {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
528
|
+
// Decision order after a merge-command failure (BAPI-572 Step 5):
|
|
529
|
+
// 1. timeout → verify already-merged, else gh_merge_timeout (ambiguous);
|
|
530
|
+
// 2. conflict-like output → gh_merge_conflict (deterministic);
|
|
531
|
+
// 3. re-read PR state / mergeability;
|
|
532
|
+
// 4. merged at expected head → success;
|
|
533
|
+
// 5. conflict mergeability → gh_merge_conflict;
|
|
534
|
+
// 6. otherwise → gh_merge_failed (verified non-merged, ambiguous).
|
|
186
535
|
if (merge.timedOut) {
|
|
536
|
+
// A hung `gh pr merge` (killed by the wall-clock timeout) is ambiguous —
|
|
537
|
+
// the merge may already have landed at GitHub. Verify before failing.
|
|
538
|
+
const verified = await verifyMergedStateAfterAmbiguousFailure();
|
|
539
|
+
if (verified)
|
|
540
|
+
return verified;
|
|
187
541
|
const reason = "gh_merge_timeout";
|
|
188
542
|
return buildResponse(request, "failed", reason, false, [
|
|
189
543
|
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
190
544
|
{ type: "merge.failed", status: "failed", reason, details: baseDetails },
|
|
191
545
|
]);
|
|
192
546
|
}
|
|
193
|
-
// BAPI-494:
|
|
194
|
-
// folds to `blocked` and
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const recheck = run("gh", ["pr", "view", String(pr), "--json", "mergeable,mergeStateStatus"], ghEnv);
|
|
203
|
-
if (recheck.status === 0) {
|
|
204
|
-
try {
|
|
205
|
-
mergeability = parseGhPrMergeabilityFields(JSON.parse(recheck.stdout));
|
|
206
|
-
isConflict = isPrMergeConflict(mergeability);
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
/* best-effort classification only */
|
|
210
|
-
}
|
|
211
|
-
}
|
|
547
|
+
// BAPI-494: a conflict / non-fast-forward failure is DETERMINISTIC — classify
|
|
548
|
+
// it distinctly (no merged-state verification) so it folds to `blocked` and
|
|
549
|
+
// remediates instead of hiding behind gh_merge_failed. Raw stdout/stderr is
|
|
550
|
+
// used only for defensive classification and is never copied into event data.
|
|
551
|
+
if (isLikelyGhMergeConflictOutput(merge)) {
|
|
552
|
+
return buildConflictResponse(request, baseDetails, expectedSha, {
|
|
553
|
+
mergeable: null,
|
|
554
|
+
mergeStateStatus: null,
|
|
555
|
+
});
|
|
212
556
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
557
|
+
// Generic non-conflict merge failure is AMBIGUOUS. Do ONE re-read that
|
|
558
|
+
// detects BOTH already-merged-at-head AND conflict mergeability, then:
|
|
559
|
+
// merged at head → success; conflict → gh_merge_conflict; else gh_merge_failed.
|
|
560
|
+
const recheck = await readPrMergeState(run, ghEnv, pr, PR_STATE_MERGEABILITY_JSON);
|
|
561
|
+
if (recheck.ok) {
|
|
562
|
+
if (isMergedAtExpectedHead(recheck.state, recheck.headOid, expectedSha)) {
|
|
563
|
+
return buildAlreadyMergedResponse(request, baseDetails, recheck.mergeCommitOid);
|
|
564
|
+
}
|
|
565
|
+
let mergeability = { mergeable: null, mergeStateStatus: null };
|
|
566
|
+
try {
|
|
567
|
+
mergeability = parseGhPrMergeabilityFields(recheck.raw);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* best-effort classification only */
|
|
571
|
+
}
|
|
572
|
+
if (isPrMergeConflict(mergeability)) {
|
|
573
|
+
return buildConflictResponse(request, baseDetails, expectedSha, mergeability);
|
|
574
|
+
}
|
|
228
575
|
}
|
|
229
576
|
const mergeFailReason = "gh_merge_failed";
|
|
230
577
|
return buildResponse(request, "failed", mergeFailReason, false, [
|
|
@@ -234,13 +581,10 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
234
581
|
}
|
|
235
582
|
// 5. Best-effort resolve the squash/merge commit SHA for the audit trail.
|
|
236
583
|
let mergeCommitSha;
|
|
237
|
-
const post = run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
|
|
584
|
+
const post = await run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
|
|
238
585
|
if (post.status === 0) {
|
|
239
586
|
try {
|
|
240
|
-
|
|
241
|
-
if (oid && typeof oid === "object" && typeof oid.oid === "string") {
|
|
242
|
-
mergeCommitSha = oid.oid;
|
|
243
|
-
}
|
|
587
|
+
mergeCommitSha = extractMergeCommitOid(JSON.parse(post.stdout));
|
|
244
588
|
}
|
|
245
589
|
catch {
|
|
246
590
|
/* best-effort only */
|
|
@@ -256,3 +600,22 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
256
600
|
]);
|
|
257
601
|
};
|
|
258
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Build a `gh_merge_conflict` failure with bounded, secret-free details. Only
|
|
605
|
+
* sanitized fields are included (head SHA + mergeability status when known); raw
|
|
606
|
+
* stdout/stderr is never copied into event data. Head-scoped to the attempted
|
|
607
|
+
* head so the fold blocks the exact head; a later rebase clears it.
|
|
608
|
+
*/
|
|
609
|
+
function buildConflictResponse(request, baseDetails, expectedSha, mergeability) {
|
|
610
|
+
const reason = "gh_merge_conflict";
|
|
611
|
+
const conflictDetails = {
|
|
612
|
+
...baseDetails,
|
|
613
|
+
head_sha: expectedSha,
|
|
614
|
+
...(mergeability.mergeable ? { mergeable: mergeability.mergeable } : {}),
|
|
615
|
+
...(mergeability.mergeStateStatus ? { mergeStateStatus: mergeability.mergeStateStatus } : {}),
|
|
616
|
+
};
|
|
617
|
+
return buildResponse(request, "failed", reason, false, [
|
|
618
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
619
|
+
{ type: "merge.conflict", status: "failed", reason, details: conflictDetails },
|
|
620
|
+
]);
|
|
621
|
+
}
|