@bridge_gpt/mcp-server 0.2.44 → 0.2.45

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.
@@ -75,6 +75,7 @@
75
75
  * privileged write subtly wrong.
76
76
  */
77
77
  import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, } from "./conductor/git-ci-types.js";
78
+ import { MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON, VERDICTLESS_DISPOSITION_FAIL_OPEN, VERDICTLESS_DISPOSITION_PARK, } from "./conductor/git-ci-types.js";
78
79
  import { evaluateReviewCondition, normalizeReviewSnapshot, parseDoneGateConfig, } from "./conductor/done-gate.js";
79
80
  import { buildGateIdentity, makeMergeActionKey } from "./conductor/merge-identity.js";
80
81
  import { resolveLocalMergeMethod, runApprovedLocalMerge, } from "./conductor/local-merge.js";
@@ -135,6 +136,10 @@ function envelope(merged, outcome, reason, retryHint, evaluatedHeadSha, prNumber
135
136
  result.http_status = diagnostics.http_status;
136
137
  if (diagnostics.completion !== undefined)
137
138
  result.completion = diagnostics.completion;
139
+ // BAPI-915: appended LAST, after every pre-existing optional diagnostic, so the
140
+ // serialized key order of every envelope that carries no waiver is unchanged.
141
+ if (diagnostics.review_waiver !== undefined)
142
+ result.review_waiver = diagnostics.review_waiver;
138
143
  return result;
139
144
  }
140
145
  // ---------------------------------------------------------------------------
