@bridge_gpt/mcp-server 0.2.9 → 0.2.12
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 +59 -7
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +263 -35
- package/build/conductor/cli.js +38 -17
- package/build/conductor/doctor.js +35 -2
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +318 -4
- package/build/conductor/epic-runtime.js +382 -18
- package/build/conductor/epic-state.js +188 -15
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +16 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +118 -19
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +105 -26
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +32 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +7 -7
- package/build/conductor-bin.js +12350 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +200 -0
- package/build/index.js +23696 -4351
- package/build/init.js +481 -0
- package/build/install-bridge.js +772 -0
- package/build/mcp-profile.js +43 -0
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-conductor.js +1 -0
- package/build/start-tickets.js +186 -10
- package/build/upgrade-cli.js +154 -0
- package/build/version.generated.js +1 -1
- package/package.json +7 -4
- package/pipelines/check-ci-ticket.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/learn-repository.json +84 -42
- package/smoke-test/SMOKE-TEST.md +11 -17
|
@@ -86,6 +86,25 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Inspect the BRIDGE_MCP_PROFILE environment variable and warn when a
|
|
91
|
+
* conductor/epic context is detected but the profile is not "conductor".
|
|
92
|
+
* Strictly read-only, never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function inspectMcpProfile(env, epicTick) {
|
|
95
|
+
const resolved_profile = env.BRIDGE_MCP_PROFILE || "core";
|
|
96
|
+
const conductor_context_detected = env.BAPI_CONDUCTOR_ENABLED === "1" ||
|
|
97
|
+
env.BAPI_CONDUCTOR_ENABLED === "true" ||
|
|
98
|
+
epicTick.registered;
|
|
99
|
+
const degraded = conductor_context_detected && resolved_profile !== "conductor";
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (degraded) {
|
|
102
|
+
warnings.push(`BRIDGE_MCP_PROFILE is "${resolved_profile}" but a conductor context is active. ` +
|
|
103
|
+
`Expected "conductor" — conductor/event/supervisor tools may be missing. ` +
|
|
104
|
+
`Ensure the spawner injects BRIDGE_MCP_PROFILE=conductor into the worker shell environment.`);
|
|
105
|
+
}
|
|
106
|
+
return { resolved_profile, conductor_context_detected, degraded, warnings };
|
|
107
|
+
}
|
|
89
108
|
/**
|
|
90
109
|
* Build the combined read-only doctor report. Composes the existing ledger
|
|
91
110
|
* doctor, git hook inspection, and the epic-tick schedule enablement check.
|
|
@@ -95,10 +114,12 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
95
114
|
const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
|
|
96
115
|
const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
|
|
97
116
|
const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
|
|
117
|
+
const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
|
|
98
118
|
return {
|
|
99
|
-
ledger: doctorLedger(),
|
|
119
|
+
ledger: await doctorLedger(),
|
|
100
120
|
git_hooks: inspectHooks(deps.hooksDeps),
|
|
101
121
|
epic_tick: epicTick,
|
|
122
|
+
mcp_profile,
|
|
102
123
|
};
|
|
103
124
|
}
|
|
104
125
|
/**
|
|
@@ -107,7 +128,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
107
128
|
* status tags consistent with the git hooks section's visual hierarchy.
|
|
108
129
|
*/
|
|
109
130
|
export function formatConductorDoctorReport(report) {
|
|
110
|
-
const { ledger, git_hooks, epic_tick } = report;
|
|
131
|
+
const { ledger, git_hooks, epic_tick, mcp_profile } = report;
|
|
111
132
|
const lines = [
|
|
112
133
|
"Conductor ledger doctor",
|
|
113
134
|
"───────────────────────",
|
|
@@ -160,5 +181,17 @@ export function formatConductorDoctorReport(report) {
|
|
|
160
181
|
for (const w of epic_tick.warnings)
|
|
161
182
|
lines.push(` - ${w}`);
|
|
162
183
|
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push("MCP Profile (optional, local)");
|
|
186
|
+
lines.push("─────────────────────────────");
|
|
187
|
+
const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
|
|
188
|
+
lines.push(`resolved profile: ${mcp_profile.resolved_profile} ${profileTag}`);
|
|
189
|
+
lines.push(`conductor context: ${mcp_profile.conductor_context_detected}`);
|
|
190
|
+
lines.push(`degraded: ${mcp_profile.degraded}`);
|
|
191
|
+
if (mcp_profile.warnings.length > 0) {
|
|
192
|
+
lines.push("mcp profile warnings:");
|
|
193
|
+
for (const w of mcp_profile.warnings)
|
|
194
|
+
lines.push(` - ${w}`);
|
|
195
|
+
}
|
|
163
196
|
return lines.join("\n");
|
|
164
197
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure done-gate config parsing, CI snapshot normalization, and deterministic
|
|
3
|
-
* gate evaluation (BAPI-395).
|
|
3
|
+
* gate evaluation (BAPI-395, BAPI-440).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Supports a heterogeneous ANDed conditions list: one or more CI conditions
|
|
6
|
+
* (`required_ci_checks_green`) plus zero or more review-state conditions
|
|
7
|
+
* (`review_state`). Fails CLOSED everywhere: unset, disabled, malformed,
|
|
8
|
+
* unknown/duplicate condition types all yield an inactive result that can never
|
|
9
|
+
* emit `gate.met`. A gate is met only when ALL conditions are met for the exact
|
|
9
10
|
* `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
|
|
10
11
|
* throws for caller input.
|
|
11
12
|
*/
|
|
12
|
-
import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
|
|
13
|
+
import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Config parsing
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
@@ -22,7 +23,7 @@ function inactiveConfig(reason) {
|
|
|
22
23
|
enabled: false,
|
|
23
24
|
valid: false,
|
|
24
25
|
reason,
|
|
25
|
-
|
|
26
|
+
conditions: [],
|
|
26
27
|
config_hash: null,
|
|
27
28
|
gate_name: DEFAULT_GATE_NAME,
|
|
28
29
|
};
|
|
@@ -62,21 +63,11 @@ function coerceConfigObject(value) {
|
|
|
62
63
|
return { kind: "invalid" };
|
|
63
64
|
}
|
|
64
65
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* condition whose `required_checks` is a non-empty array of unique, valid,
|
|
68
|
-
* normalized check names. Returns the normalized condition or `null`.
|
|
66
|
+
* Parse and validate a single `required_ci_checks_green` condition entry.
|
|
67
|
+
* Returns the normalized condition or `null` on any validation failure.
|
|
69
68
|
*/
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
if (!Array.isArray(conditions) || conditions.length !== 1)
|
|
73
|
-
return null;
|
|
74
|
-
const condition = conditions[0];
|
|
75
|
-
if (!isPlainObject(condition))
|
|
76
|
-
return null;
|
|
77
|
-
if (condition.type !== REQUIRED_CI_CHECKS_GREEN)
|
|
78
|
-
return null;
|
|
79
|
-
const rawChecks = condition.required_checks;
|
|
69
|
+
function parseCiChecksCondition(entry) {
|
|
70
|
+
const rawChecks = entry.required_checks;
|
|
80
71
|
if (!Array.isArray(rawChecks) || rawChecks.length === 0)
|
|
81
72
|
return null;
|
|
82
73
|
const normalized = [];
|
|
@@ -84,19 +75,103 @@ function parseSingleCondition(object) {
|
|
|
84
75
|
for (const raw of rawChecks) {
|
|
85
76
|
const name = normalizeCheckName(raw);
|
|
86
77
|
if (name === null)
|
|
87
|
-
return null;
|
|
78
|
+
return null;
|
|
88
79
|
if (seen.has(name))
|
|
89
|
-
return null;
|
|
80
|
+
return null;
|
|
90
81
|
seen.add(name);
|
|
91
82
|
normalized.push(name);
|
|
92
83
|
}
|
|
93
84
|
return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
|
|
94
85
|
}
|
|
86
|
+
/** Valid review source values. */
|
|
87
|
+
const VALID_REVIEW_SOURCES = new Set(["sticky_verdict", "native_review_decision", "min_approvals", "combination"]);
|
|
88
|
+
/**
|
|
89
|
+
* Parse and validate a single `review_state` condition entry.
|
|
90
|
+
* Returns the normalized condition or `null` on any validation failure.
|
|
91
|
+
*/
|
|
92
|
+
function parseReviewStateCondition(entry) {
|
|
93
|
+
const source = entry.source;
|
|
94
|
+
if (typeof source !== "string" || !VALID_REVIEW_SOURCES.has(source))
|
|
95
|
+
return null;
|
|
96
|
+
const condition = { type: REVIEW_STATE, source: source };
|
|
97
|
+
if (entry.require_sticky_verdict !== undefined) {
|
|
98
|
+
if (typeof entry.require_sticky_verdict !== "boolean")
|
|
99
|
+
return null;
|
|
100
|
+
condition.require_sticky_verdict = entry.require_sticky_verdict;
|
|
101
|
+
}
|
|
102
|
+
if (entry.require_native_decision !== undefined) {
|
|
103
|
+
if (typeof entry.require_native_decision !== "boolean")
|
|
104
|
+
return null;
|
|
105
|
+
condition.require_native_decision = entry.require_native_decision;
|
|
106
|
+
}
|
|
107
|
+
if (entry.min_approvals !== undefined) {
|
|
108
|
+
if (typeof entry.min_approvals !== "number" || !Number.isInteger(entry.min_approvals) || entry.min_approvals < 0)
|
|
109
|
+
return null;
|
|
110
|
+
condition.min_approvals = entry.min_approvals;
|
|
111
|
+
}
|
|
112
|
+
if (entry.logic !== undefined) {
|
|
113
|
+
if (entry.logic !== "and")
|
|
114
|
+
return null;
|
|
115
|
+
condition.logic = "and";
|
|
116
|
+
}
|
|
117
|
+
// For combination source, at least one sub-source must be active; otherwise
|
|
118
|
+
// the condition would trivially pass with an empty failures array (fail-open).
|
|
119
|
+
if (condition.source === "combination") {
|
|
120
|
+
const hasSticky = condition.require_sticky_verdict === true;
|
|
121
|
+
const hasNative = condition.require_native_decision === true;
|
|
122
|
+
const hasMin = typeof condition.min_approvals === "number" && condition.min_approvals > 0;
|
|
123
|
+
if (!hasSticky && !hasNative && !hasMin)
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return condition;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse the `conditions` array from a config object into a validated
|
|
130
|
+
* {@link GateCondition}[] list. Returns `null` if the array is missing, empty,
|
|
131
|
+
* contains any invalid/unknown/duplicate condition type, or any individual
|
|
132
|
+
* condition fails validation. Fail-closed.
|
|
133
|
+
*/
|
|
134
|
+
function parseConditions(object) {
|
|
135
|
+
const raw = object.conditions;
|
|
136
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
const seenTypes = new Set();
|
|
139
|
+
const parsed = [];
|
|
140
|
+
for (const entry of raw) {
|
|
141
|
+
if (!isPlainObject(entry))
|
|
142
|
+
return null;
|
|
143
|
+
const type = entry.type;
|
|
144
|
+
if (typeof type !== "string")
|
|
145
|
+
return null;
|
|
146
|
+
if (seenTypes.has(type))
|
|
147
|
+
return null; // duplicate type
|
|
148
|
+
if (type === REQUIRED_CI_CHECKS_GREEN) {
|
|
149
|
+
const condition = parseCiChecksCondition(entry);
|
|
150
|
+
if (condition === null)
|
|
151
|
+
return null;
|
|
152
|
+
seenTypes.add(type);
|
|
153
|
+
parsed.push(condition);
|
|
154
|
+
}
|
|
155
|
+
else if (type === REVIEW_STATE) {
|
|
156
|
+
const condition = parseReviewStateCondition(entry);
|
|
157
|
+
if (condition === null)
|
|
158
|
+
return null;
|
|
159
|
+
seenTypes.add(type);
|
|
160
|
+
parsed.push(condition);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
return null; // unknown condition type → fail closed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
95
168
|
/**
|
|
96
169
|
* Parse a raw `conductor_done_gate` value into a fail-closed
|
|
97
170
|
* {@link NormalizedDoneGateConfig}. Active (`enabled && valid`) only when the
|
|
98
|
-
* value is an enabled object with `enabled === true` (strict boolean) and
|
|
99
|
-
* one valid
|
|
171
|
+
* value is an enabled object with `enabled === true` (strict boolean) and at
|
|
172
|
+
* least one valid condition (CI and/or review). Every other shape is inactive
|
|
173
|
+
* with a safe reason. Fail-closed on any unknown, duplicate, or malformed
|
|
174
|
+
* condition type.
|
|
100
175
|
*/
|
|
101
176
|
export function parseDoneGateConfig(value) {
|
|
102
177
|
const coerced = coerceConfigObject(value);
|
|
@@ -111,21 +186,35 @@ export function parseDoneGateConfig(value) {
|
|
|
111
186
|
return inactiveConfig("disabled");
|
|
112
187
|
return inactiveConfig("invalid: 'enabled' must be the boolean true");
|
|
113
188
|
}
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
return inactiveConfig("invalid:
|
|
189
|
+
const conditions = parseConditions(object);
|
|
190
|
+
if (conditions === null) {
|
|
191
|
+
return inactiveConfig("invalid: conditions must be a non-empty array of valid, non-duplicate condition objects");
|
|
117
192
|
}
|
|
118
193
|
const gateName = DEFAULT_GATE_NAME;
|
|
119
194
|
const configHash = stableJsonHash({
|
|
120
195
|
gate_name: gateName,
|
|
121
|
-
|
|
122
|
-
|
|
196
|
+
conditions: conditions.map((c) => {
|
|
197
|
+
if (c.type === REQUIRED_CI_CHECKS_GREEN) {
|
|
198
|
+
return { type: c.type, required_checks: c.required_checks };
|
|
199
|
+
}
|
|
200
|
+
// review_state: include all non-undefined fields deterministically
|
|
201
|
+
const r = { type: c.type, source: c.source };
|
|
202
|
+
if (c.require_sticky_verdict !== undefined)
|
|
203
|
+
r.require_sticky_verdict = c.require_sticky_verdict;
|
|
204
|
+
if (c.require_native_decision !== undefined)
|
|
205
|
+
r.require_native_decision = c.require_native_decision;
|
|
206
|
+
if (c.min_approvals !== undefined)
|
|
207
|
+
r.min_approvals = c.min_approvals;
|
|
208
|
+
if (c.logic !== undefined)
|
|
209
|
+
r.logic = c.logic;
|
|
210
|
+
return r;
|
|
211
|
+
}),
|
|
123
212
|
});
|
|
124
213
|
return {
|
|
125
214
|
enabled: true,
|
|
126
215
|
valid: true,
|
|
127
216
|
reason: "active",
|
|
128
|
-
|
|
217
|
+
conditions,
|
|
129
218
|
config_hash: configHash,
|
|
130
219
|
gate_name: gateName,
|
|
131
220
|
};
|
|
@@ -214,8 +303,16 @@ function normalizeOneCheck(name, raw) {
|
|
|
214
303
|
export function normalizeCiSnapshot(response) {
|
|
215
304
|
const checks = [];
|
|
216
305
|
const byName = new Map();
|
|
217
|
-
|
|
218
|
-
|
|
306
|
+
// The raw `pollCiChecksForCommit` response is an envelope
|
|
307
|
+
// (`{ available, reason, action, detail: { checks, unknown_checks } }`), while
|
|
308
|
+
// unit tests and some callers pass an already-unwrapped object
|
|
309
|
+
// (`{ checks, unknown_checks }`). Prefer the unwrapped top-level fields and only
|
|
310
|
+
// fall back to `detail.*` when the top-level field is absent, so both shapes
|
|
311
|
+
// normalize identically and existing unwrapped-shape behavior is preserved.
|
|
312
|
+
const source = isPlainObject(response) ? response : undefined;
|
|
313
|
+
const detail = source && isPlainObject(source.detail) ? source.detail : undefined;
|
|
314
|
+
if (source) {
|
|
315
|
+
const rawChecks = source.checks ?? detail?.checks;
|
|
219
316
|
if (Array.isArray(rawChecks)) {
|
|
220
317
|
for (const entry of rawChecks) {
|
|
221
318
|
if (!isPlainObject(entry))
|
|
@@ -238,8 +335,9 @@ export function normalizeCiSnapshot(response) {
|
|
|
238
335
|
}
|
|
239
336
|
}
|
|
240
337
|
const unknownChecks = [];
|
|
241
|
-
|
|
242
|
-
|
|
338
|
+
const rawUnknown = source ? source.unknown_checks ?? detail?.unknown_checks : undefined;
|
|
339
|
+
if (Array.isArray(rawUnknown)) {
|
|
340
|
+
for (const raw of rawUnknown) {
|
|
243
341
|
const name = normalizeCheckName(raw);
|
|
244
342
|
if (name !== null && !unknownChecks.includes(name))
|
|
245
343
|
unknownChecks.push(name);
|
|
@@ -264,46 +362,190 @@ export function normalizeCiSnapshot(response) {
|
|
|
264
362
|
};
|
|
265
363
|
}
|
|
266
364
|
// ---------------------------------------------------------------------------
|
|
365
|
+
// Review snapshot normalization
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
const REVIEW_VERDICT_APPROVED = "approved";
|
|
368
|
+
const REVIEW_VERDICT_CHANGES_REQUESTED = "changes_requested";
|
|
369
|
+
const REVIEW_VERDICT_UNKNOWN = "unknown";
|
|
370
|
+
/**
|
|
371
|
+
* Normalize a raw backend review-status response into a {@link NormalizedReviewSnapshot}.
|
|
372
|
+
* Returns `null` when the envelope indicates unavailability (`available: false`) or
|
|
373
|
+
* is missing, so callers can fail-closed without emitting false-negative events.
|
|
374
|
+
*/
|
|
375
|
+
export function normalizeReviewSnapshot(raw) {
|
|
376
|
+
if (!isPlainObject(raw))
|
|
377
|
+
return null;
|
|
378
|
+
if (raw.available === false)
|
|
379
|
+
return null;
|
|
380
|
+
const detail = isPlainObject(raw.detail) ? raw.detail : null;
|
|
381
|
+
if (detail === null)
|
|
382
|
+
return null;
|
|
383
|
+
const reviewDecision = typeof detail.review_decision === "string" && detail.review_decision.length > 0
|
|
384
|
+
? detail.review_decision
|
|
385
|
+
: null;
|
|
386
|
+
const approvals = typeof detail.approvals === "number" && Number.isInteger(detail.approvals) && detail.approvals >= 0
|
|
387
|
+
? detail.approvals
|
|
388
|
+
: 0;
|
|
389
|
+
const rawVerdict = detail.sticky_verdict;
|
|
390
|
+
let stickyVerdict;
|
|
391
|
+
if (rawVerdict === REVIEW_VERDICT_APPROVED) {
|
|
392
|
+
stickyVerdict = "approved";
|
|
393
|
+
}
|
|
394
|
+
else if (rawVerdict === REVIEW_VERDICT_CHANGES_REQUESTED) {
|
|
395
|
+
stickyVerdict = "changes_requested";
|
|
396
|
+
}
|
|
397
|
+
else if (rawVerdict === REVIEW_VERDICT_UNKNOWN) {
|
|
398
|
+
stickyVerdict = "unknown";
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
stickyVerdict = null;
|
|
402
|
+
}
|
|
403
|
+
const headSha = typeof detail.head_sha === "string" && detail.head_sha.trim().length > 0
|
|
404
|
+
? detail.head_sha.trim()
|
|
405
|
+
: null;
|
|
406
|
+
const reviewStateHash = stableJsonHash({
|
|
407
|
+
review_decision: reviewDecision,
|
|
408
|
+
approvals,
|
|
409
|
+
sticky_verdict: stickyVerdict,
|
|
410
|
+
});
|
|
411
|
+
return { review_decision: reviewDecision, approvals, sticky_verdict: stickyVerdict, head_sha: headSha, review_state_hash: reviewStateHash };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Deterministically evaluate a review-state condition against a normalized snapshot.
|
|
415
|
+
* Pure and fail-closed: a null snapshot or any unknown/missing source yields
|
|
416
|
+
* `passed: false` and `changesRequested: false` (never a false positive).
|
|
417
|
+
*/
|
|
418
|
+
export function evaluateReviewCondition(condition, snapshot) {
|
|
419
|
+
if (snapshot === null) {
|
|
420
|
+
return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
|
|
421
|
+
}
|
|
422
|
+
const source = condition.source;
|
|
423
|
+
if (source === "sticky_verdict") {
|
|
424
|
+
if (snapshot.sticky_verdict === "approved")
|
|
425
|
+
return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
|
|
426
|
+
if (snapshot.sticky_verdict === "changes_requested")
|
|
427
|
+
return { passed: false, changesRequested: true, reason: "sticky verdict requests changes" };
|
|
428
|
+
return { passed: false, changesRequested: false, reason: `sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}` };
|
|
429
|
+
}
|
|
430
|
+
if (source === "native_review_decision") {
|
|
431
|
+
const dec = snapshot.review_decision?.toUpperCase();
|
|
432
|
+
if (dec === "APPROVED")
|
|
433
|
+
return { passed: true, changesRequested: false, reason: "native review decision approved" };
|
|
434
|
+
if (dec === "CHANGES_REQUESTED")
|
|
435
|
+
return { passed: false, changesRequested: true, reason: "native review decision requests changes" };
|
|
436
|
+
return { passed: false, changesRequested: false, reason: `native review decision not approved: ${snapshot.review_decision ?? "null"}` };
|
|
437
|
+
}
|
|
438
|
+
if (source === "min_approvals") {
|
|
439
|
+
const required = typeof condition.min_approvals === "number" ? condition.min_approvals : 1;
|
|
440
|
+
if (snapshot.approvals >= required)
|
|
441
|
+
return { passed: true, changesRequested: false, reason: `approvals ${snapshot.approvals} >= ${required}` };
|
|
442
|
+
return { passed: false, changesRequested: false, reason: `approvals ${snapshot.approvals} < ${required}` };
|
|
443
|
+
}
|
|
444
|
+
if (source === "combination") {
|
|
445
|
+
// All enabled sub-sources must pass (AND logic).
|
|
446
|
+
const requireSticky = condition.require_sticky_verdict === true;
|
|
447
|
+
const requireNative = condition.require_native_decision === true;
|
|
448
|
+
const minApprovals = typeof condition.min_approvals === "number" ? condition.min_approvals : 0;
|
|
449
|
+
const failures = [];
|
|
450
|
+
let changesRequested = false;
|
|
451
|
+
if (requireSticky) {
|
|
452
|
+
if (snapshot.sticky_verdict === "changes_requested")
|
|
453
|
+
changesRequested = true;
|
|
454
|
+
if (snapshot.sticky_verdict !== "approved")
|
|
455
|
+
failures.push(`sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}`);
|
|
456
|
+
}
|
|
457
|
+
if (requireNative) {
|
|
458
|
+
const dec = snapshot.review_decision?.toUpperCase();
|
|
459
|
+
if (dec === "CHANGES_REQUESTED")
|
|
460
|
+
changesRequested = true;
|
|
461
|
+
if (dec !== "APPROVED")
|
|
462
|
+
failures.push(`native decision not approved: ${snapshot.review_decision ?? "null"}`);
|
|
463
|
+
}
|
|
464
|
+
if (minApprovals > 0 && snapshot.approvals < minApprovals) {
|
|
465
|
+
failures.push(`approvals ${snapshot.approvals} < ${minApprovals}`);
|
|
466
|
+
}
|
|
467
|
+
if (failures.length > 0)
|
|
468
|
+
return { passed: false, changesRequested, reason: failures.join("; ") };
|
|
469
|
+
return { passed: true, changesRequested: false, reason: "all combination sources satisfied" };
|
|
470
|
+
}
|
|
471
|
+
return { passed: false, changesRequested: false, reason: `unknown review source: ${source}` };
|
|
472
|
+
}
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
267
474
|
// Gate evaluation
|
|
268
475
|
// ---------------------------------------------------------------------------
|
|
269
476
|
function failedEvaluation(reason) {
|
|
270
477
|
return { met: false, reason };
|
|
271
478
|
}
|
|
272
479
|
/**
|
|
273
|
-
* Deterministically evaluate a done gate for one binding
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* false` with a fail-closed reason
|
|
277
|
-
* identical inputs always produce deep-equal output.
|
|
480
|
+
* Deterministically evaluate a done gate for one binding against both a CI
|
|
481
|
+
* snapshot and an optional review snapshot. Returns `met: true` with canonical
|
|
482
|
+
* `gate.met` event data (allowlisted top-level keys only) only when ALL configured
|
|
483
|
+
* conditions are met; otherwise `met: false` with a fail-closed reason. Pure and
|
|
484
|
+
* deterministic: identical inputs always produce deep-equal output. Never throws.
|
|
278
485
|
*/
|
|
279
|
-
export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
|
|
280
|
-
if (!config.enabled || !config.valid || config.
|
|
486
|
+
export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso, reviewSnapshot = null) {
|
|
487
|
+
if (!config.enabled || !config.valid || config.conditions.length === 0) {
|
|
281
488
|
return failedEvaluation(`gate inactive: ${config.reason}`);
|
|
282
489
|
}
|
|
283
490
|
const headSha = normalizeSha(binding.head_sha);
|
|
284
491
|
if (headSha === null) {
|
|
285
492
|
return failedEvaluation("invalid binding: head_sha is not a valid SHA");
|
|
286
493
|
}
|
|
494
|
+
// Per-condition evaluation accumulators
|
|
495
|
+
const allFailureReasons = [];
|
|
496
|
+
// CI check status (for the structured details payload)
|
|
497
|
+
let checkResults = [];
|
|
498
|
+
let ciConditionType;
|
|
499
|
+
let requiredChecks;
|
|
500
|
+
// Review condition result (for the structured details payload)
|
|
501
|
+
let reviewResult;
|
|
287
502
|
const byName = new Map();
|
|
288
503
|
for (const check of snapshot.checks)
|
|
289
504
|
byName.set(check.name, check);
|
|
290
505
|
const unknownSet = new Set(snapshot.unknown_checks);
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
506
|
+
for (const condition of config.conditions) {
|
|
507
|
+
if (condition.type === REQUIRED_CI_CHECKS_GREEN) {
|
|
508
|
+
ciConditionType = condition.type;
|
|
509
|
+
requiredChecks = [...condition.required_checks];
|
|
510
|
+
checkResults = [];
|
|
511
|
+
const unmet = [];
|
|
512
|
+
for (const name of condition.required_checks) {
|
|
513
|
+
const check = byName.get(name);
|
|
514
|
+
if (!check) {
|
|
515
|
+
checkResults.push({ name, present: false, complete: false, green: false });
|
|
516
|
+
unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
checkResults.push({ name, present: true, complete: check.complete, green: check.green });
|
|
520
|
+
if (!check.green) {
|
|
521
|
+
unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (unmet.length > 0) {
|
|
525
|
+
allFailureReasons.push(`required checks not green: ${unmet.join(", ")}`);
|
|
526
|
+
}
|
|
299
527
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
528
|
+
else if (condition.type === REVIEW_STATE) {
|
|
529
|
+
reviewResult = evaluateReviewCondition(condition, reviewSnapshot);
|
|
530
|
+
if (!reviewResult.passed) {
|
|
531
|
+
allFailureReasons.push(`review condition not met: ${reviewResult.reason}`);
|
|
532
|
+
}
|
|
303
533
|
}
|
|
304
534
|
}
|
|
305
|
-
if (
|
|
306
|
-
return failedEvaluation(
|
|
535
|
+
if (allFailureReasons.length > 0) {
|
|
536
|
+
return failedEvaluation(allFailureReasons.join("; "));
|
|
537
|
+
}
|
|
538
|
+
// Build structured per-condition details for telemetry
|
|
539
|
+
const ciCheckStatus = {};
|
|
540
|
+
if (ciConditionType !== undefined) {
|
|
541
|
+
ciCheckStatus.condition_type = ciConditionType;
|
|
542
|
+
ciCheckStatus.required_checks = requiredChecks;
|
|
543
|
+
ciCheckStatus.check_results = checkResults;
|
|
544
|
+
}
|
|
545
|
+
const reviewStatus = {};
|
|
546
|
+
if (reviewResult !== undefined) {
|
|
547
|
+
reviewStatus.passed = reviewResult.passed;
|
|
548
|
+
reviewStatus.reason = reviewResult.reason;
|
|
307
549
|
}
|
|
308
550
|
const details = {
|
|
309
551
|
repo: binding.repo,
|
|
@@ -311,11 +553,12 @@ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
|
|
|
311
553
|
head_sha: headSha,
|
|
312
554
|
gate_name: config.gate_name,
|
|
313
555
|
config_hash: config.config_hash,
|
|
314
|
-
condition_type: config.condition.type,
|
|
315
|
-
required_checks: [...config.condition.required_checks],
|
|
316
|
-
check_results: checkResults,
|
|
317
556
|
evaluated_at: evaluatedAtIso,
|
|
557
|
+
ci_check_status: ciCheckStatus,
|
|
318
558
|
};
|
|
559
|
+
if (reviewResult !== undefined) {
|
|
560
|
+
details.review_status = reviewStatus;
|
|
561
|
+
}
|
|
319
562
|
const gateEventData = {
|
|
320
563
|
summary: `Done gate "${config.gate_name}" met for ${binding.subject}`,
|
|
321
564
|
status: "met",
|