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,237 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
3
|
+
import {
|
|
4
|
+
assessStaleFix,
|
|
5
|
+
ClosePullRequestTool,
|
|
6
|
+
groupIdFromBranch,
|
|
7
|
+
isBotActor,
|
|
8
|
+
isRemediationBranch,
|
|
9
|
+
ListRemediationPrsTool,
|
|
10
|
+
} from "#app/mcp/staleFix";
|
|
11
|
+
|
|
12
|
+
describe("isRemediationBranch / groupIdFromBranch", () => {
|
|
13
|
+
it("recognises remediate and generate branches, rejects others", () => {
|
|
14
|
+
expect(isRemediationBranch("remediate/abc123")).toBe(true);
|
|
15
|
+
expect(isRemediationBranch("remediate/batch-deadbeef")).toBe(true);
|
|
16
|
+
expect(isRemediationBranch("terramend/generate-s3-site")).toBe(true);
|
|
17
|
+
expect(isRemediationBranch("feature/foo")).toBe(false);
|
|
18
|
+
expect(isRemediationBranch("main")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("extracts the group id from a remediate branch only", () => {
|
|
22
|
+
expect(groupIdFromBranch("remediate/abc123")).toBe("abc123");
|
|
23
|
+
expect(groupIdFromBranch("remediate/batch-deadbeef")).toBe("batch-deadbeef");
|
|
24
|
+
expect(groupIdFromBranch("terramend/generate-s3")).toBeNull();
|
|
25
|
+
expect(groupIdFromBranch("main")).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("isBotActor", () => {
|
|
30
|
+
it("treats terramend logins and any [bot] as bot; a null login as non-human", () => {
|
|
31
|
+
expect(isBotActor("terramend[bot]")).toBe(true);
|
|
32
|
+
expect(isBotActor("terramenddev")).toBe(true);
|
|
33
|
+
expect(isBotActor("dependabot[bot]")).toBe(true);
|
|
34
|
+
expect(isBotActor(null)).toBe(true); // unmapped author → not provably human
|
|
35
|
+
expect(isBotActor(undefined)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("treats a real user login as non-bot", () => {
|
|
39
|
+
expect(isBotActor("alice")).toBe(false);
|
|
40
|
+
expect(isBotActor("octocat")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("assessStaleFix", () => {
|
|
45
|
+
it("escalates a human-touched branch regardless of base movement", () => {
|
|
46
|
+
const a = assessStaleFix({ baseBehindBy: 5, hasNonBotCommits: true });
|
|
47
|
+
expect(a.status).toBe("human_touched");
|
|
48
|
+
expect(a.action).toBe("escalate");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("skips a PR whose base has not advanced", () => {
|
|
52
|
+
const a = assessStaleFix({ baseBehindBy: 0, hasNonBotCommits: false });
|
|
53
|
+
expect(a.status).toBe("current");
|
|
54
|
+
expect(a.action).toBe("skip");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("flags a stale PR for refresh when the base advanced", () => {
|
|
58
|
+
const a = assessStaleFix({ baseBehindBy: 3, hasNonBotCommits: false });
|
|
59
|
+
expect(a.status).toBe("stale");
|
|
60
|
+
expect(a.action).toBe("refresh");
|
|
61
|
+
expect(a.reason).toContain("3 commit");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- tool tests ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function execText(t: ReturnType<typeof ListRemediationPrsTool>) {
|
|
68
|
+
return t.execute as (
|
|
69
|
+
p: unknown,
|
|
70
|
+
c: unknown,
|
|
71
|
+
) => Promise<{ content: [{ type: "text"; text: string }]; isError?: boolean }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("ListRemediationPrsTool", () => {
|
|
75
|
+
function makeCtx(
|
|
76
|
+
openPrs: unknown[],
|
|
77
|
+
compares: Record<string, { ahead_by: number; behind_by: number; commits: unknown[] }>,
|
|
78
|
+
): ToolContext {
|
|
79
|
+
return {
|
|
80
|
+
repo: { owner: "o", name: "r" },
|
|
81
|
+
payload: {},
|
|
82
|
+
octokit: {
|
|
83
|
+
// paginate is mocked to return the list directly; the first arg (the
|
|
84
|
+
// `pulls.list` route) just needs to exist to be referenced.
|
|
85
|
+
paginate: vi.fn(async () => openPrs),
|
|
86
|
+
rest: {
|
|
87
|
+
pulls: { list: vi.fn() },
|
|
88
|
+
repos: {
|
|
89
|
+
compareCommits: vi.fn(async ({ head }: { head: string }) => ({
|
|
90
|
+
data: compares[head] ?? { ahead_by: 1, behind_by: 0, commits: [] },
|
|
91
|
+
})),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
} as unknown as ToolContext;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it("filters to remediation branches and assesses each", async () => {
|
|
99
|
+
const openPrs = [
|
|
100
|
+
{
|
|
101
|
+
number: 1,
|
|
102
|
+
html_url: "u1",
|
|
103
|
+
title: "fix a",
|
|
104
|
+
head: { ref: "remediate/aaa" },
|
|
105
|
+
base: { ref: "main" },
|
|
106
|
+
labels: [{ name: "terramend" }],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
number: 2,
|
|
110
|
+
html_url: "u2",
|
|
111
|
+
title: "human PR",
|
|
112
|
+
head: { ref: "feature/x" },
|
|
113
|
+
base: { ref: "main" },
|
|
114
|
+
labels: [],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
number: 3,
|
|
118
|
+
html_url: "u3",
|
|
119
|
+
title: "fix b (stale)",
|
|
120
|
+
head: { ref: "remediate/bbb" },
|
|
121
|
+
base: { ref: "main" },
|
|
122
|
+
labels: [],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
const compares = {
|
|
126
|
+
"remediate/aaa": {
|
|
127
|
+
ahead_by: 1,
|
|
128
|
+
behind_by: 0,
|
|
129
|
+
commits: [{ author: { login: "terramend[bot]" } }],
|
|
130
|
+
},
|
|
131
|
+
"remediate/bbb": {
|
|
132
|
+
ahead_by: 1,
|
|
133
|
+
behind_by: 4,
|
|
134
|
+
commits: [{ author: { login: "terramend[bot]" } }],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const ctx = makeCtx(openPrs, compares);
|
|
138
|
+
const text = await execText(ListRemediationPrsTool(ctx))({}, {});
|
|
139
|
+
const out = text.content[0].text;
|
|
140
|
+
expect(out).toContain("ok: true");
|
|
141
|
+
// only the two remediation PRs appear (feature/x filtered out)
|
|
142
|
+
expect(out).toContain("remediate/aaa");
|
|
143
|
+
expect(out).toContain("remediate/bbb");
|
|
144
|
+
expect(out).not.toContain("feature/x");
|
|
145
|
+
// #1 current (behind 0) → skip; #3 stale (behind 4) → refresh
|
|
146
|
+
expect(out).toContain("skip");
|
|
147
|
+
expect(out).toContain("refresh");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("marks a branch with a human commit as escalate", async () => {
|
|
151
|
+
const openPrs = [
|
|
152
|
+
{
|
|
153
|
+
number: 5,
|
|
154
|
+
html_url: "u5",
|
|
155
|
+
title: "fix c",
|
|
156
|
+
head: { ref: "remediate/ccc" },
|
|
157
|
+
base: { ref: "main" },
|
|
158
|
+
labels: [],
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
const compares = {
|
|
162
|
+
"remediate/ccc": {
|
|
163
|
+
ahead_by: 2,
|
|
164
|
+
behind_by: 3,
|
|
165
|
+
commits: [{ author: { login: "terramend[bot]" } }, { author: { login: "alice" } }],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const ctx = makeCtx(openPrs, compares);
|
|
169
|
+
const text = await execText(ListRemediationPrsTool(ctx))({}, {});
|
|
170
|
+
const out = text.content[0].text;
|
|
171
|
+
expect(out).toContain("escalate");
|
|
172
|
+
expect(out).toContain("human_touched");
|
|
173
|
+
expect(out).toContain("alice");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("ClosePullRequestTool", () => {
|
|
178
|
+
function makeCtx(opts: {
|
|
179
|
+
push?: "disabled" | "restricted" | "enabled";
|
|
180
|
+
issueNumber?: number;
|
|
181
|
+
}): ToolContext & {
|
|
182
|
+
_update: ReturnType<typeof vi.fn>;
|
|
183
|
+
_comment: ReturnType<typeof vi.fn>;
|
|
184
|
+
} {
|
|
185
|
+
const update = vi.fn(async ({ pull_number }: { pull_number: number }) => ({
|
|
186
|
+
data: { number: pull_number, state: "closed", html_url: `u${pull_number}` },
|
|
187
|
+
}));
|
|
188
|
+
const comment = vi.fn(async () => ({ data: { id: 1 } }));
|
|
189
|
+
const ctx = {
|
|
190
|
+
repo: { owner: "o", name: "r" },
|
|
191
|
+
payload: {
|
|
192
|
+
push: opts.push ?? "restricted",
|
|
193
|
+
event:
|
|
194
|
+
opts.issueNumber !== undefined
|
|
195
|
+
? { trigger: "issue", issue_number: opts.issueNumber, is_pr: true }
|
|
196
|
+
: { trigger: "unknown" },
|
|
197
|
+
},
|
|
198
|
+
toolState: { model: undefined, createdTargets: new Set<number>() },
|
|
199
|
+
octokit: {
|
|
200
|
+
rest: {
|
|
201
|
+
issues: { createComment: comment },
|
|
202
|
+
pulls: { update },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
} as unknown as ToolContext;
|
|
206
|
+
return Object.assign(ctx, { _update: update, _comment: comment });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it("closes a PR (standalone run = in scope) and posts the comment first", async () => {
|
|
210
|
+
const ctx = makeCtx({});
|
|
211
|
+
const text = await execText(ClosePullRequestTool(ctx) as never)(
|
|
212
|
+
{ pull_number: 7, comment: "already resolved on base" },
|
|
213
|
+
{},
|
|
214
|
+
);
|
|
215
|
+
expect(text.content[0].text).toContain("state: closed");
|
|
216
|
+
expect(ctx._comment).toHaveBeenCalledOnce();
|
|
217
|
+
expect(ctx._update).toHaveBeenCalledWith(
|
|
218
|
+
expect.objectContaining({ pull_number: 7, state: "closed" }),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("is blocked under push: disabled", async () => {
|
|
223
|
+
const ctx = makeCtx({ push: "disabled" });
|
|
224
|
+
const text = await execText(ClosePullRequestTool(ctx) as never)({ pull_number: 7 }, {});
|
|
225
|
+
expect(text.isError).toBe(true);
|
|
226
|
+
expect(text.content[0].text).toContain("read-only");
|
|
227
|
+
expect(ctx._update).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("refuses to close a PR outside the run's scope", async () => {
|
|
231
|
+
const ctx = makeCtx({ issueNumber: 42 });
|
|
232
|
+
const text = await execText(ClosePullRequestTool(ctx) as never)({ pull_number: 7 }, {});
|
|
233
|
+
expect(text.isError).toBe(true);
|
|
234
|
+
expect(text.content[0].text).toContain("scoped to #42");
|
|
235
|
+
expect(ctx._update).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { addFooter } from "#app/mcp/comment";
|
|
3
|
+
import { assertTargetInScope } from "#app/mcp/scope";
|
|
4
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
5
|
+
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
6
|
+
import { log } from "#app/utils/cli";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* §27 Stale-fix self-healing. A Terramend remediation PR is "base + a minimal,
|
|
10
|
+
* proven fix". When the base branch advances after the PR is opened, the PR goes
|
|
11
|
+
* stale: its diff is computed against an old base, the concern may already be
|
|
12
|
+
* resolved upstream (a human fixed it, or the base changed the file), or the
|
|
13
|
+
* branch simply needs re-deriving on the new base. A scheduled `RefreshRemediation`
|
|
14
|
+
* run sweeps the open remediation PRs and, per PR, either re-derives the fix on
|
|
15
|
+
* the current base and force-updates it, closes it as already-resolved, or leaves
|
|
16
|
+
* it for a human when someone has added their own commits.
|
|
17
|
+
*
|
|
18
|
+
* This file is the GitHub-orchestration seam: a pure classifier (`assessStaleFix`)
|
|
19
|
+
* that decides what to do from git facts, the read tool that surfaces the open
|
|
20
|
+
* remediation PRs with their staleness, and the focused write tool that closes a
|
|
21
|
+
* now-redundant PR. The actual re-derive/validate/verify loop is driven by the
|
|
22
|
+
* mode prompt using the existing scan/validate/verify/push tools — no git merge
|
|
23
|
+
* is performed (re-deriving on the fresh base avoids conflict resolution entirely
|
|
24
|
+
* and keeps the PR diff to exactly the fix).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// the branch-name conventions Terramend opens PRs under: `remediate/<group-id>`
|
|
28
|
+
// (incl. `remediate/batch-<hash>`) for a fix and `terramend/generate-<slug>` for
|
|
29
|
+
// a generation. The naming is the primary "this is Terramend's PR" signal.
|
|
30
|
+
const REMEDIATION_BRANCH = /^(?:remediate\/|terramend\/generate-)/;
|
|
31
|
+
|
|
32
|
+
/** true when `branch` is a Terramend remediation/generation branch. */
|
|
33
|
+
export function isRemediationBranch(branch: string): boolean {
|
|
34
|
+
return REMEDIATION_BRANCH.test(branch);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** the `<group-id>` of a `remediate/<group-id>` branch (the scan group id that
|
|
38
|
+
* keys the fix), or null for a generation branch / non-remediation branch. */
|
|
39
|
+
export function groupIdFromBranch(branch: string): string | null {
|
|
40
|
+
return branch.match(/^remediate\/(.+)$/)?.[1] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** true when a commit-author login is Terramend's bot (so a commit by it is NOT
|
|
44
|
+
* a human edit). A null/absent login is treated as non-human: Terramend pushes
|
|
45
|
+
* with a git identity that often doesn't map to a GitHub user, so requiring a
|
|
46
|
+
* positive bot match would make every PR look human-touched and never refresh. */
|
|
47
|
+
export function isBotActor(login: string | null | undefined): boolean {
|
|
48
|
+
if (!login) return true;
|
|
49
|
+
const normalized = login.replace(/\[bot\]$/i, "").toLowerCase();
|
|
50
|
+
return normalized === "terramend" || normalized === "terramenddev" || /\[bot\]$/i.test(login);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type StaleFixStatus = "current" | "stale" | "human_touched";
|
|
54
|
+
export type StaleFixAction = "skip" | "refresh" | "escalate";
|
|
55
|
+
|
|
56
|
+
export interface StaleFixAssessment {
|
|
57
|
+
status: StaleFixStatus;
|
|
58
|
+
action: StaleFixAction;
|
|
59
|
+
reason: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decide what a refresh sweep should do with one open remediation PR, from git
|
|
64
|
+
* facts alone. Pure.
|
|
65
|
+
* - a branch carrying NON-bot commits is `human_touched` → `escalate` (never
|
|
66
|
+
* force-overwrite a human's work; leave it for review).
|
|
67
|
+
* - a branch whose base has NOT advanced is `current` → `skip` (the fix is still
|
|
68
|
+
* derived against the live base).
|
|
69
|
+
* - otherwise the base moved → `stale` → `refresh`: re-derive the fix on the
|
|
70
|
+
* current base, then close it if the concern is already resolved or update it.
|
|
71
|
+
*/
|
|
72
|
+
export function assessStaleFix(input: {
|
|
73
|
+
baseBehindBy: number;
|
|
74
|
+
hasNonBotCommits: boolean;
|
|
75
|
+
}): StaleFixAssessment {
|
|
76
|
+
if (input.hasNonBotCommits) {
|
|
77
|
+
return {
|
|
78
|
+
status: "human_touched",
|
|
79
|
+
action: "escalate",
|
|
80
|
+
reason:
|
|
81
|
+
"the PR branch has commit(s) not authored by terramend — auto-refresh would overwrite a human's work; label it needs-human and leave it for review",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (input.baseBehindBy <= 0) {
|
|
85
|
+
return {
|
|
86
|
+
status: "current",
|
|
87
|
+
action: "skip",
|
|
88
|
+
reason: "the base has not advanced since the PR was opened — the fix is still current",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
status: "stale",
|
|
93
|
+
action: "refresh",
|
|
94
|
+
reason:
|
|
95
|
+
`the base advanced ${input.baseBehindBy} commit(s) since the PR was opened — re-scan on the current base, ` +
|
|
96
|
+
"then close the PR if the concern is already resolved or re-derive + force-update the fix otherwise",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface AssessedRemediationPr {
|
|
101
|
+
number: number;
|
|
102
|
+
url: string;
|
|
103
|
+
title: string;
|
|
104
|
+
branch: string;
|
|
105
|
+
base: string;
|
|
106
|
+
group_id: string | null;
|
|
107
|
+
base_behind_by: number;
|
|
108
|
+
head_ahead_by: number;
|
|
109
|
+
has_non_bot_commits: boolean;
|
|
110
|
+
commit_authors: string[];
|
|
111
|
+
labels: string[];
|
|
112
|
+
status: StaleFixStatus;
|
|
113
|
+
recommended_action: StaleFixAction;
|
|
114
|
+
reason: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const ListRemediationPrsParams = type({
|
|
118
|
+
"limit?": type.number.describe(
|
|
119
|
+
"max open remediation PRs to assess (default 30). Each is one compare-commits API call.",
|
|
120
|
+
),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export function ListRemediationPrsTool(ctx: ToolContext) {
|
|
124
|
+
return tool({
|
|
125
|
+
name: "list_remediation_prs",
|
|
126
|
+
description:
|
|
127
|
+
"§27 — list the repo's OPEN Terramend remediation/generation PRs (branches `remediate/<id>` or " +
|
|
128
|
+
"`terramend/generate-<slug>`) with their staleness, so a refresh sweep knows which to act on. For each " +
|
|
129
|
+
"PR it compares the head branch against its base and returns `base_behind_by` (how many commits the " +
|
|
130
|
+
"base advanced since the PR was opened), `head_ahead_by`, `has_non_bot_commits` (a human pushed to the " +
|
|
131
|
+
"branch), the `group_id`, labels, and a `recommended_action`: `skip` (still current), `refresh` (base " +
|
|
132
|
+
"moved — re-derive the fix on the current base, then close-if-resolved or force-update), or `escalate` " +
|
|
133
|
+
"(human-touched — label needs-human, don't overwrite). Read-only. Use it as the first step of " +
|
|
134
|
+
"RefreshRemediation.",
|
|
135
|
+
parameters: ListRemediationPrsParams,
|
|
136
|
+
execute: execute(async ({ limit }) => {
|
|
137
|
+
const cap = limit ?? 30;
|
|
138
|
+
const owner = ctx.repo.owner;
|
|
139
|
+
const repo = ctx.repo.name;
|
|
140
|
+
|
|
141
|
+
const open = await ctx.octokit.paginate(ctx.octokit.rest.pulls.list, {
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
state: "open",
|
|
145
|
+
per_page: 100,
|
|
146
|
+
});
|
|
147
|
+
const remediation = open.filter((pr) => isRemediationBranch(pr.head.ref)).slice(0, cap);
|
|
148
|
+
|
|
149
|
+
const prs: AssessedRemediationPr[] = [];
|
|
150
|
+
const errors: { number: number; error: string }[] = [];
|
|
151
|
+
for (const pr of remediation) {
|
|
152
|
+
try {
|
|
153
|
+
// base..head: ahead_by = the PR's own commits, behind_by = commits on
|
|
154
|
+
// the base the PR lacks (how far the base moved since the fork point).
|
|
155
|
+
const cmp = await ctx.octokit.rest.repos.compareCommits({
|
|
156
|
+
owner,
|
|
157
|
+
repo,
|
|
158
|
+
base: pr.base.ref,
|
|
159
|
+
head: pr.head.ref,
|
|
160
|
+
});
|
|
161
|
+
const commitAuthors = [
|
|
162
|
+
...new Set(
|
|
163
|
+
(cmp.data.commits ?? [])
|
|
164
|
+
.map((c) => c.author?.login ?? c.commit.author?.name ?? null)
|
|
165
|
+
.filter((a): a is string => a !== null),
|
|
166
|
+
),
|
|
167
|
+
];
|
|
168
|
+
const hasNonBotCommits = (cmp.data.commits ?? []).some(
|
|
169
|
+
(c) => !isBotActor(c.author?.login),
|
|
170
|
+
);
|
|
171
|
+
const assessment = assessStaleFix({
|
|
172
|
+
baseBehindBy: cmp.data.behind_by,
|
|
173
|
+
hasNonBotCommits,
|
|
174
|
+
});
|
|
175
|
+
prs.push({
|
|
176
|
+
number: pr.number,
|
|
177
|
+
url: pr.html_url,
|
|
178
|
+
title: pr.title,
|
|
179
|
+
branch: pr.head.ref,
|
|
180
|
+
base: pr.base.ref,
|
|
181
|
+
group_id: groupIdFromBranch(pr.head.ref),
|
|
182
|
+
base_behind_by: cmp.data.behind_by,
|
|
183
|
+
head_ahead_by: cmp.data.ahead_by,
|
|
184
|
+
has_non_bot_commits: hasNonBotCommits,
|
|
185
|
+
commit_authors: commitAuthors,
|
|
186
|
+
labels: pr.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean),
|
|
187
|
+
status: assessment.status,
|
|
188
|
+
recommended_action: assessment.action,
|
|
189
|
+
reason: assessment.reason,
|
|
190
|
+
});
|
|
191
|
+
} catch (e) {
|
|
192
|
+
errors.push({ number: pr.number, error: e instanceof Error ? e.message : String(e) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const counts = {
|
|
197
|
+
refresh: prs.filter((p) => p.recommended_action === "refresh").length,
|
|
198
|
+
skip: prs.filter((p) => p.recommended_action === "skip").length,
|
|
199
|
+
escalate: prs.filter((p) => p.recommended_action === "escalate").length,
|
|
200
|
+
};
|
|
201
|
+
log.info(
|
|
202
|
+
`» list_remediation_prs: ${prs.length} open remediation PR(s) — ` +
|
|
203
|
+
`${counts.refresh} refresh, ${counts.skip} skip, ${counts.escalate} escalate` +
|
|
204
|
+
(errors.length ? ` (${errors.length} errored)` : ""),
|
|
205
|
+
);
|
|
206
|
+
return toolOk({
|
|
207
|
+
count: prs.length,
|
|
208
|
+
action_counts: counts,
|
|
209
|
+
pull_requests: prs,
|
|
210
|
+
...(errors.length ? { errors } : {}),
|
|
211
|
+
note:
|
|
212
|
+
prs.length === 0
|
|
213
|
+
? "No open Terramend remediation PRs to refresh."
|
|
214
|
+
: "Act on the `refresh` PRs (re-derive on the current base, then close-if-resolved or force-update); `escalate` PRs get a needs-human label and are left for a human; `skip` PRs are already current.",
|
|
215
|
+
});
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const ClosePullRequest = type({
|
|
221
|
+
pull_number: type.number.describe("the pull request number to close (not merge)."),
|
|
222
|
+
"comment?": type.string.describe(
|
|
223
|
+
"an optional comment explaining why it's being closed — posted before closing (e.g. 'concern already resolved on the base; this fix is now redundant').",
|
|
224
|
+
),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export function ClosePullRequestTool(ctx: ToolContext) {
|
|
228
|
+
return tool({
|
|
229
|
+
name: "close_pull_request",
|
|
230
|
+
description:
|
|
231
|
+
"Close (NOT merge) a pull request. §27 use: a Terramend remediation PR whose concern is already " +
|
|
232
|
+
"resolved on the current base — the fix is redundant, so close it (optionally with an explanatory " +
|
|
233
|
+
"comment) instead of leaving a stale PR open. Blocked under `push: disabled`, and bound to the run's " +
|
|
234
|
+
"scope (a comment-triggered run can only close the PR it was triggered on or one it opened; a " +
|
|
235
|
+
"standalone scheduled sweep may close any). Never merges — closing is reversible.",
|
|
236
|
+
parameters: ClosePullRequest,
|
|
237
|
+
execute: execute(async ({ pull_number, comment }) => {
|
|
238
|
+
// permission gate: closing a PR is a repo write — block it under read-only.
|
|
239
|
+
if (ctx.payload.push === "disabled") {
|
|
240
|
+
throw new Error(
|
|
241
|
+
"Closing a pull request is disabled. This repository is configured for read-only access (push: disabled).",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// scope gate: same binding as comment/label/PR-body writes (mcp/scope.ts).
|
|
245
|
+
assertTargetInScope(ctx, pull_number, "close");
|
|
246
|
+
|
|
247
|
+
const owner = ctx.repo.owner;
|
|
248
|
+
const repo = ctx.repo.name;
|
|
249
|
+
|
|
250
|
+
if (comment) {
|
|
251
|
+
await ctx.octokit.rest.issues.createComment({
|
|
252
|
+
owner,
|
|
253
|
+
repo,
|
|
254
|
+
issue_number: pull_number,
|
|
255
|
+
body: addFooter(ctx, comment),
|
|
256
|
+
});
|
|
257
|
+
log.info(`» commented before closing PR #${pull_number}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = await ctx.octokit.rest.pulls.update({
|
|
261
|
+
owner,
|
|
262
|
+
repo,
|
|
263
|
+
pull_number,
|
|
264
|
+
state: "closed",
|
|
265
|
+
});
|
|
266
|
+
ctx.toolState.wasUpdated = true;
|
|
267
|
+
log.info(`» closed pull request #${result.data.number}`);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
success: true,
|
|
271
|
+
number: result.data.number,
|
|
272
|
+
state: result.data.state,
|
|
273
|
+
url: result.data.html_url,
|
|
274
|
+
};
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type RunResult, resolveBaseRef, run } from "#app/mcp/terraform/types";
|
|
4
|
+
|
|
5
|
+
// --- infracost (cost lens) ------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface CostBreakdown {
|
|
8
|
+
/** total estimated monthly cost, or null when no resources are priced. */
|
|
9
|
+
totalMonthlyCost: number | null;
|
|
10
|
+
currency: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse `infracost breakdown --format json`. The top-level `totalMonthlyCost`
|
|
15
|
+
* is a decimal string (absent / null when a project has no priced resources);
|
|
16
|
+
* `currency` defaults to USD. A missing/unparseable cost becomes null so the
|
|
17
|
+
* caller reports "unpriced" rather than a misleading $0.00.
|
|
18
|
+
*/
|
|
19
|
+
export function parseInfracostBreakdown(stdout: string): CostBreakdown {
|
|
20
|
+
const parsed = JSON.parse(stdout || "{}") as {
|
|
21
|
+
totalMonthlyCost?: string | number | null;
|
|
22
|
+
currency?: string;
|
|
23
|
+
};
|
|
24
|
+
const raw = parsed.totalMonthlyCost;
|
|
25
|
+
const num = typeof raw === "number" ? raw : raw != null ? Number.parseFloat(raw) : Number.NaN;
|
|
26
|
+
return {
|
|
27
|
+
totalMonthlyCost: Number.isFinite(num) ? num : null,
|
|
28
|
+
currency: parsed.currency || "USD",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ResourceCost {
|
|
33
|
+
name: string;
|
|
34
|
+
monthlyCost: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse the per-resource monthly costs from `infracost breakdown --format json`
|
|
39
|
+
* (`projects[].breakdown.resources[]`), so a cost increase can be attributed to
|
|
40
|
+
* the specific resources that drove it instead of just a total. Skips unpriced
|
|
41
|
+
* (null/zero) resources; returns them sorted most-expensive first. Pure.
|
|
42
|
+
*/
|
|
43
|
+
export function parseInfracostResources(stdout: string): ResourceCost[] {
|
|
44
|
+
let parsed: {
|
|
45
|
+
projects?: {
|
|
46
|
+
breakdown?: { resources?: { name?: string; monthlyCost?: string | number | null }[] };
|
|
47
|
+
}[];
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(stdout || "{}");
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const out: ResourceCost[] = [];
|
|
55
|
+
for (const project of parsed.projects ?? []) {
|
|
56
|
+
for (const r of project.breakdown?.resources ?? []) {
|
|
57
|
+
const raw = r.monthlyCost;
|
|
58
|
+
const cost =
|
|
59
|
+
typeof raw === "number" ? raw : raw != null ? Number.parseFloat(raw) : Number.NaN;
|
|
60
|
+
if (Number.isFinite(cost) && cost > 0 && r.name) {
|
|
61
|
+
out.push({ name: r.name, monthlyCost: Math.round(cost * 100) / 100 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => b.monthlyCost - a.monthlyCost);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CostDelta {
|
|
69
|
+
currency: string;
|
|
70
|
+
baselineMonthly: number | null;
|
|
71
|
+
currentMonthly: number | null;
|
|
72
|
+
/** current − baseline, rounded to cents; null when either side is unknown. */
|
|
73
|
+
deltaMonthly: number | null;
|
|
74
|
+
direction: "increase" | "decrease" | "no-change" | "unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pure cost-delta computation: current (post-fix) vs the base-branch baseline. */
|
|
78
|
+
export function computeCostDelta(
|
|
79
|
+
baseline: CostBreakdown | null,
|
|
80
|
+
current: CostBreakdown,
|
|
81
|
+
): CostDelta {
|
|
82
|
+
const currency = current.currency || baseline?.currency || "USD";
|
|
83
|
+
const baselineMonthly = baseline?.totalMonthlyCost ?? null;
|
|
84
|
+
const currentMonthly = current.totalMonthlyCost;
|
|
85
|
+
if (baselineMonthly === null || currentMonthly === null) {
|
|
86
|
+
return { currency, baselineMonthly, currentMonthly, deltaMonthly: null, direction: "unknown" };
|
|
87
|
+
}
|
|
88
|
+
const deltaMonthly = Math.round((currentMonthly - baselineMonthly) * 100) / 100;
|
|
89
|
+
const direction = deltaMonthly > 0 ? "increase" : deltaMonthly < 0 ? "decrease" : "no-change";
|
|
90
|
+
return { currency, baselineMonthly, currentMonthly, deltaMonthly, direction };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CostEscalation {
|
|
94
|
+
/** true when the monthly increase meets/exceeds the operator's threshold. */
|
|
95
|
+
escalate: boolean;
|
|
96
|
+
reason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* §4.16-next — decide whether a cost increase is large enough to escalate the PR
|
|
101
|
+
* to human review (`needs-human`). Compares the monthly delta against the
|
|
102
|
+
* operator's `cost_increase_block_usd` threshold. No threshold set, an unknown
|
|
103
|
+
* delta, or a decrease/no-change ⇒ no escalation. Pure + deterministic so the
|
|
104
|
+
* decision is auditable, not a model judgement.
|
|
105
|
+
*/
|
|
106
|
+
export function classifyCostEscalation(
|
|
107
|
+
deltaMonthly: number | null,
|
|
108
|
+
thresholdUsd: number | undefined,
|
|
109
|
+
): CostEscalation {
|
|
110
|
+
if (thresholdUsd === undefined || deltaMonthly === null || deltaMonthly <= 0) {
|
|
111
|
+
return { escalate: false };
|
|
112
|
+
}
|
|
113
|
+
if (deltaMonthly >= thresholdUsd) {
|
|
114
|
+
return {
|
|
115
|
+
escalate: true,
|
|
116
|
+
reason: `the fix raises monthly cost by ${deltaMonthly}, at or above the ${thresholdUsd} escalation threshold`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { escalate: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
|
|
123
|
+
return run("infracost", ["breakdown", "--path", ".", "--format", "json", "--no-color"], scanCwd, {
|
|
124
|
+
INFRACOST_API_KEY: key,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Cost of the base-branch version of the same Terraform, computed in a detached
|
|
130
|
+
* git worktree so the current (fixed) checkout is never disturbed. Best-effort:
|
|
131
|
+
* any failure (no base ref, worktree add fails, infracost errors) returns null
|
|
132
|
+
* and the caller falls back to reporting current cost only.
|
|
133
|
+
*/
|
|
134
|
+
export function infracostBaseline(cwd: string, key: string, tmpdir: string): CostBreakdown | null {
|
|
135
|
+
const baseRef = resolveBaseRef(cwd);
|
|
136
|
+
if (!baseRef) return null;
|
|
137
|
+
const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
|
|
138
|
+
const prefix = prefixResult.status === 0 ? prefixResult.stdout.trim() : "";
|
|
139
|
+
// unique, unpredictable worktree path. the old `infracost-base-<pid>` name was
|
|
140
|
+
// predictable (a local actor could pre-create/symlink it) and collided when
|
|
141
|
+
// two baselines ran in the same process. mkdtemp gives a fresh PARENT dir;
|
|
142
|
+
// the worktree itself goes in a not-yet-existing child (git worktree add
|
|
143
|
+
// refuses a path that already exists). the finally removes both the git
|
|
144
|
+
// worktree registration and the parent dir.
|
|
145
|
+
const baseDir = mkdtempSync(join(tmpdir, "infracost-base-"));
|
|
146
|
+
const worktree = join(baseDir, "wt");
|
|
147
|
+
const add = run("git", ["worktree", "add", "--detach", worktree, baseRef], cwd);
|
|
148
|
+
if (add.status !== 0) {
|
|
149
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const scanCwd = prefix ? join(worktree, prefix) : worktree;
|
|
154
|
+
const r = runInfracostBreakdown(scanCwd, key);
|
|
155
|
+
if (r.missing || r.status !== 0) return null;
|
|
156
|
+
return parseInfracostBreakdown(r.stdout);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
} finally {
|
|
160
|
+
run("git", ["worktree", "remove", "--force", worktree], cwd);
|
|
161
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|