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,752 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
CheckoutPrTool,
|
|
7
|
+
checkoutPrBranch,
|
|
8
|
+
type FormatFilesResult,
|
|
9
|
+
formatFilesWithLineNumbers,
|
|
10
|
+
type PrData,
|
|
11
|
+
} from "#app/mcp/checkout";
|
|
12
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
13
|
+
import type { ToolResult } from "#app/mcp/shared";
|
|
14
|
+
|
|
15
|
+
const mocks = vi.hoisted(() => ({
|
|
16
|
+
$: vi.fn<(cmd: string, args: string[], opts?: unknown) => string>(),
|
|
17
|
+
$git: vi.fn<(sub: string, args: string[], opts?: unknown) => Promise<unknown>>(),
|
|
18
|
+
$gitFetchWithDeepen:
|
|
19
|
+
vi.fn<(args: string[], opts?: unknown, label?: string) => Promise<unknown>>(),
|
|
20
|
+
executeLifecycleHook: vi.fn<(params: unknown) => Promise<{ warning?: string }>>(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// controls for the node:fs statSync/unlinkSync overrides used by the stale
|
|
24
|
+
// git-lock sweep. everything else falls through to the real node:fs.
|
|
25
|
+
const fsCtl = vi.hoisted(() => ({
|
|
26
|
+
locks: new Map<string, number>(),
|
|
27
|
+
unlinked: [] as string[],
|
|
28
|
+
unlinkError: false,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("#app/utils/shell", () => ({ $: mocks.$ }));
|
|
32
|
+
vi.mock("#app/utils/gitAuth", () => ({
|
|
33
|
+
$git: mocks.$git,
|
|
34
|
+
$gitFetchWithDeepen: mocks.$gitFetchWithDeepen,
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("#app/utils/lifecycle", () => ({ executeLifecycleHook: mocks.executeLifecycleHook }));
|
|
37
|
+
// neutralize backoff sleeps (utils/retry sleeps via node:timers/promises,
|
|
38
|
+
// which vitest fake timers do not intercept) so retry-loop tests run instantly.
|
|
39
|
+
vi.mock("node:timers/promises", async (importOriginal) => {
|
|
40
|
+
const actual = await importOriginal<typeof import("node:timers/promises")>();
|
|
41
|
+
return { ...actual, setTimeout: () => Promise.resolve() };
|
|
42
|
+
});
|
|
43
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
44
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
45
|
+
const statSync = ((path: unknown, ...rest: unknown[]) => {
|
|
46
|
+
if (typeof path === "string" && fsCtl.locks.has(path)) {
|
|
47
|
+
return { mtimeMs: fsCtl.locks.get(path) ?? 0 } as ReturnType<typeof actual.statSync>;
|
|
48
|
+
}
|
|
49
|
+
if (typeof path === "string" && path.startsWith(".git/")) {
|
|
50
|
+
throw new Error("ENOENT");
|
|
51
|
+
}
|
|
52
|
+
return (actual.statSync as (...a: unknown[]) => unknown)(path, ...rest);
|
|
53
|
+
}) as typeof actual.statSync;
|
|
54
|
+
const unlinkSync = ((path: unknown) => {
|
|
55
|
+
if (fsCtl.unlinkError) throw new Error("EPERM");
|
|
56
|
+
fsCtl.unlinked.push(String(path));
|
|
57
|
+
}) as typeof actual.unlinkSync;
|
|
58
|
+
return { ...actual, statSync, unlinkSync };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* parses TOC entries like "- src/math.ts → lines 7-42 · diff-<hex>" into structured data.
|
|
63
|
+
*/
|
|
64
|
+
function parseTocEntries(toc: string) {
|
|
65
|
+
const entries: Array<{ filename: string; startLine: number; endLine: number }> = [];
|
|
66
|
+
for (const line of toc.split("\n")) {
|
|
67
|
+
const match = line.match(/^- (.+) → lines (\d+)-(\d+) · diff-[0-9a-f]+$/);
|
|
68
|
+
if (match) {
|
|
69
|
+
entries.push({
|
|
70
|
+
filename: match[1] ?? "",
|
|
71
|
+
startLine: parseInt(match[2] ?? "", 10),
|
|
72
|
+
endLine: parseInt(match[3] ?? "", 10),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// fixture captured by action/scripts/refresh-test-fixtures.ts. running
|
|
80
|
+
// the formatter against checked-in JSON keeps this test offline and
|
|
81
|
+
// deterministic — re-fetch the fixture (with creds) when GitHub's
|
|
82
|
+
// pulls.listFiles response shape changes, then review the snapshot diff.
|
|
83
|
+
type DiffFixture = {
|
|
84
|
+
owner: string;
|
|
85
|
+
name: string;
|
|
86
|
+
pullNumber: number;
|
|
87
|
+
files: Parameters<typeof formatFilesWithLineNumbers>[0];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function loadFixture<T>(file: string): T {
|
|
91
|
+
return JSON.parse(readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")) as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("formatFilesWithLineNumbers", () => {
|
|
95
|
+
it("generates accurate TOC line numbers for terramend/test-repo#1", () => {
|
|
96
|
+
const fx = loadFixture<DiffFixture>("terramend-test-repo-pr-1.diff.json");
|
|
97
|
+
const result: FormatFilesResult = formatFilesWithLineNumbers(fx.files);
|
|
98
|
+
|
|
99
|
+
expect(result.content.startsWith(result.toc)).toBe(true);
|
|
100
|
+
|
|
101
|
+
const contentLines = result.content.split("\n");
|
|
102
|
+
const tocEntries = parseTocEntries(result.toc);
|
|
103
|
+
expect(tocEntries.length).toBeGreaterThan(0);
|
|
104
|
+
|
|
105
|
+
for (const entry of tocEntries) {
|
|
106
|
+
// line numbers are 1-indexed, arrays are 0-indexed
|
|
107
|
+
const firstLine = contentLines[entry.startLine - 1];
|
|
108
|
+
expect(firstLine).toBeDefined();
|
|
109
|
+
// first line of each file section should be the diff header
|
|
110
|
+
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
|
111
|
+
|
|
112
|
+
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// verify adjacent files don't overlap and are contiguous
|
|
116
|
+
for (let i = 1; i < tocEntries.length; i++) {
|
|
117
|
+
const prev = tocEntries[i - 1];
|
|
118
|
+
const curr = tocEntries[i];
|
|
119
|
+
expect(curr?.startLine).toBe((prev?.endLine ?? Number.NaN) + 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(result.toc).toMatchSnapshot("toc");
|
|
123
|
+
expect(result.content).toMatchSnapshot("content");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renders binary files (no patch) with a placeholder and a TOC entry", () => {
|
|
127
|
+
const result = formatFilesWithLineNumbers([
|
|
128
|
+
{ filename: "logo.png", patch: undefined } as unknown as Parameters<
|
|
129
|
+
typeof formatFilesWithLineNumbers
|
|
130
|
+
>[0][number],
|
|
131
|
+
]);
|
|
132
|
+
expect(result.content).toContain("(binary file or no changes)");
|
|
133
|
+
expect(result.toc).toContain("logo.png");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("numbers added, removed, and context lines and passes through markers", () => {
|
|
137
|
+
const patch = [
|
|
138
|
+
"@@ -1,3 +1,3 @@ fn header",
|
|
139
|
+
" context-line",
|
|
140
|
+
"-removed-line",
|
|
141
|
+
"+added-line",
|
|
142
|
+
"\",
|
|
143
|
+
"Xunknown-line-type",
|
|
144
|
+
].join("\n");
|
|
145
|
+
const result = formatFilesWithLineNumbers([
|
|
146
|
+
{ filename: "src/a.ts", patch } as unknown as Parameters<
|
|
147
|
+
typeof formatFilesWithLineNumbers
|
|
148
|
+
>[0][number],
|
|
149
|
+
]);
|
|
150
|
+
expect(result.content).toContain("@@ -1,3 +1,3 @@ fn header");
|
|
151
|
+
expect(result.content).toContain("| 1 | 1 | | context-line");
|
|
152
|
+
expect(result.content).toContain("| 2 | | - | removed-line");
|
|
153
|
+
expect(result.content).toContain("| | 2 | + | added-line");
|
|
154
|
+
expect(result.content).toContain("\");
|
|
155
|
+
expect(result.content).toContain("Xunknown-line-type");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("handles an empty file list", () => {
|
|
159
|
+
const result = formatFilesWithLineNumbers([]);
|
|
160
|
+
expect(result.toc).toContain("## Files (0)");
|
|
161
|
+
expect(result.content.startsWith(result.toc)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// checkoutPrBranch + CheckoutPrTool (everything below uses the module mocks)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const HEAD_SHA = "headsha123abcdef";
|
|
170
|
+
const BASE_SHA = "basesha000000000";
|
|
171
|
+
|
|
172
|
+
function makePr(over: Partial<PrData> = {}): PrData {
|
|
173
|
+
return {
|
|
174
|
+
number: 5,
|
|
175
|
+
headSha: HEAD_SHA,
|
|
176
|
+
headRef: "feature",
|
|
177
|
+
headRepoFullName: "o/r",
|
|
178
|
+
baseRef: "main",
|
|
179
|
+
baseRepoFullName: "o/r",
|
|
180
|
+
maintainerCanModify: true,
|
|
181
|
+
...over,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type FakeOctokit = {
|
|
186
|
+
paginate: ReturnType<typeof vi.fn>;
|
|
187
|
+
rest: {
|
|
188
|
+
pulls: { get: ReturnType<typeof vi.fn>; listFiles: Record<string, never> };
|
|
189
|
+
repos: { compareCommits: ReturnType<typeof vi.fn> };
|
|
190
|
+
git: { createRef: ReturnType<typeof vi.fn>; deleteRef: ReturnType<typeof vi.fn> };
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function makeOctokit(): FakeOctokit {
|
|
195
|
+
return {
|
|
196
|
+
paginate: vi.fn(async () => []),
|
|
197
|
+
rest: {
|
|
198
|
+
pulls: { get: vi.fn(), listFiles: {} },
|
|
199
|
+
repos: { compareCommits: vi.fn() },
|
|
200
|
+
git: {
|
|
201
|
+
createRef: vi.fn(async () => ({ data: {} })),
|
|
202
|
+
deleteRef: vi.fn(async () => ({ data: {} })),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type BranchParams = Parameters<typeof checkoutPrBranch>[1];
|
|
209
|
+
|
|
210
|
+
function makeParams(over: Partial<Record<string, unknown>> = {}): BranchParams {
|
|
211
|
+
return {
|
|
212
|
+
octokit: over.octokit ?? makeOctokit(),
|
|
213
|
+
owner: "o",
|
|
214
|
+
name: "r",
|
|
215
|
+
gitToken: "tok",
|
|
216
|
+
toolState: over.toolState ?? {},
|
|
217
|
+
shell: "disabled",
|
|
218
|
+
postCheckoutScript: null,
|
|
219
|
+
beforeSha: over.beforeSha,
|
|
220
|
+
} as unknown as BranchParams;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type DollarResponse = string | (() => string);
|
|
224
|
+
|
|
225
|
+
function dispatch(table: Record<string, DollarResponse | DollarResponse[]>): void {
|
|
226
|
+
mocks.$.mockImplementation((cmd, args) => {
|
|
227
|
+
const key = `${cmd} ${args.join(" ")}`;
|
|
228
|
+
const entry = table[key];
|
|
229
|
+
if (entry === undefined) throw new Error(`unexpected $ call: ${key}`);
|
|
230
|
+
const next = Array.isArray(entry) ? entry.shift() : entry;
|
|
231
|
+
if (next === undefined) throw new Error(`exhausted responses for: ${key}`);
|
|
232
|
+
return typeof next === "function" ? next() : next;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** dispatch table for a clean same-repo PR #5 checkout (non-shallow) */
|
|
237
|
+
function branchDispatch(extra: Record<string, DollarResponse | DollarResponse[]> = {}): void {
|
|
238
|
+
dispatch({
|
|
239
|
+
"git rev-parse --is-shallow-repository": "false",
|
|
240
|
+
"git rev-parse HEAD": [BASE_SHA, HEAD_SHA],
|
|
241
|
+
"git checkout -B main origin/main": "",
|
|
242
|
+
"git checkout pr-5": "",
|
|
243
|
+
"git config branch.pr-5.pushRemote origin": "",
|
|
244
|
+
"git config branch.pr-5.merge refs/heads/feature": "",
|
|
245
|
+
...extra,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
describe("checkoutPrBranch", () => {
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
vi.resetAllMocks();
|
|
252
|
+
fsCtl.locks.clear();
|
|
253
|
+
fsCtl.unlinked.length = 0;
|
|
254
|
+
fsCtl.unlinkError = false;
|
|
255
|
+
mocks.$gitFetchWithDeepen.mockResolvedValue({ stdout: "", stderr: "" });
|
|
256
|
+
mocks.executeLifecycleHook.mockResolvedValue({});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("rejects attacker-controlled refs with a leading dash", async () => {
|
|
260
|
+
await expect(checkoutPrBranch(makePr({ baseRef: "-evil" }), makeParams())).rejects.toThrow(
|
|
261
|
+
/starts with '-'/,
|
|
262
|
+
);
|
|
263
|
+
await expect(
|
|
264
|
+
checkoutPrBranch(makePr({ headRef: "--upload-pack=evil" }), makeParams()),
|
|
265
|
+
).rejects.toThrow(/starts with '-'/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("checks out a same-repo PR and stores the push destination", async () => {
|
|
269
|
+
branchDispatch();
|
|
270
|
+
const toolState: Record<string, unknown> = {};
|
|
271
|
+
const result = await checkoutPrBranch(makePr(), makeParams({ toolState }));
|
|
272
|
+
|
|
273
|
+
expect(result.hookWarning).toBeUndefined();
|
|
274
|
+
expect(toolState.issueNumber).toBe(5);
|
|
275
|
+
expect(toolState.checkoutSha).toBe(HEAD_SHA);
|
|
276
|
+
expect(toolState.pushUrl).toBeUndefined();
|
|
277
|
+
expect(toolState.pushDest).toEqual({
|
|
278
|
+
remoteName: "origin",
|
|
279
|
+
remoteBranch: "feature",
|
|
280
|
+
localBranch: "pr-5",
|
|
281
|
+
});
|
|
282
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledWith(
|
|
283
|
+
["--no-tags", "origin", "main"],
|
|
284
|
+
{ token: "tok" },
|
|
285
|
+
"base branch main",
|
|
286
|
+
);
|
|
287
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledWith(
|
|
288
|
+
["--no-tags", "origin", "+pull/5/head:pr-5"],
|
|
289
|
+
{ token: "tok" },
|
|
290
|
+
"PR #5",
|
|
291
|
+
);
|
|
292
|
+
expect(mocks.executeLifecycleHook).toHaveBeenCalledWith({
|
|
293
|
+
event: "post-checkout",
|
|
294
|
+
script: null,
|
|
295
|
+
normalizeWorkingTreeAfter: true,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("surfaces a post-checkout hook warning without failing the checkout", async () => {
|
|
300
|
+
branchDispatch();
|
|
301
|
+
mocks.executeLifecycleHook.mockResolvedValue({ warning: "hook flaked" });
|
|
302
|
+
const result = await checkoutPrBranch(makePr(), makeParams());
|
|
303
|
+
expect(result.hookWarning).toBe("hook flaked");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("skips fetch+checkout when HEAD already matches the PR head SHA", async () => {
|
|
307
|
+
// dispatch deliberately omits checkout keys: any checkout call would throw
|
|
308
|
+
dispatch({
|
|
309
|
+
"git rev-parse --is-shallow-repository": "false",
|
|
310
|
+
"git rev-parse HEAD": HEAD_SHA,
|
|
311
|
+
"git config branch.pr-5.pushRemote origin": "",
|
|
312
|
+
"git config branch.pr-5.merge refs/heads/feature": "",
|
|
313
|
+
});
|
|
314
|
+
await checkoutPrBranch(makePr(), makeParams());
|
|
315
|
+
// only the base fetch happened
|
|
316
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledTimes(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("configures a named fork remote and push URL for fork PRs", async () => {
|
|
320
|
+
dispatch({
|
|
321
|
+
"git rev-parse --is-shallow-repository": "false",
|
|
322
|
+
"git rev-parse HEAD": [BASE_SHA, HEAD_SHA],
|
|
323
|
+
"git checkout -B main origin/main": "",
|
|
324
|
+
"git checkout pr-5": "",
|
|
325
|
+
"git remote add pr-5 https://github.com/fork/r.git": "",
|
|
326
|
+
"git config branch.pr-5.pushRemote pr-5": "",
|
|
327
|
+
"git config branch.pr-5.merge refs/heads/feature": "",
|
|
328
|
+
});
|
|
329
|
+
const toolState: Record<string, unknown> = {};
|
|
330
|
+
await checkoutPrBranch(
|
|
331
|
+
makePr({ headRepoFullName: "fork/r", maintainerCanModify: false }),
|
|
332
|
+
makeParams({ toolState }),
|
|
333
|
+
);
|
|
334
|
+
expect(toolState.pushUrl).toBe("https://github.com/fork/r.git");
|
|
335
|
+
expect(toolState.pushDest).toEqual({
|
|
336
|
+
remoteName: "pr-5",
|
|
337
|
+
remoteBranch: "feature",
|
|
338
|
+
localBranch: "pr-5",
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("updates the fork remote URL when the remote already exists", async () => {
|
|
343
|
+
dispatch({
|
|
344
|
+
"git rev-parse --is-shallow-repository": "false",
|
|
345
|
+
"git rev-parse HEAD": [BASE_SHA, HEAD_SHA],
|
|
346
|
+
"git checkout -B main origin/main": "",
|
|
347
|
+
"git checkout pr-5": "",
|
|
348
|
+
"git remote add pr-5 https://github.com/fork/r.git": () => {
|
|
349
|
+
throw new Error("error: remote pr-5 already exists");
|
|
350
|
+
},
|
|
351
|
+
"git remote set-url pr-5 https://github.com/fork/r.git": "",
|
|
352
|
+
"git config branch.pr-5.pushRemote pr-5": "",
|
|
353
|
+
"git config branch.pr-5.merge refs/heads/feature": "",
|
|
354
|
+
});
|
|
355
|
+
await expect(
|
|
356
|
+
checkoutPrBranch(makePr({ headRepoFullName: "fork/r" }), makeParams()),
|
|
357
|
+
).resolves.toBeDefined();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("sweeps stale git lock files but leaves fresh ones alone", async () => {
|
|
361
|
+
branchDispatch();
|
|
362
|
+
fsCtl.locks.set(".git/shallow.lock", Date.now() - 60_000);
|
|
363
|
+
fsCtl.locks.set(".git/index.lock", Date.now() - 1_000);
|
|
364
|
+
await checkoutPrBranch(makePr(), makeParams());
|
|
365
|
+
expect(fsCtl.unlinked).toContain(".git/shallow.lock");
|
|
366
|
+
expect(fsCtl.unlinked).not.toContain(".git/index.lock");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("does not fail the checkout when a stale lock cannot be removed", async () => {
|
|
370
|
+
branchDispatch();
|
|
371
|
+
fsCtl.locks.set(".git/shallow.lock", Date.now() - 60_000);
|
|
372
|
+
fsCtl.unlinkError = true;
|
|
373
|
+
await expect(checkoutPrBranch(makePr(), makeParams())).resolves.toBeDefined();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("deepens shallow clones using the compare API ahead/behind counts", async () => {
|
|
377
|
+
branchDispatch({ "git rev-parse --is-shallow-repository": "true" });
|
|
378
|
+
const octokit = makeOctokit();
|
|
379
|
+
octokit.rest.repos.compareCommits.mockResolvedValue({ data: { ahead_by: 3, behind_by: 1 } });
|
|
380
|
+
mocks.$git.mockResolvedValue({ stdout: "", stderr: "" });
|
|
381
|
+
await checkoutPrBranch(makePr(), makeParams({ octokit }));
|
|
382
|
+
expect(mocks.$git).toHaveBeenCalledWith("fetch", ["--deepen=13", "--no-tags", "origin"], {
|
|
383
|
+
token: "tok",
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("falls back to --deepen=1000 when the compare API fails", async () => {
|
|
388
|
+
branchDispatch({ "git rev-parse --is-shallow-repository": "true" });
|
|
389
|
+
const octokit = makeOctokit();
|
|
390
|
+
octokit.rest.repos.compareCommits.mockRejectedValue(new Error("API down"));
|
|
391
|
+
mocks.$git.mockResolvedValue({ stdout: "", stderr: "" });
|
|
392
|
+
await checkoutPrBranch(makePr(), makeParams({ octokit }));
|
|
393
|
+
expect(mocks.$git).toHaveBeenCalledWith("fetch", ["--deepen=1000", "--no-tags", "origin"], {
|
|
394
|
+
token: "tok",
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("treats a locally-reachable before_sha as reachable without a temp branch", async () => {
|
|
399
|
+
const beforeSha = "feedbeef00000000000000000000000000000000";
|
|
400
|
+
branchDispatch({ [`git cat-file -t ${beforeSha}`]: "commit" });
|
|
401
|
+
const octokit = makeOctokit();
|
|
402
|
+
await checkoutPrBranch(makePr(), makeParams({ octokit, beforeSha }));
|
|
403
|
+
expect(octokit.rest.git.createRef).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("fetches an unreachable before_sha via a disposable temp branch", async () => {
|
|
407
|
+
const beforeSha = "feedbeef00000000000000000000000000000000";
|
|
408
|
+
branchDispatch({
|
|
409
|
+
[`git cat-file -t ${beforeSha}`]: () => {
|
|
410
|
+
throw new Error("fatal: not a valid object name");
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
const octokit = makeOctokit();
|
|
414
|
+
await checkoutPrBranch(makePr(), makeParams({ octokit, beforeSha }));
|
|
415
|
+
const tempBranch = `terramend/tmp/${beforeSha.slice(0, 12)}`;
|
|
416
|
+
expect(octokit.rest.git.createRef).toHaveBeenCalledWith({
|
|
417
|
+
owner: "o",
|
|
418
|
+
repo: "r",
|
|
419
|
+
ref: `refs/heads/${tempBranch}`,
|
|
420
|
+
sha: beforeSha,
|
|
421
|
+
});
|
|
422
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledWith(
|
|
423
|
+
["--no-tags", "origin", tempBranch],
|
|
424
|
+
{ token: "tok" },
|
|
425
|
+
`before_sha temp branch ${tempBranch}`,
|
|
426
|
+
);
|
|
427
|
+
// async-dispose cleanup deleted the temp branch
|
|
428
|
+
expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
|
|
429
|
+
owner: "o",
|
|
430
|
+
repo: "r",
|
|
431
|
+
ref: `heads/${tempBranch}`,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("ignores temp-branch deletion failures during cleanup", async () => {
|
|
436
|
+
const beforeSha = "feedbeef00000000000000000000000000000000";
|
|
437
|
+
branchDispatch({
|
|
438
|
+
[`git cat-file -t ${beforeSha}`]: () => {
|
|
439
|
+
throw new Error("fatal: not a valid object name");
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
const octokit = makeOctokit();
|
|
443
|
+
octokit.rest.git.deleteRef.mockRejectedValue(new Error("404 Not Found"));
|
|
444
|
+
await expect(
|
|
445
|
+
checkoutPrBranch(makePr(), makeParams({ octokit, beforeSha })),
|
|
446
|
+
).resolves.toBeDefined();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("keeps retrying when the dispatchability probe itself fails (lenient)", async () => {
|
|
450
|
+
branchDispatch();
|
|
451
|
+
mocks.$gitFetchWithDeepen.mockImplementation(async (args: string[]) => {
|
|
452
|
+
if ((args[2] ?? "").startsWith("+pull/")) {
|
|
453
|
+
throw new Error("fatal: couldn't find remote ref pull/5/head");
|
|
454
|
+
}
|
|
455
|
+
return { stdout: "", stderr: "" };
|
|
456
|
+
});
|
|
457
|
+
const octokit = makeOctokit();
|
|
458
|
+
octokit.rest.pulls.get.mockRejectedValue(new Error("502 Bad Gateway"));
|
|
459
|
+
await expect(checkoutPrBranch(makePr(), makeParams({ octokit }))).rejects.toThrow(
|
|
460
|
+
/couldn't find remote ref/,
|
|
461
|
+
);
|
|
462
|
+
// probe failures never abort early: the full retry budget is spent
|
|
463
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledTimes(5);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("degrades gracefully when the temp branch cannot be created", async () => {
|
|
467
|
+
const beforeSha = "feedbeef00000000000000000000000000000000";
|
|
468
|
+
branchDispatch({
|
|
469
|
+
[`git cat-file -t ${beforeSha}`]: () => {
|
|
470
|
+
throw new Error("fatal: not a valid object name");
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
const octokit = makeOctokit();
|
|
474
|
+
octokit.rest.git.createRef.mockRejectedValue(new Error("422 Reference already exists"));
|
|
475
|
+
await expect(
|
|
476
|
+
checkoutPrBranch(makePr(), makeParams({ octokit, beforeSha })),
|
|
477
|
+
).resolves.toBeDefined();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("aborts cleanly when pull/N/head is missing because the PR moved on", async () => {
|
|
481
|
+
branchDispatch();
|
|
482
|
+
mocks.$gitFetchWithDeepen.mockImplementation(async (args: string[]) => {
|
|
483
|
+
if ((args[2] ?? "").startsWith("+pull/")) {
|
|
484
|
+
throw new Error("fatal: couldn't find remote ref pull/5/head");
|
|
485
|
+
}
|
|
486
|
+
return { stdout: "", stderr: "" };
|
|
487
|
+
});
|
|
488
|
+
const octokit = makeOctokit();
|
|
489
|
+
octokit.rest.pulls.get.mockResolvedValue({
|
|
490
|
+
data: { state: "closed", head: { sha: HEAD_SHA } },
|
|
491
|
+
});
|
|
492
|
+
await expect(checkoutPrBranch(makePr(), makeParams({ octokit }))).rejects.toThrow(
|
|
493
|
+
/no longer in the state it was at dispatch/,
|
|
494
|
+
);
|
|
495
|
+
// base fetch + a single PR fetch attempt: the abort fires before any retry
|
|
496
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledTimes(2);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("retries the missing-ref fetch while the PR still matches, then surfaces it", async () => {
|
|
500
|
+
branchDispatch();
|
|
501
|
+
mocks.$gitFetchWithDeepen.mockImplementation(async (args: string[]) => {
|
|
502
|
+
if ((args[2] ?? "").startsWith("+pull/")) {
|
|
503
|
+
throw new Error("fatal: couldn't find remote ref pull/5/head");
|
|
504
|
+
}
|
|
505
|
+
return { stdout: "", stderr: "" };
|
|
506
|
+
});
|
|
507
|
+
const octokit = makeOctokit();
|
|
508
|
+
octokit.rest.pulls.get.mockResolvedValue({
|
|
509
|
+
data: { state: "open", head: { sha: HEAD_SHA } },
|
|
510
|
+
});
|
|
511
|
+
await expect(checkoutPrBranch(makePr(), makeParams({ octokit }))).rejects.toThrow(
|
|
512
|
+
/couldn't find remote ref pull\/5\/head/,
|
|
513
|
+
);
|
|
514
|
+
// base fetch + 1 initial attempt + 3 backoff retries
|
|
515
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledTimes(5);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe("checkout_pr tool", () => {
|
|
520
|
+
const PR_FILES = [
|
|
521
|
+
{
|
|
522
|
+
filename: "src/a.ts",
|
|
523
|
+
patch: "@@ -1,2 +1,3 @@\n context\n+added\n context2",
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
function makeToolCtx(over: Partial<Record<string, unknown>> = {}): {
|
|
528
|
+
ctx: ToolContext;
|
|
529
|
+
octokit: FakeOctokit;
|
|
530
|
+
toolState: Record<string, unknown>;
|
|
531
|
+
} {
|
|
532
|
+
const octokit = (over.octokit as FakeOctokit | undefined) ?? makeOctokit();
|
|
533
|
+
const beforeSha = over.beforeSha as string | undefined;
|
|
534
|
+
octokit.rest.pulls.get.mockResolvedValue({
|
|
535
|
+
data: {
|
|
536
|
+
number: 5,
|
|
537
|
+
title: "improve things",
|
|
538
|
+
body: "pr body",
|
|
539
|
+
html_url: "https://github.com/o/r/pull/5",
|
|
540
|
+
maintainer_can_modify: true,
|
|
541
|
+
head: { sha: HEAD_SHA, ref: "feature", repo: { full_name: "o/r" } },
|
|
542
|
+
base: { ref: "main", repo: { full_name: "o/r" } },
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
octokit.paginate.mockResolvedValue(PR_FILES);
|
|
546
|
+
const toolState: Record<string, unknown> = {
|
|
547
|
+
initialHead: over.initialHead,
|
|
548
|
+
beforeSha,
|
|
549
|
+
diffCoverage: undefined,
|
|
550
|
+
};
|
|
551
|
+
const ctx = {
|
|
552
|
+
repo: { owner: "o", name: "r", data: { default_branch: "main" } },
|
|
553
|
+
payload: { shell: "disabled" },
|
|
554
|
+
octokit,
|
|
555
|
+
gitToken: "tok",
|
|
556
|
+
postCheckoutScript: null,
|
|
557
|
+
prepushScript: null,
|
|
558
|
+
toolState,
|
|
559
|
+
} as unknown as ToolContext;
|
|
560
|
+
return { ctx, octokit, toolState };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function runTool(t: { execute: unknown }, params: Record<string, unknown>): Promise<ToolResult> {
|
|
564
|
+
const exec = t.execute as (args: unknown, context?: unknown) => Promise<ToolResult>;
|
|
565
|
+
return exec(params);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function textOf(result: ToolResult): string {
|
|
569
|
+
return result.content[0]?.text ?? "";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** tool-level dispatch: status probe + the branch-checkout table + commit metadata */
|
|
573
|
+
function toolDispatch(extra: Record<string, DollarResponse | DollarResponse[]> = {}): void {
|
|
574
|
+
dispatch({
|
|
575
|
+
"git status --porcelain": "",
|
|
576
|
+
"git rev-parse --is-shallow-repository": "false",
|
|
577
|
+
"git rev-parse HEAD": [BASE_SHA, HEAD_SHA],
|
|
578
|
+
"git checkout -B main origin/main": "",
|
|
579
|
+
"git checkout pr-5": "",
|
|
580
|
+
"git config branch.pr-5.pushRemote origin": "",
|
|
581
|
+
"git config branch.pr-5.merge refs/heads/feature": "",
|
|
582
|
+
"git rev-list --count origin/main..HEAD": "2",
|
|
583
|
+
"git log --oneline --max-count=200 origin/main..HEAD": "abc one\ndef two",
|
|
584
|
+
...extra,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
beforeEach(() => {
|
|
589
|
+
vi.resetAllMocks();
|
|
590
|
+
fsCtl.locks.clear();
|
|
591
|
+
fsCtl.unlinked.length = 0;
|
|
592
|
+
fsCtl.unlinkError = false;
|
|
593
|
+
mocks.$gitFetchWithDeepen.mockResolvedValue({ stdout: "", stderr: "" });
|
|
594
|
+
mocks.executeLifecycleHook.mockResolvedValue({});
|
|
595
|
+
vi.stubEnv("TERRAMEND_TEMP_DIR", tmpdir());
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
afterEach(() => {
|
|
599
|
+
vi.unstubAllEnvs();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("refuses to run with a dirty working tree", async () => {
|
|
603
|
+
dispatch({ "git status --porcelain": " M main.tf\n?? stray.txt" });
|
|
604
|
+
const { ctx } = makeToolCtx();
|
|
605
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 3 });
|
|
606
|
+
expect(result.isError).toBe(true);
|
|
607
|
+
expect(textOf(result)).toContain("uncommitted changes");
|
|
608
|
+
// the status output is trimmed before being embedded in the message
|
|
609
|
+
expect(textOf(result)).toContain("M main.tf");
|
|
610
|
+
expect(textOf(result)).toContain("?? stray.txt");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("blocks checkout from a foreign pr-N branch (cross-PR clobber guard)", async () => {
|
|
614
|
+
dispatch({
|
|
615
|
+
"git status --porcelain": "",
|
|
616
|
+
"git symbolic-ref --short HEAD": "pr-9",
|
|
617
|
+
});
|
|
618
|
+
const { ctx } = makeToolCtx({ initialHead: { kind: "branch", name: "main" } });
|
|
619
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
620
|
+
expect(result.isError).toBe(true);
|
|
621
|
+
const text = textOf(result);
|
|
622
|
+
expect(text).toContain("cannot checkout PR #5 from branch `pr-9`");
|
|
623
|
+
expect(text).toContain("git checkout main");
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("blocks checkout from an unexpected detached HEAD", async () => {
|
|
627
|
+
dispatch({
|
|
628
|
+
"git status --porcelain": "",
|
|
629
|
+
"git symbolic-ref --short HEAD": () => {
|
|
630
|
+
throw new Error("fatal: ref HEAD is not a symbolic ref");
|
|
631
|
+
},
|
|
632
|
+
"git rev-parse HEAD": "0123456789abcdef",
|
|
633
|
+
});
|
|
634
|
+
const { ctx } = makeToolCtx({ initialHead: { kind: "detached", sha: "fedcba9876543210" } });
|
|
635
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
636
|
+
expect(result.isError).toBe(true);
|
|
637
|
+
const text = textOf(result);
|
|
638
|
+
expect(text).toContain("detached HEAD `0123456789abcdef`");
|
|
639
|
+
expect(text).toContain("git checkout fedcba9876543210");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("blocks checkout when HEAD kind differs from the run-entry kind", async () => {
|
|
643
|
+
dispatch({
|
|
644
|
+
"git status --porcelain": "",
|
|
645
|
+
"git symbolic-ref --short HEAD": () => {
|
|
646
|
+
throw new Error("fatal: not symbolic");
|
|
647
|
+
},
|
|
648
|
+
"git rev-parse HEAD": "0123456789abcdef",
|
|
649
|
+
});
|
|
650
|
+
const { ctx } = makeToolCtx({ initialHead: { kind: "branch", name: "main" } });
|
|
651
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
652
|
+
expect(result.isError).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("checks out a PR end to end and writes the formatted diff", async () => {
|
|
656
|
+
toolDispatch({ "git symbolic-ref --short HEAD": "main" });
|
|
657
|
+
const { ctx, toolState } = makeToolCtx({ initialHead: { kind: "branch", name: "main" } });
|
|
658
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
659
|
+
expect(result.isError).toBeUndefined();
|
|
660
|
+
const text = textOf(result);
|
|
661
|
+
expect(text).toContain("improve things");
|
|
662
|
+
expect(text).toContain("pr-5");
|
|
663
|
+
expect(toolState.issueNumber).toBe(5);
|
|
664
|
+
expect(toolState.commentableLinesPullNumber).toBe(5);
|
|
665
|
+
|
|
666
|
+
// the formatted diff landed on disk at the path named in the response
|
|
667
|
+
const diffPathMatch = text.match(/pr-5-[0-9a-z]+\.diff/);
|
|
668
|
+
expect(diffPathMatch).not.toBeNull();
|
|
669
|
+
const coverage = toolState.diffCoverage as { diffPath: string; totalLines: number };
|
|
670
|
+
expect(existsSync(coverage.diffPath)).toBe(true);
|
|
671
|
+
expect(readFileSync(coverage.diffPath, "utf-8")).toContain("src/a.ts");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("computes and writes an incremental diff when beforeSha is set", async () => {
|
|
675
|
+
const beforeSha = "feedbeef00000000000000000000000000000000";
|
|
676
|
+
toolDispatch({ [`git cat-file -t ${beforeSha}`]: "commit" });
|
|
677
|
+
// computeIncrementalDiff shells out via $("sh", ...) — feed it raw
|
|
678
|
+
// range-diff output containing a changed line so post-processing yields
|
|
679
|
+
// non-empty content.
|
|
680
|
+
const base = mocks.$.getMockImplementation();
|
|
681
|
+
mocks.$.mockImplementation((cmd, args, opts) => {
|
|
682
|
+
if (cmd === "sh") return " ++an added line\n";
|
|
683
|
+
return base ? base(cmd, args, opts) : "";
|
|
684
|
+
});
|
|
685
|
+
const { ctx } = makeToolCtx({ beforeSha });
|
|
686
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
687
|
+
expect(result.isError).toBeUndefined();
|
|
688
|
+
const text = textOf(result);
|
|
689
|
+
expect(text).toContain("-incremental.diff");
|
|
690
|
+
expect(text).toContain("read incrementalDiffPath FIRST");
|
|
691
|
+
const match = text.match(/incrementalDiffPath:\s*(\S+-incremental\.diff)/);
|
|
692
|
+
expect(match).not.toBeNull();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("degrades commit metadata gracefully when the base ref is unreachable", async () => {
|
|
696
|
+
toolDispatch({
|
|
697
|
+
"git rev-list --count origin/main..HEAD": () => {
|
|
698
|
+
throw new Error("fatal: bad revision 'origin/main..HEAD'");
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
const { ctx } = makeToolCtx();
|
|
702
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
703
|
+
expect(result.isError).toBeUndefined();
|
|
704
|
+
expect(textOf(result)).toContain("commit metadata is partial");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("forwards the post-checkout hook warning into the tool response", async () => {
|
|
708
|
+
toolDispatch();
|
|
709
|
+
mocks.executeLifecycleHook.mockResolvedValue({ warning: "hook flaked" });
|
|
710
|
+
const { ctx } = makeToolCtx();
|
|
711
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
712
|
+
expect(result.isError).toBeUndefined();
|
|
713
|
+
expect(textOf(result)).toContain("HOOK WARNING");
|
|
714
|
+
expect(textOf(result)).toContain("hook flaked");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("fails when TERRAMEND_TEMP_DIR is not set", async () => {
|
|
718
|
+
vi.stubEnv("TERRAMEND_TEMP_DIR", "");
|
|
719
|
+
toolDispatch();
|
|
720
|
+
const { ctx } = makeToolCtx();
|
|
721
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 5 });
|
|
722
|
+
expect(result.isError).toBe(true);
|
|
723
|
+
expect(textOf(result)).toContain("TERRAMEND_TEMP_DIR not set");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("dedupes concurrent checkout_pr calls for the same PR", async () => {
|
|
727
|
+
toolDispatch();
|
|
728
|
+
const { ctx, octokit } = makeToolCtx();
|
|
729
|
+
const tool = CheckoutPrTool(ctx);
|
|
730
|
+
const [a, b] = await Promise.all([
|
|
731
|
+
runTool(tool, { pull_number: 5 }),
|
|
732
|
+
runTool(tool, { pull_number: 5 }),
|
|
733
|
+
]);
|
|
734
|
+
expect(a.isError).toBeUndefined();
|
|
735
|
+
expect(b.isError).toBeUndefined();
|
|
736
|
+
// the underlying checkout ran exactly once
|
|
737
|
+
expect(octokit.rest.pulls.get).toHaveBeenCalledTimes(1);
|
|
738
|
+
expect(octokit.paginate).toHaveBeenCalledTimes(1);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("errors when the PR's source repository was deleted", async () => {
|
|
742
|
+
dispatch({ "git status --porcelain": "" });
|
|
743
|
+
const octokit = makeOctokit();
|
|
744
|
+
const { ctx } = makeToolCtx({ octokit });
|
|
745
|
+
octokit.rest.pulls.get.mockResolvedValue({
|
|
746
|
+
data: { head: { repo: null } },
|
|
747
|
+
});
|
|
748
|
+
const result = await runTool(CheckoutPrTool(ctx), { pull_number: 6 });
|
|
749
|
+
expect(result.isError).toBe(true);
|
|
750
|
+
expect(textOf(result)).toContain("source repository was deleted");
|
|
751
|
+
});
|
|
752
|
+
});
|