terramend 0.2.0
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/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { type } from "arktype";
|
|
5
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
6
|
+
import { execute, tool, toolOk, toolSkip } from "#app/mcp/shared";
|
|
7
|
+
import { SUBPROCESS_TIMEOUT_MS } from "#app/mcp/terraform/types";
|
|
8
|
+
import { log } from "#app/utils/cli";
|
|
9
|
+
import { resolveEnv } from "#app/utils/secrets";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Policy-as-code gate (§3.5 OPA/Conftest). An optional, opt-in tool that runs
|
|
13
|
+
* the operator's own Rego policies against the Terraform plan JSON (or the HCL),
|
|
14
|
+
* so an org's policy-as-code can gate a remediation/generation the same way the
|
|
15
|
+
* deterministic scanners do — pairs with §21 (prevent recurrence). Terramend is
|
|
16
|
+
* an ORCHESTRATOR here, never a redistributor: it invokes the external
|
|
17
|
+
* `conftest` (OPA) binary as a separate process and consumes its normalized
|
|
18
|
+
* output. Degrades green — when conftest isn't installed or no policy dir
|
|
19
|
+
* exists, it returns `ok: false` with a `code` and never fails the run.
|
|
20
|
+
*
|
|
21
|
+
* The parsing is pure + unit-tested; the tool just shells out and parses.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface PolicyFailure {
|
|
25
|
+
/** the policy rule message (the human-readable violation). */
|
|
26
|
+
msg: string;
|
|
27
|
+
/** the file conftest evaluated (repo-relative when conftest reported it). */
|
|
28
|
+
file: string;
|
|
29
|
+
/** "failure" (a deny) or "warning" (a warn). */
|
|
30
|
+
level: "failure" | "warning";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PolicyResult {
|
|
34
|
+
/** true when there were zero failures (warnings don't fail the gate). */
|
|
35
|
+
passed: boolean;
|
|
36
|
+
failures: PolicyFailure[];
|
|
37
|
+
warnings: PolicyFailure[];
|
|
38
|
+
/** total tests conftest ran (across files), for a confidence signal. */
|
|
39
|
+
tested: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ConftestResultEntry {
|
|
43
|
+
filename?: string;
|
|
44
|
+
namespace?: string;
|
|
45
|
+
successes?: number;
|
|
46
|
+
failures?: { msg?: string }[];
|
|
47
|
+
warnings?: { msg?: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse `conftest test --output json` output into a normalized PolicyResult.
|
|
52
|
+
* Conftest emits an array of per-file result objects, each with `failures` /
|
|
53
|
+
* `warnings` / `successes`. Pure; tolerant of malformed/empty input (returns a
|
|
54
|
+
* clean pass). A non-empty `failures` anywhere means the gate did NOT pass;
|
|
55
|
+
* warnings are surfaced but don't fail it.
|
|
56
|
+
*/
|
|
57
|
+
export function parseConftestOutput(stdout: string): PolicyResult {
|
|
58
|
+
let parsed: ConftestResultEntry[];
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(stdout || "[]");
|
|
61
|
+
parsed = Array.isArray(raw) ? raw : [];
|
|
62
|
+
} catch {
|
|
63
|
+
return { passed: true, failures: [], warnings: [], tested: 0 };
|
|
64
|
+
}
|
|
65
|
+
const failures: PolicyFailure[] = [];
|
|
66
|
+
const warnings: PolicyFailure[] = [];
|
|
67
|
+
let tested = 0;
|
|
68
|
+
for (const entry of parsed) {
|
|
69
|
+
const file = entry.filename || "(plan)";
|
|
70
|
+
const fails = entry.failures ?? [];
|
|
71
|
+
const warns = entry.warnings ?? [];
|
|
72
|
+
tested += (entry.successes ?? 0) + fails.length + warns.length;
|
|
73
|
+
for (const f of fails)
|
|
74
|
+
failures.push({ msg: f.msg || "policy violation", file, level: "failure" });
|
|
75
|
+
for (const w of warns)
|
|
76
|
+
warnings.push({ msg: w.msg || "policy warning", file, level: "warning" });
|
|
77
|
+
}
|
|
78
|
+
return { passed: failures.length === 0, failures, warnings, tested };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** the default dirs a repo keeps Rego policies in (checked in order). */
|
|
82
|
+
const DEFAULT_POLICY_DIRS = ["policy", "policies", ".conftest"];
|
|
83
|
+
|
|
84
|
+
/** resolve the policy dir to use: the explicit arg, else the first default dir
|
|
85
|
+
* that exists. Returns null when none is found. */
|
|
86
|
+
function resolvePolicyDir(cwd: string, explicit: string | undefined): string | null {
|
|
87
|
+
if (explicit) {
|
|
88
|
+
const abs = isAbsolute(explicit) ? explicit : join(cwd, explicit);
|
|
89
|
+
return existsSync(abs) ? abs : null;
|
|
90
|
+
}
|
|
91
|
+
for (const d of DEFAULT_POLICY_DIRS) {
|
|
92
|
+
const abs = join(cwd, d);
|
|
93
|
+
if (existsSync(abs)) return abs;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const PolicyCheckParams = type({
|
|
99
|
+
"target?": type.string.describe(
|
|
100
|
+
"the file conftest evaluates — a terraform plan JSON (preferred; produce it with `terraform show -json plan.tfplan`) or an HCL file. Default: ./plan.json, then ./tfplan.json, in the workspace.",
|
|
101
|
+
),
|
|
102
|
+
"policy_dir?": type.string.describe(
|
|
103
|
+
"dir holding the Rego policies. Default: the first of ./policy, ./policies, ./.conftest that exists.",
|
|
104
|
+
),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export function PolicyCheckTool(ctx: ToolContext) {
|
|
108
|
+
return tool({
|
|
109
|
+
name: "policy_check",
|
|
110
|
+
description:
|
|
111
|
+
"Run the repo's own policy-as-code (Rego) against the Terraform plan/HCL via the external `conftest` " +
|
|
112
|
+
"(OPA) binary, so org policy can gate a fix (§3.5). Opt-in and degrades green — returns `ok: false` " +
|
|
113
|
+
"(never fails the run) when conftest isn't installed or no policy dir is present. On success returns " +
|
|
114
|
+
"`passed` (false when any policy DENY fired), the `failures` and `warnings` (each with msg + file), and " +
|
|
115
|
+
"the count `tested`. When `passed` is false, treat it like a failed validate: fix the violation or " +
|
|
116
|
+
"label the PR needs-human — do NOT push past a policy denial.",
|
|
117
|
+
parameters: PolicyCheckParams,
|
|
118
|
+
execute: execute(async ({ target, policy_dir }) => {
|
|
119
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
120
|
+
const policyDir = resolvePolicyDir(cwd, policy_dir);
|
|
121
|
+
if (!policyDir) {
|
|
122
|
+
return toolSkip(
|
|
123
|
+
"no_policy_dir",
|
|
124
|
+
"no Rego policy dir found (looked for ./policy, ./policies, ./.conftest, or the policy_dir arg) — policy_check is opt-in",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// resolve the target file: explicit arg, else a conventional plan JSON.
|
|
128
|
+
let targetFile: string | null = null;
|
|
129
|
+
if (target) {
|
|
130
|
+
const abs = isAbsolute(target) ? target : join(cwd, target);
|
|
131
|
+
targetFile = existsSync(abs) ? abs : null;
|
|
132
|
+
if (!targetFile) {
|
|
133
|
+
return toolSkip(
|
|
134
|
+
"target_not_found",
|
|
135
|
+
`policy target '${target}' not found in the workspace`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
for (const candidate of ["plan.json", "tfplan.json"]) {
|
|
140
|
+
const abs = join(cwd, candidate);
|
|
141
|
+
if (existsSync(abs)) {
|
|
142
|
+
targetFile = abs;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!targetFile) {
|
|
147
|
+
return toolSkip(
|
|
148
|
+
"no_target",
|
|
149
|
+
"no plan JSON to evaluate — produce one with `terraform show -json plan.tfplan > plan.json`, or pass `target`",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const r = spawnSync("conftest", ["test", "--output", "json", "-p", policyDir, targetFile], {
|
|
154
|
+
cwd,
|
|
155
|
+
encoding: "utf-8",
|
|
156
|
+
env: resolveEnv("restricted") as NodeJS.ProcessEnv,
|
|
157
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
158
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
159
|
+
// bound a hung conftest (e.g. a policy that pulls a remote bundle).
|
|
160
|
+
timeout: SUBPROCESS_TIMEOUT_MS,
|
|
161
|
+
});
|
|
162
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
163
|
+
return toolSkip(
|
|
164
|
+
"conftest_not_installed",
|
|
165
|
+
"conftest (OPA) is not installed — policy_check is opt-in and best-effort; install conftest to enable it",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
// conftest exits non-zero when a policy DENY fires — that's a normal
|
|
169
|
+
// denial (JSON on stdout), not a tool error. But it ALSO exits non-zero
|
|
170
|
+
// when it couldn't evaluate at all (bad policy, parse error, no tests) —
|
|
171
|
+
// there it emits nothing parseable. Distinguish: a non-zero exit that
|
|
172
|
+
// yielded no evaluated tests AND no failures/warnings is a real error, so
|
|
173
|
+
// report it as a skip rather than a false PASS. A genuine denial has
|
|
174
|
+
// failures > 0 and flows through to `passed: false`.
|
|
175
|
+
const result = parseConftestOutput(r.stdout);
|
|
176
|
+
const evaluated =
|
|
177
|
+
result.tested > 0 || result.failures.length > 0 || result.warnings.length > 0;
|
|
178
|
+
if (r.status !== 0 && !evaluated) {
|
|
179
|
+
return toolSkip(
|
|
180
|
+
"conftest_failed",
|
|
181
|
+
`conftest could not evaluate the target: ${r.stderr.trim().slice(0, 300) || "unknown error"}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
log.info(
|
|
185
|
+
`» policy_check: ${result.passed ? "PASS" : "FAIL"} — ${result.failures.length} failure(s), ${result.warnings.length} warning(s) over ${result.tested} test(s)`,
|
|
186
|
+
);
|
|
187
|
+
return toolOk({
|
|
188
|
+
passed: result.passed,
|
|
189
|
+
policy_dir: policyDir,
|
|
190
|
+
target: targetFile,
|
|
191
|
+
failure_count: result.failures.length,
|
|
192
|
+
warning_count: result.warnings.length,
|
|
193
|
+
failures: result.failures,
|
|
194
|
+
warnings: result.warnings,
|
|
195
|
+
tested: result.tested,
|
|
196
|
+
});
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { assertUnderPrCap, recordRemediationPrOpened } from "#app/mcp/guardrails";
|
|
3
|
+
import {
|
|
4
|
+
CreatePullRequestTool,
|
|
5
|
+
pickBaseBranch,
|
|
6
|
+
resolveBaseBranch,
|
|
7
|
+
UpdatePullRequestBodyTool,
|
|
8
|
+
} from "#app/mcp/pr";
|
|
9
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
10
|
+
import type { ToolState } from "#app/toolState";
|
|
11
|
+
import { TERRAMEND_DIVIDER } from "#app/utils/buildTerramendFooter";
|
|
12
|
+
import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
|
|
13
|
+
import { $ } from "#app/utils/shell";
|
|
14
|
+
|
|
15
|
+
vi.mock("#app/utils/shell", () => ({
|
|
16
|
+
$: vi.fn(() => "feature-branch"),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("#app/mcp/guardrails", () => ({
|
|
20
|
+
assertUnderPrCap: vi.fn(),
|
|
21
|
+
recordRemediationPrOpened: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("#app/utils/patchWorkflowRunFields", () => ({
|
|
25
|
+
patchWorkflowRunFields: vi.fn(async () => undefined),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const shellMock = vi.mocked($);
|
|
29
|
+
|
|
30
|
+
describe("pickBaseBranch (deterministic base: declared → default → main → master → main)", () => {
|
|
31
|
+
it("an explicit declaration always wins", () => {
|
|
32
|
+
expect(
|
|
33
|
+
pickBaseBranch({
|
|
34
|
+
declared: "release",
|
|
35
|
+
defaultBranch: "main",
|
|
36
|
+
mainExists: true,
|
|
37
|
+
masterExists: true,
|
|
38
|
+
}),
|
|
39
|
+
).toBe("release");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses the repository default branch when nothing is declared", () => {
|
|
43
|
+
expect(pickBaseBranch({ defaultBranch: "master", mainExists: true, masterExists: true })).toBe(
|
|
44
|
+
"master",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("prefers main when neither a declaration nor a default branch is known", () => {
|
|
49
|
+
expect(pickBaseBranch({ mainExists: true, masterExists: true })).toBe("main");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to master when main does not exist", () => {
|
|
53
|
+
expect(pickBaseBranch({ mainExists: false, masterExists: true })).toBe("master");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ultimately defaults to main", () => {
|
|
57
|
+
expect(pickBaseBranch({ mainExists: false, masterExists: false })).toBe("main");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("resolveBaseBranch (ctx wiring; git not probed when a declaration or default exists)", () => {
|
|
62
|
+
const ctx = (over: { baseBranch?: string; defaultBranch?: string }): ToolContext =>
|
|
63
|
+
({
|
|
64
|
+
payload: { baseBranch: over.baseBranch },
|
|
65
|
+
repo: { data: { default_branch: over.defaultBranch } },
|
|
66
|
+
}) as unknown as ToolContext;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("prefers the explicit base_branch override", () => {
|
|
73
|
+
expect(resolveBaseBranch(ctx({ baseBranch: "release", defaultBranch: "main" }))).toBe(
|
|
74
|
+
"release",
|
|
75
|
+
);
|
|
76
|
+
expect(shellMock).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("trims the override", () => {
|
|
80
|
+
expect(resolveBaseBranch(ctx({ baseBranch: " release ", defaultBranch: "main" }))).toBe(
|
|
81
|
+
"release",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses the repository default branch when no override is set", () => {
|
|
86
|
+
expect(resolveBaseBranch(ctx({ defaultBranch: "master" }))).toBe("master");
|
|
87
|
+
expect(shellMock).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("probes git only in the last-resort case and finds main on the remote ref", () => {
|
|
91
|
+
shellMock.mockImplementation((_cmd, args) => {
|
|
92
|
+
const argList = args as string[];
|
|
93
|
+
if (argList.includes("refs/remotes/origin/main")) return "ok";
|
|
94
|
+
throw new Error("ref absent");
|
|
95
|
+
});
|
|
96
|
+
expect(resolveBaseBranch(ctx({}))).toBe("main");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("falls back to a local master ref when no main exists anywhere", () => {
|
|
100
|
+
shellMock.mockImplementation((_cmd, args) => {
|
|
101
|
+
const argList = args as string[];
|
|
102
|
+
if (argList.includes("refs/heads/master")) return "ok";
|
|
103
|
+
throw new Error("ref absent");
|
|
104
|
+
});
|
|
105
|
+
expect(resolveBaseBranch(ctx({}))).toBe("master");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("defaults to main when neither branch resolves", () => {
|
|
109
|
+
shellMock.mockImplementation(() => {
|
|
110
|
+
throw new Error("ref absent");
|
|
111
|
+
});
|
|
112
|
+
expect(resolveBaseBranch(ctx({}))).toBe("main");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── the octokit-backed tools ─────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
type ToolResultShape = { content: [{ type: "text"; text: string }]; isError?: boolean };
|
|
119
|
+
|
|
120
|
+
async function runTool(t: { execute?: unknown }, params: unknown): Promise<ToolResultShape> {
|
|
121
|
+
const exec = t.execute as (p: unknown, c: unknown) => Promise<ToolResultShape>;
|
|
122
|
+
return exec(params, {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function makeOctokit() {
|
|
126
|
+
return {
|
|
127
|
+
rest: {
|
|
128
|
+
pulls: {
|
|
129
|
+
update: vi.fn(async (_p: unknown) => ({
|
|
130
|
+
data: { number: 12, html_url: "https://gh/pr/12" },
|
|
131
|
+
})),
|
|
132
|
+
create: vi.fn(async (_p: unknown) => ({
|
|
133
|
+
data: {
|
|
134
|
+
id: 7001,
|
|
135
|
+
number: 12,
|
|
136
|
+
node_id: "PR_NODE",
|
|
137
|
+
html_url: "https://gh/pr/12",
|
|
138
|
+
title: "fix: encrypt bucket",
|
|
139
|
+
head: { ref: "feature-branch" },
|
|
140
|
+
base: { ref: "main" },
|
|
141
|
+
},
|
|
142
|
+
})),
|
|
143
|
+
requestReviewers: vi.fn(async (_p: unknown) => ({})),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function makeToolCtx(overrides?: {
|
|
150
|
+
toolState?: Partial<ToolState>;
|
|
151
|
+
triggerer?: string;
|
|
152
|
+
baseBranch?: string;
|
|
153
|
+
push?: "disabled" | "restricted" | "enabled";
|
|
154
|
+
eventIssueNumber?: number;
|
|
155
|
+
}): { ctx: ToolContext; octokit: ReturnType<typeof makeOctokit>; toolState: ToolState } {
|
|
156
|
+
const octokit = makeOctokit();
|
|
157
|
+
const toolState = { createdTargets: new Set<number>(), ...overrides?.toolState } as ToolState;
|
|
158
|
+
const event =
|
|
159
|
+
overrides?.eventIssueNumber === undefined
|
|
160
|
+
? { trigger: "unknown" }
|
|
161
|
+
: { trigger: "pull_request_opened", is_pr: true, issue_number: overrides.eventIssueNumber };
|
|
162
|
+
const ctx = {
|
|
163
|
+
octokit,
|
|
164
|
+
repo: { owner: "octo", name: "repo", data: { default_branch: "main" } },
|
|
165
|
+
payload: {
|
|
166
|
+
triggerer: overrides?.triggerer,
|
|
167
|
+
baseBranch: overrides?.baseBranch,
|
|
168
|
+
push: overrides?.push,
|
|
169
|
+
event,
|
|
170
|
+
},
|
|
171
|
+
toolState,
|
|
172
|
+
} as unknown as ToolContext;
|
|
173
|
+
return { ctx, octokit, toolState };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("UpdatePullRequestBodyTool", () => {
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
vi.clearAllMocks();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("updates the PR body with a fresh footer and marks wasUpdated", async () => {
|
|
182
|
+
const { ctx, octokit, toolState } = makeToolCtx();
|
|
183
|
+
const result = await runTool(UpdatePullRequestBodyTool(ctx), {
|
|
184
|
+
pull_number: 12,
|
|
185
|
+
body: "new description",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.isError).toBeUndefined();
|
|
189
|
+
expect(result.content[0].text).toContain("success: true");
|
|
190
|
+
expect(octokit.rest.pulls.update).toHaveBeenCalledWith(
|
|
191
|
+
expect.objectContaining({ owner: "octo", repo: "repo", pull_number: 12 }),
|
|
192
|
+
);
|
|
193
|
+
const sent = octokit.rest.pulls.update.mock.calls[0]?.[0] as { body: string };
|
|
194
|
+
expect(sent.body).toContain("new description");
|
|
195
|
+
expect(sent.body).toContain(TERRAMEND_DIVIDER);
|
|
196
|
+
expect(toolState.wasUpdated).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("strips a stale footer before appending a fresh one", async () => {
|
|
200
|
+
const { ctx, octokit } = makeToolCtx();
|
|
201
|
+
await runTool(UpdatePullRequestBodyTool(ctx), {
|
|
202
|
+
pull_number: 12,
|
|
203
|
+
body: `body text\n\n${TERRAMEND_DIVIDER}\n<sup>stale</sup>`,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const sent = octokit.rest.pulls.update.mock.calls[0]?.[0] as { body: string };
|
|
207
|
+
expect(sent.body.startsWith("body text")).toBe(true);
|
|
208
|
+
expect(sent.body).not.toContain("stale");
|
|
209
|
+
expect(sent.body.indexOf(TERRAMEND_DIVIDER)).toBe(sent.body.lastIndexOf(TERRAMEND_DIVIDER));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("repairs a double-escaped body (no real newlines) before sending", async () => {
|
|
213
|
+
const { ctx, octokit } = makeToolCtx();
|
|
214
|
+
await runTool(UpdatePullRequestBodyTool(ctx), { pull_number: 12, body: "line1\\nline2" });
|
|
215
|
+
|
|
216
|
+
const sent = octokit.rest.pulls.update.mock.calls[0]?.[0] as { body: string };
|
|
217
|
+
expect(sent.body).toContain("line1\nline2");
|
|
218
|
+
expect(sent.body).not.toContain("line1\\nline2");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("propagates API failures as tool errors", async () => {
|
|
222
|
+
const { ctx, octokit } = makeToolCtx();
|
|
223
|
+
octokit.rest.pulls.update.mockRejectedValueOnce(new Error("API down"));
|
|
224
|
+
const result = await runTool(UpdatePullRequestBodyTool(ctx), { pull_number: 12, body: "b" });
|
|
225
|
+
|
|
226
|
+
expect(result.isError).toBe(true);
|
|
227
|
+
expect(result.content[0].text).toContain("API down");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("CreatePullRequestTool", () => {
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
vi.clearAllMocks();
|
|
234
|
+
shellMock.mockReturnValue("feature-branch");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("creates the PR from the current branch against the resolved base", async () => {
|
|
238
|
+
const { ctx, octokit } = makeToolCtx();
|
|
239
|
+
const result = await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
240
|
+
|
|
241
|
+
expect(result.isError).toBeUndefined();
|
|
242
|
+
expect(assertUnderPrCap).toHaveBeenCalledWith(ctx);
|
|
243
|
+
expect(octokit.rest.pulls.create).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
owner: "octo",
|
|
246
|
+
repo: "repo",
|
|
247
|
+
head: "feature-branch",
|
|
248
|
+
base: "main",
|
|
249
|
+
draft: false,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { prNodeId: "PR_NODE" });
|
|
253
|
+
expect(recordRemediationPrOpened).toHaveBeenCalledWith(ctx);
|
|
254
|
+
const text = result.content[0].text;
|
|
255
|
+
expect(text).toContain("success: true");
|
|
256
|
+
expect(text).toContain("number: 12");
|
|
257
|
+
expect(text).toContain("head: feature-branch");
|
|
258
|
+
expect(text).toContain("base: main");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("honors an explicit base and the draft flag", async () => {
|
|
262
|
+
const { ctx, octokit } = makeToolCtx();
|
|
263
|
+
await runTool(CreatePullRequestTool(ctx), {
|
|
264
|
+
title: "t",
|
|
265
|
+
body: "b",
|
|
266
|
+
base: "release",
|
|
267
|
+
draft: true,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(octokit.rest.pulls.create).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({ base: "release", draft: true }),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("requests a review from the triggering user (best-effort)", async () => {
|
|
276
|
+
const { ctx, octokit } = makeToolCtx({ triggerer: "octocat" });
|
|
277
|
+
await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
278
|
+
|
|
279
|
+
expect(octokit.rest.pulls.requestReviewers).toHaveBeenCalledWith(
|
|
280
|
+
expect.objectContaining({ pull_number: 12, reviewers: ["octocat"] }),
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("swallows a failed review request and still succeeds", async () => {
|
|
285
|
+
const { ctx, octokit } = makeToolCtx({ triggerer: "octocat" });
|
|
286
|
+
octokit.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("cannot review own PR"));
|
|
287
|
+
const result = await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
288
|
+
|
|
289
|
+
expect(result.isError).toBeUndefined();
|
|
290
|
+
expect(result.content[0].text).toContain("success: true");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("skips the reviewer request without a triggerer", async () => {
|
|
294
|
+
const { ctx, octokit } = makeToolCtx();
|
|
295
|
+
await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
296
|
+
|
|
297
|
+
expect(octokit.rest.pulls.requestReviewers).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("skips the workflow-run patch when the PR has no node_id", async () => {
|
|
301
|
+
const { ctx, octokit } = makeToolCtx();
|
|
302
|
+
octokit.rest.pulls.create.mockResolvedValueOnce({
|
|
303
|
+
data: {
|
|
304
|
+
id: 7001,
|
|
305
|
+
number: 12,
|
|
306
|
+
node_id: "",
|
|
307
|
+
html_url: "https://gh/pr/12",
|
|
308
|
+
title: "t",
|
|
309
|
+
head: { ref: "feature-branch" },
|
|
310
|
+
base: { ref: "main" },
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
const result = await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
314
|
+
|
|
315
|
+
expect(result.isError).toBeUndefined();
|
|
316
|
+
expect(patchWorkflowRunFields).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("stops at the remediation PR cap before touching git or GitHub", async () => {
|
|
320
|
+
vi.mocked(assertUnderPrCap).mockImplementationOnce(() => {
|
|
321
|
+
throw new Error("max_prs reached (3)");
|
|
322
|
+
});
|
|
323
|
+
const { ctx, octokit } = makeToolCtx();
|
|
324
|
+
const result = await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
325
|
+
|
|
326
|
+
expect(result.isError).toBe(true);
|
|
327
|
+
expect(result.content[0].text).toContain("max_prs reached");
|
|
328
|
+
expect(octokit.rest.pulls.create).not.toHaveBeenCalled();
|
|
329
|
+
expect(recordRemediationPrOpened).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("is blocked under push: disabled (read-only access)", async () => {
|
|
333
|
+
const { ctx, octokit } = makeToolCtx({ push: "disabled" });
|
|
334
|
+
const result = await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
335
|
+
|
|
336
|
+
expect(result.isError).toBe(true);
|
|
337
|
+
expect(result.content[0].text).toMatch(/read-only access/);
|
|
338
|
+
expect(assertUnderPrCap).not.toHaveBeenCalled();
|
|
339
|
+
expect(octokit.rest.pulls.create).not.toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("REST write-tool scope binding", () => {
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
vi.clearAllMocks();
|
|
346
|
+
shellMock.mockReturnValue("feature-branch");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("update_pull_request_body refuses a PR outside the run's scope", async () => {
|
|
350
|
+
const { ctx, octokit } = makeToolCtx({ eventIssueNumber: 5 });
|
|
351
|
+
const result = await runTool(UpdatePullRequestBodyTool(ctx), {
|
|
352
|
+
pull_number: 6,
|
|
353
|
+
body: "b",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect(result.isError).toBe(true);
|
|
357
|
+
expect(result.content[0].text).toMatch(/scoped to #5; refusing to update the body of #6/);
|
|
358
|
+
expect(octokit.rest.pulls.update).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("update_pull_request_body allows the run's scoped PR", async () => {
|
|
362
|
+
const { ctx, octokit } = makeToolCtx({ eventIssueNumber: 12 });
|
|
363
|
+
const result = await runTool(UpdatePullRequestBodyTool(ctx), {
|
|
364
|
+
pull_number: 12,
|
|
365
|
+
body: "b",
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(result.isError).toBeUndefined();
|
|
369
|
+
expect(octokit.rest.pulls.update).toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("update_pull_request_body allows a PR the run just created", async () => {
|
|
373
|
+
const { ctx, octokit } = makeToolCtx({ eventIssueNumber: 5 });
|
|
374
|
+
// the run opens PR #12 (create records it as owned)…
|
|
375
|
+
await runTool(CreatePullRequestTool(ctx), { title: "t", body: "b" });
|
|
376
|
+
// …so editing #12's body is now in scope even though the trigger was #5.
|
|
377
|
+
const result = await runTool(UpdatePullRequestBodyTool(ctx), {
|
|
378
|
+
pull_number: 12,
|
|
379
|
+
body: "updated",
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(result.isError).toBeUndefined();
|
|
383
|
+
expect(octokit.rest.pulls.update).toHaveBeenCalledWith(
|
|
384
|
+
expect.objectContaining({ pull_number: 12 }),
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|