@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.
Files changed (35) hide show
  1. package/README.md +6 -3
  2. package/build/agents.generated.js +1 -1
  3. package/build/commands.generated.js +4 -3
  4. package/build/conductor/local-merge.js +458 -95
  5. package/build/estimate-epic.js +84 -0
  6. package/build/executor/job-runner.js +151 -17
  7. package/build/executor/merge-job.js +84 -10
  8. package/build/executor/worker-finalization.js +98 -18
  9. package/build/index.js +1843 -401
  10. package/build/pipelines.generated.js +16 -20
  11. package/build/readme.generated.js +1 -1
  12. package/build/review-tickets.js +15 -5
  13. package/build/sfcc/client.js +192 -50
  14. package/build/sfcc/ocapi-write-faults.js +94 -0
  15. package/build/sfcc/permissions.js +7 -22
  16. package/build/sfcc/register.js +9 -0
  17. package/build/sfcc/write-grants.js +80 -0
  18. package/build/sfcc/write-guard.js +39 -0
  19. package/build/sfcc/write-result.js +47 -0
  20. package/build/sfcc/write-tool-common.js +85 -0
  21. package/build/sfcc/writes-custom-object-def.js +141 -0
  22. package/build/sfcc/writes-object-attribute-payloads.js +97 -0
  23. package/build/sfcc/writes-site-preference-payloads.js +59 -0
  24. package/build/sfcc/writes-site-preference.js +96 -0
  25. package/build/sfcc/writes-system-object-payloads.js +213 -0
  26. package/build/sfcc/writes-system-object.js +348 -0
  27. package/build/sfcc/writes.js +66 -0
  28. package/build/version.generated.js +1 -1
  29. package/package.json +3 -3
  30. package/pipelines/idea-to-ticket.json +7 -0
  31. package/pipelines/review-ticket.json +5 -18
  32. package/public/css/main.min.css +1583 -117
  33. package/public/css/main.min.css.map +1 -1
  34. package/public/js/main.min.js +2792 -449
  35. 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 provider merge and returns the SAME `merge.*` ledger events the
13
- * backend route returns, so the rest of {@link processGateMetMerge} is unchanged.
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 { spawnSync } from "child_process";
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 with no separate scheduler thread — a `gh` call that hangs
26
- * (network stall, an auth prompt that slips past GH_PROMPT_DISABLED) would block
27
- * the Node event loop indefinitely, freezing the whole tick. Killing at 60s
28
- * yields a `timedOut` result the executor maps to a distinct `*_timeout` reason.
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
- function defaultRunCommand(cmd, args, env) {
38
- const result = spawnSync(cmd, args, {
39
- encoding: "utf8",
40
- env: { ...process.env, ...env },
41
- timeout: DEFAULT_COMMAND_TIMEOUT_MS,
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 run = deps.runCommand ?? defaultRunCommand;
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) => buildResponse(request, "failed", reason, false, [
137
- { type: "merge.failed", status: "failed", reason, details: baseDetails },
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-state (head-drift / closed guard).
151
- const view = run("gh", ["pr", "view", String(pr), "--json", "headRefOid,state"], ghEnv);
152
- if (view.timedOut)
153
- return fail("gh_pr_view_timeout");
154
- if (view.status !== 0)
155
- return fail("gh_pr_view_failed");
156
- let headOid;
157
- let state;
158
- try {
159
- const parsed = JSON.parse(view.stdout);
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
- catch {
164
- return fail("gh_pr_view_unparseable");
477
+ if (isMergedAtExpectedHead(firstRead.state, firstRead.headOid, expectedSha)) {
478
+ return buildAlreadyMergedResponse(request, baseDetails, firstRead.mergeCommitOid);
165
479
  }
166
- if (typeof state === "string" && state.toUpperCase() !== "OPEN")
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
- // 3. Revalidate required CI green for the exact head SHA.
172
- let pollResponse;
173
- try {
174
- pollResponse = await pollCi(access, expectedSha);
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
- catch {
177
- return fail("ci_poll_failed");
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 (!allRequiredChecksGreen(pollResponse, requiredChecks))
180
- return fail("ci_not_green");
181
- // 4. Provider merge.
182
- const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
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
- // A hung `gh` (killed by the wall-clock timeout) is operationally distinct
185
- // from a permission/conflict failure keep it mapped to gh_merge_timeout.
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: classify a conflict / non-fast-forward failure distinctly so it
194
- // folds to `blocked` and remediates instead of hiding behind gh_merge_failed.
195
- // First match the sanitized failure output; if inconclusive, do ONE follow-up
196
- // mergeability re-read (only AFTER a merge failure never before a successful
197
- // or guarded merge) and check it. Raw stdout/stderr is used only for defensive
198
- // classification and is never copied into event data or logs.
199
- let mergeability = { mergeable: null, mergeStateStatus: null };
200
- let isConflict = isLikelyGhMergeConflictOutput(merge);
201
- if (!isConflict) {
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
- if (isConflict) {
214
- const reason = "gh_merge_conflict";
215
- // Head-scoped to the attempted head SHA so the fold blocks the exact head; a
216
- // later rebase clears it via the stale-head logic. mergeable/mergeStateStatus
217
- // are included only when known.
218
- const conflictDetails = {
219
- ...baseDetails,
220
- head_sha: expectedSha,
221
- ...(mergeability.mergeable ? { mergeable: mergeability.mergeable } : {}),
222
- ...(mergeability.mergeStateStatus ? { mergeStateStatus: mergeability.mergeStateStatus } : {}),
223
- };
224
- return buildResponse(request, "failed", reason, false, [
225
- { type: "merge.attempted", status: "attempted", details: baseDetails },
226
- { type: "merge.conflict", status: "failed", reason, details: conflictDetails },
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
- const oid = JSON.parse(post.stdout)?.mergeCommit;
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
+ }