@@ -310,13 +315,94 @@ const SUPPORTED_REVIEW_SOURCES = new Set(["verdict_protocol", "native_review_dec
310
315
  const REVIEW_UNAVAILABLE_REASON = "review_unavailable";
311
316
  const REVIEW_SOURCE_UNSUPPORTED_REASON = "review_source_unsupported";
312
317
  const HEAD_SHA_DRIFT_REASON = "head_sha_drift";
318
+ /** Wrap a refusal envelope as a pre-check decision. */
319
+ function refuse(env) {
320
+ return { kind: "refused", envelope: env };
321
+ }
322
+ /**
323
+ * Append the waiver marker to an envelope produced AFTER a waived pre-check.
324
+ *
325
+ * Applied at the handler's return seam rather than threaded through
326
+ * `interpretMergeResponse` and `executeApprovedLocalMerge`, both of which build
327
+ * envelopes on many internal branches — one seam is auditable, a dozen threaded
328
+ * parameters are not. Every envelope downstream of a waiver carries it,
329
+ * including one that a LATER guard refused: the waiver really was applied, and
330
+ * hiding it on the refusal would leave the operator reading a `ci_not_green`
331
+ * with no idea the review had been waived to get there. This mirrors the server
332
+ * service, which folds the same marker into every post-review evidence bundle.
333
+ *
334
+ * Key order is preserved: the spread keeps the existing keys in place and the
335
+ * new one lands last, exactly where `envelope()` would have put it.
336
+ */
337
+ function withReviewWaiver(env, waiver) {
338
+ if (waiver === undefined)
339
+ return env;
340
+ return { ...env, review_waiver: waiver };
341
+ }
342
+ /**
343
+ * True only when the review carries NO verdict AT ALL for the configured source.
344
+ *
345
+ * This is narrower than "the evaluation did not pass", and the gap between the two
346
+ * is a real hole. Under `verdict_protocol` the server resolves the sticky comment
347
+ * to `null` when no comment was ever posted, but to the string `"unknown"` for
348
+ * FOUR different ambiguities: two same-head comments that contradict each other
349
+ * (BAPI-733), a stale `Reviewed-SHA`, a malformed one, and an unresolvable head.
350
+ * `evaluateReviewCondition` flattens all five into
351
+ * `{passed: false, changesRequested: false}`, so waiving on that result alone
352
+ * would waive a CONTRADICTION — and a contradiction can contain a
353
+ * changes-requested comment that merely lost the tie-break. That is precisely the
354
+ * human-escalation case, and waiving it would contradict this feature's own claim
355
+ * that an objection is never waivable.
356
+ *
357
+ * So the waiver mirrors the Python seam field for field: `api/library/vcs/epic_observers.py`
358
+ * routes `sticky is None` to `PENDING` (waivable) and EVERY `"unknown"` to
359
+ * `UNKNOWN` (never waivable), and `conductor_merge_service` admits only `PENDING`.
360
+ * No new server field is needed to do this — `sticky_verdict` already carries the
361
+ * distinction on the wire, and `normalizeReviewSnapshot` already preserves it.
362
+ *
363
+ * The raw body is consulted too, because the normalizer maps an UNRECOGNIZED
364
+ * `sticky_verdict` to `null` as well. A value outside the server's own closed
365
+ * vocabulary is not evidence that no verdict exists, so it does not get waived.
366
+ *
367
+ * `native_review_decision` has no ambiguity channel of its own — GitHub returns a
368
+ * single authoritative decision, and a non-approving, non-rejecting one genuinely
369
+ * means no decision was given — so it needs no extra guard.
370
+ */
371
+ function verdictIsGenuinelyAbsent(condition, snapshot, rawBody) {
372
+ if (condition.source !== "verdict_protocol")
373
+ return true;
374
+ // `"unknown"` (and anything else non-null) is an unresolved verdict, not a
375
+ // missing one.
376
+ if (snapshot.sticky_verdict !== null)
377
+ return false;
378
+ const detail = isPlainObject(rawBody) && isPlainObject(rawBody.detail) ? rawBody.detail : null;
379
+ if (detail === null)
380
+ return false;
381
+ const raw = detail.sticky_verdict;
382
+ return raw === null || raw === undefined;
383
+ }
384
+ /**
385
+ * The condition's disposition, with the effective default applied.
386
+ *
387
+ * ABSENT means `park`, and the default is resolved HERE rather than materialized
388
+ * onto the parsed condition, so a config that never carried the field keeps its
389
+ * byte-identical `config_hash` and therefore its merge action key.
390
+ */
391
+ function effectiveDisposition(condition) {
392
+ return condition.verdictless_disposition ?? VERDICTLESS_DISPOSITION_PARK;
393
+ }
313
394
  /**
314
395
  * Evaluate the configured `review_state` condition before the merge is sent.
315
396
  *
316
- * Returns a refusal envelope, or `null` meaning "nothing objected — continue".
317
- * Every failure direction is contained here and every one of them is a refusal:
318
- * a review this side cannot read, cannot parse, or cannot evaluate is not a
319
- * review that approved anything.
397
+ * Returns a {@link ReviewPrecheckDecision}: a refusal, an ordinary continuation,
398
+ * or a continuation carrying the bounded waiver diagnostic. Every failure
399
+ * direction is contained here and every one of them is a refusal: a review this
400
+ * side cannot read, cannot parse, or cannot evaluate is not a review that
401
+ * approved anything — and BAPI-915 changed none of that. The single direction it
402
+ * added is a review that WAS read, at this head, under an explicitly configured
403
+ * `fail_open`, carrying no objection and NO VERDICT AT ALL — not merely a verdict
404
+ * this side could not resolve. See {@link verdictIsGenuinelyAbsent} for why that
405
+ * distinction is load-bearing rather than pedantic.
320
406
  *
321
407
  * `min_approvals` and `combination` are refused OUTRIGHT rather than approximated
322
408
  * with a weaker rule. The server refuses them too, for the same reason: silently
@@ -325,9 +411,9 @@ const HEAD_SHA_DRIFT_REASON = "head_sha_drift";
325
411
  */
326
412
  async function precheckReviewCondition(deps, condition, prNumber, expectedHeadSha) {
327
413
  if (!SUPPORTED_REVIEW_SOURCES.has(condition.source)) {
328
- return envelope(false, "review_source_unsupported", REVIEW_SOURCE_UNSUPPORTED_REASON, "needs_human", expectedHeadSha, prNumber);
414
+ return refuse(envelope(false, "review_source_unsupported", REVIEW_SOURCE_UNSUPPORTED_REASON, "needs_human", expectedHeadSha, prNumber));
329
415
  }
330
- const unavailable = () => envelope(false, "review_unavailable", REVIEW_UNAVAILABLE_REASON, "needs_human", expectedHeadSha, prNumber);
416
+ const unavailable = () => refuse(envelope(false, "review_unavailable", REVIEW_UNAVAILABLE_REASON, "needs_human", expectedHeadSha, prNumber));
331
417
  // URL and header construction sit OUTSIDE the try, matching the required-check
332
418
  // resolver: a failure there is a broken dependency, not an unreadable review,
333
419
  // and it belongs to the handler's outer containment boundary. The repository
@@ -350,7 +436,12 @@ async function precheckReviewCondition(deps, condition, prNumber, expectedHeadSh
350
436
  // `normalizeReviewSnapshot` returns null for `available: false` and for any
351
437
  // body it cannot read as a review snapshot — the same answer, because both
352
438
  // mean the same thing here.
353
- const snapshot = normalizeReviewSnapshot(await readJson(reviewResp));
439
+ //
440
+ // The raw body is kept as well: `verdictIsGenuinelyAbsent` below has to tell an
441
+ // ABSENT `sticky_verdict` from one the normalizer could not recognize, and the
442
+ // normalized snapshot maps both to `null`.
443
+ const reviewBody = await readJson(reviewResp);
444
+ const snapshot = normalizeReviewSnapshot(reviewBody);
354
445
  if (snapshot === null)
355
446
  return unavailable();
356
447
  // Head equality is checked BEFORE the evaluator, and takes precedence over an
@@ -362,16 +453,42 @@ async function precheckReviewCondition(deps, condition, prNumber, expectedHeadSh
362
453
  if (typeof snapshot.head_sha === "string" && snapshot.head_sha.length > 0) {
363
454
  diagnostics.actual_head_sha = snapshot.head_sha;
364
455
  }
365
- return envelope(false, "refused", HEAD_SHA_DRIFT_REASON, "needs_human", expectedHeadSha, prNumber, diagnostics);
456
+ return refuse(envelope(false, "refused", HEAD_SHA_DRIFT_REASON, "needs_human", expectedHeadSha, prNumber, diagnostics));
366
457
  }
367
458
  const evaluation = evaluateReviewCondition(condition, snapshot);
368
459
  if (!evaluation.passed) {
460
+ // BAPI-915 — the ONE waivable direction, and every guard that narrows it has
461
+ // already run above: the source is supported, the response was readable, the
462
+ // envelope was not `available: false`, and the snapshot is bound to THIS
463
+ // head. What remains is a review that was read and carries NO VERDICT AT
464
+ // ALL — the shape `claude-review` leaves behind when it dies during
465
+ // synthesis, having posted nothing.
466
+ //
467
+ // `changesRequested` is excluded explicitly: a human objected to this exact
468
+ // head, and no configuration waives an objection. Head drift is excluded by
469
+ // construction, because the head check above returns before the evaluator
470
+ // ever runs — that ordering is load-bearing and must not be folded in.
471
+ //
472
+ // `verdictIsGenuinelyAbsent` is the third guard and it is NOT redundant with
473
+ // the evaluator: the evaluator reports the same
474
+ // `{passed: false, changesRequested: false}` for "nobody posted anything" and
475
+ // for "two same-head comments contradict each other", and only the first may
476
+ // ever be waived.
477
+ //
478
+ // The disposition is compared for EXACT equality with `fail_open`. An absent
479
+ // field and an explicit `park` both fall through to the byte-identical
480
+ // pre-BAPI-915 refusal below.
481
+ if (effectiveDisposition(condition) === VERDICTLESS_DISPOSITION_FAIL_OPEN &&
482
+ evaluation.changesRequested === false &&
483
+ verdictIsGenuinelyAbsent(condition, snapshot, reviewBody)) {
484
+ return { kind: "waived", waiver: MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON };
485
+ }
369
486
  // A wait, not a park: a verdict for THIS head may still be posted. The
370
487
  // evaluator's own reason is forwarded verbatim rather than flattened, so the
371
488
  // caller can tell "no verdict yet" from "changes requested".
372
- return envelope(false, "review_not_approved", evaluation.reason, "retry_later", expectedHeadSha, prNumber);
489
+ return refuse(envelope(false, "review_not_approved", evaluation.reason, "retry_later", expectedHeadSha, prNumber));
373
490
  }
374
- return null;
491
+ return { kind: "proceed" };
375
492
  }
376
493
  // ---------------------------------------------------------------------------
377
494
  // Server-response interpretation
@@ -691,10 +808,18 @@ export async function mergePullRequestHandler(deps, args) {
691
808
  // carries a `review_state` condition, and it returns BEFORE the pre-POST
692
809
  // section below — so a refusal here constructs no merge URL, no merge
693
810
  // headers, no action key, and sends no merge request.
811
+ //
812
+ // BAPI-915: the decision is now three-valued. A refusal still returns before
813
+ // anything below runs; a waiver continues into the identical merge path and
814
+ // is carried through to the reported envelope so the operator can see that
815
+ // the merge advanced on CI evidence alone.
816
+ let reviewWaiver;
694
817
  if (resolution.reviewCondition !== null) {
695
- const refusal = await precheckReviewCondition(deps, resolution.reviewCondition, prNumber, expectedHeadSha);
696
- if (refusal !== null)
697
- return text(refusal);
818
+ const decision = await precheckReviewCondition(deps, resolution.reviewCondition, prNumber, expectedHeadSha);
819
+ if (decision.kind === "refused")
820
+ return text(decision.envelope);
821
+ if (decision.kind === "waived")
822
+ reviewWaiver = decision.waiver;
698
823
  }
699
824
  // --- pre-POST section ---------------------------------------------------
700
825
  const gateIdentity = buildGateIdentity(DEFAULT_GATE_NAME, resolution.configHash);
@@ -729,6 +854,7 @@ export async function mergePullRequestHandler(deps, args) {
729
854
  catch {
730
855
  return text(envelope(false, "unknown", "merge_request_not_observed", "retry_later", expectedHeadSha, prNumber, {
731
856
  hint: UNKNOWN_HINT,
857
+ review_waiver: reviewWaiver,
732
858
  }));
733
859
  }
734
860
  if (!mergeResp.ok) {
@@ -736,10 +862,11 @@ export async function mergePullRequestHandler(deps, args) {
736
862
  // status only — raw upstream error text is never forwarded to the agent.
737
863
  await deps.handleResponse(mergeResp).catch(() => "");
738
864
  if (mergeResp.status === 409) {
739
- return text(envelope(false, "action_key_mismatch", "action_key_mismatch", "needs_human", expectedHeadSha, prNumber));
865
+ return text(envelope(false, "action_key_mismatch", "action_key_mismatch", "needs_human", expectedHeadSha, prNumber, { review_waiver: reviewWaiver }));
740
866
  }
741
867
  return text(envelope(false, "error", "merge_request_failed", "needs_human", expectedHeadSha, prNumber, {
742
868
  http_status: mergeResp.status,
869
+ review_waiver: reviewWaiver,
743
870
  }));
744
871
  }
745
872
  const mergeJson = await readJson(mergeResp);
@@ -749,9 +876,9 @@ export async function mergePullRequestHandler(deps, args) {
749
876
  if (executionMode === "local" &&
750
877
  isPlainObject(mergeJson) &&
751
878
  mergeJson.status === LOCAL_APPROVAL_STATUS) {
752
- return text(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey));
879
+ return text(withReviewWaiver(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey), reviewWaiver));
753
880
  }
754
- return text(interpretMergeResponse(mergeJson, expectedHeadSha, prNumber));
881
+ return text(withReviewWaiver(interpretMergeResponse(mergeJson, expectedHeadSha, prNumber), reviewWaiver));
755
882
  }
756
883
  catch {
757
884
  // Containment: never throw through the MCP transport, and never serialize the