@bridge_gpt/mcp-server 0.2.2 → 0.2.3
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 +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +468 -59
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +16 -8
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event `data` normalization and the tool-native-field boundary.
|
|
3
|
+
*
|
|
4
|
+
* Conductor events carry a small, semantically-normalized `data` object. Only an
|
|
5
|
+
* allowlisted set of top-level keys is accepted; any tool-native field (branch,
|
|
6
|
+
* commitSha, jiraTicket, …) must be nested under `data.raw`. Oversized inline
|
|
7
|
+
* payloads are rejected BEFORE any SQLite transaction so large diffs/transcripts
|
|
8
|
+
* are passed by reference instead. {@link prepareDataJsonForStorage} ties the
|
|
9
|
+
* pipeline together: normalize -> validate size -> redact -> compact JSON, with
|
|
10
|
+
* redaction guaranteed to run before the JSON can be inserted.
|
|
11
|
+
*/
|
|
12
|
+
import { ConductorValidationError } from "./errors.js";
|
|
13
|
+
import { redactSecrets } from "./redaction.js";
|
|
14
|
+
/** Allowlisted top-level `data` keys. Everything else must go under `raw`. */
|
|
15
|
+
export const ALLOWED_DATA_KEYS = [
|
|
16
|
+
"summary",
|
|
17
|
+
"status",
|
|
18
|
+
"message",
|
|
19
|
+
"details",
|
|
20
|
+
"reason",
|
|
21
|
+
"metrics",
|
|
22
|
+
"labels",
|
|
23
|
+
"references",
|
|
24
|
+
"payload_ref",
|
|
25
|
+
"raw",
|
|
26
|
+
];
|
|
27
|
+
const ALLOWED_DATA_KEY_SET = new Set(ALLOWED_DATA_KEYS);
|
|
28
|
+
/** Max characters for any single inline string anywhere in `data`. */
|
|
29
|
+
export const MAX_INLINE_STRING_CHARS = 16_384;
|
|
30
|
+
/** Max characters for the whole serialized `data` object. */
|
|
31
|
+
export const MAX_TOTAL_DATA_CHARS = 65_536;
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate and return event `data` as a JSON object. Missing data defaults to
|
|
37
|
+
* `{}`. Non-object data and non-allowlisted top-level keys are rejected with an
|
|
38
|
+
* actionable {@link ConductorValidationError}. Error messages name offending
|
|
39
|
+
* keys only — never their (possibly secret) values.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeEventData(data) {
|
|
42
|
+
if (data === undefined || data === null) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
if (!isPlainObject(data)) {
|
|
46
|
+
throw new ConductorValidationError("Event data must be a JSON object (received an array or primitive).");
|
|
47
|
+
}
|
|
48
|
+
const offending = Object.keys(data).filter((key) => !ALLOWED_DATA_KEY_SET.has(key));
|
|
49
|
+
if (offending.length > 0) {
|
|
50
|
+
throw new ConductorValidationError(`Event data has non-allowlisted top-level field(s): ${offending.join(", ")}. ` +
|
|
51
|
+
`Place tool-native fields under "data.raw" instead. Allowed top-level keys: ${ALLOWED_DATA_KEYS.join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
/** Recursively find the longest inline string length within a value. */
|
|
56
|
+
function maxInlineStringLength(value) {
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
return value.length;
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
let max = 0;
|
|
62
|
+
for (const item of value) {
|
|
63
|
+
max = Math.max(max, maxInlineStringLength(item));
|
|
64
|
+
}
|
|
65
|
+
return max;
|
|
66
|
+
}
|
|
67
|
+
if (isPlainObject(value)) {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const val of Object.values(value)) {
|
|
70
|
+
max = Math.max(max, maxInlineStringLength(val));
|
|
71
|
+
}
|
|
72
|
+
return max;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reject oversized inline payloads BEFORE serialization/storage. Callers must
|
|
78
|
+
* store large diffs/transcripts externally and pass a reference via
|
|
79
|
+
* `data.references` or `data.payload_ref`.
|
|
80
|
+
*/
|
|
81
|
+
export function validateLargePayloadReferences(data) {
|
|
82
|
+
const longest = maxInlineStringLength(data);
|
|
83
|
+
if (longest > MAX_INLINE_STRING_CHARS) {
|
|
84
|
+
throw new ConductorValidationError(`Event data contains an inline string of ${longest} characters, exceeding the ` +
|
|
85
|
+
`${MAX_INLINE_STRING_CHARS}-character limit. Store large diffs/transcripts externally and ` +
|
|
86
|
+
`pass a reference via "data.references" or "data.payload_ref".`);
|
|
87
|
+
}
|
|
88
|
+
let serializedLength = 0;
|
|
89
|
+
try {
|
|
90
|
+
serializedLength = JSON.stringify(data)?.length ?? 0;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new ConductorValidationError("Event data is not JSON-serializable.");
|
|
94
|
+
}
|
|
95
|
+
if (serializedLength > MAX_TOTAL_DATA_CHARS) {
|
|
96
|
+
throw new ConductorValidationError(`Serialized event data is ${serializedLength} characters, exceeding the ` +
|
|
97
|
+
`${MAX_TOTAL_DATA_CHARS}-character limit. Pass large content by reference via ` +
|
|
98
|
+
`"data.references" or "data.payload_ref".`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Full storage-preparation pipeline for the `data_json` column:
|
|
103
|
+
* 1. normalize (allowlist + object check),
|
|
104
|
+
* 2. validate inline payload size (outside any SQLite transaction),
|
|
105
|
+
* 3. redact secrets recursively,
|
|
106
|
+
* 4. serialize to compact JSON.
|
|
107
|
+
* Redaction is guaranteed to happen before the returned JSON can be inserted.
|
|
108
|
+
*/
|
|
109
|
+
export function prepareDataJsonForStorage(data) {
|
|
110
|
+
const normalized = normalizeEventData(data);
|
|
111
|
+
validateLargePayloadReferences(normalized);
|
|
112
|
+
const redacted = redactSecrets(normalized);
|
|
113
|
+
return JSON.stringify(redacted);
|
|
114
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined, strictly read-only conductor doctor report (BAPI-395, BAPI-418).
|
|
3
|
+
*
|
|
4
|
+
* Extends the existing SQLite ledger health report with local git hook health
|
|
5
|
+
* and Epic Supervisor schedule enablement status. Missing hooks and non-worktree
|
|
6
|
+
* directories are reported as a degraded OPTIONAL capability — never a fatal
|
|
7
|
+
* failure. This module performs NO writes: no hook installation, no schema
|
|
8
|
+
* migration, no event emission, and no scheduler unit creation.
|
|
9
|
+
*/
|
|
10
|
+
import { doctorConductorLedger } from "./store.js";
|
|
11
|
+
import { inspectConductorGitHooks } from "./git-hooks.js";
|
|
12
|
+
/**
|
|
13
|
+
* Inspect the local schedule metadata store for a registered epic-tick schedule.
|
|
14
|
+
* Strictly read-only: composes orchestrateScheduleList (read-only list path) and
|
|
15
|
+
* never creates, updates, or deletes any unit. A missing or erroring schedule is
|
|
16
|
+
* mapped to a degraded state, never a thrown exception.
|
|
17
|
+
*
|
|
18
|
+
* Schedule-run is loaded lazily via dynamic import to avoid eagerly pulling its
|
|
19
|
+
* node:fs/promises dependency into the module graph when doctor.ts is imported.
|
|
20
|
+
*/
|
|
21
|
+
export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
|
|
22
|
+
try {
|
|
23
|
+
// Lazily import schedule-run to avoid pulling node:fs/promises into the graph
|
|
24
|
+
// when doctor.ts is first imported (which would break node:fs mocks in tests).
|
|
25
|
+
const schedRun = orchestrateListOverride
|
|
26
|
+
? null
|
|
27
|
+
: await import("../schedule-run.js");
|
|
28
|
+
const doList = orchestrateListOverride
|
|
29
|
+
? orchestrateListOverride
|
|
30
|
+
: (d) => schedRun.orchestrateScheduleList({ json: false }, d);
|
|
31
|
+
const resolvedDeps = orchestrateListOverride
|
|
32
|
+
? deps
|
|
33
|
+
: (deps ?? schedRun.createDefaultScheduleRunDeps());
|
|
34
|
+
const commandLabel = schedRun
|
|
35
|
+
? schedRun.scheduleCommandLabel
|
|
36
|
+
: (m) => m.command ?? (m.idea_file ? "full-automation" : "(unknown)");
|
|
37
|
+
const runStatus = schedRun
|
|
38
|
+
? schedRun.latestRunStatus
|
|
39
|
+
: (m) => {
|
|
40
|
+
const h = m.run_history;
|
|
41
|
+
return h && h.length > 0 ? h[h.length - 1].status : "";
|
|
42
|
+
};
|
|
43
|
+
const report = await doList(resolvedDeps);
|
|
44
|
+
const entry = report.entries.find((e) => commandLabel(e.metadata) === "epic-tick");
|
|
45
|
+
if (!entry) {
|
|
46
|
+
return {
|
|
47
|
+
registered: false,
|
|
48
|
+
backend: null,
|
|
49
|
+
next_fire_iso: null,
|
|
50
|
+
latest_run_status: null,
|
|
51
|
+
degraded: true,
|
|
52
|
+
warnings: [
|
|
53
|
+
"No epic-tick schedule is registered. Run: " +
|
|
54
|
+
"npx -y @bridge_gpt/mcp-server schedule-run create --in 1m --command epic-tick -- --epic-key <EPIC-KEY>",
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const m = entry.metadata;
|
|
59
|
+
const latest = runStatus(m);
|
|
60
|
+
const degraded = entry.status !== "active";
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (degraded) {
|
|
63
|
+
const msg = entry.status === "backend-unavailable"
|
|
64
|
+
? `epic-tick schedule status is "backend-unavailable": the OS scheduler backend is unreachable. Check that the scheduler daemon is running.`
|
|
65
|
+
: `epic-tick schedule status is "${entry.status}" (expected "active"); re-register if stale.`;
|
|
66
|
+
warnings.push(msg);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
registered: true,
|
|
70
|
+
backend: m.backend ?? null,
|
|
71
|
+
next_fire_iso: m.run_at_iso ?? null,
|
|
72
|
+
latest_run_status: latest || null,
|
|
73
|
+
degraded,
|
|
74
|
+
warnings,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
return {
|
|
80
|
+
registered: false,
|
|
81
|
+
backend: null,
|
|
82
|
+
next_fire_iso: null,
|
|
83
|
+
latest_run_status: null,
|
|
84
|
+
degraded: true,
|
|
85
|
+
warnings: [`Failed to inspect epic-tick schedule: ${msg}`],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build the combined read-only doctor report. Composes the existing ledger
|
|
91
|
+
* doctor, git hook inspection, and the epic-tick schedule enablement check.
|
|
92
|
+
* Never mutates the ledger, the hooks, the schema, or the OS scheduler.
|
|
93
|
+
*/
|
|
94
|
+
export async function buildConductorDoctorReport(deps = {}) {
|
|
95
|
+
const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
|
|
96
|
+
const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
|
|
97
|
+
const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
|
|
98
|
+
return {
|
|
99
|
+
ledger: doctorLedger(),
|
|
100
|
+
git_hooks: inspectHooks(deps.hooksDeps),
|
|
101
|
+
epic_tick: epicTick,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Render the combined doctor report as human-readable text: ledger health,
|
|
106
|
+
* git hooks section, and an Epic Supervisor Schedule section using semantic
|
|
107
|
+
* status tags consistent with the git hooks section's visual hierarchy.
|
|
108
|
+
*/
|
|
109
|
+
export function formatConductorDoctorReport(report) {
|
|
110
|
+
const { ledger, git_hooks, epic_tick } = report;
|
|
111
|
+
const lines = [
|
|
112
|
+
"Conductor ledger doctor",
|
|
113
|
+
"───────────────────────",
|
|
114
|
+
`path: ${ledger.path}`,
|
|
115
|
+
`directory exists: ${ledger.directory_exists}`,
|
|
116
|
+
`database exists: ${ledger.database_exists}`,
|
|
117
|
+
`directory mode: ${ledger.directory_mode ?? "n/a"} (ok: ${ledger.directory_permissions_ok})`,
|
|
118
|
+
`database mode: ${ledger.database_mode ?? "n/a"} (ok: ${ledger.database_permissions_ok})`,
|
|
119
|
+
`schema present: ${ledger.schema_present}`,
|
|
120
|
+
`journal mode: ${ledger.journal_mode ?? "n/a"}`,
|
|
121
|
+
`user_version: ${ledger.user_version ?? "n/a"}`,
|
|
122
|
+
`event count: ${ledger.event_count ?? "n/a"}`,
|
|
123
|
+
`max seq: ${ledger.max_seq ?? "n/a"}`,
|
|
124
|
+
`busy_timeout_ms: ${ledger.retention.busy_timeout_ms}`,
|
|
125
|
+
`retention_days: ${ledger.retention.retention_days}`,
|
|
126
|
+
`retention_max_rows: ${ledger.retention.retention_max_rows}`,
|
|
127
|
+
`message_cooldown_ms: ${ledger.message_relay.message_cooldown_ms}`,
|
|
128
|
+
`degraded (network FS): ${ledger.degraded}`,
|
|
129
|
+
];
|
|
130
|
+
if (ledger.warnings.length > 0) {
|
|
131
|
+
lines.push("warnings:");
|
|
132
|
+
for (const w of ledger.warnings)
|
|
133
|
+
lines.push(` - ${w}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push("git hooks (optional, local)");
|
|
137
|
+
lines.push("───────────────────────────");
|
|
138
|
+
lines.push(`is git worktree: ${git_hooks.is_worktree}`);
|
|
139
|
+
lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
|
|
140
|
+
lines.push(`degraded: ${git_hooks.degraded}`);
|
|
141
|
+
for (const hook of git_hooks.hooks) {
|
|
142
|
+
lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present}`);
|
|
143
|
+
}
|
|
144
|
+
if (git_hooks.warnings.length > 0) {
|
|
145
|
+
lines.push("git hook warnings:");
|
|
146
|
+
for (const w of git_hooks.warnings)
|
|
147
|
+
lines.push(` - ${w}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push("Epic Supervisor Schedule (optional, local)");
|
|
151
|
+
lines.push("──────────────────────────────────────────");
|
|
152
|
+
const registeredTag = epic_tick.registered ? "[SUCCESS] registered" : "[WARNING] not registered";
|
|
153
|
+
lines.push(`registered: ${registeredTag}`);
|
|
154
|
+
lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
|
|
155
|
+
lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
|
|
156
|
+
lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
|
|
157
|
+
lines.push(`degraded: ${epic_tick.degraded}`);
|
|
158
|
+
if (epic_tick.warnings.length > 0) {
|
|
159
|
+
lines.push("epic-tick warnings:");
|
|
160
|
+
for (const w of epic_tick.warnings)
|
|
161
|
+
lines.push(` - ${w}`);
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure done-gate config parsing, CI snapshot normalization, and deterministic
|
|
3
|
+
* gate evaluation (BAPI-395).
|
|
4
|
+
*
|
|
5
|
+
* v1 supports exactly one gate condition — `required_ci_checks_green` — and fails
|
|
6
|
+
* CLOSED everywhere: unset, disabled, malformed, and unsupported config all yield
|
|
7
|
+
* an inactive result that can never emit `gate.met`. A gate is met only when every
|
|
8
|
+
* configured required check is present, complete, and green for the exact
|
|
9
|
+
* `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
|
|
10
|
+
* throws for caller input.
|
|
11
|
+
*/
|
|
12
|
+
import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Config parsing
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
/** Build a fail-closed inactive config with the given reason. */
|
|
20
|
+
function inactiveConfig(reason) {
|
|
21
|
+
return {
|
|
22
|
+
enabled: false,
|
|
23
|
+
valid: false,
|
|
24
|
+
reason,
|
|
25
|
+
condition: null,
|
|
26
|
+
config_hash: null,
|
|
27
|
+
gate_name: DEFAULT_GATE_NAME,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Coerce a raw config value into a JSON object, or `null` when it is unset or not
|
|
32
|
+
* an object. Accepts objects directly and non-empty JSON strings that parse to
|
|
33
|
+
* objects; treats `undefined`, `null`, empty/whitespace strings, and empty
|
|
34
|
+
* objects as unset. Malformed JSON, JSON arrays/primitives, and non-objects
|
|
35
|
+
* return `null` (the caller maps that to a fail-closed reason).
|
|
36
|
+
*/
|
|
37
|
+
function coerceConfigObject(value) {
|
|
38
|
+
if (value === undefined || value === null)
|
|
39
|
+
return { kind: "unset" };
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (trimmed.length === 0)
|
|
43
|
+
return { kind: "unset" };
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(trimmed);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { kind: "invalid" };
|
|
50
|
+
}
|
|
51
|
+
if (!isPlainObject(parsed))
|
|
52
|
+
return { kind: "invalid" };
|
|
53
|
+
if (Object.keys(parsed).length === 0)
|
|
54
|
+
return { kind: "unset" };
|
|
55
|
+
return { kind: "object", object: parsed };
|
|
56
|
+
}
|
|
57
|
+
if (isPlainObject(value)) {
|
|
58
|
+
if (Object.keys(value).length === 0)
|
|
59
|
+
return { kind: "unset" };
|
|
60
|
+
return { kind: "object", object: value };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "invalid" };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract and validate the single v1 condition from a config object. Requires
|
|
66
|
+
* `conditions` to be an array containing exactly one `required_ci_checks_green`
|
|
67
|
+
* condition whose `required_checks` is a non-empty array of unique, valid,
|
|
68
|
+
* normalized check names. Returns the normalized condition or `null`.
|
|
69
|
+
*/
|
|
70
|
+
function parseSingleCondition(object) {
|
|
71
|
+
const conditions = object.conditions;
|
|
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;
|
|
80
|
+
if (!Array.isArray(rawChecks) || rawChecks.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
const normalized = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const raw of rawChecks) {
|
|
85
|
+
const name = normalizeCheckName(raw);
|
|
86
|
+
if (name === null)
|
|
87
|
+
return null; // invalid name invalidates the whole config
|
|
88
|
+
if (seen.has(name))
|
|
89
|
+
return null; // duplicate after normalization
|
|
90
|
+
seen.add(name);
|
|
91
|
+
normalized.push(name);
|
|
92
|
+
}
|
|
93
|
+
return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse a raw `conductor_done_gate` value into a fail-closed
|
|
97
|
+
* {@link NormalizedDoneGateConfig}. Active (`enabled && valid`) only when the
|
|
98
|
+
* value is an enabled object with `enabled === true` (strict boolean) and exactly
|
|
99
|
+
* one valid v1 condition. Every other shape is inactive with a safe reason.
|
|
100
|
+
*/
|
|
101
|
+
export function parseDoneGateConfig(value) {
|
|
102
|
+
const coerced = coerceConfigObject(value);
|
|
103
|
+
if (coerced.kind === "unset")
|
|
104
|
+
return inactiveConfig("unset");
|
|
105
|
+
if (coerced.kind === "invalid")
|
|
106
|
+
return inactiveConfig("malformed");
|
|
107
|
+
const object = coerced.object;
|
|
108
|
+
// `enabled` must be the strict boolean `true`; truthy non-booleans are rejected.
|
|
109
|
+
if (object.enabled !== true) {
|
|
110
|
+
if (object.enabled === false)
|
|
111
|
+
return inactiveConfig("disabled");
|
|
112
|
+
return inactiveConfig("invalid: 'enabled' must be the boolean true");
|
|
113
|
+
}
|
|
114
|
+
const condition = parseSingleCondition(object);
|
|
115
|
+
if (condition === null) {
|
|
116
|
+
return inactiveConfig("invalid: expected exactly one valid required_ci_checks_green condition");
|
|
117
|
+
}
|
|
118
|
+
const gateName = DEFAULT_GATE_NAME;
|
|
119
|
+
const configHash = stableJsonHash({
|
|
120
|
+
gate_name: gateName,
|
|
121
|
+
type: condition.type,
|
|
122
|
+
required_checks: condition.required_checks,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
enabled: true,
|
|
126
|
+
valid: true,
|
|
127
|
+
reason: "active",
|
|
128
|
+
condition,
|
|
129
|
+
config_hash: configHash,
|
|
130
|
+
gate_name: gateName,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// CI snapshot normalization
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
const SUCCESS_STATES = new Set(["success", "passed", "succeeded"]);
|
|
137
|
+
const COMPLETE_STATES = new Set([
|
|
138
|
+
"completed",
|
|
139
|
+
"complete",
|
|
140
|
+
"success",
|
|
141
|
+
"passed",
|
|
142
|
+
"succeeded",
|
|
143
|
+
"failure",
|
|
144
|
+
"failed",
|
|
145
|
+
"error",
|
|
146
|
+
"cancelled",
|
|
147
|
+
"canceled",
|
|
148
|
+
"timed_out",
|
|
149
|
+
"action_required",
|
|
150
|
+
"neutral",
|
|
151
|
+
"skipped",
|
|
152
|
+
]);
|
|
153
|
+
function asLowerString(value) {
|
|
154
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : undefined;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Normalize one raw check entry (already keyed by `name`) into a
|
|
158
|
+
* {@link NormalizedCiCheck}. Returns `null` for malformed entries so they are
|
|
159
|
+
* skipped rather than poisoning sibling checks.
|
|
160
|
+
*/
|
|
161
|
+
function normalizeOneCheck(name, raw) {
|
|
162
|
+
const checkName = normalizeCheckName(name);
|
|
163
|
+
if (checkName === null)
|
|
164
|
+
return null;
|
|
165
|
+
if (!isPlainObject(raw)) {
|
|
166
|
+
// A bare name with no status info is not usable as a green check.
|
|
167
|
+
return { name: checkName, complete: false, green: false };
|
|
168
|
+
}
|
|
169
|
+
const status = asLowerString(raw.status);
|
|
170
|
+
const conclusion = asLowerString(raw.conclusion);
|
|
171
|
+
const explicitComplete = typeof raw.complete === "boolean" ? raw.complete : undefined;
|
|
172
|
+
const explicitPassed = typeof raw.passed === "boolean" ? raw.passed : undefined;
|
|
173
|
+
let complete = false;
|
|
174
|
+
if (explicitComplete !== undefined) {
|
|
175
|
+
complete = explicitComplete;
|
|
176
|
+
}
|
|
177
|
+
else if (conclusion !== undefined && COMPLETE_STATES.has(conclusion)) {
|
|
178
|
+
complete = true;
|
|
179
|
+
}
|
|
180
|
+
else if (status !== undefined && COMPLETE_STATES.has(status)) {
|
|
181
|
+
complete = true;
|
|
182
|
+
}
|
|
183
|
+
let green = false;
|
|
184
|
+
if (complete) {
|
|
185
|
+
if (explicitPassed === true) {
|
|
186
|
+
green = true;
|
|
187
|
+
}
|
|
188
|
+
else if (conclusion !== undefined && SUCCESS_STATES.has(conclusion)) {
|
|
189
|
+
green = true;
|
|
190
|
+
}
|
|
191
|
+
else if (conclusion === undefined &&
|
|
192
|
+
explicitPassed === undefined &&
|
|
193
|
+
status !== undefined &&
|
|
194
|
+
SUCCESS_STATES.has(status)) {
|
|
195
|
+
green = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// An explicit failure signal can never be green even if complete.
|
|
199
|
+
if (explicitPassed === false)
|
|
200
|
+
green = false;
|
|
201
|
+
const state = conclusion ?? status ?? (explicitPassed === true ? "passed" : undefined);
|
|
202
|
+
const check = { name: checkName, complete, green };
|
|
203
|
+
if (state !== undefined)
|
|
204
|
+
check.state = state;
|
|
205
|
+
return check;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Normalize a raw `poll_ci_checks` response into a SHA-keyed
|
|
209
|
+
* {@link NormalizedCiSnapshot}. Supports both array-shaped and object/map-shaped
|
|
210
|
+
* `checks`. Pending, missing, failed, cancelled, malformed, and unknown checks are
|
|
211
|
+
* never treated as green. `all_passed`/`all_complete` are recomputed from the
|
|
212
|
+
* normalized set, never trusted from the raw aggregate flags alone.
|
|
213
|
+
*/
|
|
214
|
+
export function normalizeCiSnapshot(response) {
|
|
215
|
+
const checks = [];
|
|
216
|
+
const byName = new Map();
|
|
217
|
+
if (isPlainObject(response)) {
|
|
218
|
+
const rawChecks = response.checks;
|
|
219
|
+
if (Array.isArray(rawChecks)) {
|
|
220
|
+
for (const entry of rawChecks) {
|
|
221
|
+
if (!isPlainObject(entry))
|
|
222
|
+
continue;
|
|
223
|
+
const normalized = normalizeOneCheck(entry.name, entry);
|
|
224
|
+
if (normalized && !byName.has(normalized.name)) {
|
|
225
|
+
byName.set(normalized.name, normalized);
|
|
226
|
+
checks.push(normalized);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (isPlainObject(rawChecks)) {
|
|
231
|
+
for (const [name, value] of Object.entries(rawChecks)) {
|
|
232
|
+
const normalized = normalizeOneCheck(name, value);
|
|
233
|
+
if (normalized && !byName.has(normalized.name)) {
|
|
234
|
+
byName.set(normalized.name, normalized);
|
|
235
|
+
checks.push(normalized);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const unknownChecks = [];
|
|
241
|
+
if (isPlainObject(response) && Array.isArray(response.unknown_checks)) {
|
|
242
|
+
for (const raw of response.unknown_checks) {
|
|
243
|
+
const name = normalizeCheckName(raw);
|
|
244
|
+
if (name !== null && !unknownChecks.includes(name))
|
|
245
|
+
unknownChecks.push(name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const allComplete = checks.length > 0 && checks.every((c) => c.complete);
|
|
249
|
+
const allPassed = checks.length > 0 && checks.every((c) => c.green) && unknownChecks.length === 0;
|
|
250
|
+
// Stable, order-independent hash: sort by name so array vs map and differing
|
|
251
|
+
// input order collide, while any status/conclusion change shifts the hash.
|
|
252
|
+
const hashInput = {
|
|
253
|
+
checks: [...checks]
|
|
254
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
255
|
+
.map((c) => ({ name: c.name, complete: c.complete, green: c.green })),
|
|
256
|
+
unknown_checks: [...unknownChecks].sort(),
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
checks,
|
|
260
|
+
unknown_checks: unknownChecks,
|
|
261
|
+
check_state_hash: stableJsonHash(hashInput),
|
|
262
|
+
all_complete: allComplete,
|
|
263
|
+
all_passed: allPassed,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Gate evaluation
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
function failedEvaluation(reason) {
|
|
270
|
+
return { met: false, reason };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Deterministically evaluate a done gate for one binding. Returns `met: true` with
|
|
274
|
+
* canonical `gate.met` event data (allowlisted top-level keys only) when every
|
|
275
|
+
* configured required check is present, complete, and green; otherwise `met:
|
|
276
|
+
* false` with a fail-closed reason and no event data. Pure and deterministic:
|
|
277
|
+
* identical inputs always produce deep-equal output.
|
|
278
|
+
*/
|
|
279
|
+
export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
|
|
280
|
+
if (!config.enabled || !config.valid || config.condition === null) {
|
|
281
|
+
return failedEvaluation(`gate inactive: ${config.reason}`);
|
|
282
|
+
}
|
|
283
|
+
const headSha = normalizeSha(binding.head_sha);
|
|
284
|
+
if (headSha === null) {
|
|
285
|
+
return failedEvaluation("invalid binding: head_sha is not a valid SHA");
|
|
286
|
+
}
|
|
287
|
+
const byName = new Map();
|
|
288
|
+
for (const check of snapshot.checks)
|
|
289
|
+
byName.set(check.name, check);
|
|
290
|
+
const unknownSet = new Set(snapshot.unknown_checks);
|
|
291
|
+
const checkResults = [];
|
|
292
|
+
const unmet = [];
|
|
293
|
+
for (const name of config.condition.required_checks) {
|
|
294
|
+
const check = byName.get(name);
|
|
295
|
+
if (!check) {
|
|
296
|
+
checkResults.push({ name, present: false, complete: false, green: false });
|
|
297
|
+
unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
checkResults.push({ name, present: true, complete: check.complete, green: check.green });
|
|
301
|
+
if (!check.green) {
|
|
302
|
+
unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (unmet.length > 0) {
|
|
306
|
+
return failedEvaluation(`required checks not green: ${unmet.join(", ")}`);
|
|
307
|
+
}
|
|
308
|
+
const details = {
|
|
309
|
+
repo: binding.repo,
|
|
310
|
+
pr_number: binding.pr_number,
|
|
311
|
+
head_sha: headSha,
|
|
312
|
+
gate_name: config.gate_name,
|
|
313
|
+
config_hash: config.config_hash,
|
|
314
|
+
condition_type: config.condition.type,
|
|
315
|
+
required_checks: [...config.condition.required_checks],
|
|
316
|
+
check_results: checkResults,
|
|
317
|
+
evaluated_at: evaluatedAtIso,
|
|
318
|
+
};
|
|
319
|
+
const gateEventData = {
|
|
320
|
+
summary: `Done gate "${config.gate_name}" met for ${binding.subject}`,
|
|
321
|
+
status: "met",
|
|
322
|
+
details,
|
|
323
|
+
};
|
|
324
|
+
return { met: true, reason: "met", gateEventData };
|
|
325
|
+
}
|