@bridge_gpt/mcp-server 0.2.12 → 0.2.13

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Client-side (local) conductor merge executor (F4).
3
+ *
4
+ * Performs the GitHub merge in the epic-tick process using the agent's OWN
5
+ * granted `gh` credentials, instead of the hosted Bridge API merge route (which
6
+ * would require the backend to hold global GitHub write scope — the reason a
7
+ * smoke-test merge failed with `provider_unauthorized`). Opt-in only: the runtime
8
+ * selects this executor when `policy_json.local_merge.enabled === true`.
9
+ *
10
+ * It mirrors the backend merge guard's decision order — approval precondition →
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.
14
+ *
15
+ * Subprocess safety: argument arrays (never a shell string), a validated numeric
16
+ * PR number, a merge method from a fixed allowlist, `GH_PROMPT_DISABLED` to avoid
17
+ * interactive hangs, and no token/secret ever logged. Default OFF everywhere.
18
+ */
19
+ import { spawnSync } from "child_process";
20
+ import { pollCiChecksForCommit, } from "./bridge-api-client.js";
21
+ const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
22
+ /**
23
+ * Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
24
+ * stateless process with no separate scheduler thread — a `gh` call that hangs
25
+ * (network stall, an auth prompt that slips past GH_PROMPT_DISABLED) would block
26
+ * the Node event loop indefinitely, freezing the whole tick. Killing at 60s
27
+ * yields a `timedOut` result the executor maps to a distinct `*_timeout` reason.
28
+ */
29
+ const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
30
+ /** Coerce an untrusted method value to a safe allowlisted method (default squash). */
31
+ export function resolveLocalMergeMethod(value) {
32
+ return typeof value === "string" && MERGE_METHODS.has(value)
33
+ ? value
34
+ : "squash";
35
+ }
36
+ function defaultRunCommand(cmd, args, env) {
37
+ const result = spawnSync(cmd, args, {
38
+ encoding: "utf8",
39
+ env: { ...process.env, ...env },
40
+ timeout: DEFAULT_COMMAND_TIMEOUT_MS,
41
+ });
42
+ // On timeout spawnSync kills the child (status: null, signal: "SIGTERM") and
43
+ // sets `error.code === "ETIMEDOUT"`. Surface that as a first-class flag.
44
+ const timedOut = result.error?.code === "ETIMEDOUT" ||
45
+ result.signal === "SIGTERM";
46
+ return {
47
+ status: result.status,
48
+ stdout: result.stdout ?? "",
49
+ stderr: result.stderr ?? "",
50
+ timedOut,
51
+ };
52
+ }
53
+ function buildResponse(request, status, reason, terminal, ledgerEvents) {
54
+ return {
55
+ action_key: request.action_key,
56
+ repo_name: request.repo_name,
57
+ pr_number: request.pr_number,
58
+ expected_head_sha: request.expected_head_sha,
59
+ status,
60
+ reason,
61
+ terminal,
62
+ ledger_events: ledgerEvents,
63
+ };
64
+ }
65
+ /**
66
+ * Decide whether every required CI check is green for the polled head SHA. When
67
+ * the gate lists no required checks, fall back to the poll's `all_passed` flag.
68
+ * Defensive against the poll response's exact shape: a check counts as green if
69
+ * any of `conclusion==="success"`, `status==="success"`, `green===true`, or
70
+ * `bucket==="pass"`.
71
+ */
72
+ export function allRequiredChecksGreen(pollResponse, requiredChecks) {
73
+ if (pollResponse === null || typeof pollResponse !== "object")
74
+ return false;
75
+ let obj = pollResponse;
76
+ // pollCiChecksForCommit returns the `{available, reason, action, detail}`
77
+ // envelope; checks/all_passed live under `detail`. Unwrap it (envelope-
78
+ // tolerant, mirroring normalizeCiSnapshot) — reading the top level alone made
79
+ // every required check look missing → merge always failed ci_not_green.
80
+ const maybeDetail = obj.detail;
81
+ if (maybeDetail &&
82
+ typeof maybeDetail === "object" &&
83
+ (Array.isArray(maybeDetail.checks) ||
84
+ "all_passed" in maybeDetail)) {
85
+ obj = maybeDetail;
86
+ }
87
+ const rawChecks = Array.isArray(obj.checks) ? obj.checks : [];
88
+ if (requiredChecks.length === 0) {
89
+ return obj.all_passed === true;
90
+ }
91
+ const byName = new Map();
92
+ for (const c of rawChecks) {
93
+ const name = typeof c.name === "string" ? c.name : null;
94
+ if (name !== null)
95
+ byName.set(name, c);
96
+ }
97
+ const isGreen = (c) => {
98
+ if (!c)
99
+ return false;
100
+ const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
101
+ const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
102
+ const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
103
+ return c.green === true || conclusion === "success" || status === "success" || bucket === "pass";
104
+ };
105
+ return requiredChecks.every((name) => isGreen(byName.get(name)));
106
+ }
107
+ /**
108
+ * Build a `(access, request) => Promise<ConductorMergeResponse>` that performs the
109
+ * merge locally via `gh`. Drop-in replacement for `mergePullRequestForGate` used
110
+ * by {@link processGateMetMerge}'s `merge` seam.
111
+ */
112
+ export function makeLocalMergeExecutor(options = {}, deps = {}) {
113
+ const method = resolveLocalMergeMethod(options.method);
114
+ const run = deps.runCommand ?? defaultRunCommand;
115
+ const pollCi = deps.pollCi ?? pollCiChecksForCommit;
116
+ // Non-interactive + no-noise env for gh; the caller's env (incl. any token)
117
+ // is overlaid first, then the safety pins.
118
+ const ghEnv = {
119
+ ...deps.env,
120
+ GH_PROMPT_DISABLED: "1",
121
+ GH_NO_UPDATE_NOTIFIER: "1",
122
+ };
123
+ return async (access, request) => {
124
+ const pr = request.pr_number;
125
+ const expectedSha = request.expected_head_sha;
126
+ const requiredChecks = request.gate?.required_checks ?? [];
127
+ const baseDetails = {
128
+ action_key: request.action_key,
129
+ repo: request.repo_name,
130
+ pr_number: pr,
131
+ expected_head_sha: expectedSha,
132
+ merge_method: method,
133
+ executor: "local",
134
+ };
135
+ const fail = (reason) => buildResponse(request, "failed", reason, false, [
136
+ { type: "merge.failed", status: "failed", reason, details: baseDetails },
137
+ ]);
138
+ // 1. Approval precondition — never merge when approval is required.
139
+ if (options.approvalRequired) {
140
+ return buildResponse(request, "pending_approval", "local_merge_approval_required", false, [
141
+ {
142
+ type: "merge.pending_approval",
143
+ status: "pending_approval",
144
+ reason: "local_merge_approval_required",
145
+ details: baseDetails,
146
+ },
147
+ ]);
148
+ }
149
+ // 2. Re-read PR head + open-state (head-drift / closed guard).
150
+ const view = run("gh", ["pr", "view", String(pr), "--json", "headRefOid,state"], ghEnv);
151
+ if (view.timedOut)
152
+ return fail("gh_pr_view_timeout");
153
+ if (view.status !== 0)
154
+ return fail("gh_pr_view_failed");
155
+ let headOid;
156
+ let state;
157
+ try {
158
+ const parsed = JSON.parse(view.stdout);
159
+ headOid = parsed.headRefOid;
160
+ state = parsed.state;
161
+ }
162
+ catch {
163
+ return fail("gh_pr_view_unparseable");
164
+ }
165
+ if (typeof state === "string" && state.toUpperCase() !== "OPEN")
166
+ return fail("pr_not_open");
167
+ if (typeof headOid !== "string" || headOid.toLowerCase() !== expectedSha.toLowerCase()) {
168
+ return fail("head_drift");
169
+ }
170
+ // 3. Revalidate required CI green for the exact head SHA.
171
+ let pollResponse;
172
+ try {
173
+ pollResponse = await pollCi(access, expectedSha);
174
+ }
175
+ catch {
176
+ return fail("ci_poll_failed");
177
+ }
178
+ if (!allRequiredChecksGreen(pollResponse, requiredChecks))
179
+ return fail("ci_not_green");
180
+ // 4. Provider merge.
181
+ const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
182
+ if (merge.status !== 0) {
183
+ const mergeFailReason = merge.timedOut ? "gh_merge_timeout" : "gh_merge_failed";
184
+ return buildResponse(request, "failed", mergeFailReason, false, [
185
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
186
+ { type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails },
187
+ ]);
188
+ }
189
+ // 5. Best-effort resolve the squash/merge commit SHA for the audit trail.
190
+ let mergeCommitSha;
191
+ const post = run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
192
+ if (post.status === 0) {
193
+ try {
194
+ const oid = JSON.parse(post.stdout)?.mergeCommit;
195
+ if (oid && typeof oid === "object" && typeof oid.oid === "string") {
196
+ mergeCommitSha = oid.oid;
197
+ }
198
+ }
199
+ catch {
200
+ /* best-effort only */
201
+ }
202
+ }
203
+ const succeededDetails = {
204
+ ...baseDetails,
205
+ ...(mergeCommitSha ? { merge_commit_sha: mergeCommitSha } : {}),
206
+ };
207
+ return buildResponse(request, "succeeded", null, true, [
208
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
209
+ { type: "merge.succeeded", status: "succeeded", details: succeededDetails },
210
+ ]);
211
+ };
212
+ }
@@ -9,7 +9,7 @@
9
9
  * check. All event types come from the existing conductor taxonomy and use only
