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,371 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import * as core from "@actions/core";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import type { AuthorPermission, PayloadEvent } from "#app/external";
|
|
5
|
+
import { BUILTIN_MODE_NAMES } from "#app/modes";
|
|
6
|
+
import { log } from "#app/utils/cli";
|
|
7
|
+
import { parseRemediationCommand } from "#app/utils/remediationCommand";
|
|
8
|
+
import type { RepoSettings } from "#app/utils/runContext";
|
|
9
|
+
import { validateCompatibility } from "#app/utils/versioning";
|
|
10
|
+
import packageJson from "#package.json" with { type: "json" };
|
|
11
|
+
|
|
12
|
+
// tool permission enum types for inputs
|
|
13
|
+
const ShellPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
14
|
+
const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
15
|
+
|
|
16
|
+
// schema for JSON payload passed via prompt (internal dispatch invocation)
|
|
17
|
+
// note: permissions are intentionally NOT included here to prevent injection attacks
|
|
18
|
+
// permissions are derived from event.authorPermission instead
|
|
19
|
+
export const JsonPayload = type({
|
|
20
|
+
"~terramend": "true",
|
|
21
|
+
version: "string",
|
|
22
|
+
"model?": "string | undefined",
|
|
23
|
+
prompt: "string",
|
|
24
|
+
"triggerer?": "string | undefined",
|
|
25
|
+
|
|
26
|
+
"eventInstructions?": "string",
|
|
27
|
+
"previousRunsNote?": "string",
|
|
28
|
+
"event?": "object",
|
|
29
|
+
"timeout?": "string | undefined",
|
|
30
|
+
"progressComment?": type({
|
|
31
|
+
id: "string",
|
|
32
|
+
type: "'issue' | 'review'",
|
|
33
|
+
}).or("undefined"),
|
|
34
|
+
"generateSummary?": "boolean | undefined",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// permission levels that indicate collaborator status (have push access)
|
|
38
|
+
const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
|
|
39
|
+
|
|
40
|
+
// check if the event author has collaborator-level permissions
|
|
41
|
+
function isCollaborator(event: PayloadEvent): boolean {
|
|
42
|
+
const perm = event.authorPermission;
|
|
43
|
+
return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// inputs schema - action inputs from core.getInput()
|
|
47
|
+
// note: tool permissions use .or("undefined") because getInput() || undefined
|
|
48
|
+
// explicitly sets the property to undefined when empty, which is different from
|
|
49
|
+
// the property being absent. arktype's "prop?" means "optional to include" but
|
|
50
|
+
// if included, must match the type - so we need to explicitly allow undefined.
|
|
51
|
+
export const Inputs = type({
|
|
52
|
+
prompt: "string",
|
|
53
|
+
"model?": type.string.or("undefined"),
|
|
54
|
+
"mode?": type.string.or("undefined"),
|
|
55
|
+
"timeout?": type.string.or("undefined"),
|
|
56
|
+
"push?": PushPermissionInput.or("undefined"),
|
|
57
|
+
"shell?": ShellPermissionInput.or("undefined"),
|
|
58
|
+
"cwd?": type.string.or("undefined"),
|
|
59
|
+
"output_schema?": type.string.or("undefined"),
|
|
60
|
+
// Terraform remediation config (all optional; defaults applied downstream)
|
|
61
|
+
"scan_scope?": type.string.or("undefined"),
|
|
62
|
+
"severity_threshold?": type.string.or("undefined"),
|
|
63
|
+
"max_prs?": type.string.or("undefined"),
|
|
64
|
+
"allowed_paths?": type.string.or("undefined"),
|
|
65
|
+
"base_branch?": type.string.or("undefined"),
|
|
66
|
+
"allow_replace?": type.string.or("undefined"),
|
|
67
|
+
"protected_paths?": type.string.or("undefined"),
|
|
68
|
+
"autonomy_threshold?": type.string.or("undefined"),
|
|
69
|
+
"gitleaks?": type.string.or("undefined"),
|
|
70
|
+
"cost_increase_block_usd?": type.string.or("undefined"),
|
|
71
|
+
"module_catalogue?": type.string.or("undefined"),
|
|
72
|
+
"terratest?": type.string.or("undefined"),
|
|
73
|
+
"terraform_mcp?": type.string.or("undefined"),
|
|
74
|
+
"review_instructions?": type.string.or("undefined"),
|
|
75
|
+
"fp_filtering_instructions?": type.string.or("undefined"),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export type Inputs = typeof Inputs.infer;
|
|
79
|
+
|
|
80
|
+
function isPayloadEvent(value: unknown): value is PayloadEvent {
|
|
81
|
+
return typeof value === "object" && value !== null && "trigger" in value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveCwd(cwd: string | undefined): string | undefined {
|
|
85
|
+
const workspace = process.env.GITHUB_WORKSPACE;
|
|
86
|
+
if (!cwd) return workspace;
|
|
87
|
+
if (isAbsolute(cwd)) return cwd;
|
|
88
|
+
return workspace ? resolve(workspace, cwd) : cwd;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ResolvedPromptInput = string | typeof JsonPayload.infer;
|
|
92
|
+
|
|
93
|
+
export function resolvePromptInput(): ResolvedPromptInput {
|
|
94
|
+
const prompt = core.getInput("prompt", { required: true });
|
|
95
|
+
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(prompt);
|
|
99
|
+
} catch {
|
|
100
|
+
// JSON parse error is fine (plain text prompt)
|
|
101
|
+
return prompt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!parsed || typeof parsed !== "object" || !("~terramend" in parsed)) {
|
|
105
|
+
// if it doesn't look like a terramend payload, return the plain text prompt
|
|
106
|
+
return prompt;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// validation errors should propagate
|
|
110
|
+
const jsonPayload = JsonPayload.assert(parsed);
|
|
111
|
+
validateCompatibility(jsonPayload.version, packageJson.version);
|
|
112
|
+
return jsonPayload;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveNonPromptInputs() {
|
|
116
|
+
return Inputs.omit("prompt").assert({
|
|
117
|
+
model: core.getInput("model") || undefined,
|
|
118
|
+
mode: core.getInput("mode") || undefined,
|
|
119
|
+
timeout: core.getInput("timeout") || undefined,
|
|
120
|
+
cwd: core.getInput("cwd") || undefined,
|
|
121
|
+
push: core.getInput("push") || undefined,
|
|
122
|
+
shell: core.getInput("shell") || undefined,
|
|
123
|
+
scan_scope: core.getInput("scan_scope") || undefined,
|
|
124
|
+
severity_threshold: core.getInput("severity_threshold") || undefined,
|
|
125
|
+
max_prs: core.getInput("max_prs") || undefined,
|
|
126
|
+
allowed_paths: core.getInput("allowed_paths") || undefined,
|
|
127
|
+
base_branch: core.getInput("base_branch") || undefined,
|
|
128
|
+
allow_replace: core.getInput("allow_replace") || undefined,
|
|
129
|
+
protected_paths: core.getInput("protected_paths") || undefined,
|
|
130
|
+
autonomy_threshold: core.getInput("autonomy_threshold") || undefined,
|
|
131
|
+
gitleaks: core.getInput("gitleaks") || undefined,
|
|
132
|
+
cost_increase_block_usd: core.getInput("cost_increase_block_usd") || undefined,
|
|
133
|
+
module_catalogue: core.getInput("module_catalogue") || undefined,
|
|
134
|
+
terratest: core.getInput("terratest") || undefined,
|
|
135
|
+
terraform_mcp: core.getInput("terraform_mcp") || undefined,
|
|
136
|
+
review_instructions: core.getInput("review_instructions") || undefined,
|
|
137
|
+
fp_filtering_instructions: core.getInput("fp_filtering_instructions") || undefined,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Canonicalize the `mode` input against the built-in mode names
|
|
143
|
+
* (case-insensitive). Returns the canonical name (e.g. "remediate" →
|
|
144
|
+
* "Remediate") when it matches a built-in mode, letting CI pin a mode
|
|
145
|
+
* deterministically. Unknown non-empty values warn and return undefined so the
|
|
146
|
+
* agent falls back to prompt-driven `select_mode` — mirroring how an unknown
|
|
147
|
+
* `model` slug degrades to auto-select rather than hard-failing the run.
|
|
148
|
+
*/
|
|
149
|
+
export function parseMode(raw: string | undefined): string | undefined {
|
|
150
|
+
const v = raw?.trim();
|
|
151
|
+
if (!v) return undefined;
|
|
152
|
+
const match = BUILTIN_MODE_NAMES.find((name) => name.toLowerCase() === v.toLowerCase());
|
|
153
|
+
if (!match) {
|
|
154
|
+
log.warning(
|
|
155
|
+
`» unknown mode "${v}" — agent will select a mode (valid: ${BUILTIN_MODE_NAMES.join(", ")})`,
|
|
156
|
+
);
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return match;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** parse scan_scope; "diff" or "full" (default). */
|
|
163
|
+
function parseScanScope(raw: string | undefined): "diff" | "full" | undefined {
|
|
164
|
+
const v = raw?.trim().toLowerCase();
|
|
165
|
+
return v === "diff" || v === "full" ? v : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const SEVERITY_VALUES = new Set(["critical", "high", "medium", "low", "info"]);
|
|
169
|
+
|
|
170
|
+
/** parse the severity_threshold input; undefined when unset or not a valid level. */
|
|
171
|
+
function parseSeverityThreshold(raw: string | undefined): string | undefined {
|
|
172
|
+
if (!raw) return undefined;
|
|
173
|
+
const v = raw.trim().toLowerCase();
|
|
174
|
+
return SEVERITY_VALUES.has(v) ? v : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** parse max_prs; a positive integer, else undefined (downstream default is 1). */
|
|
178
|
+
function parseMaxPrs(raw: string | undefined): number | undefined {
|
|
179
|
+
if (!raw) return undefined;
|
|
180
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
181
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** parse cost_increase_block_usd; a positive number of dollars/month, else
|
|
185
|
+
* undefined (no cost escalation). */
|
|
186
|
+
function parseCostIncreaseBlock(raw: string | undefined): number | undefined {
|
|
187
|
+
if (!raw) return undefined;
|
|
188
|
+
const n = Number.parseFloat(raw.trim());
|
|
189
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** parse a comma-separated glob list (allowed_paths / protected_paths);
|
|
193
|
+
* undefined when unset or empty after trimming. */
|
|
194
|
+
function parseGlobList(raw: string | undefined): string[] | undefined {
|
|
195
|
+
if (!raw) return undefined;
|
|
196
|
+
const globs = raw
|
|
197
|
+
.split(",")
|
|
198
|
+
.map((g) => g.trim())
|
|
199
|
+
.filter(Boolean);
|
|
200
|
+
return globs.length > 0 ? globs : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** parse a boolean-ish action input ("true"/"1"/"yes" → true). undefined/unset
|
|
204
|
+
* and any other value → false. */
|
|
205
|
+
function parseBooleanInput(raw: string | undefined): boolean {
|
|
206
|
+
if (!raw) return false;
|
|
207
|
+
return ["true", "1", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** parse the base_branch override; trims and strips a leading `refs/heads/`,
|
|
211
|
+
* undefined when unset (downstream resolves the run-start branch / default). */
|
|
212
|
+
export function parseBaseBranch(raw: string | undefined): string | undefined {
|
|
213
|
+
const v = raw?.trim().replace(/^refs\/heads\//, "");
|
|
214
|
+
return v || undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** parse the comma-separated allow_replace list — resource addresses (or globs,
|
|
218
|
+
* or `*`/`all`) permitted to be destroyed/replaced; undefined when unset. */
|
|
219
|
+
export function parseAllowReplace(raw: string | undefined): string[] | undefined {
|
|
220
|
+
if (!raw) return undefined;
|
|
221
|
+
const entries = raw
|
|
222
|
+
.split(",")
|
|
223
|
+
.map((a) => a.trim())
|
|
224
|
+
.filter(Boolean);
|
|
225
|
+
return entries.length > 0 ? entries : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const isTerramend = (actor: string | null | undefined): boolean => {
|
|
229
|
+
actor = actor?.replace("[bot]", "");
|
|
230
|
+
return !!actor && (actor === "terramend" || actor === "terramenddev");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export function resolvePayload(
|
|
234
|
+
resolvedPromptInput: ResolvedPromptInput,
|
|
235
|
+
repoSettings: RepoSettings,
|
|
236
|
+
) {
|
|
237
|
+
const [prompt, jsonPayload] =
|
|
238
|
+
typeof resolvedPromptInput !== "string"
|
|
239
|
+
? [resolvedPromptInput.prompt, resolvedPromptInput]
|
|
240
|
+
: [resolvedPromptInput, undefined];
|
|
241
|
+
|
|
242
|
+
const inputs = resolveNonPromptInputs();
|
|
243
|
+
|
|
244
|
+
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
|
|
245
|
+
const rawEvent = jsonPayload?.event;
|
|
246
|
+
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
|
|
247
|
+
|
|
248
|
+
const model = jsonPayload?.model ?? inputs.model ?? repoSettings.model ?? undefined;
|
|
249
|
+
|
|
250
|
+
// determine shell permission - strictest setting wins
|
|
251
|
+
// precedence: disabled > restricted > enabled
|
|
252
|
+
// non-collaborators always get at least "restricted"
|
|
253
|
+
const isNonCollaborator = !isCollaborator(event);
|
|
254
|
+
const repoShell = repoSettings.shell ?? "restricted";
|
|
255
|
+
const inputShell = inputs.shell;
|
|
256
|
+
|
|
257
|
+
// resolve shell: start with repo setting, then apply restrictions
|
|
258
|
+
let resolvedShell = repoShell;
|
|
259
|
+
|
|
260
|
+
// input can only make it stricter (disabled > restricted > enabled)
|
|
261
|
+
if (inputShell === "disabled") {
|
|
262
|
+
resolvedShell = "disabled";
|
|
263
|
+
} else if (inputShell === "restricted" && resolvedShell === "enabled") {
|
|
264
|
+
resolvedShell = "restricted";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// non-collaborators get at least "restricted" (can't have "enabled")
|
|
268
|
+
if (isNonCollaborator && resolvedShell === "enabled") {
|
|
269
|
+
resolvedShell = "restricted";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// build payload - precedence: inputs > repoSettings > fallbacks
|
|
273
|
+
// note: modes are NOT in payload - they come from repoSettings in main()
|
|
274
|
+
return {
|
|
275
|
+
"~terramend": true as const,
|
|
276
|
+
version: jsonPayload?.version ?? packageJson.version,
|
|
277
|
+
model,
|
|
278
|
+
// deterministic mode pin for CI (action input only — not accepted from the
|
|
279
|
+
// JSON payload, which is the internal dispatch surface). undefined → the
|
|
280
|
+
// agent chooses via select_mode as before.
|
|
281
|
+
mode: parseMode(inputs.mode),
|
|
282
|
+
prompt,
|
|
283
|
+
triggerer:
|
|
284
|
+
jsonPayload?.triggerer ??
|
|
285
|
+
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
|
286
|
+
(!isTerramend(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
|
287
|
+
eventInstructions: jsonPayload?.eventInstructions,
|
|
288
|
+
previousRunsNote: jsonPayload?.previousRunsNote,
|
|
289
|
+
event,
|
|
290
|
+
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
|
291
|
+
cwd: resolveCwd(inputs.cwd),
|
|
292
|
+
progressComment: jsonPayload?.progressComment,
|
|
293
|
+
generateSummary: jsonPayload?.generateSummary,
|
|
294
|
+
|
|
295
|
+
// permissions: inputs > repoSettings > fallbacks
|
|
296
|
+
push: inputs.push ?? repoSettings.push ?? "restricted",
|
|
297
|
+
shell: resolvedShell,
|
|
298
|
+
|
|
299
|
+
// Terraform remediation config — consumed by mcp/terraform.ts + the
|
|
300
|
+
// Remediate mode. Defaults are applied at the consumer, not here, so
|
|
301
|
+
// "unset" stays distinguishable from an explicit value.
|
|
302
|
+
scanScope: parseScanScope(inputs.scan_scope),
|
|
303
|
+
severityThreshold: parseSeverityThreshold(inputs.severity_threshold),
|
|
304
|
+
maxPrs: parseMaxPrs(inputs.max_prs),
|
|
305
|
+
allowedPaths: parseGlobList(inputs.allowed_paths),
|
|
306
|
+
// §2.7 — globs the fixer must never auto-modify (inverse of allowed_paths).
|
|
307
|
+
protectedPaths: parseGlobList(inputs.protected_paths),
|
|
308
|
+
// §3.9 — minimum severity at which a security concern escalates to a human.
|
|
309
|
+
autonomyThreshold: parseSeverityThreshold(inputs.autonomy_threshold),
|
|
310
|
+
// §2.8 — opt in to the external gitleaks engine on top of the built-in
|
|
311
|
+
// secret scanner (best-effort; degrades to built-in only when absent).
|
|
312
|
+
gitleaks: parseBooleanInput(inputs.gitleaks),
|
|
313
|
+
// §4.16-next — monthly $ increase at/above which a fix is escalated to a
|
|
314
|
+
// human (needs-human). undefined disables cost escalation.
|
|
315
|
+
costIncreaseBlockUsd: parseCostIncreaseBlock(inputs.cost_increase_block_usd),
|
|
316
|
+
// §4.14 + module catalogue — operator-approved modules a fix/generation
|
|
317
|
+
// should prefer; raw string, structured by `parseModuleCatalogue` in the
|
|
318
|
+
// `list_modules` tool.
|
|
319
|
+
moduleCatalogue: inputs.module_catalogue,
|
|
320
|
+
// §28 — opt in to scaffolding a Go Terratest smoke test + a native
|
|
321
|
+
// `*.tftest.hcl` (both plan the module directly) when generating a reusable
|
|
322
|
+
// module; also widens the push allow-list so the test files can be written.
|
|
323
|
+
terratest: parseBooleanInput(inputs.terratest),
|
|
324
|
+
// P2.2 — opt in to HashiCorp's terraform-mcp-server as a second MCP server
|
|
325
|
+
// for the agent (live Terraform Registry knowledge: current module versions
|
|
326
|
+
// and provider argument shapes). Requires docker on the runner; degrades
|
|
327
|
+
// green with a note when absent. See utils/terraformMcp.ts.
|
|
328
|
+
terraformMcp: parseBooleanInput(inputs.terraform_mcp),
|
|
329
|
+
// §3.12 — a `@terramend fix …` command parsed from the triggering comment
|
|
330
|
+
// body (the prompt), scoping the run to a specific concern/severity/file.
|
|
331
|
+
// null when the prompt isn't a recognised command.
|
|
332
|
+
remediationCommand: parseRemediationCommand(prompt),
|
|
333
|
+
// explicit base-branch override; when unset the effective base is resolved
|
|
334
|
+
// at PR time (run-start branch → repo default) — see resolveBaseBranch.
|
|
335
|
+
baseBranch: parseBaseBranch(inputs.base_branch),
|
|
336
|
+
// resource addresses the operator allows the remediation to destroy/replace
|
|
337
|
+
// — consumed by the destroy-block guardrail (mcp/guardrails.ts). Unset means
|
|
338
|
+
// no destructive change to a stateful resource is permitted.
|
|
339
|
+
allowReplace: parseAllowReplace(inputs.allow_replace),
|
|
340
|
+
// §5.5 — org-specific review policy + FP precedents from the workflow file
|
|
341
|
+
// (owner-controlled, same trust surface as repo settings). merged into the
|
|
342
|
+
// Review mode instructions in main() via mergeReviewModeInstructions.
|
|
343
|
+
reviewInstructions: inputs.review_instructions,
|
|
344
|
+
fpFilteringInstructions: inputs.fp_filtering_instructions,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type ResolvedPayload = ReturnType<typeof resolvePayload>;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Parse and validate the optional `output_schema` action input. Returns the
|
|
352
|
+
* parsed object when present, or `undefined` when absent. Throws on invalid
|
|
353
|
+
* JSON or non-object payloads — these are workflow-author errors that should
|
|
354
|
+
* surface immediately, not silently degrade to "no schema".
|
|
355
|
+
*/
|
|
356
|
+
export function resolveOutputSchema(): Record<string, unknown> | undefined {
|
|
357
|
+
const raw = core.getInput("output_schema");
|
|
358
|
+
if (!raw) return undefined;
|
|
359
|
+
|
|
360
|
+
let parsed: unknown;
|
|
361
|
+
try {
|
|
362
|
+
parsed = JSON.parse(raw);
|
|
363
|
+
} catch {
|
|
364
|
+
throw new Error(`invalid output_schema: not valid JSON`);
|
|
365
|
+
}
|
|
366
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
367
|
+
throw new Error(`invalid output_schema: must be a JSON object`);
|
|
368
|
+
}
|
|
369
|
+
log.info("» structured output schema provided — output will be required");
|
|
370
|
+
return parsed as Record<string, unknown>;
|
|
371
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** stdlib-only Terramend API fetch for entryPost.ts (no node_modules). */
|
|
2
|
+
|
|
3
|
+
type PostApiFetchOptions = {
|
|
4
|
+
path: string;
|
|
5
|
+
method?: string | undefined;
|
|
6
|
+
headers?: Record<string, string> | undefined;
|
|
7
|
+
body?: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function getApiUrl(): string {
|
|
11
|
+
return process.env.API_URL || "https://terramend.com";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function postApiFetch(options: PostApiFetchOptions): Promise<Response> {
|
|
15
|
+
const url = new URL(options.path, getApiUrl());
|
|
16
|
+
|
|
17
|
+
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
18
|
+
if (bypassSecret) {
|
|
19
|
+
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const headers: Record<string, string> = {
|
|
23
|
+
...options.headers,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (bypassSecret) {
|
|
27
|
+
headers["x-vercel-protection-bypass"] = bypassSecret;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!options.body) {
|
|
31
|
+
for (const key of Object.keys(headers)) {
|
|
32
|
+
if (key.toLowerCase() === "content-type") delete headers[key];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const init: RequestInit = {
|
|
41
|
+
method: options.method ?? "GET",
|
|
42
|
+
headers,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
};
|
|
45
|
+
if (options.body) init.body = options.body;
|
|
46
|
+
|
|
47
|
+
return await fetch(url, init);
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timeoutId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
7
|
+
import {
|
|
8
|
+
fetchPreviousSnapshot,
|
|
9
|
+
persistSummary,
|
|
10
|
+
readSummaryFile,
|
|
11
|
+
SUMMARY_FILE_NAME,
|
|
12
|
+
SUMMARY_SCAFFOLD,
|
|
13
|
+
seedSummaryFile,
|
|
14
|
+
summaryFilePath,
|
|
15
|
+
} from "#app/utils/prSummary";
|
|
16
|
+
|
|
17
|
+
vi.mock("#app/utils/apiFetch", () => ({
|
|
18
|
+
apiFetch: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("#app/utils/cli", () => ({
|
|
22
|
+
log: {
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
debug: vi.fn(),
|
|
25
|
+
warning: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
success: vi.fn(),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("#app/utils/patchWorkflowRunFields", () => ({
|
|
32
|
+
patchWorkflowRunFields: vi.fn(async () => undefined),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { apiFetch } from "#app/utils/apiFetch";
|
|
36
|
+
import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
|
|
37
|
+
|
|
38
|
+
const apiFetchMock = vi.mocked(apiFetch);
|
|
39
|
+
const patchMock = vi.mocked(patchWorkflowRunFields);
|
|
40
|
+
|
|
41
|
+
const TEMP = mkdtempSync(join(tmpdir(), "terramend-pr-summary-"));
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
rmSync(TEMP, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// long enough to clear the 60-char minimum snapshot length
|
|
52
|
+
const LONG_SNAPSHOT = `# PR summary\n\n${"meaningful cross-run context. ".repeat(4)}`.trim();
|
|
53
|
+
|
|
54
|
+
let dirCounter = 0;
|
|
55
|
+
function freshDir(): string {
|
|
56
|
+
dirCounter += 1;
|
|
57
|
+
return join(TEMP, `run-${dirCounter}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("summaryFilePath", () => {
|
|
61
|
+
it("joins the tmpdir with the well-known file name", () => {
|
|
62
|
+
expect(summaryFilePath("/tmp/run")).toBe(join("/tmp/run", SUMMARY_FILE_NAME));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("seedSummaryFile", () => {
|
|
67
|
+
it("seeds with the scaffold on first runs (no previous snapshot)", async () => {
|
|
68
|
+
const dir = freshDir();
|
|
69
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: null });
|
|
70
|
+
expect(path).toBe(summaryFilePath(dir));
|
|
71
|
+
await expect(readFile(path, "utf8")).resolves.toBe(SUMMARY_SCAFFOLD);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("seeds with the previous snapshot when it is substantive", async () => {
|
|
75
|
+
const dir = freshDir();
|
|
76
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
|
|
77
|
+
await expect(readFile(path, "utf8")).resolves.toBe(LONG_SNAPSHOT);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to the scaffold when the previous snapshot is too short", async () => {
|
|
81
|
+
const dir = freshDir();
|
|
82
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: "tiny" });
|
|
83
|
+
await expect(readFile(path, "utf8")).resolves.toBe(SUMMARY_SCAFFOLD);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("readSummaryFile", () => {
|
|
88
|
+
it("returns null when the file does not exist", async () => {
|
|
89
|
+
await expect(readSummaryFile(join(TEMP, "missing.md"))).resolves.toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null when the content is below the sanity minimum", async () => {
|
|
93
|
+
const dir = freshDir();
|
|
94
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: null });
|
|
95
|
+
// the scaffold itself is above the minimum; overwrite with something tiny
|
|
96
|
+
const { writeFile } = await import("node:fs/promises");
|
|
97
|
+
await writeFile(path, " short ", "utf8");
|
|
98
|
+
await expect(readSummaryFile(path)).resolves.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns the trimmed content for valid snapshots", async () => {
|
|
102
|
+
const dir = freshDir();
|
|
103
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: `${LONG_SNAPSHOT}\n\n` });
|
|
104
|
+
await expect(readSummaryFile(path)).resolves.toBe(LONG_SNAPSHOT);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("caps oversized snapshots at the maximum length", async () => {
|
|
108
|
+
const dir = freshDir();
|
|
109
|
+
const huge = "x".repeat(40_000);
|
|
110
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: huge });
|
|
111
|
+
const read = await readSummaryFile(path);
|
|
112
|
+
expect(read).toHaveLength(32_768);
|
|
113
|
+
expect(huge.startsWith(read ?? "")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("fetchPreviousSnapshot", () => {
|
|
118
|
+
function ctxWith(token: string | undefined): ToolContext {
|
|
119
|
+
return {
|
|
120
|
+
githubInstallationToken: token,
|
|
121
|
+
repo: { owner: "acme", name: "infra" },
|
|
122
|
+
} as unknown as ToolContext;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("returns null without an installation token", async () => {
|
|
126
|
+
await expect(fetchPreviousSnapshot(ctxWith(undefined), 7)).resolves.toBeNull();
|
|
127
|
+
expect(apiFetchMock).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null on non-ok responses", async () => {
|
|
131
|
+
apiFetchMock.mockResolvedValueOnce({ ok: false } as Response);
|
|
132
|
+
await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns the snapshot from the API response", async () => {
|
|
136
|
+
apiFetchMock.mockResolvedValueOnce({
|
|
137
|
+
ok: true,
|
|
138
|
+
json: async () => ({ snapshot: "previous summary" }),
|
|
139
|
+
} as Response);
|
|
140
|
+
|
|
141
|
+
await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBe("previous summary");
|
|
142
|
+
expect(apiFetchMock).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
path: "/api/repo/acme/infra/pr/7/summary-comment",
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: { authorization: "Bearer tok" },
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns null for empty or missing snapshots", async () => {
|
|
152
|
+
apiFetchMock.mockResolvedValueOnce({
|
|
153
|
+
ok: true,
|
|
154
|
+
json: async () => ({ snapshot: "" }),
|
|
155
|
+
} as Response);
|
|
156
|
+
await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
|
|
157
|
+
|
|
158
|
+
apiFetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response);
|
|
159
|
+
await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null when the API call throws", async () => {
|
|
163
|
+
apiFetchMock.mockRejectedValueOnce(new Error("network down"));
|
|
164
|
+
await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("persistSummary", () => {
|
|
169
|
+
function ctxWith(toolState: Record<string, unknown>): ToolContext {
|
|
170
|
+
return { toolState } as unknown as ToolContext;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it("does nothing when no summary file was seeded", async () => {
|
|
174
|
+
await persistSummary(ctxWith({}));
|
|
175
|
+
expect(patchMock).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does nothing when persistence was already attempted", async () => {
|
|
179
|
+
await persistSummary(ctxWith({ summaryFilePath: "/tmp/x.md", summaryPersistAttempted: true }));
|
|
180
|
+
expect(patchMock).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("skips the PATCH when the file is missing or invalid", async () => {
|
|
184
|
+
const toolState: Record<string, unknown> = {
|
|
185
|
+
summaryFilePath: join(TEMP, "never-written.md"),
|
|
186
|
+
};
|
|
187
|
+
await persistSummary(ctxWith(toolState));
|
|
188
|
+
expect(toolState.summaryPersistAttempted).toBe(true);
|
|
189
|
+
expect(patchMock).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("skips the PATCH when the agent never edited the seed", async () => {
|
|
193
|
+
const dir = freshDir();
|
|
194
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
|
|
195
|
+
await persistSummary(ctxWith({ summaryFilePath: path, summarySeed: `${LONG_SNAPSHOT}\n` }));
|
|
196
|
+
expect(patchMock).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("persists the snapshot when the agent edited the file", async () => {
|
|
200
|
+
const dir = freshDir();
|
|
201
|
+
const edited = `${LONG_SNAPSHOT}\n\n## What changed\n\n- reviewed the auth flow end to end`;
|
|
202
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: edited });
|
|
203
|
+
const ctx = ctxWith({ summaryFilePath: path, summarySeed: SUMMARY_SCAFFOLD });
|
|
204
|
+
|
|
205
|
+
await persistSummary(ctx);
|
|
206
|
+
|
|
207
|
+
expect(patchMock).toHaveBeenCalledWith(ctx, { summarySnapshot: edited });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("persists even without a recorded seed", async () => {
|
|
211
|
+
const dir = freshDir();
|
|
212
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
|
|
213
|
+
await persistSummary(ctxWith({ summaryFilePath: path }));
|
|
214
|
+
expect(patchMock).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("swallows PATCH failures (best-effort)", async () => {
|
|
218
|
+
const dir = freshDir();
|
|
219
|
+
const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
|
|
220
|
+
patchMock.mockRejectedValueOnce(new Error("api down"));
|
|
221
|
+
|
|
222
|
+
await expect(persistSummary(ctxWith({ summaryFilePath: path }))).resolves.toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
});
|