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,198 @@
|
|
|
1
|
+
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "#app/lifecycle";
|
|
2
|
+
import { log } from "#app/utils/cli";
|
|
3
|
+
import {
|
|
4
|
+
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
|
5
|
+
SPAWN_TIMEOUT_CODE,
|
|
6
|
+
SpawnTimeoutError,
|
|
7
|
+
spawn,
|
|
8
|
+
} from "#app/utils/subprocess";
|
|
9
|
+
|
|
10
|
+
export interface ExecuteLifecycleHookParams {
|
|
11
|
+
event: string;
|
|
12
|
+
script: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* when true, after the hook runs (success or failure), discard tracked-file
|
|
15
|
+
* mods so the agent doesn't see hook-generated drift (e.g. `pnpm install`
|
|
16
|
+
* rewriting a lockfile). untracked files are preserved — hooks that
|
|
17
|
+
* intentionally materialize files (e.g. a `.env` from a template) stay
|
|
18
|
+
* visible to the agent. skipped (with a warning) if the tree had
|
|
19
|
+
* pre-existing tracked changes before the hook ran, so we never clobber
|
|
20
|
+
* pre-existing work; pre-existing untracked files are ignored for this
|
|
21
|
+
* gate because `git restore --staged --worktree .` doesn't touch them
|
|
22
|
+
* anyway. no-op when no script was configured.
|
|
23
|
+
*/
|
|
24
|
+
normalizeWorkingTreeAfter?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** structured failure info — `output` on the `exit` variant is trimmed
|
|
28
|
+
* stderr, falling back to stdout when stderr is empty. */
|
|
29
|
+
export type LifecycleHookFailure =
|
|
30
|
+
| { kind: "exit"; exitCode: number; output: string }
|
|
31
|
+
| { kind: "timeout" }
|
|
32
|
+
| { kind: "spawn"; spawnError: string };
|
|
33
|
+
|
|
34
|
+
/** one-line, agent-facing description of a hook failure. empty string when
|
|
35
|
+
* there was no failure, so callers can pass the result straight through to a
|
|
36
|
+
* prompt section that omits itself on empty. */
|
|
37
|
+
export function describeSetupFailure(failure: LifecycleHookFailure | undefined): string {
|
|
38
|
+
if (!failure) return "";
|
|
39
|
+
switch (failure.kind) {
|
|
40
|
+
case "exit":
|
|
41
|
+
return `It exited with code ${failure.exitCode}. Output:\n\n${failure.output || "(empty)"}`;
|
|
42
|
+
case "timeout":
|
|
43
|
+
return "It timed out and was killed before completing.";
|
|
44
|
+
case "spawn":
|
|
45
|
+
return `It failed to start: ${failure.spawnError}`;
|
|
46
|
+
default: {
|
|
47
|
+
const _exhaustive: never = failure;
|
|
48
|
+
return _exhaustive satisfies never;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LifecycleHookResult {
|
|
54
|
+
/**
|
|
55
|
+
* human-readable warning when the hook failed. includes retry guidance:
|
|
56
|
+
* transient spawn/exit errors are worth retrying, timeouts and
|
|
57
|
+
* persistent failures are not. absent when the hook succeeded or was
|
|
58
|
+
* skipped. setup/post-checkout callers surface this verbatim; prepush
|
|
59
|
+
* builds its own message from `failure` instead.
|
|
60
|
+
*/
|
|
61
|
+
warning?: string;
|
|
62
|
+
/**
|
|
63
|
+
* structured failure info — undefined when the hook succeeded or was
|
|
64
|
+
* skipped. lets callers compose their own messaging without parsing the
|
|
65
|
+
* `warning` string.
|
|
66
|
+
*/
|
|
67
|
+
failure?: LifecycleHookFailure;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* execute a lifecycle hook script if one is configured.
|
|
72
|
+
*
|
|
73
|
+
* soft-fails: instead of throwing on hook errors, returns a warning string
|
|
74
|
+
* (and structured failure info) so callers can choose how to surface it
|
|
75
|
+
* (mcp tools relay it to the agent; setup logs it and adds a prompt banner).
|
|
76
|
+
* timeouts are flagged as non-retryable in the warning text.
|
|
77
|
+
*/
|
|
78
|
+
export async function executeLifecycleHook(
|
|
79
|
+
params: ExecuteLifecycleHookParams,
|
|
80
|
+
): Promise<LifecycleHookResult> {
|
|
81
|
+
if (!params.script) return {};
|
|
82
|
+
|
|
83
|
+
log.info(`» executing ${params.event} lifecycle hook...`);
|
|
84
|
+
|
|
85
|
+
// snapshot tracked-file mods BEFORE the hook runs so we can distinguish
|
|
86
|
+
// hook-generated drift from pre-existing work. both hook windows should
|
|
87
|
+
// start clean in normal operation (setup runs before any working-tree
|
|
88
|
+
// writes; checkout_pr refuses to run with a dirty tree), but if that
|
|
89
|
+
// invariant breaks we'd rather warn than discard whatever was there.
|
|
90
|
+
// pre-existing untracked files don't matter here — `git restore --staged
|
|
91
|
+
// --worktree .` never touches untracked files, so they're never at risk.
|
|
92
|
+
const preHookTrackedCount = params.normalizeWorkingTreeAfter
|
|
93
|
+
? (await runGitLines(["diff", "--name-only", "HEAD"])).length
|
|
94
|
+
: 0;
|
|
95
|
+
|
|
96
|
+
// single try/finally so normalization fires on success AND failure paths.
|
|
97
|
+
// a hook that fails partway through (e.g. `pnpm install` updates the
|
|
98
|
+
// lockfile then explodes on a peer-dep conflict) leaves the same kind of
|
|
99
|
+
// drift a successful run does, and the agent will see it next regardless
|
|
100
|
+
// of which path we took. failure-mode messaging is unchanged; the only
|
|
101
|
+
// delta is that we don't return tracked drift to the agent.
|
|
102
|
+
let result: LifecycleHookResult;
|
|
103
|
+
try {
|
|
104
|
+
try {
|
|
105
|
+
const spawnResult = await spawn({
|
|
106
|
+
cmd: "bash",
|
|
107
|
+
args: ["-c", params.script],
|
|
108
|
+
env: process.env,
|
|
109
|
+
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
|
|
110
|
+
activityTimeout: 0,
|
|
111
|
+
onStdout: (chunk) => process.stdout.write(chunk),
|
|
112
|
+
onStderr: (chunk) => process.stderr.write(chunk),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (spawnResult.exitCode !== 0) {
|
|
116
|
+
const output = (spawnResult.stderr || spawnResult.stdout).trim();
|
|
117
|
+
result = {
|
|
118
|
+
failure: { kind: "exit", output, exitCode: spawnResult.exitCode },
|
|
119
|
+
warning:
|
|
120
|
+
`lifecycle hook '${params.event}' failed with exit code ${spawnResult.exitCode}. ` +
|
|
121
|
+
`output: ${output || "(empty)"}. ` +
|
|
122
|
+
`retry the operation if the failure looks flaky (network blips, transient rate limits). ` +
|
|
123
|
+
`do NOT retry if the script is broken (missing commands, syntax errors) or the error is persistent.`,
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
log.info(`» ${params.event} lifecycle hook completed successfully`);
|
|
127
|
+
result = {};
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const isTimeout =
|
|
131
|
+
err instanceof SpawnTimeoutError &&
|
|
132
|
+
(err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
|
|
133
|
+
if (isTimeout) {
|
|
134
|
+
const minutes = Math.round(LIFECYCLE_HOOK_TIMEOUT_MS / 60000);
|
|
135
|
+
result = {
|
|
136
|
+
failure: { kind: "timeout" },
|
|
137
|
+
warning:
|
|
138
|
+
`lifecycle hook '${params.event}' timed out after ${minutes}min. ` +
|
|
139
|
+
`do NOT retry — the script is likely hung or doing too much work. ` +
|
|
140
|
+
`ask the repo owner to simplify the hook (e.g. move long-running work out of the hook, add caching, or split it).`,
|
|
141
|
+
};
|
|
142
|
+
} else {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
result = {
|
|
145
|
+
failure: { kind: "spawn", spawnError: msg },
|
|
146
|
+
warning:
|
|
147
|
+
`lifecycle hook '${params.event}' failed to spawn: ${msg}. ` +
|
|
148
|
+
`this is likely a transient failure — retry the operation.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
if (params.normalizeWorkingTreeAfter) {
|
|
154
|
+
await normalizeWorkingTreeAfterHook({ event: params.event, preHookTrackedCount });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* discard tracked-file mods left by a lifecycle hook so the agent's next
|
|
162
|
+
* `git status` matches the pre-hook state. untracked files (e.g. a `.env`
|
|
163
|
+
* the hook materialized from a template) are left alone — the agent decides
|
|
164
|
+
* what to do with them. skipped (with a warning) when the tree had
|
|
165
|
+
* pre-existing tracked changes before the hook ran, so pre-existing work
|
|
166
|
+
* is never clobbered. idempotent: a second call on a clean tree is a no-op
|
|
167
|
+
* and stays quiet.
|
|
168
|
+
*/
|
|
169
|
+
async function normalizeWorkingTreeAfterHook(params: {
|
|
170
|
+
event: string;
|
|
171
|
+
preHookTrackedCount: number;
|
|
172
|
+
}): Promise<void> {
|
|
173
|
+
if (params.preHookTrackedCount > 0) {
|
|
174
|
+
log.warning(
|
|
175
|
+
`» working tree had ${params.preHookTrackedCount} pre-existing tracked changes before ${params.event} hook; ` +
|
|
176
|
+
`skipping post-hook normalization to avoid clobbering pre-existing work`,
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const trackedCount = (await runGitLines(["diff", "--name-only", "HEAD"])).length;
|
|
181
|
+
if (trackedCount === 0) return;
|
|
182
|
+
await runGit(["restore", "--staged", "--worktree", "."]);
|
|
183
|
+
log.info(`» discarded ${trackedCount} tracked changes from ${params.event} hook`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runGit(args: string[]): Promise<string> {
|
|
187
|
+
const result = await spawn({ cmd: "git", args, env: process.env, activityTimeout: 0 });
|
|
188
|
+
if (result.exitCode !== 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return result.stdout;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function runGitLines(args: string[]): Promise<string[]> {
|
|
197
|
+
return (await runGit(args)).split("\n").filter(Boolean);
|
|
198
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import * as core from "@actions/core";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { AgentUsage } from "#app/agents/shared";
|
|
4
|
+
import {
|
|
5
|
+
formatIndentedField,
|
|
6
|
+
formatJsonValue,
|
|
7
|
+
formatUsageSummary,
|
|
8
|
+
log,
|
|
9
|
+
withLogPrefix,
|
|
10
|
+
writeSummary,
|
|
11
|
+
} from "#app/utils/log";
|
|
12
|
+
|
|
13
|
+
const coreMock = vi.hoisted(() => {
|
|
14
|
+
const summary = {
|
|
15
|
+
addRaw: vi.fn(),
|
|
16
|
+
write: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
summary.addRaw.mockReturnValue(summary);
|
|
19
|
+
summary.write.mockResolvedValue(summary);
|
|
20
|
+
return {
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
warning: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
debug: vi.fn(),
|
|
25
|
+
notice: vi.fn(),
|
|
26
|
+
isDebug: vi.fn(() => false),
|
|
27
|
+
startGroup: vi.fn(),
|
|
28
|
+
endGroup: vi.fn(),
|
|
29
|
+
summary,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
vi.mock("@actions/core", () => coreMock);
|
|
34
|
+
|
|
35
|
+
const globalsState = vi.hoisted(() => ({ isGitHubActions: false, isInsideDocker: false }));
|
|
36
|
+
|
|
37
|
+
vi.mock("#app/utils/globals", () => ({
|
|
38
|
+
get isGitHubActions() {
|
|
39
|
+
return globalsState.isGitHubActions;
|
|
40
|
+
},
|
|
41
|
+
get isInsideDocker() {
|
|
42
|
+
return globalsState.isInsideDocker;
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
function lastInfoMessage(): string {
|
|
47
|
+
const call = vi.mocked(core.info).mock.calls.at(-1);
|
|
48
|
+
if (!call) throw new Error("expected core.info to have been called");
|
|
49
|
+
return call[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
// clearAllMocks keeps mockReturnValue overrides — restore the default
|
|
55
|
+
coreMock.isDebug.mockReturnValue(false);
|
|
56
|
+
vi.unstubAllEnvs();
|
|
57
|
+
globalsState.isGitHubActions = false;
|
|
58
|
+
globalsState.isInsideDocker = false;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("log.info / warning / error / success", () => {
|
|
62
|
+
it("joins string, object, and Error arguments into one line", () => {
|
|
63
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
64
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
65
|
+
const error = new Error("kaboom");
|
|
66
|
+
log.info("hello", { a: 1 }, error);
|
|
67
|
+
|
|
68
|
+
const message = lastInfoMessage();
|
|
69
|
+
expect(message).toContain("hello");
|
|
70
|
+
expect(message).toContain('{"a":1}');
|
|
71
|
+
expect(message).toContain("kaboom");
|
|
72
|
+
expect(message).toContain(`${error.stack}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("routes warnings and errors to the matching core methods", () => {
|
|
76
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
77
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
78
|
+
log.warning("careful");
|
|
79
|
+
log.error("broken");
|
|
80
|
+
expect(core.warning).toHaveBeenCalledWith("careful");
|
|
81
|
+
expect(core.error).toHaveBeenCalledWith("broken");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("prefixes success messages with a chevron", () => {
|
|
85
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
86
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
87
|
+
log.success("done");
|
|
88
|
+
expect(lastInfoMessage()).toBe("» done");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("adds an ISO timestamp when debug mode is enabled", () => {
|
|
92
|
+
vi.stubEnv("LOG_LEVEL", "debug");
|
|
93
|
+
log.info("timed");
|
|
94
|
+
expect(lastInfoMessage()).toMatch(/^\[\d{4}-\d{2}-\d{2}T.*\] timed$/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("withLogPrefix", () => {
|
|
99
|
+
it("prefixes every line of a multi-line message in magenta", async () => {
|
|
100
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
101
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
102
|
+
await withLogPrefix("[task]", async () => {
|
|
103
|
+
log.info("line one\nline two");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const message = lastInfoMessage();
|
|
107
|
+
const lines = message.split("\n");
|
|
108
|
+
expect(lines).toHaveLength(2);
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
expect(line).toContain("\x1b[35m[task]\x1b[0m ");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not prefix messages logged outside the context", () => {
|
|
115
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
116
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
117
|
+
log.info("plain");
|
|
118
|
+
expect(lastInfoMessage()).toBe("plain");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("log.debug", () => {
|
|
123
|
+
it("uses core.debug when the runner debug flag is on", () => {
|
|
124
|
+
vi.mocked(core.isDebug).mockReturnValue(true);
|
|
125
|
+
log.debug("runner-debug");
|
|
126
|
+
expect(core.debug).toHaveBeenCalledWith("runner-debug");
|
|
127
|
+
expect(core.info).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("falls back to core.info with a [DEBUG] marker when LOG_LEVEL=debug", () => {
|
|
131
|
+
vi.stubEnv("LOG_LEVEL", "debug");
|
|
132
|
+
log.debug("local-debug");
|
|
133
|
+
expect(core.debug).not.toHaveBeenCalled();
|
|
134
|
+
expect(lastInfoMessage()).toMatch(/\[DEBUG\] local-debug$/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("also honors ACTIONS_STEP_DEBUG=true for local debug", () => {
|
|
138
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
139
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "true");
|
|
140
|
+
log.debug("step-debug");
|
|
141
|
+
expect(lastInfoMessage()).toMatch(/\[DEBUG\] step-debug$/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("is silent when no debug mode is enabled", () => {
|
|
145
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
146
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
147
|
+
log.debug("quiet");
|
|
148
|
+
expect(core.debug).not.toHaveBeenCalled();
|
|
149
|
+
expect(core.info).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("groups", () => {
|
|
154
|
+
it("uses core groups in GitHub Actions, with the plain prefix", async () => {
|
|
155
|
+
globalsState.isGitHubActions = true;
|
|
156
|
+
await withLogPrefix("[task]", async () => {
|
|
157
|
+
log.startGroup("setup");
|
|
158
|
+
});
|
|
159
|
+
log.endGroup();
|
|
160
|
+
expect(core.startGroup).toHaveBeenCalledWith("[task] setup");
|
|
161
|
+
expect(core.endGroup).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses console groups locally", () => {
|
|
165
|
+
const groupSpy = vi.spyOn(console, "group").mockImplementation(() => {});
|
|
166
|
+
const groupEndSpy = vi.spyOn(console, "groupEnd").mockImplementation(() => {});
|
|
167
|
+
|
|
168
|
+
log.startGroup("local");
|
|
169
|
+
log.endGroup();
|
|
170
|
+
|
|
171
|
+
expect(groupSpy).toHaveBeenCalledWith("local");
|
|
172
|
+
expect(groupEndSpy).toHaveBeenCalled();
|
|
173
|
+
groupSpy.mockRestore();
|
|
174
|
+
groupEndSpy.mockRestore();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("log.group runs the callback between start and end", () => {
|
|
178
|
+
globalsState.isGitHubActions = true;
|
|
179
|
+
const fn = vi.fn(() => {
|
|
180
|
+
expect(core.startGroup).toHaveBeenCalled();
|
|
181
|
+
expect(core.endGroup).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
log.group("wrapped", fn);
|
|
184
|
+
expect(fn).toHaveBeenCalled();
|
|
185
|
+
expect(core.endGroup).toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("log.box", () => {
|
|
190
|
+
it("draws a box with a title line", () => {
|
|
191
|
+
log.box("hello", { title: "Greeting" });
|
|
192
|
+
const message = lastInfoMessage();
|
|
193
|
+
expect(message).toContain("┌ Greeting ");
|
|
194
|
+
expect(message).toContain("│ hello");
|
|
195
|
+
expect(message).toContain("└");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("draws a box without a title", () => {
|
|
199
|
+
log.box("hello");
|
|
200
|
+
const message = lastInfoMessage();
|
|
201
|
+
expect(message).toMatch(/┌─+┐/);
|
|
202
|
+
expect(message).toContain("│ hello │");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("wraps long lines at maxWidth", () => {
|
|
206
|
+
log.box("alpha beta gamma delta", { maxWidth: 14 });
|
|
207
|
+
const message = lastInfoMessage();
|
|
208
|
+
const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
|
|
209
|
+
expect(contentLines.length).toBeGreaterThan(1);
|
|
210
|
+
for (const line of contentLines) {
|
|
211
|
+
expect(line.length).toBeLessThanOrEqual(14 + 2);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("breaks words longer than the box width into chunks", () => {
|
|
216
|
+
log.box("abcdefghijklmnopqrstuvwxyz", { maxWidth: 12 });
|
|
217
|
+
const message = lastInfoMessage();
|
|
218
|
+
expect(message).toContain("abcdefghij");
|
|
219
|
+
expect(message).toContain("klmnopqrst");
|
|
220
|
+
expect(message).toContain("uvwxyz");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("handles a word that splits into exact chunks with no remainder", () => {
|
|
224
|
+
// 20 chars with maxWidth 12 (padding 1) → two exact 10-char chunks
|
|
225
|
+
log.box("abcdefghijklmnopqrst", { maxWidth: 12 });
|
|
226
|
+
const message = lastInfoMessage();
|
|
227
|
+
const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
|
|
228
|
+
expect(contentLines).toHaveLength(2);
|
|
229
|
+
expect(message).toContain("abcdefghij");
|
|
230
|
+
expect(message).toContain("klmnopqrst");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("drops a trailing empty word left over after wrapping", () => {
|
|
234
|
+
// the inner line ends with a space: the final empty word forces a wrap
|
|
235
|
+
// that leaves nothing to flush after the loop
|
|
236
|
+
log.box("abcdefghij \nnext", { maxWidth: 12 });
|
|
237
|
+
const message = lastInfoMessage();
|
|
238
|
+
const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
|
|
239
|
+
expect(contentLines).toHaveLength(2);
|
|
240
|
+
expect(message).toContain("abcdefghij");
|
|
241
|
+
expect(message).toContain("next");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("log.table", () => {
|
|
246
|
+
it("renders header objects and plain string cells", () => {
|
|
247
|
+
log.table([
|
|
248
|
+
[{ data: "Name", header: true }, "Value"],
|
|
249
|
+
["tokens", "42"],
|
|
250
|
+
]);
|
|
251
|
+
const message = lastInfoMessage();
|
|
252
|
+
expect(message).toContain("Name");
|
|
253
|
+
expect(message).toContain("tokens");
|
|
254
|
+
expect(message).toContain("42");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("prints the title before the table when provided", () => {
|
|
258
|
+
log.table([["only"]], { title: "Usage" });
|
|
259
|
+
const calls = vi.mocked(core.info).mock.calls.map((call) => call[0]);
|
|
260
|
+
expect(calls.some((message) => message.includes("Usage"))).toBe(true);
|
|
261
|
+
expect(calls).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("log.separator", () => {
|
|
266
|
+
it("prints 50 dashes by default", () => {
|
|
267
|
+
log.separator();
|
|
268
|
+
expect(lastInfoMessage()).toBe("─".repeat(50));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("honors a custom length", () => {
|
|
272
|
+
log.separator(3);
|
|
273
|
+
expect(lastInfoMessage()).toBe("───");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("log.toolCall", () => {
|
|
278
|
+
it("renders empty input as a bare call", () => {
|
|
279
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
280
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
281
|
+
log.toolCall({ toolName: "Read", input: {} });
|
|
282
|
+
expect(lastInfoMessage()).toBe("» Read()");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("renders compact JSON input inline", () => {
|
|
286
|
+
vi.stubEnv("LOG_LEVEL", "");
|
|
287
|
+
vi.stubEnv("ACTIONS_STEP_DEBUG", "");
|
|
288
|
+
log.toolCall({ toolName: "Read", input: { file: "a.ts" } });
|
|
289
|
+
expect(lastInfoMessage()).toBe('» Read({"file":"a.ts"})');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("writeSummary", () => {
|
|
294
|
+
it("does nothing outside GitHub Actions", async () => {
|
|
295
|
+
await writeSummary("text");
|
|
296
|
+
expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does nothing inside Docker even in GitHub Actions", async () => {
|
|
300
|
+
globalsState.isGitHubActions = true;
|
|
301
|
+
globalsState.isInsideDocker = true;
|
|
302
|
+
await writeSummary("text");
|
|
303
|
+
expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("does nothing when GITHUB_STEP_SUMMARY is unset", async () => {
|
|
307
|
+
globalsState.isGitHubActions = true;
|
|
308
|
+
vi.stubEnv("GITHUB_STEP_SUMMARY", "");
|
|
309
|
+
await writeSummary("text");
|
|
310
|
+
expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("overwrites the job summary when fully configured", async () => {
|
|
314
|
+
globalsState.isGitHubActions = true;
|
|
315
|
+
vi.stubEnv("GITHUB_STEP_SUMMARY", "/tmp/summary.md");
|
|
316
|
+
await writeSummary("# report");
|
|
317
|
+
expect(coreMock.summary.addRaw).toHaveBeenCalledWith("# report");
|
|
318
|
+
expect(coreMock.summary.write).toHaveBeenCalledWith({ overwrite: true });
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("formatJsonValue", () => {
|
|
323
|
+
it("uses compact JSON for short values", () => {
|
|
324
|
+
expect(formatJsonValue({ a: 1 })).toBe('{"a":1}');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("pretty-prints values whose compact form exceeds 80 chars", () => {
|
|
328
|
+
const value = { key: "x".repeat(100) };
|
|
329
|
+
expect(formatJsonValue(value)).toBe(JSON.stringify(value, null, 2));
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("formatIndentedField", () => {
|
|
334
|
+
it("renders single-line content inline", () => {
|
|
335
|
+
expect(formatIndentedField("label", "value")).toBe(" label: value\n");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("indents continuation lines by four spaces", () => {
|
|
339
|
+
expect(formatIndentedField("label", "first\nsecond\nthird")).toBe(
|
|
340
|
+
" label: first\n second\n third\n",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("formatUsageSummary", () => {
|
|
346
|
+
it("returns an empty string for no entries", () => {
|
|
347
|
+
expect(formatUsageSummary([])).toBe("");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("renders a single row without a totals row, recovering non-cached input", () => {
|
|
351
|
+
const entries: AgentUsage[] = [
|
|
352
|
+
{
|
|
353
|
+
agent: "claude",
|
|
354
|
+
inputTokens: 1500,
|
|
355
|
+
outputTokens: 200,
|
|
356
|
+
cacheReadTokens: 400,
|
|
357
|
+
cacheWriteTokens: 100,
|
|
358
|
+
costUsd: 0.5,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
const summary = formatUsageSummary(entries);
|
|
362
|
+
expect(summary).toContain("| claude | 1,000 | 400 | 100 | 200 | 1,700 | 0.5000 |");
|
|
363
|
+
expect(summary).not.toContain("**Total**");
|
|
364
|
+
expect(summary).toContain("<details>");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("shows an em dash for missing or zero cost and clamps negative non-cached input", () => {
|
|
368
|
+
const entries: AgentUsage[] = [
|
|
369
|
+
// cache fields exceed inputTokens — non-cached input must clamp to 0
|
|
370
|
+
{ agent: "weird", inputTokens: 100, outputTokens: 10, cacheReadTokens: 300 },
|
|
371
|
+
];
|
|
372
|
+
const summary = formatUsageSummary(entries);
|
|
373
|
+
expect(summary).toContain("| weird | 0 | 300 | 0 | 10 | 310 | — |");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("adds a bold totals row when there are multiple entries", () => {
|
|
377
|
+
const entries: AgentUsage[] = [
|
|
378
|
+
{ agent: "a", inputTokens: 1000, outputTokens: 100, costUsd: 0.25 },
|
|
379
|
+
{
|
|
380
|
+
agent: "b",
|
|
381
|
+
inputTokens: 2000,
|
|
382
|
+
outputTokens: 200,
|
|
383
|
+
cacheReadTokens: 500,
|
|
384
|
+
cacheWriteTokens: 250,
|
|
385
|
+
costUsd: 0.75,
|
|
386
|
+
},
|
|
387
|
+
];
|
|
388
|
+
const summary = formatUsageSummary(entries);
|
|
389
|
+
expect(summary).toContain(
|
|
390
|
+
"| **Total** | **2,250** | **500** | **250** | **300** | **3,300** | **1.0000** |",
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("shows an em dash in the totals row when no entry has a cost", () => {
|
|
395
|
+
const entries: AgentUsage[] = [
|
|
396
|
+
{ agent: "a", inputTokens: 10, outputTokens: 1 },
|
|
397
|
+
{ agent: "b", inputTokens: 20, outputTokens: 2 },
|
|
398
|
+
];
|
|
399
|
+
const summary = formatUsageSummary(entries);
|
|
400
|
+
expect(summary).toContain("| **Total** | **30** | **0** | **0** | **3** | **33** | — |");
|
|
401
|
+
});
|
|
402
|
+
});
|