@bridge_gpt/mcp-server 0.2.68 → 0.2.70
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 +226 -320
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +650 -98
- package/build/check-update-cli.js +74 -0
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/claude-settings.js +84 -2
- package/build/cli-help.js +7 -1
- package/build/codex-bridge-profile.js +1 -1
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +27 -14
- package/build/doctor.js +203 -38
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +131 -120
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +68 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +115 -4
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1214 -465
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +629 -350
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +43 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +28 -89
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +360 -20
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -112,45 +112,16 @@ export function resolveLocalMergeMethod(value) {
|
|
|
112
112
|
? value
|
|
113
113
|
: "squash";
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
* the Bridge credential store. A merge mode that silently borrowed the other
|
|
121
|
-
* mode's credential would make "which identity performed this write" unanswerable
|
|
122
|
-
* from the run's own configuration, which is the property this whole change
|
|
123
|
-
* exists to establish.
|
|
124
|
-
*
|
|
125
|
-
* Neither key is ever routed through `resolveBapiCredentials`,
|
|
126
|
-
* `resolveCredentialBundle`, project configuration, or install state. A GitHub
|
|
127
|
-
* merge credential is not a Bridge credential and must not acquire Bridge's
|
|
128
|
-
* persistence.
|
|
129
|
-
*/
|
|
130
|
-
export const LOCAL_MERGE_CREDENTIAL_ENV_KEYS = {
|
|
131
|
-
local: "BAPI_LOCAL_MERGE_TOKEN",
|
|
132
|
-
machine_token: "BAPI_MACHINE_MERGE_TOKEN",
|
|
133
|
-
};
|
|
115
|
+
// BAPI-1452: the credential kinds, keys, and resolver live in a dependency-free
|
|
116
|
+
// module so read-only callers need not import this executor; re-exported here
|
|
117
|
+
// so this module's public surface is unchanged.
|
|
118
|
+
import { LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, resolveLocalMergeCredential, resolveLocalMergeCredentialDetailed, } from "./merge-credential.js";
|
|
119
|
+
export { LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, resolveLocalMergeCredential, resolveLocalMergeCredentialDetailed, };
|
|
134
120
|
/** The named, mode-specific refusal when the configured credential is absent. */
|
|
135
121
|
export const LOCAL_MERGE_CREDENTIAL_MISSING_REASONS = {
|
|
136
122
|
local: "local_merge_credential_missing",
|
|
137
123
|
machine_token: "machine_merge_credential_missing",
|
|
138
124
|
};
|
|
139
|
-
/**
|
|
140
|
-
* Resolve the one credential a kind is allowed to read.
|
|
141
|
-
*
|
|
142
|
-
* Returns `null` for an absent or blank value — never the other kind's value and
|
|
143
|
-
* never an inherited one.
|
|
144
|
-
*/
|
|
145
|
-
export function resolveLocalMergeCredential(env, kind) {
|
|
146
|
-
const raw = env[LOCAL_MERGE_CREDENTIAL_ENV_KEYS[kind]];
|
|
147
|
-
if (typeof raw !== "string")
|
|
148
|
-
return null;
|
|
149
|
-
const token = raw.trim();
|
|
150
|
-
if (token.length === 0)
|
|
151
|
-
return null;
|
|
152
|
-
return { kind, token };
|
|
153
|
-
}
|
|
154
125
|
/**
|
|
155
126
|
* Environment variables the `gh` child is allowed to inherit.
|
|
156
127
|
*
|
|
@@ -205,6 +176,8 @@ const CHILD_ENV_ALLOWLIST = [
|
|
|
205
176
|
const CHILD_ENV_DENYLIST = new Set([
|
|
206
177
|
"GH_TOKEN",
|
|
207
178
|
"GITHUB_TOKEN",
|
|
179
|
+
// BAPI-1452: the canonical local key, alongside its deprecated alias.
|
|
180
|
+
"GITHUB_MERGE_TOKEN",
|
|
208
181
|
"BAPI_LOCAL_MERGE_TOKEN",
|
|
209
182
|
"BAPI_MACHINE_MERGE_TOKEN",
|
|
210
183
|
]);
|
|
@@ -1013,19 +986,141 @@ async function resolveProbeRepository(run, ghEnv, supplied) {
|
|
|
1013
986
|
const resolved = view.stdout.trim();
|
|
1014
987
|
return OWNER_NAME_PATTERN.test(resolved) ? resolved : null;
|
|
1015
988
|
}
|
|
1016
|
-
/**
|
|
989
|
+
/** Map a preflight result to its kind-independent verdict (BAPI-1452). */
|
|
990
|
+
export function classifyLocalGhPreflight(result) {
|
|
991
|
+
if (result.ok)
|
|
992
|
+
return "passed";
|
|
993
|
+
switch (result.reason) {
|
|
994
|
+
case "local_gh_unavailable":
|
|
995
|
+
return "gh_unavailable";
|
|
996
|
+
case "local_merge_credential_missing":
|
|
997
|
+
case "machine_merge_credential_missing":
|
|
998
|
+
return "credential_missing";
|
|
999
|
+
case "local_merge_credential_invalid":
|
|
1000
|
+
case "machine_merge_credential_invalid":
|
|
1001
|
+
return "credential_invalid";
|
|
1002
|
+
case "local_merge_credential_unpermitted":
|
|
1003
|
+
case "machine_merge_credential_unpermitted":
|
|
1004
|
+
return "no_access";
|
|
1005
|
+
case "local_merge_probe_unavailable":
|
|
1006
|
+
case "machine_merge_probe_unavailable":
|
|
1007
|
+
return "probe_unavailable";
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** Mode-specific reason sets, so no call site re-derives a spelling. */
|
|
1017
1011
|
const PREFLIGHT_REASONS = {
|
|
1018
1012
|
local: {
|
|
1019
1013
|
missing: "local_merge_credential_missing",
|
|
1020
1014
|
invalid: "local_merge_credential_invalid",
|
|
1021
1015
|
unpermitted: "local_merge_credential_unpermitted",
|
|
1016
|
+
unavailable: "local_merge_probe_unavailable",
|
|
1022
1017
|
},
|
|
1023
1018
|
machine_token: {
|
|
1024
1019
|
missing: "machine_merge_credential_missing",
|
|
1025
1020
|
invalid: "machine_merge_credential_invalid",
|
|
1026
1021
|
unpermitted: "machine_merge_credential_unpermitted",
|
|
1022
|
+
unavailable: "machine_merge_probe_unavailable",
|
|
1027
1023
|
},
|
|
1028
1024
|
};
|
|
1025
|
+
// --------------------------------------------------------------------------- //
|
|
1026
|
+
// BAPI-1452: bounded parsing of one `gh api --include` response
|
|
1027
|
+
// --------------------------------------------------------------------------- //
|
|
1028
|
+
/** Status line of an `--include` response: anchored, first line only. */
|
|
1029
|
+
const HTTP_STATUS_LINE_PATTERN = /^HTTP\/\S+ ([0-9]{3})(?:\s|$)/;
|
|
1030
|
+
/** `gh`'s own error suffix, e.g. `gh: Not Found (HTTP 404)`. Bounded digits. */
|
|
1031
|
+
const GH_STDERR_STATUS_PATTERN = /\(HTTP ([0-9]{3})\)/;
|
|
1032
|
+
/** One response header line. Anything else in the header block is malformed. */
|
|
1033
|
+
const HTTP_HEADER_LINE_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[^\n]*$/;
|
|
1034
|
+
/**
|
|
1035
|
+
* Hard cap on the output a probe will parse. A repository record or a one-item
|
|
1036
|
+
* pull-request page is tens of kilobytes; anything near this is not a response
|
|
1037
|
+
* this probe understands.
|
|
1038
|
+
*/
|
|
1039
|
+
const MAX_PROBE_OUTPUT_CHARS = 2_000_000;
|
|
1040
|
+
/** Bounded prefix of stderr searched for the fallback status. */
|
|
1041
|
+
const MAX_PROBE_STDERR_SCAN_CHARS = 4_096;
|
|
1042
|
+
/** Parse a JSON body, returning `undefined` when it is not valid JSON. */
|
|
1043
|
+
function parseProbeBody(body) {
|
|
1044
|
+
try {
|
|
1045
|
+
return JSON.parse(body);
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Run one `gh api --include` read and reduce it to a status plus parsed body.
|
|
1053
|
+
*
|
|
1054
|
+
* The status comes ONLY from stdout's first line. When a non-zero command left
|
|
1055
|
+
* no usable stdout status, the bounded `(HTTP nnn)` suffix on the (already
|
|
1056
|
+
* sanitized) stderr is the single permitted fallback, and it yields no body. A
|
|
1057
|
+
* non-2xx status needs no body; a 200 must carry a parseable JSON body or the
|
|
1058
|
+
* read is malformed.
|
|
1059
|
+
*/
|
|
1060
|
+
async function readProbeResponse(run, ghEnv, endpoint) {
|
|
1061
|
+
let result;
|
|
1062
|
+
try {
|
|
1063
|
+
result = await run("gh", ["api", "--include", endpoint], ghEnv);
|
|
1064
|
+
}
|
|
1065
|
+
catch {
|
|
1066
|
+
return { kind: "unavailable", reason: "probe_transport_failed" };
|
|
1067
|
+
}
|
|
1068
|
+
if (result.timedOut === true)
|
|
1069
|
+
return { kind: "unavailable", reason: "probe_timed_out" };
|
|
1070
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
1071
|
+
if (stdout.length > MAX_PROBE_OUTPUT_CHARS) {
|
|
1072
|
+
return { kind: "unavailable", reason: "probe_response_malformed" };
|
|
1073
|
+
}
|
|
1074
|
+
const normalized = stdout.replace(/\r\n/g, "\n");
|
|
1075
|
+
const firstLineEnd = normalized.indexOf("\n");
|
|
1076
|
+
const firstLine = firstLineEnd === -1 ? normalized : normalized.slice(0, firstLineEnd);
|
|
1077
|
+
const statusMatch = HTTP_STATUS_LINE_PATTERN.exec(firstLine);
|
|
1078
|
+
if (!statusMatch) {
|
|
1079
|
+
if (result.status !== 0 && normalized.trim().length === 0) {
|
|
1080
|
+
const stderr = sanitizeLocalMergeOutput(typeof result.stderr === "string" ? result.stderr : "", [ghEnv.GH_TOKEN]).slice(0, MAX_PROBE_STDERR_SCAN_CHARS);
|
|
1081
|
+
const fallback = GH_STDERR_STATUS_PATTERN.exec(stderr);
|
|
1082
|
+
if (fallback)
|
|
1083
|
+
return { kind: "status", status: Number(fallback[1]), body: undefined };
|
|
1084
|
+
// A failed command that produced no status anywhere is a transport-level
|
|
1085
|
+
// failure, never evidence of denied access.
|
|
1086
|
+
return { kind: "unavailable", reason: "probe_transport_failed" };
|
|
1087
|
+
}
|
|
1088
|
+
// Unexpected non-empty output, or an exit-0 read with nothing to parse.
|
|
1089
|
+
return { kind: "unavailable", reason: "probe_response_malformed" };
|
|
1090
|
+
}
|
|
1091
|
+
const status = Number(statusMatch[1]);
|
|
1092
|
+
const separator = normalized.indexOf("\n\n");
|
|
1093
|
+
const headerBlock = separator === -1 ? normalized.slice(firstLine.length) : normalized.slice(firstLine.length, separator);
|
|
1094
|
+
for (const line of headerBlock.split("\n")) {
|
|
1095
|
+
if (line.length === 0)
|
|
1096
|
+
continue;
|
|
1097
|
+
if (!HTTP_HEADER_LINE_PATTERN.test(line)) {
|
|
1098
|
+
return { kind: "unavailable", reason: "probe_response_malformed" };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const bodyText = separator === -1 ? "" : normalized.slice(separator + 2).trim();
|
|
1102
|
+
if (status !== 200)
|
|
1103
|
+
return { kind: "status", status, body: undefined };
|
|
1104
|
+
const body = parseProbeBody(bodyText);
|
|
1105
|
+
if (body === undefined)
|
|
1106
|
+
return { kind: "unavailable", reason: "probe_response_malformed" };
|
|
1107
|
+
return { kind: "status", status, body };
|
|
1108
|
+
}
|
|
1109
|
+
/** The repository-metadata decision: push, proven no-push, or unusable. */
|
|
1110
|
+
function readPushPermission(body) {
|
|
1111
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
1112
|
+
return "malformed";
|
|
1113
|
+
const permissions = body.permissions;
|
|
1114
|
+
if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) {
|
|
1115
|
+
return "malformed";
|
|
1116
|
+
}
|
|
1117
|
+
const push = permissions.push;
|
|
1118
|
+
if (push === true)
|
|
1119
|
+
return "push";
|
|
1120
|
+
if (push === false)
|
|
1121
|
+
return "no_push";
|
|
1122
|
+
return "malformed";
|
|
1123
|
+
}
|
|
1029
1124
|
/**
|
|
1030
1125
|
* Probe whether this host can merge with the CONFIGURED credential, before any
|
|
1031
1126
|
* merge is attempted.
|
|
@@ -1043,17 +1138,24 @@ const PREFLIGHT_REASONS = {
|
|
|
1043
1138
|
* credential this run was configured with usable HERE, for THIS repository",
|
|
1044
1139
|
* which is the question whose answer predicts the merge.
|
|
1045
1140
|
*
|
|
1046
|
-
*
|
|
1141
|
+
* Five outcomes, each fail-closed for a merge:
|
|
1047
1142
|
*
|
|
1048
1143
|
* 1. `gh` cannot be invoked -> `local_gh_unavailable`.
|
|
1049
1144
|
* 2. No credential configured -> the kind's `*_credential_missing`, returned
|
|
1050
1145
|
* BEFORE any privileged subprocess runs.
|
|
1051
1146
|
* 3. `gh auth status` rejects the credential -> the kind's `*_credential_invalid`.
|
|
1052
|
-
* 4.
|
|
1053
|
-
*
|
|
1147
|
+
* 4. PROVEN no access -> the kind's `*_credential_unpermitted`.
|
|
1148
|
+
* 5. BAPI-1452: no usable evidence -> the kind's `*_probe_unavailable`, with a
|
|
1149
|
+
* closed {@link LocalGhProbeUnavailableReason}.
|
|
1054
1150
|
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1151
|
+
* BAPI-1452 made the capability probe two bounded reads with `--include`:
|
|
1152
|
+
* `repos/{owner}/{name}` (the push permission is read ONLY here) and
|
|
1153
|
+
* `repos/{owner}/{name}/pulls?per_page=1` (proves pull requests are readable).
|
|
1154
|
+
* Only a 404 on either read, or a well-formed 200 metadata record whose
|
|
1155
|
+
* `permissions.push` is `false`, proves no access. 401, 403, 429, 5xx, any
|
|
1156
|
+
* other status, a transport failure, a timeout, and malformed output are all
|
|
1157
|
+
* `probe_unavailable` — a non-zero exit alone is never read as denial. No read
|
|
1158
|
+
* is retried; each runs under the runner's existing wall-clock timeout.
|
|
1057
1159
|
*
|
|
1058
1160
|
* Deliberately async and built on the same `run` seam as the merge itself. The
|
|
1059
1161
|
* read-only equivalent in `conductor/doctor.ts` (`inspectLocalMerge`) probes
|
|
@@ -1076,6 +1178,8 @@ export async function preflightLocalGh(run, ghEnv, context = { kind: "local" })
|
|
|
1076
1178
|
// The credential check comes AFTER the capability check and BEFORE every
|
|
1077
1179
|
// privileged call: "gh is missing" and "gh is fine but nothing configured it"
|
|
1078
1180
|
// are different fixes, and neither should cost an authenticated request.
|
|
1181
|
+
if (context.credentialInvalid === true)
|
|
1182
|
+
return { ok: false, reason: reasons.invalid };
|
|
1079
1183
|
const credentialPresent = context.credentialPresent ?? (typeof ghEnv.GH_TOKEN === "string" && ghEnv.GH_TOKEN.length > 0);
|
|
1080
1184
|
if (!credentialPresent)
|
|
1081
1185
|
return { ok: false, reason: reasons.missing };
|
|
@@ -1091,25 +1195,106 @@ export async function preflightLocalGh(run, ghEnv, context = { kind: "local" })
|
|
|
1091
1195
|
// A credential can authenticate and still be unable to merge — a read-scoped
|
|
1092
1196
|
// token, or a machine user who was never granted write on this repository.
|
|
1093
1197
|
// That distinction is the difference between "rotate the token" and "grant the
|
|
1094
|
-
// account access", so it is worth
|
|
1198
|
+
// account access", so it is worth two read-only requests to establish.
|
|
1095
1199
|
const repository = await resolveProbeRepository(run, ghEnv, context.repository);
|
|
1096
|
-
if (repository
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1200
|
+
if (repository === null)
|
|
1201
|
+
return { ok: true };
|
|
1202
|
+
const unavailable = (probeReason) => ({
|
|
1203
|
+
ok: false,
|
|
1204
|
+
reason: reasons.unavailable,
|
|
1205
|
+
probe_unavailable_reason: probeReason,
|
|
1206
|
+
});
|
|
1207
|
+
const metadata = await readProbeResponse(run, ghEnv, `repos/${repository}`);
|
|
1208
|
+
if (metadata.kind === "unavailable")
|
|
1209
|
+
return unavailable(metadata.reason);
|
|
1210
|
+
if (metadata.status === 404)
|
|
1211
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1212
|
+
if (metadata.status !== 200)
|
|
1213
|
+
return unavailable("probe_provider_status_inconclusive");
|
|
1214
|
+
const push = readPushPermission(metadata.body);
|
|
1215
|
+
if (push === "malformed")
|
|
1216
|
+
return unavailable("probe_response_malformed");
|
|
1217
|
+
if (push === "no_push")
|
|
1218
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1219
|
+
const pulls = await readProbeResponse(run, ghEnv, `repos/${repository}/pulls?per_page=1`);
|
|
1220
|
+
if (pulls.kind === "unavailable")
|
|
1221
|
+
return unavailable(pulls.reason);
|
|
1222
|
+
if (pulls.status === 404)
|
|
1223
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1224
|
+
if (pulls.status !== 200)
|
|
1225
|
+
return unavailable("probe_provider_status_inconclusive");
|
|
1226
|
+
// Push permission is read only from the repository record above; the pull
|
|
1227
|
+
// request page only has to have the expected shape.
|
|
1228
|
+
if (!Array.isArray(pulls.body))
|
|
1229
|
+
return unavailable("probe_response_malformed");
|
|
1111
1230
|
return { ok: true };
|
|
1112
1231
|
}
|
|
1232
|
+
/**
|
|
1233
|
+
* BAPI-1452 — the launch-time capability probe, with the SAME credential
|
|
1234
|
+
* resolution and the SAME `gh` runner a merge uses.
|
|
1235
|
+
*
|
|
1236
|
+
* Resolves the kind's credential from `env` (canonical key, then — for the
|
|
1237
|
+
* local kind only — the deprecated alias), builds the allowlisted child
|
|
1238
|
+
* environment, and runs {@link preflightLocalGh} against an explicit,
|
|
1239
|
+
* validated `owner/name`. Returns only the verdict and closed reason; the
|
|
1240
|
+
* token and every byte of subprocess output stay inside this call.
|
|
1241
|
+
*
|
|
1242
|
+
* Read-only: `gh --version`, `gh auth status`, and two `gh api` GETs.
|
|
1243
|
+
*/
|
|
1244
|
+
export async function probeLocalMergeCapability(context, deps = {}) {
|
|
1245
|
+
const env = deps.env ?? process.env;
|
|
1246
|
+
let credential;
|
|
1247
|
+
let invalid;
|
|
1248
|
+
if (context.resolved !== undefined) {
|
|
1249
|
+
credential = context.resolved.credential;
|
|
1250
|
+
invalid = context.resolved.invalid;
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
const resolution = resolveLocalMergeCredentialDetailed(env, context.kind);
|
|
1254
|
+
credential = resolution.status === "resolved" ? resolution.credential : null;
|
|
1255
|
+
invalid = resolution.status === "invalid";
|
|
1256
|
+
}
|
|
1257
|
+
const ghEnv = buildLocalMergeChildEnv(env, credential);
|
|
1258
|
+
const rawRun = deps.runCommand ?? defaultRunCommand;
|
|
1259
|
+
const run = (cmd, args, childEnv) => Promise.resolve(rawRun(cmd, args, childEnv, deps.signal));
|
|
1260
|
+
return preflightLocalGh(run, ghEnv, {
|
|
1261
|
+
kind: context.kind,
|
|
1262
|
+
repository: context.repository,
|
|
1263
|
+
credentialPresent: credential !== null,
|
|
1264
|
+
credentialInvalid: invalid,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* BAPI-1453 — re-run the capability probe for the merge-job path, with the
|
|
1269
|
+
* EXACT credential, environment base, runner, and abort signal a failed merge
|
|
1270
|
+
* just used (the {@link LocalMergeDeps} it was built from).
|
|
1271
|
+
*
|
|
1272
|
+
* Unlike {@link probeLocalMergeCapability} this resolves no credential of its
|
|
1273
|
+
* own: the question is whether the credential that just failed `gh pr view` can
|
|
1274
|
+
* see the repository, so asking about any other credential would answer the
|
|
1275
|
+
* wrong question. `repository` must be the server-carried provider `owner/name`;
|
|
1276
|
+
* there is no `gh repo view` fallback on this path, so a caller without one
|
|
1277
|
+
* must not call this at all.
|
|
1278
|
+
*
|
|
1279
|
+
* Read-only and value-free: returns only the verdict and closed reason.
|
|
1280
|
+
*/
|
|
1281
|
+
export async function probeLocalMergeRepositoryAccess(repository, deps) {
|
|
1282
|
+
const credential = deps.credential ?? null;
|
|
1283
|
+
const reasons = PREFLIGHT_REASONS[credential?.kind ?? "local"];
|
|
1284
|
+
// `preflightLocalGh` falls back to `gh repo view` for a value it cannot use.
|
|
1285
|
+
// That fallback is forbidden here, so an unusable value is "no evidence".
|
|
1286
|
+
if (typeof repository !== "string" || !OWNER_NAME_PATTERN.test(repository)) {
|
|
1287
|
+
return { ok: false, reason: reasons.unavailable, probe_unavailable_reason: "probe_response_malformed" };
|
|
1288
|
+
}
|
|
1289
|
+
const ghEnv = buildLocalMergeChildEnv(deps.env ?? process.env, credential);
|
|
1290
|
+
const rawRun = deps.runCommand ?? defaultRunCommand;
|
|
1291
|
+
const run = (cmd, args, childEnv) => Promise.resolve(rawRun(cmd, args, childEnv, deps.signal));
|
|
1292
|
+
return preflightLocalGh(run, ghEnv, {
|
|
1293
|
+
kind: credential?.kind ?? "local",
|
|
1294
|
+
repository,
|
|
1295
|
+
credentialPresent: credential !== null,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1113
1298
|
/**
|
|
1114
1299
|
* Execute a merge the server has ALREADY approved, using the operator's `gh`.
|
|
1115
1300
|
*
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge credential configuration keys and their resolution (BAPI-1452).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `local-merge.ts` so read-only callers — `doctor`, the shared
|
|
5
|
+
* readiness requirements, and installer guidance — can name the keys and
|
|
6
|
+
* resolve presence WITHOUT importing the merge executor and its process
|
|
7
|
+
* runner. Dependency-free: no `child_process`, no filesystem, no network.
|
|
8
|
+
* `local-merge.ts` re-exports everything here, so its public surface is
|
|
9
|
+
* unchanged.
|
|
10
|
+
*
|
|
11
|
+
* BAPI-1461 adds {@link resolveLocalMergeCredentialDetailedAsync}: the same
|
|
12
|
+
* ladder plus ONE more rung for the local kind only — a token stored once in
|
|
13
|
+
* the user-scoped credential store under `github-merge:github.com/<owner>/<repo>`.
|
|
14
|
+
* The store is read through an injected reader, so this module still has no
|
|
15
|
+
* filesystem, process, or network dependency. The two synchronous resolvers
|
|
16
|
+
* below are unchanged, so every caller that stays env-only keeps its exact
|
|
17
|
+
* behavior, and machine-user mode never gains persistence.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The one process-launch configuration key each credential kind reads.
|
|
21
|
+
*
|
|
22
|
+
* There is NO fallback between them and no fallback to anything else: not to an
|
|
23
|
+
* inherited `GH_TOKEN` or `GITHUB_TOKEN`, not to a `gh auth` session, and not to
|
|
24
|
+
* the Bridge credential store. A merge mode that silently borrowed the other
|
|
25
|
+
* mode's credential would make "which identity performed this write" unanswerable
|
|
26
|
+
* from the run's own configuration, which is the property this whole change
|
|
27
|
+
* exists to establish.
|
|
28
|
+
*
|
|
29
|
+
* Neither key is ever routed through `resolveBapiCredentials`,
|
|
30
|
+
* `resolveCredentialBundle`, project configuration, or install state. A GitHub
|
|
31
|
+
* merge credential is not a Bridge credential and must not acquire Bridge's
|
|
32
|
+
* persistence.
|
|
33
|
+
*
|
|
34
|
+
* BAPI-1452: the local kind's canonical key is now `GITHUB_MERGE_TOKEN`. The
|
|
35
|
+
* pre-BAPI-1452 spelling survives ONLY as {@link LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY},
|
|
36
|
+
* a same-kind alias read after the canonical key. It is still never a fallback
|
|
37
|
+
* between kinds: the machine kind reads only `BAPI_MACHINE_MERGE_TOKEN`, which
|
|
38
|
+
* has no alias and is not deprecated.
|
|
39
|
+
*/
|
|
40
|
+
export const LOCAL_MERGE_CREDENTIAL_ENV_KEYS = {
|
|
41
|
+
local: "GITHUB_MERGE_TOKEN",
|
|
42
|
+
machine_token: "BAPI_MACHINE_MERGE_TOKEN",
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* BAPI-1452: the deprecated, still-supported spelling of the LOCAL credential
|
|
46
|
+
* key. Read only when the canonical {@link LOCAL_MERGE_CREDENTIAL_ENV_KEYS}.local
|
|
47
|
+
* value is absent or blank — a non-empty canonical value that fails validation
|
|
48
|
+
* is NOT rescued by it. There is no removal date.
|
|
49
|
+
*/
|
|
50
|
+
export const LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY = "BAPI_LOCAL_MERGE_TOKEN";
|
|
51
|
+
/**
|
|
52
|
+
* BAPI-1452: the shape a configured credential value must have to be used.
|
|
53
|
+
*
|
|
54
|
+
* Printable, non-whitespace ASCII of bounded length — every GitHub token format
|
|
55
|
+
* fits, and a value carrying an embedded space, a control character, or a pasted
|
|
56
|
+
* multi-line blob does not. Deliberately not a token-format check: a shape this
|
|
57
|
+
* loose can reject only values that cannot possibly authenticate.
|
|
58
|
+
*/
|
|
59
|
+
const MERGE_CREDENTIAL_VALUE_PATTERN = /^[\x21-\x7E]{1,4096}$/;
|
|
60
|
+
/** Classify one raw environment value without exposing it. */
|
|
61
|
+
function classifyCredentialValue(raw) {
|
|
62
|
+
if (typeof raw !== "string")
|
|
63
|
+
return "absent";
|
|
64
|
+
const token = raw.trim();
|
|
65
|
+
if (token.length === 0)
|
|
66
|
+
return "absent";
|
|
67
|
+
return MERGE_CREDENTIAL_VALUE_PATTERN.test(token) ? token : "invalid";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve one credential kind and report where it came from (BAPI-1452).
|
|
71
|
+
*
|
|
72
|
+
* Local kind: `GITHUB_MERGE_TOKEN` first, then the deprecated
|
|
73
|
+
* `BAPI_LOCAL_MERGE_TOKEN`. An absent or whitespace-only canonical value permits
|
|
74
|
+
* the alias; a non-empty canonical value that fails validation returns
|
|
75
|
+
* `invalid` and the alias is not consulted. Machine kind: only
|
|
76
|
+
* `BAPI_MACHINE_MERGE_TOKEN`. Never the other kind's key, never an inherited
|
|
77
|
+
* `GH_TOKEN`/`GITHUB_TOKEN`.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveLocalMergeCredentialDetailed(env, kind) {
|
|
80
|
+
const primaryKey = LOCAL_MERGE_CREDENTIAL_ENV_KEYS[kind];
|
|
81
|
+
const keys = kind === "local" ? [primaryKey, LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY] : [primaryKey];
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
const classified = classifyCredentialValue(env[key]);
|
|
84
|
+
if (classified === "absent")
|
|
85
|
+
continue;
|
|
86
|
+
if (classified === "invalid")
|
|
87
|
+
return { status: "invalid", source_key: key };
|
|
88
|
+
return {
|
|
89
|
+
status: "resolved",
|
|
90
|
+
credential: { kind, token: classified },
|
|
91
|
+
source_key: key,
|
|
92
|
+
via_alias: key === LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { status: "missing" };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the one credential a kind is allowed to read.
|
|
99
|
+
*
|
|
100
|
+
* Returns `null` for an absent or blank value — never the other kind's value and
|
|
101
|
+
* never an inherited one. BAPI-1452: also `null` for a value that fails
|
|
102
|
+
* validation, so no privileged subprocess ever runs with it; callers that must
|
|
103
|
+
* tell "invalid" from "missing" use {@link resolveLocalMergeCredentialDetailed}.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveLocalMergeCredential(env, kind) {
|
|
106
|
+
const resolution = resolveLocalMergeCredentialDetailed(env, kind);
|
|
107
|
+
return resolution.status === "resolved" ? resolution.credential : null;
|
|
108
|
+
}
|
|
109
|
+
/** BAPI-1461: whether a value has the shape a merge credential must have. */
|
|
110
|
+
export function isUsableMergeCredentialValue(raw) {
|
|
111
|
+
const classified = classifyCredentialValue(raw);
|
|
112
|
+
return classified !== "absent" && classified !== "invalid";
|
|
113
|
+
}
|
|
114
|
+
/** The `source_key` reported for a credential that came from the store. */
|
|
115
|
+
export const STORED_MERGE_CREDENTIAL_SOURCE_KEY = "store";
|
|
116
|
+
/**
|
|
117
|
+
* BAPI-1461: resolve one kind with the stored-entry fallback.
|
|
118
|
+
*
|
|
119
|
+
* The order for the local kind is fixed: `GITHUB_MERGE_TOKEN`, then
|
|
120
|
+
* `BAPI_LOCAL_MERGE_TOKEN`, then the stored entry. The synchronous resolver
|
|
121
|
+
* runs first and its answer wins whenever it resolves or reports an invalid
|
|
122
|
+
* value, so an invalid canonical value is never rescued by the alias or the
|
|
123
|
+
* store. `machine_token` returns the synchronous answer unchanged. The store is
|
|
124
|
+
* consulted only when `allowStoredEntry === true` and a reader was supplied.
|
|
125
|
+
* Nothing here reads `gh auth`, `GH_TOKEN`, `GITHUB_TOKEN`, or a Bridge key.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveLocalMergeCredentialDetailedAsync(env, kind, options = {}) {
|
|
128
|
+
const sync = resolveLocalMergeCredentialDetailed(env, kind);
|
|
129
|
+
if (sync.status === "resolved") {
|
|
130
|
+
return {
|
|
131
|
+
status: "resolved",
|
|
132
|
+
credential: sync.credential,
|
|
133
|
+
source: sync.via_alias ? "alias" : "env",
|
|
134
|
+
source_key: sync.source_key,
|
|
135
|
+
via_alias: sync.via_alias,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (sync.status === "invalid") {
|
|
139
|
+
return {
|
|
140
|
+
status: "invalid",
|
|
141
|
+
source: sync.source_key === LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY ? "alias" : "env",
|
|
142
|
+
source_key: sync.source_key,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (kind !== "local" || options.allowStoredEntry !== true || !options.readStoredEntry) {
|
|
146
|
+
return { status: "missing" };
|
|
147
|
+
}
|
|
148
|
+
let stored;
|
|
149
|
+
try {
|
|
150
|
+
stored = await options.readStoredEntry();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { status: "store-unavailable" };
|
|
154
|
+
}
|
|
155
|
+
if (stored.status === "unavailable")
|
|
156
|
+
return { status: "store-unavailable" };
|
|
157
|
+
if (stored.status === "absent")
|
|
158
|
+
return { status: "missing" };
|
|
159
|
+
const classified = classifyCredentialValue(stored.token);
|
|
160
|
+
if (classified === "absent")
|
|
161
|
+
return { status: "missing" };
|
|
162
|
+
if (classified === "invalid") {
|
|
163
|
+
return { status: "invalid", source: "store", source_key: STORED_MERGE_CREDENTIAL_SOURCE_KEY };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
status: "resolved",
|
|
167
|
+
credential: { kind, token: classified },
|
|
168
|
+
source: "store",
|
|
169
|
+
source_key: STORED_MERGE_CREDENTIAL_SOURCE_KEY,
|
|
170
|
+
via_alias: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -42,7 +42,7 @@ import { MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS, MERGE_WORKFLOW_CODEOWNE
|
|
|
42
42
|
*/
|
|
43
43
|
export const MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION = "re-run once the Bridge API is reachable and repository credentials resolve; merge " +
|
|
44
44
|
"eligibility is UNKNOWN here, not confirmed missing. If the server reports no " +
|
|
45
|
-
"merge-execution block at all,
|
|
45
|
+
"merge-execution block at all, email info@bridgegpt.ai.";
|
|
46
46
|
/**
|
|
47
47
|
* Remediation for a CONFIRMED insufficiency. Names the one command that fixes it.
|
|
48
48
|
*
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Already-merged child detection and skip selection for `drive-epic` (BAPI-1510).
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers plus one injected classification call. Nothing here prints, reads a
|
|
5
|
+
* file, or touches the network except through the injected `classify` function.
|
|
6
|
+
*
|
|
7
|
+
* The posture is conservative: only a current server verdict of `done` (completed
|
|
8
|
+
* delivery binding, confirmed merge commit, covered by the indexed cut) is ever
|
|
9
|
+
* skipped on its own. `probably_done` needs the operator's explicit `--skip-child`.
|
|
10
|
+
* Every uncertain, failed, or malformed result keeps the child in the plan.
|
|
11
|
+
*/
|
|
12
|
+
/** The server's per-request bound on child keys. */
|
|
13
|
+
export const CHILD_MERGE_STATUS_BATCH_SIZE = 100;
|
|
14
|
+
/**
|
|
15
|
+
* Classify every plan child against one previously resolved cut, in batches of at
|
|
16
|
+
* most {@link CHILD_MERGE_STATUS_BATCH_SIZE}. Any failed or malformed batch fails
|
|
17
|
+
* the WHOLE check: a partial result must never feed a skip set.
|
|
18
|
+
*/
|
|
19
|
+
export async function classifyPlanChildren(classify, epicKey, childKeys, cutCommitSha) {
|
|
20
|
+
const results = [];
|
|
21
|
+
try {
|
|
22
|
+
for (let i = 0; i < childKeys.length; i += CHILD_MERGE_STATUS_BATCH_SIZE) {
|
|
23
|
+
const batch = childKeys.slice(i, i + CHILD_MERGE_STATUS_BATCH_SIZE);
|
|
24
|
+
const response = await classify({ epicKey, childKeys: batch, cutCommitSha });
|
|
25
|
+
if (response.cut_commit_sha !== cutCommitSha ||
|
|
26
|
+
response.children.length !== batch.length ||
|
|
27
|
+
response.children.some((child, index) => child.ticket_key !== batch[index])) {
|
|
28
|
+
return { ok: false };
|
|
29
|
+
}
|
|
30
|
+
results.push(...response.children);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { ok: false };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, results };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decide the skip set from current verdicts.
|
|
40
|
+
*
|
|
41
|
+
* - Automatic: `done` and not named by `--keep-child`.
|
|
42
|
+
* - Explicit: `--skip-child`, honored only for `done` or `probably_done`.
|
|
43
|
+
* - `results === null` means the check could not run: no automatic skips, and every
|
|
44
|
+
* explicit skip is a refusal.
|
|
45
|
+
*/
|
|
46
|
+
export function selectSkips(input) {
|
|
47
|
+
const planKeys = new Set(input.planKeys);
|
|
48
|
+
const keep = new Set(input.keepChildren);
|
|
49
|
+
const refusals = [];
|
|
50
|
+
const skipKeys = new Set();
|
|
51
|
+
const confirmedKeys = new Set();
|
|
52
|
+
for (const key of input.skipChildren) {
|
|
53
|
+
if (!planKeys.has(key))
|
|
54
|
+
refusals.push({ ticketKey: key, reason: "not_in_plan" });
|
|
55
|
+
}
|
|
56
|
+
if (input.results === null) {
|
|
57
|
+
for (const key of input.skipChildren) {
|
|
58
|
+
if (planKeys.has(key))
|
|
59
|
+
refusals.push({ ticketKey: key, reason: "check_unavailable" });
|
|
60
|
+
}
|
|
61
|
+
return { skipKeys, confirmedKeys, refusals };
|
|
62
|
+
}
|
|
63
|
+
const byKey = new Map(input.results.map((result) => [result.ticket_key, result]));
|
|
64
|
+
const explicit = new Set(input.skipChildren);
|
|
65
|
+
for (const key of input.planKeys) {
|
|
66
|
+
const verdict = byKey.get(key)?.classification;
|
|
67
|
+
if (explicit.has(key)) {
|
|
68
|
+
if (verdict === "done" || verdict === "probably_done") {
|
|
69
|
+
skipKeys.add(key);
|
|
70
|
+
confirmedKeys.add(key);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
refusals.push({ ticketKey: key, reason: "not_skippable" });
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (verdict === "done" && !keep.has(key))
|
|
78
|
+
skipKeys.add(key);
|
|
79
|
+
}
|
|
80
|
+
return { skipKeys, confirmedKeys, refusals };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The preview entries: every `done` and `probably_done` child, every `unknown`
|
|
84
|
+
* child, and every proven-merged child that merged after the index. A child that
|
|
85
|
+
* simply has no merged PR is left out. `will_skip` is true only for children in
|
|
86
|
+
* the skip set.
|
|
87
|
+
*/
|
|
88
|
+
export function buildAlreadyDoneEntries(results, skipKeys) {
|
|
89
|
+
return results
|
|
90
|
+
.filter((result) => result.classification === "done" ||
|
|
91
|
+
result.classification === "probably_done" ||
|
|
92
|
+
result.classification === "unknown" ||
|
|
93
|
+
result.reason === "merged_after_index")
|
|
94
|
+
.map((result) => ({ ...result, will_skip: skipKeys.has(result.ticket_key) }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable trimming of already-merged children out of an epic plan DAG (BAPI-1510).
|
|
3
|
+
*
|
|
4
|
+
* Pure: returns newly constructed data and never mutates the caller's plan, so the
|
|
5
|
+
* operator's plan sidecar stays byte-identical. A skipped node is removed together
|
|
6
|
+
* with every reference to it — `depends_on` entries and edges naming it on either
|
|
7
|
+
* end — so the trimmed plan has no dangling dependency and still passes server-side
|
|
8
|
+
* plan validation. Node order and retained edge order are preserved, and every other
|
|
9
|
+
* property (status, automations, touched files, unknown metadata) is carried over.
|
|
10
|
+
*/
|
|
11
|
+
function cloneJson(value) {
|
|
12
|
+
return JSON.parse(JSON.stringify(value));
|
|
13
|
+
}
|
|
14
|
+
export function trimDonePlanNodes(plan, skipKeys) {
|
|
15
|
+
const trimmed = cloneJson(plan);
|
|
16
|
+
const nodes = [];
|
|
17
|
+
for (const node of trimmed.nodes ?? []) {
|
|
18
|
+
if (skipKeys.has(node.ticket_key))
|
|
19
|
+
continue;
|
|
20
|
+
if (Array.isArray(node.depends_on)) {
|
|
21
|
+
node.depends_on = node.depends_on.filter((key) => !skipKeys.has(key));
|
|
22
|
+
}
|
|
23
|
+
nodes.push(node);
|
|
24
|
+
}
|
|
25
|
+
trimmed.nodes = nodes;
|
|
26
|
+
if (Array.isArray(trimmed.edges)) {
|
|
27
|
+
trimmed.edges = trimmed.edges.filter((edge) => !skipKeys.has(edge.from) && !skipKeys.has(edge.to));
|
|
28
|
+
}
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|