@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.
- package/README.md +6 -6
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/checkpoint-store.js +50 -4
- package/build/conduct-epic/cli.js +74 -6
- package/build/conductor/done-gate.js +39 -1
- package/build/conductor/git-ci-types.js +34 -0
- package/build/index.js +9 -9
- package/build/merge-pull-request.js +143 -16
- package/build/readme.generated.js +1 -1
- package/build/version.generated.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* review that
|
|
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
|
-
|
|
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
|
|
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
|
|
696
|
-
if (
|
|
697
|
-
return text(
|
|
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
|