@bridge_gpt/mcp-server 0.2.67 → 0.2.68
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 +178 -15
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1374 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +18 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +964 -768
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +544 -37
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +29 -5
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `install-bridge conductor` — the owner-confirmed repository gate (BAPI-1391).
|
|
3
|
+
*
|
|
4
|
+
* Before this module, the conductor installer refused to run without at least
|
|
5
|
+
* one `--required-check`, so a repository whose CI used other names, ran outside
|
|
6
|
+
* GitHub Actions, gated on local commands, or had no CI at all could not be set
|
|
7
|
+
* up without reshaping it. BAPI-1390 stores an owner-confirmed GATE PROFILE —
|
|
8
|
+
* provider checks, executor-run local commands, both (`mixed`), or `none` — and
|
|
9
|
+
* makes admission prove it. This module is how the installer proposes a gate,
|
|
10
|
+
* how the owner confirms or edits it, and how a local gate gets its first proof.
|
|
11
|
+
*
|
|
12
|
+
* Five properties are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Discovery never confirms.** The deterministic collector
|
|
15
|
+
* (`conductor/gate-discovery.ts`) reads the working tree and the provider's
|
|
16
|
+
* real check history and PROPOSES a typed gate. A proposal is stored only
|
|
17
|
+
* after the owner confirms it at a prompt, or supplies explicit gate flags,
|
|
18
|
+
* which are owner input. No LLM ever writes a gate.
|
|
19
|
+
* 2. **The capability probe precedes every gate write.** A server that does not
|
|
20
|
+
* advertise `gate_profile.v1` gets "Bridge API update required" and nothing
|
|
21
|
+
* is written — including the qualification enqueue, and the short-lived
|
|
22
|
+
* executor whose claim carries `job_types` (an older server answers that key
|
|
23
|
+
* with 422). The probe is a read and runs after the base-URL consent.
|
|
24
|
+
* 3. **Removals are visible.** The stored and proposed gates are shown side by
|
|
25
|
+
* side before any write; a confirm that removes a member says so, and a
|
|
26
|
+
* flag-driven removal needs `--confirm-gate` or an explicit yes.
|
|
27
|
+
* 4. **A refused write changes nothing.** BAPI-1390's PUT is admin-only and
|
|
28
|
+
* compare-and-swaps the revision; on a refusal the installer shows the PRIOR
|
|
29
|
+
* gate, never the proposal, as what is in force.
|
|
30
|
+
* 5. **Qualification is reported from the server, never from the shell.** The
|
|
31
|
+
* short-lived executor's exit code proves nothing; only the job state and
|
|
32
|
+
* BAPI-1390's qualification reading do. A failed qualification warns and the
|
|
33
|
+
* install still succeeds — the local gate simply stays unqualified, so
|
|
34
|
+
* admission keeps refusing with `local_command_unqualified`.
|
|
35
|
+
*
|
|
36
|
+
* BAPI-1405 adds an epic-branch CI step after the gate (see the last section).
|
|
37
|
+
* It keeps property 1: it inspects workflow triggers and epic-branch rules on
|
|
38
|
+
* GitHub, explains them, and records only an explicit include/decline choice.
|
|
39
|
+
* It never confirms a gate, authorizes a merge, or changes an approval rule
|
|
40
|
+
* without an explicit typed "yes".
|
|
41
|
+
*/
|
|
42
|
+
import { ConductorBridgeApiError, GATE_PROFILE_CAPABILITY_TOKEN, enqueueConductorGateQualification, fetchConductorGateCheckHistory, fetchConductorGateProfile, fetchConductorGateQualificationJob, probeConductorGateProfileCapability, putConductorGateProfile, putEpicBranchCiDecision, } from "./conductor/bridge-api-client.js";
|
|
43
|
+
import { applyApprovalExemption, assessApprovalExemptionScope, buildEpicBranchCiEvidence, exemptionManualSteps, inspectEpicBranchWorkflowCoverage, readEpicBranchRulesViaGh, } from "./conductor/epic-branch-ci-inspection.js";
|
|
44
|
+
import { describeEvidence, discoverGate, LOCAL_COMMAND_TIMEOUT_SECONDS, } from "./conductor/gate-discovery.js";
|
|
45
|
+
import { PRINTED_EXECUTOR_INSTALL_SERVICE_COMMAND, PRINTED_EXECUTOR_ID_INSTRUCTION, PRINTED_QUALIFY_GATE_COMMAND, PRINTED_REPO_WIDE_EXECUTOR_COMMAND, } from "./printed-remedy-commands.js";
|
|
46
|
+
export function emptyConductorGateFlags() {
|
|
47
|
+
return {
|
|
48
|
+
providerChecks: [],
|
|
49
|
+
localCommands: [],
|
|
50
|
+
gateNone: false,
|
|
51
|
+
confirmGate: false,
|
|
52
|
+
clearGate: false,
|
|
53
|
+
qualifyGate: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Flags that NAME the gate's members. */
|
|
57
|
+
export function hasExplicitGateMembers(flags) {
|
|
58
|
+
return flags.providerChecks.length > 0 || flags.localCommands.length > 0 || flags.gateNone;
|
|
59
|
+
}
|
|
60
|
+
/** Any flag that is explicit owner input about the stored gate. */
|
|
61
|
+
export function hasExplicitGateInput(flags) {
|
|
62
|
+
return (hasExplicitGateMembers(flags) ||
|
|
63
|
+
flags.migrationGuard !== undefined ||
|
|
64
|
+
flags.customizedCommands !== undefined);
|
|
65
|
+
}
|
|
66
|
+
/** A literal command name; the same shape the discovery collector produces. */
|
|
67
|
+
const LITERAL_COMMAND_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
68
|
+
/**
|
|
69
|
+
* A literal argv word. Plain words only: quoting, globbing, redirection,
|
|
70
|
+
* substitution, and every other piece of shell syntax are refused, because a
|
|
71
|
+
* gate command is run as an argv list and never through a shell. Splitting a
|
|
72
|
+
* quoted string would mean guessing at a shell's parse.
|
|
73
|
+
*/
|
|
74
|
+
const LITERAL_ARGV_WORD_RE = /^[A-Za-z0-9_./:=@%+,-]+$/;
|
|
75
|
+
/** A discovery candidate id (`npm:test`, `make:check`, `python:pytest`, `tox`). */
|
|
76
|
+
const CANDIDATE_ID_RE = /^[a-z0-9]+(?::[a-z0-9_-]+)?$/;
|
|
77
|
+
const MAX_LITERAL_ARGV = 32;
|
|
78
|
+
const MAX_CHECK_NAME_LENGTH = 200;
|
|
79
|
+
/** Parse one `--local-command` value. Errors are fixed text naming the rule. */
|
|
80
|
+
export function parseLocalCommandFlag(raw) {
|
|
81
|
+
const eq = raw.indexOf("=");
|
|
82
|
+
if (eq === -1) {
|
|
83
|
+
if (!CANDIDATE_ID_RE.test(raw)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: "--local-command takes a discovered candidate id (for example npm:test) or " +
|
|
87
|
+
"<name>=<command words> (for example unit=pytest -q).",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, value: { form: "candidate", id: raw } };
|
|
91
|
+
}
|
|
92
|
+
const name = raw.slice(0, eq).trim();
|
|
93
|
+
const words = raw
|
|
94
|
+
.slice(eq + 1)
|
|
95
|
+
.split(/\s+/)
|
|
96
|
+
.filter((w) => w.length > 0);
|
|
97
|
+
if (!LITERAL_COMMAND_NAME_RE.test(name)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: "--local-command name must be 1-64 letters, digits, '.', '_' or '-', starting with a letter or digit.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (words.length === 0 || words.length > MAX_LITERAL_ARGV) {
|
|
104
|
+
return { ok: false, error: `--local-command needs between 1 and ${MAX_LITERAL_ARGV} command words.` };
|
|
105
|
+
}
|
|
106
|
+
if (words.some((w) => !LITERAL_ARGV_WORD_RE.test(w))) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: "--local-command words must be plain words: quotes, globs, pipes, redirection, " +
|
|
110
|
+
"variables, and other shell syntax are not supported, because the command runs " +
|
|
111
|
+
"without a shell.",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, value: { form: "literal", name, argv: words } };
|
|
115
|
+
}
|
|
116
|
+
/** Parse one `--migration-guard provider:<check>` / `local:<command>` value. */
|
|
117
|
+
export function parseMigrationGuardFlag(raw) {
|
|
118
|
+
const colon = raw.indexOf(":");
|
|
119
|
+
const kind = colon === -1 ? "" : raw.slice(0, colon);
|
|
120
|
+
const ref = colon === -1 ? "" : raw.slice(colon + 1).trim();
|
|
121
|
+
if ((kind !== "provider" && kind !== "local") || ref.length === 0) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: "--migration-guard must be provider:<exact check name> or local:<command name or id>.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: { kind, ref } };
|
|
128
|
+
}
|
|
129
|
+
/** Whether a provider check name is renderable and storable. */
|
|
130
|
+
export function isValidCheckName(name) {
|
|
131
|
+
return (name.trim() === name &&
|
|
132
|
+
name.length > 0 &&
|
|
133
|
+
name.length <= MAX_CHECK_NAME_LENGTH &&
|
|
134
|
+
!/[\u0000-\u001F\u007F]/.test(name));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validate flag COMBINATIONS before any effect. Returns fixed error text, or
|
|
138
|
+
* null. Member references are resolved later, against discovery and the stored
|
|
139
|
+
* profile; this only refuses combinations that can never be right.
|
|
140
|
+
*/
|
|
141
|
+
export function validateConductorGateFlags(flags, context) {
|
|
142
|
+
for (const check of flags.providerChecks) {
|
|
143
|
+
if (!isValidCheckName(check)) {
|
|
144
|
+
return `Invalid provider check name: it must be 1-${MAX_CHECK_NAME_LENGTH} characters with no control characters.`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (flags.gateNone && (flags.providerChecks.length > 0 || flags.localCommands.length > 0)) {
|
|
148
|
+
return "--gate-none is exclusive: it cannot be combined with --provider-check, --required-check, or --local-command.";
|
|
149
|
+
}
|
|
150
|
+
if (flags.gateNone && flags.migrationGuard !== undefined) {
|
|
151
|
+
return "--gate-none has no members, so it cannot take a --migration-guard.";
|
|
152
|
+
}
|
|
153
|
+
if (flags.clearGate && (hasExplicitGateInput(flags) || flags.confirmGate || flags.qualifyGate)) {
|
|
154
|
+
return "--clear-gate runs on its own: it cannot be combined with gate flags, --confirm-gate, or --qualify-gate.";
|
|
155
|
+
}
|
|
156
|
+
if (flags.clearGate && context.replaceDoneGate) {
|
|
157
|
+
return "--clear-gate cannot be combined with --replace-done-gate.";
|
|
158
|
+
}
|
|
159
|
+
if (flags.confirmGate && !hasExplicitGateInput(flags)) {
|
|
160
|
+
return ("--confirm-gate confirms explicit gate flags. Supply --provider-check, --local-command, " +
|
|
161
|
+
"--gate-none, --migration-guard, or --customized-command, or run without it to confirm " +
|
|
162
|
+
"the discovered proposal at a prompt.");
|
|
163
|
+
}
|
|
164
|
+
if (flags.migrationGuard?.kind === "provider" && flags.providerChecks.length > 0 &&
|
|
165
|
+
!flags.providerChecks.includes(flags.migrationGuard.ref)) {
|
|
166
|
+
return "--migration-guard provider:<check> must name one of the supplied provider checks exactly.";
|
|
167
|
+
}
|
|
168
|
+
if (context.replaceDoneGate && (flags.gateNone ||
|
|
169
|
+
(flags.localCommands.length > 0 && flags.providerChecks.length === 0))) {
|
|
170
|
+
return "--replace-done-gate rebuilds the CI gate from provider checks, so it needs at least one --provider-check.";
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** The job types the short-lived qualifying executor may claim — exactly one. */
|
|
175
|
+
export const QUALIFICATION_EXECUTOR_JOB_TYPES = ["gate_qualification"];
|
|
176
|
+
/**
|
|
177
|
+
* The production short-lived qualifying executor, built on the EXISTING
|
|
178
|
+
* executor CLI so credentials resolve through the same bundle path, the base
|
|
179
|
+
* URL stays explicit, and the repository-wide scope passes the CLI's own claim
|
|
180
|
+
* scope validation. This is the `runExecutorCli` `runExecutor` OVERRIDE route
|
|
181
|
+
* (the ticket's second option): the CLI parses an ordinary `--repo-wide --once`
|
|
182
|
+
* invocation, and the override narrows the parsed options to
|
|
183
|
+
* `jobTypes: ["gate_qualification"]` and `once: true` before the loop starts,
|
|
184
|
+
* so its claims carry `job_types: ["gate_qualification"]` and it can never
|
|
185
|
+
* take an implement or merge job. No new CLI flag is added.
|
|
186
|
+
*/
|
|
187
|
+
export function createQualificationExecutorRunner(seams) {
|
|
188
|
+
return async ({ repoName, baseUrl }) => {
|
|
189
|
+
let started = false;
|
|
190
|
+
const exitCode = await seams.runExecutorCli(["--repo", repoName, "--repo-wide", "--once", "--base-url", baseUrl], {
|
|
191
|
+
errorLog: seams.errorLog,
|
|
192
|
+
runExecutor: async (options, deps, httpClient, runnerSeams) => {
|
|
193
|
+
started = true;
|
|
194
|
+
return seams.runExecutor({ ...options, jobTypes: QUALIFICATION_EXECUTOR_JOB_TYPES, once: true }, deps, httpClient, runnerSeams);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return { started, exitCode };
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const YES = new Set(["y", "yes"]);
|
|
201
|
+
async function ask(io, promptText) {
|
|
202
|
+
if (!io.promptLine)
|
|
203
|
+
return false;
|
|
204
|
+
const answer = await io.promptLine(promptText);
|
|
205
|
+
return YES.has((answer ?? "").trim().toLowerCase());
|
|
206
|
+
}
|
|
207
|
+
/** The fixed update-required refusal (property 2). */
|
|
208
|
+
export const GATE_UPDATE_REQUIRED_MESSAGE = `Bridge API update required: this server does not advertise ${GATE_PROFILE_CAPABILITY_TOKEN}, so ` +
|
|
209
|
+
"it cannot store a confirmed repository gate. Nothing was written. Upgrade the Bridge API " +
|
|
210
|
+
"deployment, then re-run.";
|
|
211
|
+
/** Probe `gate_profile.v1`; print the refusal and return false when absent. */
|
|
212
|
+
export async function requireGateProfileCapability(io, access) {
|
|
213
|
+
const probe = await probeConductorGateProfileCapability(access, io.fetchJson);
|
|
214
|
+
if (probe.supported)
|
|
215
|
+
return true;
|
|
216
|
+
io.errorLog(GATE_UPDATE_REQUIRED_MESSAGE);
|
|
217
|
+
if (probe.reason === "unauthorized") {
|
|
218
|
+
io.errorLog(" (the capability probe was refused: check the repository API key.)");
|
|
219
|
+
}
|
|
220
|
+
else if (probe.reason === "unreachable") {
|
|
221
|
+
io.errorLog(" (the capability probe could not reach the Bridge API.)");
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
export function kindForMembers(providerChecks, localCommands) {
|
|
226
|
+
if (providerChecks.length > 0 && localCommands.length > 0)
|
|
227
|
+
return "mixed";
|
|
228
|
+
if (providerChecks.length > 0)
|
|
229
|
+
return "provider";
|
|
230
|
+
if (localCommands.length > 0)
|
|
231
|
+
return "local";
|
|
232
|
+
return "none";
|
|
233
|
+
}
|
|
234
|
+
/** The stored (or legacy) profile as a desired gate. */
|
|
235
|
+
export function desiredFromStored(profile) {
|
|
236
|
+
const guard = profile.migration_guard;
|
|
237
|
+
let migrationGuard = null;
|
|
238
|
+
if (guard?.kind === "provider")
|
|
239
|
+
migrationGuard = { kind: "provider", name: guard.id };
|
|
240
|
+
if (guard?.kind === "local") {
|
|
241
|
+
const command = profile.local_commands.find((c) => c.id === guard.id || c.name === guard.id);
|
|
242
|
+
if (command)
|
|
243
|
+
migrationGuard = { kind: "local", name: command.name };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind: profile.kind,
|
|
247
|
+
providerChecks: [...profile.provider_checks],
|
|
248
|
+
localCommands: profile.local_commands.map((c) => ({
|
|
249
|
+
name: c.name,
|
|
250
|
+
argv: [...c.argv],
|
|
251
|
+
timeout_seconds: c.timeout_seconds,
|
|
252
|
+
})),
|
|
253
|
+
migrationGuard,
|
|
254
|
+
customizedCommands: [...profile.customized_commands],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function sameCommand(a, b) {
|
|
258
|
+
return (a.name === b.name &&
|
|
259
|
+
a.timeout_seconds === b.timeout_seconds &&
|
|
260
|
+
a.argv.length === b.argv.length &&
|
|
261
|
+
a.argv.every((w, i) => w === b.argv[i]));
|
|
262
|
+
}
|
|
263
|
+
/** Whether `desired` is exactly what `stored` already holds (no write needed). */
|
|
264
|
+
export function gateMatchesStored(desired, stored) {
|
|
265
|
+
const sameGuard = desired.migrationGuard === null
|
|
266
|
+
? stored.migrationGuard === null
|
|
267
|
+
: stored.migrationGuard !== null &&
|
|
268
|
+
desired.migrationGuard.kind === stored.migrationGuard.kind &&
|
|
269
|
+
desired.migrationGuard.name === stored.migrationGuard.name;
|
|
270
|
+
const sameCustomized = desired.customizedCommands === undefined ||
|
|
271
|
+
(stored.customizedCommands !== undefined &&
|
|
272
|
+
desired.customizedCommands.length === stored.customizedCommands.length &&
|
|
273
|
+
desired.customizedCommands.every((c, i) => c === stored.customizedCommands?.[i]));
|
|
274
|
+
return (desired.kind === stored.kind &&
|
|
275
|
+
desired.providerChecks.length === stored.providerChecks.length &&
|
|
276
|
+
desired.providerChecks.every((c, i) => c === stored.providerChecks[i]) &&
|
|
277
|
+
desired.localCommands.length === stored.localCommands.length &&
|
|
278
|
+
desired.localCommands.every((c, i) => sameCommand(c, stored.localCommands[i])) &&
|
|
279
|
+
sameGuard &&
|
|
280
|
+
sameCustomized);
|
|
281
|
+
}
|
|
282
|
+
/** Human descriptions of every member `next` removes from `prior`. */
|
|
283
|
+
export function gateRemovals(prior, next) {
|
|
284
|
+
if (prior === null)
|
|
285
|
+
return [];
|
|
286
|
+
const removed = [];
|
|
287
|
+
for (const check of prior.providerChecks) {
|
|
288
|
+
if (!next.providerChecks.includes(check))
|
|
289
|
+
removed.push(`provider check "${check}"`);
|
|
290
|
+
}
|
|
291
|
+
for (const command of prior.localCommands) {
|
|
292
|
+
if (!next.localCommands.some((c) => sameCommand(c, command))) {
|
|
293
|
+
removed.push(`local command ${command.name}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (prior.migrationGuard !== null && next.migrationGuard === null) {
|
|
297
|
+
removed.push(`migration guard (${prior.migrationGuard.kind} ${prior.migrationGuard.name})`);
|
|
298
|
+
}
|
|
299
|
+
return removed;
|
|
300
|
+
}
|
|
301
|
+
/** The PUT body for a confirm of `desired` against the revision the server reported. */
|
|
302
|
+
export function confirmRequestFor(desired, expectedRevision) {
|
|
303
|
+
const guard = desired.migrationGuard === null ? null : { kind: desired.migrationGuard.kind, id: desired.migrationGuard.name };
|
|
304
|
+
return {
|
|
305
|
+
action: "confirm",
|
|
306
|
+
confirm: true,
|
|
307
|
+
expected_revision: expectedRevision,
|
|
308
|
+
kind: desired.kind,
|
|
309
|
+
provider_checks: desired.providerChecks.map((name) => ({ name })),
|
|
310
|
+
local_commands: desired.localCommands.map((c) => ({
|
|
311
|
+
name: c.name,
|
|
312
|
+
argv: [...c.argv],
|
|
313
|
+
timeout_seconds: c.timeout_seconds,
|
|
314
|
+
})),
|
|
315
|
+
migration_guard: guard,
|
|
316
|
+
...(desired.customizedCommands !== undefined ? { customized_commands: desired.customizedCommands } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function resolveLocalCommand(flag, proposal) {
|
|
320
|
+
if (flag.form === "literal") {
|
|
321
|
+
return { name: flag.name, argv: [...flag.argv], timeout_seconds: LOCAL_COMMAND_TIMEOUT_SECONDS };
|
|
322
|
+
}
|
|
323
|
+
const candidate = proposal?.localCandidates.find((c) => c.id === flag.id);
|
|
324
|
+
if (!candidate)
|
|
325
|
+
return null;
|
|
326
|
+
return { name: candidate.name, argv: [...candidate.argv], timeout_seconds: candidate.timeoutSeconds };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Build the desired gate from explicit flags over the current confirmed gate.
|
|
330
|
+
*
|
|
331
|
+
* Member flags (`--provider-check`/`--required-check`, `--local-command`,
|
|
332
|
+
* `--gate-none`) replace the members entirely — the flags ARE the gate. Without
|
|
333
|
+
* them the current members are kept and only the guard or customized commands
|
|
334
|
+
* change. A guard whose member was removed is dropped (and shown as a removal).
|
|
335
|
+
*/
|
|
336
|
+
export function desiredFromFlags(flags, current, proposal) {
|
|
337
|
+
let providerChecks;
|
|
338
|
+
let localCommands;
|
|
339
|
+
if (hasExplicitGateMembers(flags)) {
|
|
340
|
+
providerChecks = [...flags.providerChecks];
|
|
341
|
+
localCommands = [];
|
|
342
|
+
for (const flag of flags.localCommands) {
|
|
343
|
+
const resolved = resolveLocalCommand(flag, proposal);
|
|
344
|
+
if (resolved === null) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: `--local-command ${flag.form === "candidate" ? flag.id : flag.name} is not a command ` +
|
|
348
|
+
"discovery found in this repository. Use a listed candidate id, or <name>=<command words>.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (localCommands.some((c) => c.name === resolved.name)) {
|
|
352
|
+
return { ok: false, error: `--local-command ${resolved.name} is supplied twice.` };
|
|
353
|
+
}
|
|
354
|
+
localCommands.push(resolved);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (current !== null) {
|
|
358
|
+
providerChecks = [...current.providerChecks];
|
|
359
|
+
localCommands = current.localCommands.map((c) => ({ ...c, argv: [...c.argv] }));
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
error: "There is no confirmed gate to change. Name its members with --provider-check, " +
|
|
365
|
+
"--local-command, or --gate-none.",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
let migrationGuard = null;
|
|
369
|
+
if (flags.migrationGuard !== undefined) {
|
|
370
|
+
const { kind, ref } = flags.migrationGuard;
|
|
371
|
+
if (kind === "provider") {
|
|
372
|
+
if (!providerChecks.includes(ref)) {
|
|
373
|
+
return { ok: false, error: "--migration-guard provider:<check> must name a provider check of the gate exactly." };
|
|
374
|
+
}
|
|
375
|
+
migrationGuard = { kind, name: ref };
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const byCandidate = proposal?.localCandidates.find((c) => c.id === ref)?.name;
|
|
379
|
+
const member = localCommands.find((c) => c.name === ref || c.name === byCandidate);
|
|
380
|
+
if (!member) {
|
|
381
|
+
return { ok: false, error: "--migration-guard local:<command> must name a local command of the gate." };
|
|
382
|
+
}
|
|
383
|
+
migrationGuard = { kind, name: member.name };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (current?.migrationGuard) {
|
|
387
|
+
const keep = current.migrationGuard.kind === "provider"
|
|
388
|
+
? providerChecks.includes(current.migrationGuard.name)
|
|
389
|
+
: localCommands.some((c) => c.name === current.migrationGuard?.name);
|
|
390
|
+
migrationGuard = keep ? current.migrationGuard : null;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
desired: {
|
|
395
|
+
kind: kindForMembers(providerChecks, localCommands),
|
|
396
|
+
providerChecks,
|
|
397
|
+
localCommands,
|
|
398
|
+
migrationGuard,
|
|
399
|
+
customizedCommands: flags.customizedCommands,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Rendering
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
const KIND_CONSEQUENCE = Object.freeze({
|
|
407
|
+
provider: "every check must have actually reported on this repository before an unattended run is admitted",
|
|
408
|
+
local: "the executor runs these commands before each auto-merge; they must pass once under the executor " +
|
|
409
|
+
"(qualification) before an unattended run is admitted",
|
|
410
|
+
mixed: "BOTH legs gate every merge: each provider check must report green AND each local command must pass",
|
|
411
|
+
none: "no gate: an unattended run is admitted, but every child merge waits for your approval",
|
|
412
|
+
});
|
|
413
|
+
function quoted(names) {
|
|
414
|
+
return names.map((n) => `"${n}"`).join(", ");
|
|
415
|
+
}
|
|
416
|
+
/** One-gate description lines (members, conjunction, guard, customized). */
|
|
417
|
+
export function describeGate(gate, indent = " ") {
|
|
418
|
+
const lines = [];
|
|
419
|
+
const members = [];
|
|
420
|
+
if (gate.providerChecks.length > 0)
|
|
421
|
+
members.push(`provider checks ${quoted(gate.providerChecks)}`);
|
|
422
|
+
if (gate.localCommands.length > 0) {
|
|
423
|
+
members.push(`local commands ${gate.localCommands.map((c) => `${c.name} (${c.argv.join(" ")})`).join(", ")}`);
|
|
424
|
+
}
|
|
425
|
+
lines.push(`${indent}${gate.kind}` +
|
|
426
|
+
(members.length > 0 ? ` — ${members.join(gate.kind === "mixed" ? " AND " : "; ")}` : ""));
|
|
427
|
+
lines.push(`${indent} ${KIND_CONSEQUENCE[gate.kind]}`);
|
|
428
|
+
if (gate.migrationGuard !== null) {
|
|
429
|
+
lines.push(`${indent} migration guard: ${gate.migrationGuard.kind} ${gate.migrationGuard.name}`);
|
|
430
|
+
}
|
|
431
|
+
if (gate.customizedCommands !== undefined && gate.customizedCommands.length > 0) {
|
|
432
|
+
lines.push(`${indent} customized commands: ${gate.customizedCommands.join(", ")}`);
|
|
433
|
+
}
|
|
434
|
+
return lines;
|
|
435
|
+
}
|
|
436
|
+
function describeHistory(proposal) {
|
|
437
|
+
switch (proposal.history.status) {
|
|
438
|
+
case "available":
|
|
439
|
+
return proposal.providerCandidates.length === 0 && proposal.unreportedProviderNames.length > 0
|
|
440
|
+
? "available — none of the looked-up names has reported recently"
|
|
441
|
+
: `available${proposal.history.complete ? "" : " (partial walk)"}`;
|
|
442
|
+
case "unavailable":
|
|
443
|
+
return "UNAVAILABLE — provider check history could not be read";
|
|
444
|
+
default:
|
|
445
|
+
return "not queried — no CI check names were found to look up";
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Render the discovery proposal as the prominent "Proposed repository gate"
|
|
450
|
+
* section. Returns the numbered candidates in display order.
|
|
451
|
+
*/
|
|
452
|
+
export function renderGateProposal(io, proposal) {
|
|
453
|
+
const numbered = [];
|
|
454
|
+
io.log("");
|
|
455
|
+
io.log("Proposed repository gate");
|
|
456
|
+
io.log("────────────────────────");
|
|
457
|
+
io.log(" Discovery read this working tree and the provider's recent check history.");
|
|
458
|
+
io.log(" It saved nothing: a gate is stored only after you confirm it.");
|
|
459
|
+
io.log(` provider history: ${describeHistory(proposal)}`);
|
|
460
|
+
if (proposal.history.status === "unavailable") {
|
|
461
|
+
io.log(" ⚠ Provider history is unavailable, so no provider check can be proposed. That is " +
|
|
462
|
+
"unknown, not absent: type exact check names below, or retry later.");
|
|
463
|
+
}
|
|
464
|
+
if (proposal.noCandidates) {
|
|
465
|
+
io.log(" No candidates found.");
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
io.log(" Candidates:");
|
|
469
|
+
for (const candidate of proposal.providerCandidates) {
|
|
470
|
+
numbered.push({ kind: "provider", name: candidate.name });
|
|
471
|
+
io.log(` [${numbered.length}] provider check "${candidate.name}" — reported on this repository ` +
|
|
472
|
+
`(confidence ${candidate.confidence})`);
|
|
473
|
+
for (const e of candidate.evidence)
|
|
474
|
+
io.log(` evidence: ${describeEvidence(e)}`);
|
|
475
|
+
}
|
|
476
|
+
for (const candidate of proposal.localCandidates) {
|
|
477
|
+
numbered.push({ kind: "local", candidate });
|
|
478
|
+
io.log(` [${numbered.length}] local command ${candidate.name} (${candidate.argv.join(" ")}) ` +
|
|
479
|
+
`id ${candidate.id} (confidence ${candidate.confidence})`);
|
|
480
|
+
for (const e of candidate.evidence)
|
|
481
|
+
io.log(` evidence: ${describeEvidence(e)}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (proposal.unreportedProviderNames.length > 0) {
|
|
485
|
+
io.log(" Not proposed — never seen reporting, or unknown:");
|
|
486
|
+
for (const candidate of proposal.unreportedProviderNames) {
|
|
487
|
+
const status = candidate.outcome === "unseen"
|
|
488
|
+
? "not seen on recent default-branch commits or merged pull requests"
|
|
489
|
+
: "history unknown";
|
|
490
|
+
io.log(` - "${candidate.name}": ${status}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const varying = proposal.evidence.filter((e) => e.nameMayVary);
|
|
494
|
+
if (varying.length > 0) {
|
|
495
|
+
io.log(" Names that may vary (a matrix or expression job reports a different exact name):");
|
|
496
|
+
for (const e of varying)
|
|
497
|
+
io.log(` - ${describeEvidence(e)}`);
|
|
498
|
+
io.log(" Type the exact reported name below if you want one in the gate.");
|
|
499
|
+
}
|
|
500
|
+
const contextOnly = proposal.evidence.filter((e) => e.kind === "husky_hook" ||
|
|
501
|
+
e.kind === "pre_commit_config" ||
|
|
502
|
+
e.kind === "git_pre_push_hook" ||
|
|
503
|
+
e.kind === "buildkite" ||
|
|
504
|
+
e.kind === "jenkinsfile" ||
|
|
505
|
+
(e.kind === "circleci_job" && !e.nameMayVary));
|
|
506
|
+
if (contextOnly.length > 0) {
|
|
507
|
+
io.log(" Other evidence (context only; never run by the gate):");
|
|
508
|
+
for (const e of contextOnly)
|
|
509
|
+
io.log(` - ${describeEvidence(e)}`);
|
|
510
|
+
}
|
|
511
|
+
const proposedDesired = desiredFromProposalSelection(proposal, proposal.proposed.providerChecks, proposal.proposed.localCommandIds);
|
|
512
|
+
io.log(" Proposal:");
|
|
513
|
+
for (const line of describeGate(proposedDesired))
|
|
514
|
+
io.log(line);
|
|
515
|
+
return numbered;
|
|
516
|
+
}
|
|
517
|
+
function desiredFromProposalSelection(proposal, providerChecks, localIds) {
|
|
518
|
+
const localCommands = localIds
|
|
519
|
+
.map((id) => proposal.localCandidates.find((c) => c.id === id))
|
|
520
|
+
.filter((c) => c !== undefined)
|
|
521
|
+
.map((c) => ({ name: c.name, argv: [...c.argv], timeout_seconds: c.timeoutSeconds }));
|
|
522
|
+
return {
|
|
523
|
+
kind: kindForMembers(providerChecks, localCommands),
|
|
524
|
+
providerChecks: [...providerChecks],
|
|
525
|
+
localCommands,
|
|
526
|
+
migrationGuard: null,
|
|
527
|
+
customizedCommands: undefined,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function describeStoredHeading(current) {
|
|
531
|
+
if (!current.confirmed || current.profile === null) {
|
|
532
|
+
return current.stored_malformed
|
|
533
|
+
? "Stored gate: a stored value could not be read, so it is NOT confirmed"
|
|
534
|
+
: "Stored gate: none confirmed";
|
|
535
|
+
}
|
|
536
|
+
return current.source === "legacy"
|
|
537
|
+
? "Stored gate (legacy --required-check setup, treated as confirmed):"
|
|
538
|
+
: `Stored gate (revision ${current.revision}):`;
|
|
539
|
+
}
|
|
540
|
+
/** The side-by-side comparison shown before every gate write (property 3). */
|
|
541
|
+
export function renderGateComparison(io, current, desired, removals) {
|
|
542
|
+
io.log("");
|
|
543
|
+
io.log(` ${describeStoredHeading(current)}`);
|
|
544
|
+
if (current.confirmed && current.profile !== null) {
|
|
545
|
+
for (const line of describeGate(desiredFromStored(current.profile)))
|
|
546
|
+
io.log(line);
|
|
547
|
+
}
|
|
548
|
+
io.log(" Proposed gate:");
|
|
549
|
+
for (const line of describeGate(desired))
|
|
550
|
+
io.log(line);
|
|
551
|
+
if (removals.length > 0) {
|
|
552
|
+
io.log(` ⚠ This REMOVES ${removals.length} member(s) from the confirmed gate: ${removals.join("; ")}.`);
|
|
553
|
+
io.log(" Unattended runs then merge with fewer checks.");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Discovery + interactive editing
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
function historyLookupFor(io, access) {
|
|
560
|
+
return async (names) => {
|
|
561
|
+
try {
|
|
562
|
+
const history = await fetchConductorGateCheckHistory(access, names, io.fetchJson);
|
|
563
|
+
return {
|
|
564
|
+
ok: true,
|
|
565
|
+
complete: history.complete,
|
|
566
|
+
unavailableReason: history.unavailable_reason,
|
|
567
|
+
outcomes: Object.fromEntries(history.checks.map((c) => [c.name, c.outcome])),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Sanitized and bounded: an unreadable history is "unavailable", never empty.
|
|
572
|
+
return { ok: false };
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/** Run discovery, or an explicit empty proposal when no discovery seam exists. */
|
|
577
|
+
export async function runGateDiscovery(io, access, gateDeps) {
|
|
578
|
+
const fs = gateDeps?.discoveryFs;
|
|
579
|
+
if (!fs) {
|
|
580
|
+
return {
|
|
581
|
+
providerCandidates: [],
|
|
582
|
+
unreportedProviderNames: [],
|
|
583
|
+
localCandidates: [],
|
|
584
|
+
evidence: [],
|
|
585
|
+
history: { status: "not_queried" },
|
|
586
|
+
proposed: { kind: "none", providerChecks: [], localCommandIds: [] },
|
|
587
|
+
noCandidates: true,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return discoverGate({ fs, lookupHistory: historyLookupFor(io, access) });
|
|
591
|
+
}
|
|
592
|
+
const MAX_SELECTION_ATTEMPTS = 3;
|
|
593
|
+
const MAX_TYPED_CHECKS = 32;
|
|
594
|
+
/**
|
|
595
|
+
* Let the owner accept or edit the proposal, then ask the explicit
|
|
596
|
+
* confirmation. Returns the confirmed gate, or null when the owner declined
|
|
597
|
+
* (nothing is stored then).
|
|
598
|
+
*/
|
|
599
|
+
async function editProposalInteractively(io, proposal, numbered, gateDeps) {
|
|
600
|
+
const prompt = io.promptLine;
|
|
601
|
+
if (!prompt)
|
|
602
|
+
return null;
|
|
603
|
+
let providerChecks = [...proposal.proposed.providerChecks];
|
|
604
|
+
let localIds = [...proposal.proposed.localCommandIds];
|
|
605
|
+
let chosen = false;
|
|
606
|
+
for (let attempt = 0; attempt < MAX_SELECTION_ATTEMPTS && !chosen; attempt++) {
|
|
607
|
+
const answer = (await prompt('Gate members — press Enter to accept the proposal, type numbers (e.g. 1,3) to choose, or "none" for no gate: ')).trim();
|
|
608
|
+
if (answer === "") {
|
|
609
|
+
chosen = true;
|
|
610
|
+
}
|
|
611
|
+
else if (answer.toLowerCase() === "none") {
|
|
612
|
+
providerChecks = [];
|
|
613
|
+
localIds = [];
|
|
614
|
+
chosen = true;
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
const picks = answer.split(",").map((p) => Number.parseInt(p.trim(), 10));
|
|
618
|
+
if (picks.every((n) => Number.isInteger(n) && n >= 1 && n <= numbered.length)) {
|
|
619
|
+
providerChecks = [];
|
|
620
|
+
localIds = [];
|
|
621
|
+
for (const n of picks) {
|
|
622
|
+
const entry = numbered[n - 1];
|
|
623
|
+
if (entry.kind === "provider" && !providerChecks.includes(entry.name))
|
|
624
|
+
providerChecks.push(entry.name);
|
|
625
|
+
if (entry.kind === "local" && !localIds.includes(entry.candidate.id))
|
|
626
|
+
localIds.push(entry.candidate.id);
|
|
627
|
+
}
|
|
628
|
+
chosen = true;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
io.log(` Choose numbers between 1 and ${numbered.length}, "none", or press Enter.`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!chosen)
|
|
636
|
+
return null;
|
|
637
|
+
// An exact owner-typed check name is allowed (E-15): a matrix job's reported
|
|
638
|
+
// name, or a check that has not reported yet. It is stored verbatim and
|
|
639
|
+
// admission refuses it until it really reports.
|
|
640
|
+
while (providerChecks.length < MAX_TYPED_CHECKS) {
|
|
641
|
+
const typed = (await prompt("Exact provider check name to add (Enter to finish): ")).trim();
|
|
642
|
+
if (typed === "")
|
|
643
|
+
break;
|
|
644
|
+
if (!isValidCheckName(typed)) {
|
|
645
|
+
io.log(" That name cannot be stored (empty, over 200 characters, or control characters).");
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (!providerChecks.includes(typed))
|
|
649
|
+
providerChecks.push(typed);
|
|
650
|
+
io.log(` Added "${typed}". Admission refuses it until it has actually reported on this ` +
|
|
651
|
+
"repository's default branch or a merged pull request — run CI once if it has not.");
|
|
652
|
+
}
|
|
653
|
+
const desired = desiredFromProposalSelection(proposal, providerChecks, localIds);
|
|
654
|
+
// A migration guard matters only when the repository carries Alembic
|
|
655
|
+
// migrations; BAPI-1390's admission then requires one.
|
|
656
|
+
const hasAlembic = gateDeps?.discoveryFs ? await gateDeps.discoveryFs.exists("alembic.ini") : false;
|
|
657
|
+
if (hasAlembic && desired.kind !== "none") {
|
|
658
|
+
const members = [
|
|
659
|
+
...desired.providerChecks.map((name) => ({ kind: "provider", name })),
|
|
660
|
+
...desired.localCommands.map((c) => ({ kind: "local", name: c.name })),
|
|
661
|
+
];
|
|
662
|
+
io.log(" This repository has alembic.ini, so admission requires a migration guard member:");
|
|
663
|
+
members.forEach((m, i) => io.log(` [${i + 1}] ${m.kind} ${m.name}`));
|
|
664
|
+
const pick = Number.parseInt((await prompt("Migration guard member number (Enter for none): ")).trim(), 10);
|
|
665
|
+
if (Number.isInteger(pick) && pick >= 1 && pick <= members.length) {
|
|
666
|
+
desired.migrationGuard = members[pick - 1];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return desired;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Decide the repository gate for this run (BEFORE any write).
|
|
673
|
+
*
|
|
674
|
+
* 1. The capability probe (property 2) — refused ⇒ exit 1, nothing written.
|
|
675
|
+
* 2. Read the stored gate (BAPI-1390 GET; a legacy `--required-check` setup
|
|
676
|
+
* reads as confirmed).
|
|
677
|
+
* 3. Explicit gate flags ⇒ owner input, shown against the stored gate.
|
|
678
|
+
* No flags and a confirmed gate ⇒ use it unchanged (no write).
|
|
679
|
+
* No flags and no confirmed gate ⇒ discovery proposal, then the owner
|
|
680
|
+
* confirms or edits it at a prompt; without a prompt, exit 1 having saved
|
|
681
|
+
* nothing.
|
|
682
|
+
*/
|
|
683
|
+
export async function resolveConductorGate(io, access, flags, gateDeps, options) {
|
|
684
|
+
io.log("");
|
|
685
|
+
io.log("Repository gate — what must pass before a child merges…");
|
|
686
|
+
if (!(await requireGateProfileCapability(io, access)))
|
|
687
|
+
return { kind: "exit", code: 1 };
|
|
688
|
+
let current;
|
|
689
|
+
try {
|
|
690
|
+
current = await fetchConductorGateProfile(access, io.fetchJson);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
io.errorLog("The stored repository gate could not be read. Nothing was written; retry shortly.");
|
|
694
|
+
return { kind: "exit", code: 1 };
|
|
695
|
+
}
|
|
696
|
+
const currentDesired = current.confirmed && current.profile !== null ? desiredFromStored(current.profile) : null;
|
|
697
|
+
if (hasExplicitGateInput(flags)) {
|
|
698
|
+
const needsDiscovery = flags.localCommands.some((c) => c.form === "candidate") ||
|
|
699
|
+
(flags.migrationGuard?.kind === "local" && CANDIDATE_ID_RE.test(flags.migrationGuard.ref) &&
|
|
700
|
+
flags.migrationGuard.ref.includes(":"));
|
|
701
|
+
const proposal = needsDiscovery ? await runGateDiscovery(io, access, gateDeps) : null;
|
|
702
|
+
const built = desiredFromFlags(flags, currentDesired, proposal);
|
|
703
|
+
if (!built.ok) {
|
|
704
|
+
io.errorLog(`Error: ${built.error}`);
|
|
705
|
+
io.errorLog("Nothing was written.");
|
|
706
|
+
return { kind: "exit", code: 1 };
|
|
707
|
+
}
|
|
708
|
+
const desired = built.desired;
|
|
709
|
+
const removals = gateRemovals(currentDesired, desired);
|
|
710
|
+
const unchanged = current.source === "stored" && currentDesired !== null && gateMatchesStored(desired, currentDesired);
|
|
711
|
+
io.log(" Gate flags supplied: they are your explicit gate input.");
|
|
712
|
+
renderGateComparison(io, current, desired, removals);
|
|
713
|
+
if (unchanged) {
|
|
714
|
+
io.log(" The confirmed gate already matches — no gate write is needed.");
|
|
715
|
+
return { kind: "plan", plan: { current, desired: currentDesired, write: null, removals: [], origin: "stored" } };
|
|
716
|
+
}
|
|
717
|
+
if (removals.length > 0 && !flags.confirmGate && !options.dryRun) {
|
|
718
|
+
const ok = await ask(io, "Confirm this gate, including the removals above? [y/N]: ");
|
|
719
|
+
if (!ok) {
|
|
720
|
+
io.errorLog("Gate change not confirmed — nothing was written, and the stored gate is unchanged.");
|
|
721
|
+
io.errorLog("Pass --confirm-gate to confirm a removal from a script.");
|
|
722
|
+
return { kind: "exit", code: 1 };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
kind: "plan",
|
|
727
|
+
plan: { current, desired, write: confirmRequestFor(desired, current.revision), removals, origin: "flags" },
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (currentDesired !== null) {
|
|
731
|
+
io.log(current.source === "legacy"
|
|
732
|
+
? " Using the confirmed gate from this repository's existing --required-check setup."
|
|
733
|
+
: ` Using the confirmed gate (revision ${current.revision}).`);
|
|
734
|
+
for (const line of describeGate(currentDesired))
|
|
735
|
+
io.log(line);
|
|
736
|
+
return { kind: "plan", plan: { current, desired: currentDesired, write: null, removals: [], origin: "stored" } };
|
|
737
|
+
}
|
|
738
|
+
const proposal = await runGateDiscovery(io, access, gateDeps);
|
|
739
|
+
const numbered = renderGateProposal(io, proposal);
|
|
740
|
+
if (options.dryRun) {
|
|
741
|
+
io.log(" Dry run — a real run asks you to confirm or edit this proposal. Nothing was stored.");
|
|
742
|
+
return { kind: "unconfirmed-preview", current };
|
|
743
|
+
}
|
|
744
|
+
if (!io.promptLine) {
|
|
745
|
+
io.errorLog("");
|
|
746
|
+
io.errorLog("No confirmed gate is stored, and discovery alone never stores one. Re-run in a terminal to " +
|
|
747
|
+
"confirm or edit the proposal, or pass explicit gate flags (--provider-check, --local-command, " +
|
|
748
|
+
"or --gate-none). Nothing was written.");
|
|
749
|
+
return { kind: "exit", code: 1 };
|
|
750
|
+
}
|
|
751
|
+
const desired = await editProposalInteractively(io, proposal, numbered, gateDeps);
|
|
752
|
+
if (desired === null) {
|
|
753
|
+
io.errorLog("No gate was chosen. Nothing was written.");
|
|
754
|
+
return { kind: "exit", code: 1 };
|
|
755
|
+
}
|
|
756
|
+
renderGateComparison(io, current, desired, []);
|
|
757
|
+
const confirmed = await ask(io, "Store this repository gate? [y/N]: ");
|
|
758
|
+
if (!confirmed) {
|
|
759
|
+
io.errorLog("Gate not confirmed — nothing was written. A confirmed gate is required to continue.");
|
|
760
|
+
return { kind: "exit", code: 1 };
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
kind: "plan",
|
|
764
|
+
plan: { current, desired, write: confirmRequestFor(desired, current.revision), removals: [], origin: "interactive" },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const GATE_WRITE_REFUSAL_TEXT = Object.freeze({
|
|
768
|
+
"stale-revision": "the stored gate changed since it was read. Re-run to see the current gate.",
|
|
769
|
+
unauthorized: "the API key was rejected, or it is not an admin key for this repository.",
|
|
770
|
+
validation: "the server refused the gate as invalid (for example an unknown customized command).",
|
|
771
|
+
"unknown-stored-state": "the stored done gate cannot be read with certainty, so its CI checks cannot be updated.",
|
|
772
|
+
"state-unavailable": "the server could not read or lock the gate right now. Retry shortly.",
|
|
773
|
+
"unsupported-protocol": "the server answered in a form this installer cannot verify.",
|
|
774
|
+
network: "the Bridge API could not be reached.",
|
|
775
|
+
timeout: "the Bridge API did not answer in time.",
|
|
776
|
+
server: "the Bridge API reported an internal error.",
|
|
777
|
+
rejected: "the Bridge API rejected the request.",
|
|
778
|
+
});
|
|
779
|
+
/**
|
|
780
|
+
* Send the plan's PUT. On a refusal, print the reason and the PRIOR gate as
|
|
781
|
+
* what is in force (property 4) and return null.
|
|
782
|
+
*/
|
|
783
|
+
export async function storeConductorGate(io, access, plan) {
|
|
784
|
+
if (plan.write === null)
|
|
785
|
+
return plan.current;
|
|
786
|
+
const outcome = await putConductorGateProfile(access, plan.write, io.postJson);
|
|
787
|
+
if (!outcome.ok) {
|
|
788
|
+
io.errorLog(`Repository gate not stored (${outcome.refusal}): ${GATE_WRITE_REFUSAL_TEXT[outcome.refusal]}`);
|
|
789
|
+
io.errorLog(` Nothing was written. ${describeStoredHeading(plan.current)}`);
|
|
790
|
+
if (plan.current.confirmed && plan.current.profile !== null) {
|
|
791
|
+
for (const line of describeGate(desiredFromStored(plan.current.profile)))
|
|
792
|
+
io.errorLog(line);
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
io.log(` stored repository gate: ${outcome.profile.profile?.kind ?? "none"} (revision ${outcome.profile.revision})`);
|
|
797
|
+
return outcome.profile;
|
|
798
|
+
}
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// Qualification (property 5)
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
/** How long to keep reading the job after the short-lived executor returns. */
|
|
803
|
+
export const QUALIFICATION_OBSERVATION_BUDGET_MS = 60_000;
|
|
804
|
+
export const QUALIFICATION_OBSERVATION_INTERVAL_MS = 3_000;
|
|
805
|
+
function executorRemedy(io) {
|
|
806
|
+
io.errorLog(" Start a repository-wide executor to run it:");
|
|
807
|
+
io.errorLog(` ${PRINTED_REPO_WIDE_EXECUTOR_COMMAND}`);
|
|
808
|
+
io.errorLog(` or ${PRINTED_EXECUTOR_INSTALL_SERVICE_COMMAND}`);
|
|
809
|
+
io.errorLog(` ${PRINTED_EXECUTOR_ID_INSTRUCTION}`);
|
|
810
|
+
}
|
|
811
|
+
function isTerminal(job) {
|
|
812
|
+
return job.job_status !== "queued" && job.job_status !== "running";
|
|
813
|
+
}
|
|
814
|
+
async function readJob(io, access, jobId) {
|
|
815
|
+
try {
|
|
816
|
+
return await fetchConductorGateQualificationJob(access, jobId, io.fetchJson);
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/** Print the verified outcome. Every message is fixed text; nothing echoes command output. */
|
|
823
|
+
function reportJob(io, job) {
|
|
824
|
+
if (job.qualification_state === "stale" || job.failure === "stale") {
|
|
825
|
+
io.errorLog(` Stale after gate edit — the gate changed while job ${job.job_id} ran, so nothing was ` +
|
|
826
|
+
"recorded for the current gate. Qualify it again:");
|
|
827
|
+
io.errorLog(` ${PRINTED_QUALIFY_GATE_COMMAND}`);
|
|
828
|
+
return "stale";
|
|
829
|
+
}
|
|
830
|
+
if (job.job_status === "queued") {
|
|
831
|
+
io.errorLog(` Queued — job ${job.job_id} is waiting for a repository-wide executor (verified).`);
|
|
832
|
+
executorRemedy(io);
|
|
833
|
+
return "queued";
|
|
834
|
+
}
|
|
835
|
+
if (job.job_status === "running") {
|
|
836
|
+
io.log(` Running — job ${job.job_id} is running on an executor (verified). Re-check readiness later.`);
|
|
837
|
+
return "running";
|
|
838
|
+
}
|
|
839
|
+
if (job.job_status === "succeeded" && job.qualification_state === "qualified") {
|
|
840
|
+
io.log(` Qualified — the local gate passed under the executor at revision ${job.revision}.`);
|
|
841
|
+
return "qualified";
|
|
842
|
+
}
|
|
843
|
+
if (job.job_status === "succeeded") {
|
|
844
|
+
io.errorLog(" Failed — a confirmed command did not pass under the executor. The installation itself " +
|
|
845
|
+
"succeeded, but the local gate stays unqualified, so unattended auto-merge stays closed.");
|
|
846
|
+
io.errorLog(` Fix the command, then qualify again: ${PRINTED_QUALIFY_GATE_COMMAND}`);
|
|
847
|
+
return "failed";
|
|
848
|
+
}
|
|
849
|
+
io.errorLog(` Failed — the executor could not run job ${job.job_id} (no clean default-branch checkout, ` +
|
|
850
|
+
"or it was interrupted). The local gate stays unqualified.");
|
|
851
|
+
io.errorLog(` Qualify again: ${PRINTED_QUALIFY_GATE_COMMAND}`);
|
|
852
|
+
return "could_not_run";
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Enqueue the qualification for `revision` and drain it with the short-lived
|
|
856
|
+
* executor, reporting ONLY verified state. Never fatal to the install.
|
|
857
|
+
*/
|
|
858
|
+
export async function runConductorGateQualification(io, access, gateDeps, input) {
|
|
859
|
+
io.log("");
|
|
860
|
+
io.log("Gate qualification — running the confirmed local commands once under the executor…");
|
|
861
|
+
// Re-probed immediately before the enqueue and the executor start (E-6): the
|
|
862
|
+
// executor's claim carries `job_types`, which an older server answers with 422.
|
|
863
|
+
if (!(await requireGateProfileCapability(io, access)))
|
|
864
|
+
return "refused";
|
|
865
|
+
const enqueued = await enqueueConductorGateQualification(access, { expectedRevision: input.revision }, io.postJson);
|
|
866
|
+
if (enqueued.kind === "refused") {
|
|
867
|
+
const text = {
|
|
868
|
+
"profile-not-confirmed": "no confirmed gate is stored for this repository.",
|
|
869
|
+
"no-local-commands": "the confirmed gate has no local commands, so there is nothing to qualify.",
|
|
870
|
+
"stale-revision": "the gate changed since it was confirmed. Re-run to qualify the current gate.",
|
|
871
|
+
"state-unavailable": "the server could not enqueue it right now. Nothing was queued; retry shortly.",
|
|
872
|
+
unauthorized: "the API key was rejected for this repository.",
|
|
873
|
+
"unsupported-protocol": "the server does not support gate qualification.",
|
|
874
|
+
};
|
|
875
|
+
io.errorLog(` Not queued: ${text[enqueued.refusal] ?? "the Bridge API rejected the request."}`);
|
|
876
|
+
return enqueued.refusal === "no-local-commands" ? "not_applicable" : "refused";
|
|
877
|
+
}
|
|
878
|
+
if (enqueued.kind === "uncertain") {
|
|
879
|
+
io.errorLog(" The enqueue did not answer clearly, so this run cannot say whether a job is queued. " +
|
|
880
|
+
"Check readiness, or qualify again:");
|
|
881
|
+
io.errorLog(` ${PRINTED_QUALIFY_GATE_COMMAND}`);
|
|
882
|
+
executorRemedy(io);
|
|
883
|
+
return "unverified";
|
|
884
|
+
}
|
|
885
|
+
const verified = await readJob(io, access, enqueued.jobId);
|
|
886
|
+
if (verified === null) {
|
|
887
|
+
io.errorLog(` The server accepted job ${enqueued.jobId}, but its state could not be verified, so this run ` +
|
|
888
|
+
"does not report it as queued.");
|
|
889
|
+
executorRemedy(io);
|
|
890
|
+
return "unverified";
|
|
891
|
+
}
|
|
892
|
+
if (isTerminal(verified))
|
|
893
|
+
return reportJob(io, verified);
|
|
894
|
+
const runExecutor = gateDeps?.runQualificationExecutor;
|
|
895
|
+
if (!runExecutor) {
|
|
896
|
+
return reportJob(io, verified);
|
|
897
|
+
}
|
|
898
|
+
io.log(` Starting a short-lived repository-wide executor for job ${verified.job_id} ` +
|
|
899
|
+
"(qualification jobs only; command output appears below, on this terminal only)…");
|
|
900
|
+
let run;
|
|
901
|
+
try {
|
|
902
|
+
run = await runExecutor({ repoName: access.repoName, baseUrl: input.baseUrl });
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
run = { started: false, exitCode: 1 };
|
|
906
|
+
}
|
|
907
|
+
if (!run.started) {
|
|
908
|
+
io.errorLog(" The short-lived executor could not start on this host.");
|
|
909
|
+
}
|
|
910
|
+
// The executor's exit code proves nothing (property 5): read the server.
|
|
911
|
+
const now = gateDeps?.now ?? (() => Date.now());
|
|
912
|
+
const sleep = gateDeps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
913
|
+
const deadline = now() + QUALIFICATION_OBSERVATION_BUDGET_MS;
|
|
914
|
+
let latest = null;
|
|
915
|
+
while (true) {
|
|
916
|
+
const read = await readJob(io, access, verified.job_id);
|
|
917
|
+
if (read !== null)
|
|
918
|
+
latest = read;
|
|
919
|
+
if (latest !== null && isTerminal(latest))
|
|
920
|
+
break;
|
|
921
|
+
// A queued job after the executor returned will not move on its own here.
|
|
922
|
+
if (latest !== null && latest.job_status === "queued")
|
|
923
|
+
break;
|
|
924
|
+
const remaining = deadline - now();
|
|
925
|
+
if (remaining <= 0)
|
|
926
|
+
break;
|
|
927
|
+
await sleep(Math.min(QUALIFICATION_OBSERVATION_INTERVAL_MS, remaining));
|
|
928
|
+
}
|
|
929
|
+
if (latest === null) {
|
|
930
|
+
io.errorLog(` Job ${verified.job_id}'s state could not be read after the executor returned.`);
|
|
931
|
+
executorRemedy(io);
|
|
932
|
+
return "unverified";
|
|
933
|
+
}
|
|
934
|
+
return reportJob(io, latest);
|
|
935
|
+
}
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Standalone actions: --clear-gate and --qualify-gate
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
/** `--clear-gate`: BAPI-1390's clear action under the revision compare-and-swap. */
|
|
940
|
+
export async function runClearConductorGate(io, access, options) {
|
|
941
|
+
if (!(await requireGateProfileCapability(io, access)))
|
|
942
|
+
return 1;
|
|
943
|
+
let current;
|
|
944
|
+
try {
|
|
945
|
+
current = await fetchConductorGateProfile(access, io.fetchJson);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
io.errorLog("The stored repository gate could not be read. Nothing was written; retry shortly.");
|
|
949
|
+
return 1;
|
|
950
|
+
}
|
|
951
|
+
io.log("");
|
|
952
|
+
io.log(` ${describeStoredHeading(current)}`);
|
|
953
|
+
if (current.confirmed && current.profile !== null) {
|
|
954
|
+
for (const line of describeGate(desiredFromStored(current.profile)))
|
|
955
|
+
io.log(line);
|
|
956
|
+
}
|
|
957
|
+
if (current.source === "unconfirmed" && !current.stored_malformed) {
|
|
958
|
+
io.log(" There is no stored gate to clear. Nothing was written.");
|
|
959
|
+
return 0;
|
|
960
|
+
}
|
|
961
|
+
io.log(" Clearing removes the confirmed gate, its qualification, and its CI-check projection; " +
|
|
962
|
+
"unattended runs then need a newly confirmed gate.");
|
|
963
|
+
if (options.dryRun) {
|
|
964
|
+
io.log(" Dry run — would send the clear action. Nothing was written.");
|
|
965
|
+
return 0;
|
|
966
|
+
}
|
|
967
|
+
if (!(await ask(io, "Clear the confirmed repository gate? [y/N]: "))) {
|
|
968
|
+
io.log(" Not cleared — nothing was written.");
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
const outcome = await putConductorGateProfile(access, { action: "clear", confirm: true, expected_revision: current.revision }, io.postJson);
|
|
972
|
+
if (!outcome.ok) {
|
|
973
|
+
io.errorLog(`Repository gate not cleared (${outcome.refusal}): ${GATE_WRITE_REFUSAL_TEXT[outcome.refusal]}`);
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
976
|
+
io.log(` repository gate cleared (${outcome.profile.outcome ?? "cleared"}).`);
|
|
977
|
+
return 0;
|
|
978
|
+
}
|
|
979
|
+
/** `--qualify-gate` on its own: qualify the CURRENT confirmed gate. */
|
|
980
|
+
export async function runQualifyConductorGate(io, access, gateDeps, options) {
|
|
981
|
+
if (!(await requireGateProfileCapability(io, access)))
|
|
982
|
+
return 1;
|
|
983
|
+
let current;
|
|
984
|
+
try {
|
|
985
|
+
current = await fetchConductorGateProfile(access, io.fetchJson);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
io.errorLog("The stored repository gate could not be read. Nothing was queued; retry shortly.");
|
|
989
|
+
return 1;
|
|
990
|
+
}
|
|
991
|
+
if (!current.confirmed || current.profile === null) {
|
|
992
|
+
io.errorLog("No confirmed repository gate is stored, so there is nothing to qualify. Confirm one first " +
|
|
993
|
+
"with install-bridge conductor. Nothing was queued.");
|
|
994
|
+
return 1;
|
|
995
|
+
}
|
|
996
|
+
const profile = current.profile;
|
|
997
|
+
io.log("");
|
|
998
|
+
io.log(` Confirmed gate (revision ${current.revision}):`);
|
|
999
|
+
for (const line of describeGate(desiredFromStored(profile)))
|
|
1000
|
+
io.log(line);
|
|
1001
|
+
if (profile.local_commands.length === 0) {
|
|
1002
|
+
io.log(` Qualification is not applicable: a ${profile.kind} gate has no local commands. Nothing was queued.`);
|
|
1003
|
+
return 0;
|
|
1004
|
+
}
|
|
1005
|
+
if (options.dryRun) {
|
|
1006
|
+
io.log(" Dry run — would enqueue one qualification job and start a short-lived executor. Nothing was queued.");
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
await runConductorGateQualification(io, access, gateDeps, {
|
|
1010
|
+
revision: current.revision,
|
|
1011
|
+
baseUrl: options.baseUrl,
|
|
1012
|
+
});
|
|
1013
|
+
// A failed qualification warns; it never fails the command (property 5).
|
|
1014
|
+
return 0;
|
|
1015
|
+
}
|
|
1016
|
+
// ---------------------------------------------------------------------------
|
|
1017
|
+
// BAPI-1405: epic-branch CI inspection, advice, and the explicit decision
|
|
1018
|
+
// ---------------------------------------------------------------------------
|
|
1019
|
+
//
|
|
1020
|
+
// An unattended epic run merges each child into `epic/<EPIC>` through a pull
|
|
1021
|
+
// request. Whether any CI runs on that pull request depends on the repository's
|
|
1022
|
+
// workflow triggers, and whether a human approval is needed depends on its
|
|
1023
|
+
// branch rules. This step READS both, explains what it found in plain words, and
|
|
1024
|
+
// asks the owner to choose `include` or `decline`. It keeps property 1: the
|
|
1025
|
+
// inspection only reports and proposes. It never confirms a gate, never
|
|
1026
|
+
// authorizes a merge, and never turns an unreadable answer into `decline`.
|
|
1027
|
+
//
|
|
1028
|
+
// The approval-rule exemption is a separate, warning-toned offer. Only an
|
|
1029
|
+
// explicit typed "yes" lets it change a ruleset, and only through the owner's
|
|
1030
|
+
// own `gh` login. The `--epic-branch-ci-decision` flag never grants that consent.
|
|
1031
|
+
/** The two values `--epic-branch-ci-decision` accepts. */
|
|
1032
|
+
export const EPIC_BRANCH_CI_DECISION_FLAG_VALUES = ["include", "decline"];
|
|
1033
|
+
/**
|
|
1034
|
+
* Parse one `--epic-branch-ci-decision` value. Exact and lower-case only:
|
|
1035
|
+
* anything else is refused at parse time, before install starts.
|
|
1036
|
+
*/
|
|
1037
|
+
export function parseEpicBranchCiDecisionFlag(raw) {
|
|
1038
|
+
if (EPIC_BRANCH_CI_DECISION_FLAG_VALUES.includes(raw)) {
|
|
1039
|
+
return { ok: true, value: raw };
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
error: "--epic-branch-ci-decision must be include or decline.",
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The never-created branch name used for the rules read when no real epic
|
|
1048
|
+
* branch is known locally.
|
|
1049
|
+
*
|
|
1050
|
+
* GitHub's "rules for a branch" endpoint answers for a branch NAME, so a name
|
|
1051
|
+
* that matches `epic/**` shows which rulesets would apply to an epic branch.
|
|
1052
|
+
* GitHub documents that the branch need not exist, so the rules reader trusts
|
|
1053
|
+
* an empty list on an unproven branch as "no approvals", reported `inferred`
|
|
1054
|
+
* until that behavior is verified live. A placeholder
|
|
1055
|
+
* shaped like a real key (for example `epic/BAPI-0`) is deliberately avoided:
|
|
1056
|
+
* it could collide with a real branch and be mistaken for one.
|
|
1057
|
+
*/
|
|
1058
|
+
export const EPIC_BRANCH_CI_PROBE_BRANCH = "epic/bridge-install-probe";
|
|
1059
|
+
/** The concrete `epic/<EPIC>` shape: one path segment after `epic/`. */
|
|
1060
|
+
const EPIC_BRANCH_NAME_RE = /^epic\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
1061
|
+
/** The three coverage conclusions, printed first and verbatim. */
|
|
1062
|
+
export const EPIC_BRANCH_CI_COVERAGE_CONCLUSION = Object.freeze({
|
|
1063
|
+
covers: "A pull_request trigger covers epic-branch PRs.",
|
|
1064
|
+
does_not_cover: "No inspected trigger covers epic-branch PRs.",
|
|
1065
|
+
could_not_tell: "Could not tell whether a pull_request trigger covers epic-branch PRs.",
|
|
1066
|
+
});
|
|
1067
|
+
/** One next step per bounded reason token. Unknown tokens get the generic step. */
|
|
1068
|
+
const COVERAGE_NEXT_STEP = Object.freeze({
|
|
1069
|
+
no_pr_trigger: "add a pull_request trigger whose branches list includes 'epic/**'.",
|
|
1070
|
+
branch_filter_excludes_epic: "add 'epic/**' to this workflow's pull_request branches list.",
|
|
1071
|
+
branch_filter_uncertain: "make the pull_request branches list name 'epic/**' directly, so it clearly matches epic branches.",
|
|
1072
|
+
path_filter: "a paths filter decides per change whether the workflow starts; check it matches what children change, " +
|
|
1073
|
+
"or add 'epic/**' to a pull_request trigger without a paths filter.",
|
|
1074
|
+
types_filter: "include opened and synchronize in the pull_request types, or remove the types list.",
|
|
1075
|
+
indirect_trigger: "confirm the workflow that triggers this one runs on pull requests into 'epic/**'.",
|
|
1076
|
+
reusable_workflow_job: "confirm the calling workflow runs on pull requests into 'epic/**'.",
|
|
1077
|
+
unparseable: "check the file is valid YAML with a top-level on: key.",
|
|
1078
|
+
unsupported_structure: "write the pull_request trigger as a plain mapping with a branches list that includes 'epic/**'.",
|
|
1079
|
+
listing_failed: "check that .github/workflows is readable, then re-run the install.",
|
|
1080
|
+
file_limit_reached: "too many workflow files to inspect; confirm by hand that one includes 'epic/**'.",
|
|
1081
|
+
read_failed: "check the workflow file is readable and not unusually large, then re-run the install.",
|
|
1082
|
+
no_workflows: "add a workflow with a pull_request trigger whose branches list includes 'epic/**'.",
|
|
1083
|
+
});
|
|
1084
|
+
const GENERIC_COVERAGE_NEXT_STEP = "check by hand that a pull_request trigger includes 'epic/**'.";
|
|
1085
|
+
function coverageNextStep(reason) {
|
|
1086
|
+
return COVERAGE_NEXT_STEP[reason] ?? GENERIC_COVERAGE_NEXT_STEP;
|
|
1087
|
+
}
|
|
1088
|
+
/** Wrap a runner so a spawn failure (for example `gh` not installed) is an exit code, never a throw. */
|
|
1089
|
+
function safeGhRunner(run) {
|
|
1090
|
+
return async (args, opts) => {
|
|
1091
|
+
try {
|
|
1092
|
+
return await run(args, opts);
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
return { exitCode: 127, stdout: "", stderr: "gh could not be started" };
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* The default branch's classic protection, labelled as CONTEXT. It is evidence
|
|
1101
|
+
* about the repository, never the epic-branch answer. Null when not read.
|
|
1102
|
+
*/
|
|
1103
|
+
export function defaultBranchContextLine(rules) {
|
|
1104
|
+
const ctx = rules?.defaultBranchClassicApprovals;
|
|
1105
|
+
if (ctx === undefined)
|
|
1106
|
+
return null;
|
|
1107
|
+
const name = rules?.defaultBranch ? rules.defaultBranch : "the default branch";
|
|
1108
|
+
let finding;
|
|
1109
|
+
if (ctx === "yes") {
|
|
1110
|
+
const n = rules?.defaultBranchClassicApprovalCount;
|
|
1111
|
+
finding =
|
|
1112
|
+
typeof n === "number"
|
|
1113
|
+
? `classic protection requires ${n} approval${n === 1 ? "" : "s"} on ${name}`
|
|
1114
|
+
: `classic protection requires approvals (count unknown) on ${name}`;
|
|
1115
|
+
}
|
|
1116
|
+
else if (ctx === "no") {
|
|
1117
|
+
finding = `classic protection requires no approvals on ${name}`;
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
finding = `could not tell whether classic protection requires approvals on ${name}`;
|
|
1121
|
+
}
|
|
1122
|
+
return ` Default branch (context): ${finding} — this is not the epic-branch answer.`;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Print the findings in decision-support order: the coverage conclusion, the
|
|
1126
|
+
* approval-rule finding, the recommendation, then indented evidence for each
|
|
1127
|
+
* unresolved workflow with its reason and one next step.
|
|
1128
|
+
*/
|
|
1129
|
+
export function renderEpicBranchCiFindings(io, coverage, rules) {
|
|
1130
|
+
io.log(` ${EPIC_BRANCH_CI_COVERAGE_CONCLUSION[coverage.coverage]}`);
|
|
1131
|
+
const approvals = rules?.approvalsRequired ?? "could_not_tell";
|
|
1132
|
+
if (approvals === "yes") {
|
|
1133
|
+
const count = rules?.requiredApprovingReviewCount;
|
|
1134
|
+
io.log(" Approval rules apply to epic-branch PRs: yes" +
|
|
1135
|
+
(typeof count === "number" ? ` (${count} required approval${count === 1 ? "" : "s"}).` : " (count unknown)."));
|
|
1136
|
+
}
|
|
1137
|
+
else if (approvals === "no") {
|
|
1138
|
+
io.log(" Approval rules apply to epic-branch PRs: no.");
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
io.log(" Approval rules apply to epic-branch PRs: could not tell" +
|
|
1142
|
+
(rules?.reason ? ` (${rules.reason}).` : "."));
|
|
1143
|
+
}
|
|
1144
|
+
const contextLine = defaultBranchContextLine(rules);
|
|
1145
|
+
if (contextLine !== null)
|
|
1146
|
+
io.log(contextLine);
|
|
1147
|
+
if (coverage.coverage === "covers") {
|
|
1148
|
+
io.log(" Recommended: include epic-branch CI. Trigger coverage means a workflow should start on " +
|
|
1149
|
+
"epic-branch PRs; it is not proof that a check runs or passes there.");
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
io.log(" Recommended: add 'epic/**' to the pull_request base-branch filter, for example:");
|
|
1153
|
+
io.log(" on:");
|
|
1154
|
+
io.log(" pull_request:");
|
|
1155
|
+
io.log(" branches: [main, 'epic/**']");
|
|
1156
|
+
io.log(" Then include epic-branch CI below.");
|
|
1157
|
+
}
|
|
1158
|
+
const unresolved = coverage.workflows.filter((w) => w.coverage !== "covers");
|
|
1159
|
+
if (coverage.coverage !== "covers" && unresolved.length > 0) {
|
|
1160
|
+
io.log(" Workflows that do not clearly cover epic-branch PRs:");
|
|
1161
|
+
for (const w of unresolved) {
|
|
1162
|
+
io.log(` ${w.path}: ${w.coverage} (${w.reason}) — next step: ${coverageNextStep(w.reason)}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
else if (coverage.coverage !== "covers") {
|
|
1166
|
+
io.log(` (${coverage.reason}) — next step: ${coverageNextStep(coverage.reason)}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Decide include/decline for this run. Never guesses:
|
|
1171
|
+
*
|
|
1172
|
+
* * an explicit `--epic-branch-ci-decision` flag is owner input and wins;
|
|
1173
|
+
* * otherwise, at a prompt, only a typed `include` or `decline` counts —
|
|
1174
|
+
* Include is the displayed default, but a blank or other answer records
|
|
1175
|
+
* nothing;
|
|
1176
|
+
* * without a prompt, nothing is recorded and the owner is told how to record
|
|
1177
|
+
* one.
|
|
1178
|
+
*
|
|
1179
|
+
* Returns the decision, or null when none was made.
|
|
1180
|
+
*/
|
|
1181
|
+
export async function resolveEpicBranchCiDecision(io, flag) {
|
|
1182
|
+
io.log(" Include: each child waits for the checks that run on its epic-branch PR before it merges.");
|
|
1183
|
+
io.log(" Decline: children merge into the epic branch without waiting for checks that never run there. " +
|
|
1184
|
+
"Problems then surface only at the human-gated integration PR into the base branch.");
|
|
1185
|
+
if (flag !== null) {
|
|
1186
|
+
io.log(` Using --epic-branch-ci-decision=${flag}.`);
|
|
1187
|
+
return flag;
|
|
1188
|
+
}
|
|
1189
|
+
if (!io.promptLine) {
|
|
1190
|
+
io.log(" No epic-branch CI decision was recorded: this run cannot prompt. Re-run in a terminal, or pass " +
|
|
1191
|
+
"--epic-branch-ci-decision=include or --epic-branch-ci-decision=decline. Until one is recorded, " +
|
|
1192
|
+
"runs behave as include.");
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
const answer = (await io.promptLine('Epic-branch CI — type "include" (default) or "decline": ')).trim().toLowerCase();
|
|
1196
|
+
if (answer === "include" || answer === "decline")
|
|
1197
|
+
return answer;
|
|
1198
|
+
io.log(" No epic-branch CI decision was recorded: type include or decline to choose. Re-run to choose, or " +
|
|
1199
|
+
"pass --epic-branch-ci-decision=include or --epic-branch-ci-decision=decline. Until one is " +
|
|
1200
|
+
"recorded, runs behave as include.");
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
function printExemptionManualSteps(io, ownerRepo, branch) {
|
|
1204
|
+
io.log(" To exclude epic/** from the approval rule yourself:");
|
|
1205
|
+
// The never-created probe name is not a real branch, so the steps keep the
|
|
1206
|
+
// `epic/<EPIC>` placeholder for it.
|
|
1207
|
+
const steps = branch === EPIC_BRANCH_CI_PROBE_BRANCH
|
|
1208
|
+
? exemptionManualSteps(ownerRepo)
|
|
1209
|
+
: exemptionManualSteps(ownerRepo, branch);
|
|
1210
|
+
for (const line of steps)
|
|
1211
|
+
io.log(` ${line}`);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The approval-rule exemption offer (requirement 6). Separate from the
|
|
1215
|
+
* include/decline choice and warning-toned. A ruleset is changed only after an
|
|
1216
|
+
* explicit typed "yes", a passed scope check, and a confirming re-read.
|
|
1217
|
+
*/
|
|
1218
|
+
export async function offerApprovalExemption(io, seams, run, ownerRepo, branch, rules) {
|
|
1219
|
+
const approvals = rules?.approvalsRequired ?? "could_not_tell";
|
|
1220
|
+
if (approvals === "no")
|
|
1221
|
+
return "not_needed";
|
|
1222
|
+
if (approvals === "could_not_tell") {
|
|
1223
|
+
io.log("");
|
|
1224
|
+
io.log(" Could not tell whether an approval rule applies to epic-branch PRs, so no exemption was offered " +
|
|
1225
|
+
"and nothing was changed.");
|
|
1226
|
+
printExemptionManualSteps(io, ownerRepo, branch);
|
|
1227
|
+
return "could_not_tell";
|
|
1228
|
+
}
|
|
1229
|
+
io.log("");
|
|
1230
|
+
io.log(" Warning: an approval rule applies to epic-branch PRs.");
|
|
1231
|
+
io.log(" Each child merge waits for a human approval unless the merging account can bypass the rule. " +
|
|
1232
|
+
"Whether it can is unknown: check the ruleset's bypass list.");
|
|
1233
|
+
io.log(" Bridge can exclude epic/** from this approval rule with your own gh login. It changes only the " +
|
|
1234
|
+
"ruleset's excluded branches, and only for a repository ruleset that holds nothing but the approval rule.");
|
|
1235
|
+
if (!io.promptLine) {
|
|
1236
|
+
io.log(" This run cannot prompt, so nothing was changed. The --epic-branch-ci-decision flag never grants this.");
|
|
1237
|
+
printExemptionManualSteps(io, ownerRepo, branch);
|
|
1238
|
+
return "printed_steps";
|
|
1239
|
+
}
|
|
1240
|
+
const consent = await ask(io, "Exclude epic/** from the approval rule now? Type yes to change it [y/N]: ");
|
|
1241
|
+
if (!consent) {
|
|
1242
|
+
io.log(" Not changed.");
|
|
1243
|
+
printExemptionManualSteps(io, ownerRepo, branch);
|
|
1244
|
+
return "declined";
|
|
1245
|
+
}
|
|
1246
|
+
const assess = seams.assessExemption ?? assessApprovalExemptionScope;
|
|
1247
|
+
let assessment;
|
|
1248
|
+
try {
|
|
1249
|
+
assessment = await assess(run, ownerRepo, branch);
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
assessment = { eligible: false, reason: "scope_unreadable", rulesetIds: [] };
|
|
1253
|
+
}
|
|
1254
|
+
if (!assessment.eligible) {
|
|
1255
|
+
io.log(` Not changed: the approval rule is not safe to change automatically (${assessment.reason}).`);
|
|
1256
|
+
printExemptionManualSteps(io, ownerRepo, branch);
|
|
1257
|
+
return "printed_steps";
|
|
1258
|
+
}
|
|
1259
|
+
const apply = seams.applyExemption ?? applyApprovalExemption;
|
|
1260
|
+
let applied;
|
|
1261
|
+
try {
|
|
1262
|
+
applied = await apply(run, ownerRepo, branch, assessment);
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
applied = { confirmed: false, reason: "apply_uncertain" };
|
|
1266
|
+
}
|
|
1267
|
+
if (applied.confirmed) {
|
|
1268
|
+
io.log(" Excluded epic/** from the approval rule. A re-read confirmed no approval rule remains and required checks are unchanged.");
|
|
1269
|
+
return "applied";
|
|
1270
|
+
}
|
|
1271
|
+
io.log(` Could not confirm the change (${applied.reason}). Check the ruleset yourself; nothing else will be attempted.`);
|
|
1272
|
+
printExemptionManualSteps(io, ownerRepo, branch);
|
|
1273
|
+
return "could_not_tell";
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Run the epic-branch CI step: detect the provider, inspect workflow triggers
|
|
1277
|
+
* and epic-branch rules, print findings, resolve include/decline, offer the
|
|
1278
|
+
* approval exemption, and record an explicit decision with its evidence.
|
|
1279
|
+
*
|
|
1280
|
+
* Never blocks the install: a failed read or write prints a warning and the
|
|
1281
|
+
* step returns. It never writes `decline` unless the owner chose it.
|
|
1282
|
+
*/
|
|
1283
|
+
export async function runEpicBranchCiInspection(io, access, gateDeps, options) {
|
|
1284
|
+
const result = {
|
|
1285
|
+
status: "completed",
|
|
1286
|
+
coverage: null,
|
|
1287
|
+
rules: null,
|
|
1288
|
+
exemptionOutcome: null,
|
|
1289
|
+
decision: null,
|
|
1290
|
+
evidence: null,
|
|
1291
|
+
recorded: false,
|
|
1292
|
+
manualAction: null,
|
|
1293
|
+
};
|
|
1294
|
+
io.log("");
|
|
1295
|
+
io.log("Epic-branch CI — do checks run on pull requests into epic branches?");
|
|
1296
|
+
const seams = gateDeps?.epicBranchCi;
|
|
1297
|
+
if (!seams) {
|
|
1298
|
+
io.log(" skipped — this caller supplied no epic-branch CI inspection seams. Nothing was recorded.");
|
|
1299
|
+
return { ...result, status: "skipped_no_seams" };
|
|
1300
|
+
}
|
|
1301
|
+
if (options.dryRun) {
|
|
1302
|
+
io.log(" Dry run — would inspect workflow triggers and epic-branch rules, then ask include or decline. Nothing was recorded.");
|
|
1303
|
+
return { ...result, status: "skipped_dry_run" };
|
|
1304
|
+
}
|
|
1305
|
+
let provider;
|
|
1306
|
+
try {
|
|
1307
|
+
provider = await seams.detectProvider();
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
provider = { kind: "unknown" };
|
|
1311
|
+
}
|
|
1312
|
+
if (provider.kind !== "github") {
|
|
1313
|
+
io.log(" The epic-branch CI inspection is GitHub-only, and this repository's remote is not on github.com" +
|
|
1314
|
+
(provider.kind === "unknown" ? " (or could not be read)" : "") +
|
|
1315
|
+
". No epic-branch CI decision was recorded.");
|
|
1316
|
+
return { ...result, status: "skipped_not_github" };
|
|
1317
|
+
}
|
|
1318
|
+
const ownerRepo = provider.ownerRepo;
|
|
1319
|
+
const run = safeGhRunner(seams.runGh);
|
|
1320
|
+
let branch = EPIC_BRANCH_CI_PROBE_BRANCH;
|
|
1321
|
+
try {
|
|
1322
|
+
const found = seams.findExampleEpicBranch ? await seams.findExampleEpicBranch() : null;
|
|
1323
|
+
if (found !== null && EPIC_BRANCH_NAME_RE.test(found))
|
|
1324
|
+
branch = found;
|
|
1325
|
+
}
|
|
1326
|
+
catch {
|
|
1327
|
+
// Keep the probe name: the rules reader reports could_not_tell for it.
|
|
1328
|
+
}
|
|
1329
|
+
let coverage;
|
|
1330
|
+
try {
|
|
1331
|
+
coverage = await (seams.inspectCoverage ?? inspectEpicBranchWorkflowCoverage)(seams.fs);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
coverage = { coverage: "could_not_tell", reason: "read_failed", workflows: [] };
|
|
1335
|
+
}
|
|
1336
|
+
let rules;
|
|
1337
|
+
try {
|
|
1338
|
+
rules = await (seams.readRules ?? readEpicBranchRulesViaGh)(run, ownerRepo, branch);
|
|
1339
|
+
}
|
|
1340
|
+
catch {
|
|
1341
|
+
rules = null;
|
|
1342
|
+
}
|
|
1343
|
+
result.coverage = coverage.coverage;
|
|
1344
|
+
result.rules = rules;
|
|
1345
|
+
renderEpicBranchCiFindings(io, coverage, rules);
|
|
1346
|
+
const decision = await resolveEpicBranchCiDecision(io, options.decisionFlag);
|
|
1347
|
+
const exemptionOutcome = await offerApprovalExemption(io, seams, run, ownerRepo, branch, rules);
|
|
1348
|
+
const evidence = buildEpicBranchCiEvidence(coverage.coverage, rules, exemptionOutcome);
|
|
1349
|
+
result.decision = decision;
|
|
1350
|
+
result.exemptionOutcome = exemptionOutcome;
|
|
1351
|
+
result.evidence = evidence;
|
|
1352
|
+
// No decision is not a failure: nothing recorded behaves like include, and
|
|
1353
|
+
// the reason was already printed. It is not added to the manual steps.
|
|
1354
|
+
if (decision === null)
|
|
1355
|
+
return result;
|
|
1356
|
+
const record = seams.recordDecision ??
|
|
1357
|
+
((acc, request) => putEpicBranchCiDecision(acc, acc.repoName, request, io.postJson));
|
|
1358
|
+
try {
|
|
1359
|
+
await record(access, { decision, evidence });
|
|
1360
|
+
result.recorded = true;
|
|
1361
|
+
io.log(` recorded epic-branch CI decision: ${decision}`);
|
|
1362
|
+
}
|
|
1363
|
+
catch (err) {
|
|
1364
|
+
const kind = err instanceof ConductorBridgeApiError ? err.kind : "error";
|
|
1365
|
+
io.errorLog(`Warning: the epic-branch CI decision could not be recorded (${kind}). The install continues; ` +
|
|
1366
|
+
"re-run it to record the decision.");
|
|
1367
|
+
result.manualAction =
|
|
1368
|
+
`epic-branch CI: the ${decision} decision was not recorded; re-run with ` +
|
|
1369
|
+
`--epic-branch-ci-decision=${decision}.`;
|
|
1370
|
+
}
|
|
1371
|
+
return result;
|
|
1372
|
+
}
|