@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
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
|
|
2
|
+
// This file is produced by scripts/bundle-pipelines.js
|
|
3
|
+
export const SHARED_RESOURCES = {
|
|
4
|
+
"epic-contract-resolution-duty.md": "# Epic-contract resolution duty (version 1)\n\nThis ticket is a confirmed child of an epic. Resolve every review finding — Confirmed Improvements, Needs Scrutiny, Open Questions, and every clarifying answer — against the epic's current contracts before you finalize it. This duty adds evidence to your decision. It does not replace the review's other rules, and it does not tell you to accept or reject critique by default.\n\n## 1. What counts as an obligation\n\n- The **current text** of the parent epic and of each sibling ticket is the requirement source. A normative statement there (\"must\", \"never\", \"owns\", \"exposes\", \"no migration\", an acceptance criterion) is a mandatory obligation.\n- **Inspected code** at `fresh_base_root` is feasibility evidence: it shows what exists and what a change would touch. Code does not create an obligation, and a changed file is not a changed scope.\n- A stale attachment, an old review document, or an earlier plan never overrides current ticket text. If you already met such historical material and it disagrees with current text, say so in one line, decide from current text, and name the ticket that owns the disputed requirement. A discrepancy alone is never a blocker.\n\n## 2. Choose relevant sources, not every source\n\n- Start from the parent requirements and the sibling inventory summaries in the context response.\n- Request a sibling's text (`sibling_keys`) only when a finding, the parent, a summary, an explicit reference, or an inspected interface ties it to the change: it produces something this ticket consumes, consumes something this ticket produces, or owns a behavior the finding would change.\n- Leave irrelevant siblings unhydrated. Never claim anything about a sibling whose text you did not receive.\n- A completed sibling's contract still binds. Its work has shipped, so the obligation is now also code.\n\n## 3. Resolve each finding\n\nFor each finding, check the proposed change's effect on every relevant producer or consumer obligation. Then choose one:\n\n- **Accept** it when it is a beneficial, in-scope fix that keeps every cited obligation. A finding is not suspect because a critic raised it.\n- **Adapt** it when its goal is sound but its form breaks an obligation, and a compatible form exists. Record the adapted direction.\n- **Reject** it when it would break an obligation and no compatible form serves its goal.\n- **Select a supported alternative** when the ticket, a sibling, or the code already offers a compatible way to meet the goal.\n\nRecord for each decision: the contract source (ticket key and `content_sha256`), the obligation in a short quote or paraphrase, the effect of the change on it, and the inspected code location (`file:symbol` at `base_sha`).\n\n## 4. Report coverage truthfully\n\n- Cite only sources whose `outcome` is `fetched` or `partial`. For `partial`, cite only the returned range.\n- When the response lists gaps — `missing`, `failed`, `unhydrated`, an incomplete inventory, a reached bound — disclose them in one plain line and keep resolving with the evidence you have. Missing context is a limitation, not a conflict and not a reason to stop.\n- Never write that a contract was checked when its text was not returned.\n\n## 5. Before final capture, revalidate what you relied on\n\nPass each cited source's `ticket_key` and `content_sha256` in `revalidate`. Reassess only the decisions whose sources came back `changed`. If revalidation is `unavailable`, disclose that and keep the earlier evidence; do not claim the source is unchanged.\n\n## 6. The only stop: mandatory incompatibility\n\nStop automatic finalization **only** when all of these hold:\n\n1. Two or more cited, current, mandatory obligations cannot all hold at once.\n2. You tried a supported interpretation of each, rejecting or adapting the suggestion, and every compatible alternative you could find.\n3. No decision within this ticket's scope can satisfy them all without abandoning an explicit obligation.\n\nAmbiguity, disagreement between reviewers, several valid options, optional improvements, unavailable context, and imperfect documentation never meet this threshold. Resolve those automatically and record your choice.\n\nWhen the threshold is met, set the finding's status to `mandatory_incompatibility` and record: the exact conflicting obligations with their sources, every alternative you attempted and why it failed, and the one decision you recommend (which obligation should change, and who owns it). Do not record that finding as accepted or dismissed, do not write a binding instruction that claims every obligation holds, and do not report the review as finalized. Waiving another ticket's obligation is that ticket owner's decision; route it to them.\n"
|
|
5
|
+
};
|
package/build/start-tickets.js
CHANGED
|
@@ -63,6 +63,8 @@ import path from "path";
|
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
import { validateHttpBaseUrl } from "./base-url.js";
|
|
65
65
|
import { VERSION } from "./version.generated.js";
|
|
66
|
+
import { getNoticeDecision } from "./session-upgrade-notice.js";
|
|
67
|
+
import { formatUpdateAdvice } from "./upgrade-advice.js";
|
|
66
68
|
import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
|
|
67
69
|
import { LEGACY_SERVER_NAMES, MCP_PACKAGE_NAME, MCP_SERVER_NAME } from "./mcp-identity.js";
|
|
68
70
|
// BAPI-941: the shared drift diagnostic formatter. The leaf drift module imports
|
|
@@ -199,6 +201,7 @@ export function parseStartTicketsArgs(argv) {
|
|
|
199
201
|
let hostPlatform;
|
|
200
202
|
let baseBranch = "main";
|
|
201
203
|
let conductorEnabled = false;
|
|
204
|
+
let verificationScope;
|
|
202
205
|
// BAPI-948: `--guard-stale-branch` (and `--conductor`, which implies it) opt the
|
|
203
206
|
// packaged CLI into the F7 stale-branch guard. Tracked separately from
|
|
204
207
|
// `conductorEnabled` so the pilot can take the safety check WITHOUT taking the
|
|
@@ -221,6 +224,13 @@ export function parseStartTicketsArgs(argv) {
|
|
|
221
224
|
const keys = [];
|
|
222
225
|
for (let i = 0; i < argv.length; i++) {
|
|
223
226
|
const arg = argv[i];
|
|
227
|
+
if (arg === "--verification-scope" || arg.startsWith("--verification-scope=")) {
|
|
228
|
+
const value = arg.startsWith("--verification-scope=") ? arg.slice("--verification-scope=".length) : argv[++i];
|
|
229
|
+
if (value !== "full" && value !== "targeted")
|
|
230
|
+
return { status: "error", message: "--verification-scope requires full or targeted" };
|
|
231
|
+
verificationScope = value;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
224
234
|
const takeValue = () => {
|
|
225
235
|
// The `--flag=value` form is handled inline by callers; this reads the
|
|
226
236
|
// separate next token for the `--flag value` form.
|
|
@@ -552,6 +562,7 @@ export function parseStartTicketsArgs(argv) {
|
|
|
552
562
|
agentName: resolvedAgentName,
|
|
553
563
|
baseBranch,
|
|
554
564
|
conductorEnabled,
|
|
565
|
+
...(verificationScope ? { verificationScope } : {}),
|
|
555
566
|
workflow,
|
|
556
567
|
reviewRounds,
|
|
557
568
|
// BAPI-948: include `guardStaleWorktree` ONLY when the run asked for the F7
|
|
@@ -622,7 +633,7 @@ resumeMode = false, workflow = "implement", reviewRounds,
|
|
|
622
633
|
// command.
|
|
623
634
|
baseBranch, bridgeApiBaseUrlOverride,
|
|
624
635
|
// BAPI-1365: a recognized operator `--tier`, forwarded to the review prompt.
|
|
625
|
-
manualTier) {
|
|
636
|
+
manualTier, verificationScope) {
|
|
626
637
|
if (!isSupportedStartTicketsPlatform(deps.platform)) {
|
|
627
638
|
return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
|
|
628
639
|
}
|
|
@@ -643,7 +654,7 @@ manualTier) {
|
|
|
643
654
|
// falls back to the basename-derived repo name (the 403 root cause), and
|
|
644
655
|
// (BAPI-586/BAPI-801) the run base so the spawned worker opens its PR
|
|
645
656
|
// against it. The base assignment stays outermost.
|
|
646
|
-
buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier), repoName, platform), prBaseBranch, platform),
|
|
657
|
+
buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope), repoName, platform), prBaseBranch, platform),
|
|
647
658
|
spawnTerminalTab: deps.spawnTerminalTab,
|
|
648
659
|
},
|
|
649
660
|
};
|
|
@@ -1384,7 +1395,9 @@ export async function resumeWorktrees(deps, options) {
|
|
|
1384
1395
|
* Single line (no newlines, no `;`, no quotes/apostrophes) so it stays safe when
|
|
1385
1396
|
* single-quoted into a shell command and embedded in terminal-launcher args.
|
|
1386
1397
|
*/
|
|
1387
|
-
export function buildResumeModeRemediationFinalizeInstruction() {
|
|
1398
|
+
export function buildResumeModeRemediationFinalizeInstruction(scope = "full") {
|
|
1399
|
+
if (scope === "targeted")
|
|
1400
|
+
return "Explicit epic targeted verification scope: run focused repair checks and mandatory local obligations once at the final artifact. Defer only server-approved CI coverage joins to named required checks. Missing coverage stays local and actual failures remain blockers. Do one renewal pass and one confirming readiness read. Never treat a focused subset as suite proof.";
|
|
1388
1401
|
return ("Resume-mode remediation finalize: you were re-dispatched to fix a blocked ticket " +
|
|
1389
1402
|
"(a merge conflict, a CI failure, or requested review changes). First rebase against " +
|
|
1390
1403
|
"the current base branch and resolve the merge conflicts. A clean textual merge can " +
|
|
@@ -1446,7 +1459,9 @@ export function buildAgentPrompt(key, opts = {}) {
|
|
|
1446
1459
|
// read $BAPI_BASE_BRANCH for them.
|
|
1447
1460
|
parts.push(buildPrBaseContractLaunchInstruction());
|
|
1448
1461
|
}
|
|
1449
|
-
if (opts.
|
|
1462
|
+
if (opts.verificationScope === "targeted")
|
|
1463
|
+
parts.push(buildResumeModeRemediationFinalizeInstruction("targeted"));
|
|
1464
|
+
else if (opts.resumeMode)
|
|
1450
1465
|
parts.push(buildResumeModeRemediationFinalizeInstruction());
|
|
1451
1466
|
return parts.join(" ");
|
|
1452
1467
|
}
|
|
@@ -1498,13 +1513,13 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias, launchArg
|
|
|
1498
1513
|
}
|
|
1499
1514
|
}
|
|
1500
1515
|
/** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
|
|
1501
|
-
export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
|
|
1502
|
-
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
|
|
1516
|
+
export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
|
|
1517
|
+
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier, verificationScope }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
|
|
1503
1518
|
return `cd '${shSquoteInner(worktreePath)}' && ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "darwin")}`;
|
|
1504
1519
|
}
|
|
1505
1520
|
/** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
|
|
1506
|
-
export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
|
|
1507
|
-
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
|
|
1521
|
+
export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
|
|
1522
|
+
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier, verificationScope }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
|
|
1508
1523
|
return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "win32")}`;
|
|
1509
1524
|
}
|
|
1510
1525
|
/**
|
|
@@ -1515,10 +1530,10 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
|
|
|
1515
1530
|
* injected as `--model` at the spawn boundary. `conductorEnabled` appends the
|
|
1516
1531
|
* BAPI-586 PR-base contract instruction to the prompt (opt-in via `--conductor`).
|
|
1517
1532
|
*/
|
|
1518
|
-
export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
|
|
1533
|
+
export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
|
|
1519
1534
|
if (platform === "win32")
|
|
1520
|
-
return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
|
|
1521
|
-
return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
|
|
1535
|
+
return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope);
|
|
1536
|
+
return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope);
|
|
1522
1537
|
}
|
|
1523
1538
|
/**
|
|
1524
1539
|
* Build the shell command run inside a spawned tab/session for an ARBITRARY
|
|
@@ -2146,7 +2161,7 @@ export function buildDryRunResults(keys, overrides) {
|
|
|
2146
2161
|
* PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
|
|
2147
2162
|
* POSIX fallback for unsupported platforms.
|
|
2148
2163
|
*/
|
|
2149
|
-
export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch, manualTier) {
|
|
2164
|
+
export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch, manualTier, verificationScope) {
|
|
2150
2165
|
return {
|
|
2151
2166
|
worktrunkBinary: resolveWorktrunkBinary(platform, env),
|
|
2152
2167
|
// The builder accepts an optional resolved modelAlias; the dry-run caller
|
|
@@ -2198,7 +2213,7 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
|
|
|
2198
2213
|
* the secret-free MCP provisioning preview. Pure platform formatting only — no
|
|
2199
2214
|
* preflight, no routing failures.
|
|
2200
2215
|
*/
|
|
2201
|
-
export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds, manualTier) {
|
|
2216
|
+
export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds, manualTier, verificationScope) {
|
|
2202
2217
|
const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName, workflow, reviewRounds, baseBranch, manualTier);
|
|
2203
2218
|
const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
|
|
2204
2219
|
const agentInvocation = build(key, "<worktree-path>", modelAlias);
|
|
@@ -3922,7 +3937,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
|
|
|
3922
3937
|
// epic.base_branch override applied above) so the spawned worker gets
|
|
3923
3938
|
// BAPI_BASE_BRANCH — every conductor worker, and a plain worker whose base
|
|
3924
3939
|
// is non-default.
|
|
3925
|
-
options.baseBranch, bridgeApiBaseUrl, isModelTier(options.injectedTier) ? options.injectedTier : undefined);
|
|
3940
|
+
options.baseBranch, bridgeApiBaseUrl, isModelTier(options.injectedTier) ? options.injectedTier : undefined, options.verificationScope);
|
|
3926
3941
|
if (!platformConfig.ok)
|
|
3927
3942
|
return { ok: false, error: platformConfig.error };
|
|
3928
3943
|
// BAPI-527: resolve the base start point new worktrees are cut from BEFORE any
|
|
@@ -4150,6 +4165,19 @@ export async function runStartTicketsCli(argv, overrides = {}) {
|
|
|
4150
4165
|
const options = parsed.options;
|
|
4151
4166
|
const rawDeps = overrides.deps ?? createDefaultStartTicketsDeps();
|
|
4152
4167
|
const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
|
|
4168
|
+
// BAPI-1446: one cache-only upgrade reminder per run, on the existing stderr
|
|
4169
|
+
// sink. The decision is evaluated over the EFFECTIVE environment, so a worker's
|
|
4170
|
+
// markers (set when this CLI is itself run by an unattended process) keep it
|
|
4171
|
+
// silent. It reads the local cache only, never the network, and any failure is
|
|
4172
|
+
// contained: it never changes command execution or the exit status.
|
|
4173
|
+
try {
|
|
4174
|
+
const decision = (overrides.upgradeNotice ?? ((env) => getNoticeDecision(env, VERSION)))(rawDeps.env);
|
|
4175
|
+
if (decision.show)
|
|
4176
|
+
errorLog(formatUpdateAdvice(decision.current, decision.latest));
|
|
4177
|
+
}
|
|
4178
|
+
catch {
|
|
4179
|
+
// Advisory only.
|
|
4180
|
+
}
|
|
4153
4181
|
// The parser validates `--agent`, so this resolves to a real spec; the guard
|
|
4154
4182
|
// is defensive only.
|
|
4155
4183
|
const agent = resolveAgentSpec(options.agentName);
|
|
@@ -4192,7 +4220,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
|
|
|
4192
4220
|
const branch = resolveBranchForTicket(key, options.branchOverrides);
|
|
4193
4221
|
const routedRow = routedByKey.get(key);
|
|
4194
4222
|
const modelAlias = routedRow?.modelAlias ?? null;
|
|
4195
|
-
for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds, isModelTier(options.injectedTier) ? options.injectedTier : undefined)) {
|
|
4223
|
+
for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds, isModelTier(options.injectedTier) ? options.injectedTier : undefined, options.verificationScope)) {
|
|
4196
4224
|
log(line);
|
|
4197
4225
|
}
|
|
4198
4226
|
log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent, { workflow: options.workflow })}`);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only test-command detection (BAPI-1460).
|
|
3
|
+
*
|
|
4
|
+
* Proposes the one runnable command a repository's CI workflow should run. It
|
|
5
|
+
* READS ONLY: the stored command, the learned `unit_testing_stack` prose, and a
|
|
6
|
+
* handful of repository files. It never runs the command, never runs a package
|
|
7
|
+
* manager, never imports Python, never evaluates a Make recipe, never prompts,
|
|
8
|
+
* and never writes. Its dependency surface has no subprocess runner, no prompt
|
|
9
|
+
* function, and no configuration writer, so it cannot.
|
|
10
|
+
*
|
|
11
|
+
* ## Order
|
|
12
|
+
*
|
|
13
|
+
* 1. A stored command (`GET /setup/testing-config`) wins, with no question.
|
|
14
|
+
* 2. Otherwise repository files are scanned, in a fixed order: JavaScript
|
|
15
|
+
* (`package.json` `scripts.test`, runner from the lockfile), Make (a real
|
|
16
|
+
* `test:` target), pytest (each listed marker), Go (`go.mod`), Rust
|
|
17
|
+
* (`Cargo.toml`).
|
|
18
|
+
* 3. When more than one stack is detected, the learned `unit_testing_stack`
|
|
19
|
+
* prose picks among them through a fixed vocabulary. It is a hint and is
|
|
20
|
+
* never interpreted as a command.
|
|
21
|
+
* 4. No candidate means exactly one required question, with no default.
|
|
22
|
+
*
|
|
23
|
+
* ## Caller contract (the setup command)
|
|
24
|
+
*
|
|
25
|
+
* - `question: "none"` — use `selected` as is.
|
|
26
|
+
* - `question: "accept_or_replace"` — ask once, offering `selected` as the
|
|
27
|
+
* default and the other candidates as alternatives.
|
|
28
|
+
* - `question: "required"` — ask once with no default. An empty answer is the
|
|
29
|
+
* named cancellation outcome {@link TEST_COMMAND_DETECTION_CANCELLED}; nothing
|
|
30
|
+
* is stored.
|
|
31
|
+
* - Every chosen or typed command is validated with `validateTestCommand` and
|
|
32
|
+
* saved through `PUT /setup/testing-config`. An invalid answer is explained
|
|
33
|
+
* with the validation message and is never stored.
|
|
34
|
+
*
|
|
35
|
+
* A detected command is an UNVERIFIED proposal: passing the syntax rules does
|
|
36
|
+
* not mean the command was run or that it works.
|
|
37
|
+
*/
|
|
38
|
+
import { readStoredTestCommand, readUnitTestingStack, } from "./testing-config-client.js";
|
|
39
|
+
/** The stacks detection recognizes, in detection order. */
|
|
40
|
+
export const DETECTED_STACKS = ["javascript", "make", "python", "go", "rust"];
|
|
41
|
+
/** Named outcome for an empty answer to the required question. */
|
|
42
|
+
export const TEST_COMMAND_DETECTION_CANCELLED = "test_command_not_provided";
|
|
43
|
+
class DetectionReadError extends Error {
|
|
44
|
+
reason;
|
|
45
|
+
file;
|
|
46
|
+
constructor(reason, file) {
|
|
47
|
+
super(reason);
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
this.file = file;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isMissing(err) {
|
|
53
|
+
const code = typeof err === "object" && err !== null ? err.code : undefined;
|
|
54
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
55
|
+
}
|
|
56
|
+
async function readOptional(deps, relPath) {
|
|
57
|
+
try {
|
|
58
|
+
return await deps.readFile(relPath);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (isMissing(err))
|
|
62
|
+
return null;
|
|
63
|
+
throw new DetectionReadError("repository_read_failed", relPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function detectJavaScript(deps) {
|
|
67
|
+
const raw = await readOptional(deps, "package.json");
|
|
68
|
+
if (raw === null)
|
|
69
|
+
return null;
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new DetectionReadError("malformed_repository_file", "package.json");
|
|
76
|
+
}
|
|
77
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
78
|
+
throw new DetectionReadError("malformed_repository_file", "package.json");
|
|
79
|
+
}
|
|
80
|
+
const scripts = parsed.scripts;
|
|
81
|
+
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts))
|
|
82
|
+
return null;
|
|
83
|
+
const test = scripts.test;
|
|
84
|
+
if (typeof test !== "string" || test.trim().length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
if ((await readOptional(deps, "pnpm-lock.yaml")) !== null) {
|
|
87
|
+
return { stack: "javascript", command: "pnpm test", provenance: "package.json + pnpm-lock.yaml", verified: false };
|
|
88
|
+
}
|
|
89
|
+
if ((await readOptional(deps, "yarn.lock")) !== null) {
|
|
90
|
+
return { stack: "javascript", command: "yarn test", provenance: "package.json + yarn.lock", verified: false };
|
|
91
|
+
}
|
|
92
|
+
return { stack: "javascript", command: "npm test", provenance: "package.json", verified: false };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a Makefile declares a real `test` target: a rule line (not a recipe,
|
|
96
|
+
* comment, or variable assignment such as `test := x`) whose target list
|
|
97
|
+
* includes `test`. `.PHONY: test` alone does not count. Recipes are never read.
|
|
98
|
+
*/
|
|
99
|
+
export function makefileHasTestTarget(text) {
|
|
100
|
+
for (const line of text.split(/\r\n|\n|\r/)) {
|
|
101
|
+
if (/^[\t #]/.test(line))
|
|
102
|
+
continue;
|
|
103
|
+
const rule = /^([^:=#]+?)\s*::?(?!=)/.exec(line);
|
|
104
|
+
if (!rule)
|
|
105
|
+
continue;
|
|
106
|
+
if (rule[1].trim().split(/\s+/).includes("test"))
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
async function detectMake(deps) {
|
|
112
|
+
// GNU make's own lookup order; only the first file present is read.
|
|
113
|
+
for (const name of ["GNUmakefile", "makefile", "Makefile"]) {
|
|
114
|
+
const raw = await readOptional(deps, name);
|
|
115
|
+
if (raw === null)
|
|
116
|
+
continue;
|
|
117
|
+
if (!makefileHasTestTarget(raw))
|
|
118
|
+
return null;
|
|
119
|
+
return { stack: "make", command: "make test", provenance: name, verified: false };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
async function detectPython(deps) {
|
|
124
|
+
if ((await readOptional(deps, "pytest.ini")) !== null) {
|
|
125
|
+
return { stack: "python", command: "pytest", provenance: "pytest.ini", verified: false };
|
|
126
|
+
}
|
|
127
|
+
const pyproject = await readOptional(deps, "pyproject.toml");
|
|
128
|
+
if (pyproject !== null && /^\s*\[tool\.pytest\.ini_options\]\s*$/m.test(pyproject)) {
|
|
129
|
+
return { stack: "python", command: "pytest", provenance: "pyproject.toml [tool.pytest.ini_options]", verified: false };
|
|
130
|
+
}
|
|
131
|
+
const setupCfg = await readOptional(deps, "setup.cfg");
|
|
132
|
+
if (setupCfg !== null && /^\s*\[tool:pytest\]\s*$/m.test(setupCfg)) {
|
|
133
|
+
return { stack: "python", command: "pytest", provenance: "setup.cfg [tool:pytest]", verified: false };
|
|
134
|
+
}
|
|
135
|
+
if ((await readOptional(deps, "tests/conftest.py")) !== null) {
|
|
136
|
+
return { stack: "python", command: "pytest", provenance: "tests/conftest.py", verified: false };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
async function detectGo(deps) {
|
|
141
|
+
if ((await readOptional(deps, "go.mod")) === null)
|
|
142
|
+
return null;
|
|
143
|
+
return { stack: "go", command: "go test ./...", provenance: "go.mod", verified: false };
|
|
144
|
+
}
|
|
145
|
+
async function detectRust(deps) {
|
|
146
|
+
if ((await readOptional(deps, "Cargo.toml")) === null)
|
|
147
|
+
return null;
|
|
148
|
+
return { stack: "rust", command: "cargo test", provenance: "Cargo.toml", verified: false };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Words in the learned `unit_testing_stack` prose that name each stack. Matched
|
|
152
|
+
* as whole words, case-insensitively. Order matters only for ties, which pick
|
|
153
|
+
* the earlier detected candidate.
|
|
154
|
+
*/
|
|
155
|
+
export const STACK_HINT_VOCABULARY = {
|
|
156
|
+
javascript: ["javascript", "typescript", "node", "nodejs", "node.js", "jest", "vitest", "mocha", "npm", "pnpm", "yarn"],
|
|
157
|
+
// Bare "make" and "go" are ordinary English words, so only unambiguous forms count.
|
|
158
|
+
make: ["makefile", "make test", "gnu make"],
|
|
159
|
+
python: ["python", "pytest", "unittest"],
|
|
160
|
+
go: ["golang", "go test", "go.mod", "go modules"],
|
|
161
|
+
rust: ["rust", "cargo"],
|
|
162
|
+
};
|
|
163
|
+
function escapeRegExp(text) {
|
|
164
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
165
|
+
}
|
|
166
|
+
/** The stacks the prose names, using {@link STACK_HINT_VOCABULARY} only. */
|
|
167
|
+
export function stacksNamedByHint(hint) {
|
|
168
|
+
const named = new Set();
|
|
169
|
+
if (!hint)
|
|
170
|
+
return named;
|
|
171
|
+
for (const stack of DETECTED_STACKS) {
|
|
172
|
+
for (const word of STACK_HINT_VOCABULARY[stack]) {
|
|
173
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9_.])${escapeRegExp(word)}(?=$|[^A-Za-z0-9_])`, "i");
|
|
174
|
+
if (pattern.test(hint)) {
|
|
175
|
+
named.add(stack);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return named;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Detect candidates from repository files, in the fixed stack order. Throws
|
|
184
|
+
* {@link DetectionReadError} for an unreadable or malformed file.
|
|
185
|
+
*/
|
|
186
|
+
async function detectCandidates(deps) {
|
|
187
|
+
const found = [];
|
|
188
|
+
for (const detect of [detectJavaScript, detectMake, detectPython, detectGo, detectRust]) {
|
|
189
|
+
const candidate = await detect(deps);
|
|
190
|
+
if (candidate)
|
|
191
|
+
found.push(candidate);
|
|
192
|
+
}
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
/** Propose a test command. Read-only; never throws for an expected failure. */
|
|
196
|
+
export async function detectTestCommand(options, deps) {
|
|
197
|
+
if (!options.skipStoredRead) {
|
|
198
|
+
const stored = await readStoredTestCommand(deps.config);
|
|
199
|
+
if (!stored.ok)
|
|
200
|
+
return { status: "failed", reason: stored.reason };
|
|
201
|
+
if (stored.testCommand !== null) {
|
|
202
|
+
return {
|
|
203
|
+
status: "stored",
|
|
204
|
+
selected: stored.testCommand,
|
|
205
|
+
provenance: "stored configuration",
|
|
206
|
+
question: "none",
|
|
207
|
+
candidates: [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let candidates;
|
|
212
|
+
try {
|
|
213
|
+
candidates = await detectCandidates(deps);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
if (err instanceof DetectionReadError)
|
|
217
|
+
return { status: "failed", reason: err.reason, file: err.file };
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
if (candidates.length === 0) {
|
|
221
|
+
return { status: "none_detected", selected: null, question: "required", candidates };
|
|
222
|
+
}
|
|
223
|
+
if (candidates.length === 1) {
|
|
224
|
+
return {
|
|
225
|
+
status: "detected",
|
|
226
|
+
selected: candidates[0].command,
|
|
227
|
+
provenance: candidates[0].provenance,
|
|
228
|
+
question: "none",
|
|
229
|
+
selectedByStackHint: false,
|
|
230
|
+
candidates,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// The learned stack is advisory: an unreadable hint falls back to the first
|
|
234
|
+
// candidate rather than stopping detection.
|
|
235
|
+
const stackHint = await readUnitTestingStack(deps.config);
|
|
236
|
+
const named = stacksNamedByHint(stackHint.ok ? stackHint.value : null);
|
|
237
|
+
const hinted = candidates.find((c) => named.has(c.stack));
|
|
238
|
+
const chosen = hinted ?? candidates[0];
|
|
239
|
+
return {
|
|
240
|
+
status: "detected",
|
|
241
|
+
selected: chosen.command,
|
|
242
|
+
provenance: chosen.provenance,
|
|
243
|
+
question: "accept_or_replace",
|
|
244
|
+
selectedByStackHint: hinted !== undefined,
|
|
245
|
+
candidates,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stored-test-command rules, mirrored from Python (BAPI-1460).
|
|
3
|
+
*
|
|
4
|
+
* The canonical rules live in `api/library/config/test_command_validation.py`;
|
|
5
|
+
* the server re-checks every write. This mirror exists so detection can explain
|
|
6
|
+
* a bad answer before a round trip, and so the CI renderer can refuse to put a
|
|
7
|
+
* bad command into a workflow file at all. Keep the bound, the reason codes, and
|
|
8
|
+
* the blank-character set identical in both languages.
|
|
9
|
+
*
|
|
10
|
+
* Validation establishes permitted SYNTAX only — one line, no NUL, no GitHub
|
|
11
|
+
* Actions expression opener, a bounded length. It never establishes that the
|
|
12
|
+
* command runs. Error messages are fixed text and never include the command.
|
|
13
|
+
*/
|
|
14
|
+
/** Upper bound on the command, counted in Unicode code points. */
|
|
15
|
+
export const TEST_COMMAND_MAX_CODE_POINTS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Characters that count as blank when deciding whether a command is empty.
|
|
18
|
+
*
|
|
19
|
+
* An explicit list rather than `String.prototype.trim()`, because Python's
|
|
20
|
+
* `str.strip()` and JavaScript's `trim()` recognize different characters.
|
|
21
|
+
*/
|
|
22
|
+
export const TEST_COMMAND_BLANK_CHARACTERS = new Set(Array.from("\t\n\u000b\u000c\r \u0085\u00a0\u1680" +
|
|
23
|
+
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a" +
|
|
24
|
+
"\u2028\u2029\u202f\u205f\u3000\ufeff"));
|
|
25
|
+
/** Stable, non-revealing reason codes. Identical to the Python reason codes. */
|
|
26
|
+
export const TEST_COMMAND_VALIDATION_REASONS = [
|
|
27
|
+
"not_a_string",
|
|
28
|
+
"empty",
|
|
29
|
+
"multi_line",
|
|
30
|
+
"nul_byte",
|
|
31
|
+
"expression_opener",
|
|
32
|
+
"too_long",
|
|
33
|
+
];
|
|
34
|
+
const MESSAGES = {
|
|
35
|
+
not_a_string: "test_command must be a string",
|
|
36
|
+
empty: "test_command must not be empty",
|
|
37
|
+
multi_line: "test_command must be a single line (no newline or carriage return)",
|
|
38
|
+
nul_byte: "test_command must not contain a NUL byte",
|
|
39
|
+
expression_opener: "test_command must not contain the GitHub Actions expression opener ${{",
|
|
40
|
+
too_long: `test_command must be at most ${TEST_COMMAND_MAX_CODE_POINTS} characters`,
|
|
41
|
+
};
|
|
42
|
+
/** A refused command. `reason` is a fixed code; the message never echoes input. */
|
|
43
|
+
export class TestCommandValidationError extends Error {
|
|
44
|
+
reason;
|
|
45
|
+
constructor(reason) {
|
|
46
|
+
super(MESSAGES[reason]);
|
|
47
|
+
this.name = "TestCommandValidationError";
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Return `command` unchanged when it is an acceptable test command; throw
|
|
53
|
+
* {@link TestCommandValidationError} otherwise. Checks run on the raw input.
|
|
54
|
+
*/
|
|
55
|
+
export function validateTestCommand(command) {
|
|
56
|
+
if (typeof command !== "string")
|
|
57
|
+
throw new TestCommandValidationError("not_a_string");
|
|
58
|
+
if (command.includes("\n") || command.includes("\r")) {
|
|
59
|
+
throw new TestCommandValidationError("multi_line");
|
|
60
|
+
}
|
|
61
|
+
if (command.includes("\u0000"))
|
|
62
|
+
throw new TestCommandValidationError("nul_byte");
|
|
63
|
+
if (command.includes("${{"))
|
|
64
|
+
throw new TestCommandValidationError("expression_opener");
|
|
65
|
+
const codePoints = Array.from(command);
|
|
66
|
+
if (codePoints.length > TEST_COMMAND_MAX_CODE_POINTS) {
|
|
67
|
+
throw new TestCommandValidationError("too_long");
|
|
68
|
+
}
|
|
69
|
+
if (codePoints.every((ch) => TEST_COMMAND_BLANK_CHARACTERS.has(ch))) {
|
|
70
|
+
throw new TestCommandValidationError("empty");
|
|
71
|
+
}
|
|
72
|
+
return command;
|
|
73
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed client for the stored test command (BAPI-1460).
|
|
3
|
+
*
|
|
4
|
+
* Wraps `GET` / `PUT /setup/testing-config` and the existing
|
|
5
|
+
* `GET /jira/config-field/unit_testing_stack` reader. It reuses the shared
|
|
6
|
+
* {@link BridgeApiUrls} builders: `/setup/...` is a root-level mount, so it is
|
|
7
|
+
* built from `buildApiUrl`, while the config-field reader sits under the
|
|
8
|
+
* `/jira` mount and uses `buildGetUrl`.
|
|
9
|
+
*
|
|
10
|
+
* Every non-success is a typed outcome with a closed `reason`. No upstream
|
|
11
|
+
* error body, header, URL, API key, or command is ever placed in an outcome:
|
|
12
|
+
* a transport error's message can carry the request URL, and a server 422 body
|
|
13
|
+
* is fixed text the caller does not need verbatim.
|
|
14
|
+
*/
|
|
15
|
+
import { TestCommandValidationError, validateTestCommand } from "./test-command-validation.js";
|
|
16
|
+
/** Root-level path of the testing-config handlers. */
|
|
17
|
+
export const TESTING_CONFIG_PATH = "/setup/testing-config";
|
|
18
|
+
/** The `/jira`-mounted config-field path for the learned unit-testing stack. */
|
|
19
|
+
export const UNIT_TESTING_STACK_FIELD_PATH = "/config-field/unit_testing_stack";
|
|
20
|
+
function failure(reason, status) {
|
|
21
|
+
return { ok: false, reason, status };
|
|
22
|
+
}
|
|
23
|
+
function statusFailure(status) {
|
|
24
|
+
if (status === 401)
|
|
25
|
+
return failure("unauthenticated", status);
|
|
26
|
+
if (status === 403)
|
|
27
|
+
return failure("forbidden", status);
|
|
28
|
+
if (status === 404)
|
|
29
|
+
return failure("not_found", status);
|
|
30
|
+
if (status === 409)
|
|
31
|
+
return failure("invalid_stored_command", status);
|
|
32
|
+
if (status === 422)
|
|
33
|
+
return failure("invalid_command", status);
|
|
34
|
+
return failure("backend_failure", status);
|
|
35
|
+
}
|
|
36
|
+
async function readJsonObject(resp) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(await resp.text());
|
|
39
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// fall through
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** Extract and re-validate a stored command; `undefined` means malformed. */
|
|
49
|
+
function storedCommand(body) {
|
|
50
|
+
const value = body.test_command;
|
|
51
|
+
if (value === null)
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof value !== "string")
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return validateTestCommand(value);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof TestCommandValidationError)
|
|
60
|
+
return undefined;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function testingConfigUrl(deps, params) {
|
|
65
|
+
const url = new URL(deps.urls.buildApiUrl(TESTING_CONFIG_PATH));
|
|
66
|
+
for (const [key, value] of Object.entries(params ?? {}))
|
|
67
|
+
url.searchParams.set(key, value);
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
70
|
+
/** `GET /setup/testing-config`. A stored value that fails validation is `invalid_stored_command`. */
|
|
71
|
+
export async function readStoredTestCommand(deps) {
|
|
72
|
+
let resp;
|
|
73
|
+
try {
|
|
74
|
+
resp = await deps.fetch(testingConfigUrl(deps, { repo_name: deps.repoName }), {
|
|
75
|
+
headers: await deps.getGetHeaders(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return failure("transport_failure", 0);
|
|
80
|
+
}
|
|
81
|
+
if (!resp.ok)
|
|
82
|
+
return statusFailure(resp.status);
|
|
83
|
+
const body = await readJsonObject(resp);
|
|
84
|
+
if (!body)
|
|
85
|
+
return failure("malformed_response", resp.status);
|
|
86
|
+
const command = storedCommand(body);
|
|
87
|
+
if (command === undefined)
|
|
88
|
+
return failure("invalid_stored_command", resp.status);
|
|
89
|
+
return { ok: true, testCommand: command };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `PUT /setup/testing-config`. The command is validated locally first, so a
|
|
93
|
+
* refused value never leaves the process; the server re-validates regardless.
|
|
94
|
+
*/
|
|
95
|
+
export async function writeStoredTestCommand(deps, testCommand) {
|
|
96
|
+
try {
|
|
97
|
+
validateTestCommand(testCommand);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof TestCommandValidationError)
|
|
101
|
+
return failure("invalid_command", 0);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
let resp;
|
|
105
|
+
try {
|
|
106
|
+
resp = await deps.fetch(testingConfigUrl(deps), {
|
|
107
|
+
method: "PUT",
|
|
108
|
+
headers: await deps.getPostHeaders(),
|
|
109
|
+
body: JSON.stringify({ repo_name: deps.repoName, test_command: testCommand }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return failure("transport_failure", 0);
|
|
114
|
+
}
|
|
115
|
+
if (!resp.ok)
|
|
116
|
+
return statusFailure(resp.status);
|
|
117
|
+
const body = await readJsonObject(resp);
|
|
118
|
+
if (!body || typeof body.unit_testing_instructions_updated !== "boolean") {
|
|
119
|
+
return failure("malformed_response", resp.status);
|
|
120
|
+
}
|
|
121
|
+
const command = storedCommand(body);
|
|
122
|
+
if (command === undefined)
|
|
123
|
+
return failure("invalid_stored_command", resp.status);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
testCommand: command,
|
|
127
|
+
unitTestingInstructionsUpdated: body.unit_testing_instructions_updated,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** `GET /jira/config-field/unit_testing_stack`. Prose; a hint, never a command. */
|
|
131
|
+
export async function readUnitTestingStack(deps) {
|
|
132
|
+
let resp;
|
|
133
|
+
try {
|
|
134
|
+
resp = await deps.fetch(deps.urls.buildGetUrl(UNIT_TESTING_STACK_FIELD_PATH, { repo_name: deps.repoName }), { headers: await deps.getGetHeaders() });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return failure("transport_failure", 0);
|
|
138
|
+
}
|
|
139
|
+
if (!resp.ok)
|
|
140
|
+
return statusFailure(resp.status);
|
|
141
|
+
const body = await readJsonObject(resp);
|
|
142
|
+
if (!body)
|
|
143
|
+
return failure("malformed_response", resp.status);
|
|
144
|
+
const value = body.value;
|
|
145
|
+
if (value === null || value === undefined)
|
|
146
|
+
return { ok: true, value: null };
|
|
147
|
+
if (typeof value !== "string")
|
|
148
|
+
return failure("malformed_response", resp.status);
|
|
149
|
+
return { ok: true, value };
|
|
150
|
+
}
|