10
10
  * allowlisted top-level data keys.
11
11
  */
12
- import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
12
+ import { GIT_CI_PRODUCER, REVIEW_STATE, } from "./git-ci-types.js";
13
13
  import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
14
14
  import { observeReviewWithResolved } from "./pr-review-producer.js";
15
15
  import { fetchEffectiveSupervisorSetup, fetchActiveEpicRuns, fetchEpicRunState, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
@@ -455,7 +455,17 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
455
455
  rawConfig = undefined;
456
456
  }
457
457
  const gateConfig = parseDoneGateConfig(rawConfig);
458
- if (gateConfig.enabled && gateConfig.valid) {
458
+ // F6: this poll-driven path observes CI only — it has NO review snapshot
459
+ // (unlike observeWithResolved, which fetches one via observeReviewWithResolved).
460
+ // A composite gate with any review_state condition can therefore never be
461
+ // satisfied here and would only fail closed silently. Rather than pretend to
462
+ // be a gate path, refuse to evaluate review-gated configs and defer to
463
+ // `wait_for_done_gate` (the complete observer). CI-only gates still emit here.
464
+ const requiresReview = gateConfig.conditions.some((c) => c.type === REVIEW_STATE);
465
+ if (gateConfig.enabled && gateConfig.valid && requiresReview) {
466
+ result.reason = "review-gated config: gate.met deferred to wait_for_done_gate (poll path is CI-only)";
467
+ }
468
+ if (gateConfig.enabled && gateConfig.valid && !requiresReview) {
459
469
  const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
460
470
  if (evaluation.met) {
461
471
  result.gate_met = true;
@@ -180,11 +180,14 @@ export function resolveConductorStoreConfig(env = process.env) {
180
180
  * event type to the same CHECK vocabulary. Bumped to 6 in BAPI-445 because the
181
181
  * pre-implementation spec re-review verdict feature adds the `spec_review.passed`
182
182
  * and `spec_review.changes_requested` event types to the same `events.type`
183
- * CHECK vocabulary. Older ledgers stamped at a lower version are rebuilt by
184
- * {@link migrateConductorSchemaIfNeeded} so their CHECK clause accepts the
185
- * current taxonomy.
183
+ * CHECK vocabulary. Bumped to 7 because the durable parse-after-merge fold adds
184
+ * the `parse.triggered` event type to the same `events.type` CHECK vocabulary
185
+ * (replacing the in-memory parse-wait map so stateless epic-tick invocations can
186
+ * fold a merged ticket to `done`). Older ledgers stamped at a lower version are
187
+ * rebuilt by {@link migrateConductorSchemaIfNeeded} so their CHECK clause accepts
188
+ * the current taxonomy.
186
189
  */
187
- export const CURRENT_CONDUCTOR_SCHEMA_VERSION = 6;
190
+ export const CURRENT_CONDUCTOR_SCHEMA_VERSION = 7;
188
191
  /** Render the taxonomy `CHECK (type IN (...))` clause from the single source of truth. */
189
192
  function buildTypeCheckClause() {
190
193
  const list = SEMANTIC_EVENT_TYPES.map((t) => `'${t}'`).join(", ");
@@ -43,6 +43,10 @@ export const SEMANTIC_EVENT_TYPES = [
43
43
  // mis-folds a spec-review outcome as the implementation PR's review state.
44
44
  "spec_review.passed",
45
45
  "spec_review.changes_requested",
46
+ // Durable parse-after-merge marker. Emitted by epic-tick when it triggers a
47
+ // post-merge repository re-index, so the (stateless) reconcile loop can fold a
48
+ // merged ticket to `done` from the ledger instead of an in-memory wait map.
49
+ "parse.triggered",
46
50
  ];
47
51
  /**
48
52
  * Type guard: returns `true` only when `value` is one of the exact taxonomy