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,923 @@
|
|
|
1
|
+
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
|
2
|
+
import { type } from "arktype";
|
|
3
|
+
import { formatMcpToolRef } from "#app/external";
|
|
4
|
+
import { deleteProgressComment } from "#app/mcp/comment";
|
|
5
|
+
import { assertTargetInScope } from "#app/mcp/scope";
|
|
6
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
7
|
+
import { execute, tool } from "#app/mcp/shared";
|
|
8
|
+
import type { CommentableLines } from "#app/toolState";
|
|
9
|
+
import { getApiUrl } from "#app/utils/apiUrl";
|
|
10
|
+
import { buildTerramendFooter } from "#app/utils/buildTerramendFooter";
|
|
11
|
+
import { log } from "#app/utils/cli";
|
|
12
|
+
import {
|
|
13
|
+
countLinesInRanges,
|
|
14
|
+
getDiffCoverageBreakdown,
|
|
15
|
+
renderDiffCoverageBreakdown,
|
|
16
|
+
} from "#app/utils/diffCoverage";
|
|
17
|
+
import { fixDoubleEscapedString } from "#app/utils/fixDoubleEscapedString";
|
|
18
|
+
import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
|
|
19
|
+
import { retry } from "#app/utils/retry";
|
|
20
|
+
|
|
21
|
+
export type { CommentableLines };
|
|
22
|
+
|
|
23
|
+
function getHttpStatus(err: unknown): number | undefined {
|
|
24
|
+
if (typeof err !== "object" || err === null) return undefined;
|
|
25
|
+
const status = (err as Record<string, unknown>).status;
|
|
26
|
+
return typeof status === "number" ? status : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* detect GitHub's generic server-side 422 ("An internal error occurred,
|
|
31
|
+
* please try again.") that sometimes fires on `POST /pulls/{n}/reviews`.
|
|
32
|
+
*
|
|
33
|
+
* the body is stable across occurrences and distinct from every other 422
|
|
34
|
+
* cause we care about (anchor validation, body length, malformed suggestion
|
|
35
|
+
* blocks) — those all cite the specific problem. treating this as a
|
|
36
|
+
* transient server error unlocks bounded in-tool retry instead of surfacing
|
|
37
|
+
* it to the agent with the generic "likely causes (1)(2)(3)" prompt, which
|
|
38
|
+
* induces whack-a-mole comment dropping on content that was never the issue.
|
|
39
|
+
*/
|
|
40
|
+
export function isTransientReviewError(err: unknown): boolean {
|
|
41
|
+
if (getHttpStatus(err) !== 422) return false;
|
|
42
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
43
|
+
return /internal error occurred, please try again/i.test(msg);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// backoff schedule for transient GitHub 422 "internal error" responses on the
|
|
47
|
+
// reviews endpoint. 3 attempts total (initial + 2 retries) with 1s/3s delays
|
|
48
|
+
// — most transient GH errors clear within a few seconds, and longer delays
|
|
49
|
+
// push review submission past agent-perceived responsiveness.
|
|
50
|
+
export const TRANSIENT_REVIEW_RETRY_DELAYS_MS = [1_000, 3_000];
|
|
51
|
+
|
|
52
|
+
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* parse a PR file's patch to determine which line numbers on each side are
|
|
56
|
+
* valid anchors for inline comments. GitHub only accepts comments on lines
|
|
57
|
+
* inside a diff hunk: added/context lines on RIGHT, removed/context lines
|
|
58
|
+
* on LEFT.
|
|
59
|
+
*/
|
|
60
|
+
export function commentableLinesForFile(patch: string | undefined): CommentableLines {
|
|
61
|
+
const right = new Set<number>();
|
|
62
|
+
const left = new Set<number>();
|
|
63
|
+
if (!patch) return { RIGHT: right, LEFT: left };
|
|
64
|
+
|
|
65
|
+
let oldLine = 0;
|
|
66
|
+
let newLine = 0;
|
|
67
|
+
for (const line of patch.split("\n")) {
|
|
68
|
+
const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
69
|
+
if (hunk) {
|
|
70
|
+
oldLine = parseInt(hunk[1]!, 10);
|
|
71
|
+
newLine = parseInt(hunk[2]!, 10);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const changeType = line[0];
|
|
75
|
+
if (changeType === "+") {
|
|
76
|
+
right.add(newLine);
|
|
77
|
+
newLine++;
|
|
78
|
+
} else if (changeType === "-") {
|
|
79
|
+
left.add(oldLine);
|
|
80
|
+
oldLine++;
|
|
81
|
+
} else if (changeType === " ") {
|
|
82
|
+
right.add(newLine);
|
|
83
|
+
left.add(oldLine);
|
|
84
|
+
newLine++;
|
|
85
|
+
oldLine++;
|
|
86
|
+
}
|
|
87
|
+
// "\" (no newline marker) and anything else: skip, don't advance counters
|
|
88
|
+
}
|
|
89
|
+
return { RIGHT: right, LEFT: left };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function buildCommentableMap(
|
|
93
|
+
ctx: ToolContext,
|
|
94
|
+
pullNumber: number,
|
|
95
|
+
): Promise<Map<string, CommentableLines>> {
|
|
96
|
+
// prefer the snapshot captured by checkout_pr — it matches the diff GitHub
|
|
97
|
+
// will anchor to (commit_id=checkoutSha). refetching via listFiles at review
|
|
98
|
+
// time gives the LATEST PR state, which can drift from what the agent
|
|
99
|
+
// actually reviewed if the PR was updated mid-run.
|
|
100
|
+
//
|
|
101
|
+
// only reuse the cache if it was built for THIS pull request AND for the
|
|
102
|
+
// sha we will anchor the review to. a second checkout_pr that bumps
|
|
103
|
+
// checkoutSha but fails before repopulating the cache (e.g., listFiles 5xx)
|
|
104
|
+
// would otherwise leave a stale snapshot keyed to the right PR number but
|
|
105
|
+
// the wrong sha, silently mis-validating comments.
|
|
106
|
+
const cached = ctx.toolState.commentableLinesByFile;
|
|
107
|
+
const cachedFor = ctx.toolState.commentableLinesPullNumber;
|
|
108
|
+
const cachedSha = ctx.toolState.commentableLinesCheckoutSha;
|
|
109
|
+
const currentSha = ctx.toolState.checkoutSha;
|
|
110
|
+
if (cached && cachedFor === pullNumber && cachedSha && cachedSha === currentSha) return cached;
|
|
111
|
+
|
|
112
|
+
const files: PullFile[] = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
|
|
113
|
+
owner: ctx.repo.owner,
|
|
114
|
+
repo: ctx.repo.name,
|
|
115
|
+
pull_number: pullNumber,
|
|
116
|
+
per_page: 100,
|
|
117
|
+
});
|
|
118
|
+
const map = new Map<string, CommentableLines>();
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
map.set(file.filename, commentableLinesForFile(file.patch));
|
|
121
|
+
}
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type ReviewCommentInput = NonNullable<
|
|
126
|
+
RestEndpointMethodTypes["pulls"]["createReview"]["parameters"]["comments"]
|
|
127
|
+
>[number];
|
|
128
|
+
|
|
129
|
+
export interface DroppedComment {
|
|
130
|
+
path: string;
|
|
131
|
+
line: number;
|
|
132
|
+
startLine?: number | undefined;
|
|
133
|
+
side: "LEFT" | "RIGHT";
|
|
134
|
+
reason: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function validateInlineComments(
|
|
138
|
+
comments: ReviewCommentInput[],
|
|
139
|
+
map: Map<string, CommentableLines>,
|
|
140
|
+
): { valid: ReviewCommentInput[]; dropped: DroppedComment[] } {
|
|
141
|
+
const valid: ReviewCommentInput[] = [];
|
|
142
|
+
const dropped: DroppedComment[] = [];
|
|
143
|
+
for (const c of comments) {
|
|
144
|
+
const side = c.side === "LEFT" ? "LEFT" : "RIGHT";
|
|
145
|
+
const line = c.line ?? 0;
|
|
146
|
+
const startLine = c.start_line ?? line;
|
|
147
|
+
const lines = map.get(c.path);
|
|
148
|
+
const record = (reason: string): void => {
|
|
149
|
+
const entry: DroppedComment = { path: c.path, line, side, reason };
|
|
150
|
+
if (c.start_line != null) entry.startLine = c.start_line;
|
|
151
|
+
dropped.push(entry);
|
|
152
|
+
};
|
|
153
|
+
if (!lines) {
|
|
154
|
+
record(`file not in PR diff`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (lines.LEFT.size === 0 && lines.RIGHT.size === 0) {
|
|
158
|
+
// file is in the PR but has no textual patch — usually binary, a
|
|
159
|
+
// pure rename with no content change, or a mode-only change. GitHub
|
|
160
|
+
// won't accept inline comments on these regardless of line number.
|
|
161
|
+
record(`file has no textual diff (binary, pure rename, or mode change)`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const anchors = lines[side];
|
|
165
|
+
if (!anchors.has(line)) {
|
|
166
|
+
record(`line ${line} (${side}) is not inside a diff hunk`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// GitHub requires start_line <= line. both anchors could be valid but
|
|
170
|
+
// inverted (e.g. start=44, line=42) — GitHub 422s with "invalid line
|
|
171
|
+
// numbers". catch it here so the agent sees a precise reason.
|
|
172
|
+
if (c.start_line != null && c.start_line > line) {
|
|
173
|
+
record(
|
|
174
|
+
`start_line ${c.start_line} is after line ${line} — ranges must satisfy start_line <= line`,
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (startLine !== line && !anchors.has(startLine)) {
|
|
179
|
+
record(`start_line ${startLine} (${side}) is not inside a diff hunk`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
valid.push(c);
|
|
183
|
+
}
|
|
184
|
+
return { valid, dropped };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// cap the detail list so a pathological run (agent emits hundreds of invalid
|
|
188
|
+
// comments on a huge PR) doesn't push the review body past GitHub's ~65KB
|
|
189
|
+
// limit and fail the whole submission with a body-too-long 422.
|
|
190
|
+
export const MAX_DROPPED_COMMENT_LINES = 50;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* reason a create_pull_request_review call should be skipped without hitting
|
|
194
|
+
* GitHub. returned by reviewSkipDecision; null means submit normally.
|
|
195
|
+
*/
|
|
196
|
+
export type ReviewSkipDecision =
|
|
197
|
+
| { kind: "no-issues"; reason: string }
|
|
198
|
+
| { kind: "empty-downgraded-approve"; reason: string };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* decision returned by duplicateReviewDecision when a session has already
|
|
202
|
+
* submitted a review and the current call would be a duplicate.
|
|
203
|
+
*/
|
|
204
|
+
export type DuplicateReviewDecision = {
|
|
205
|
+
kind: "already-submitted";
|
|
206
|
+
reviewId: number;
|
|
207
|
+
reason: string;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* decide whether a second create_pull_request_review call in the same session
|
|
212
|
+
* is a duplicate of an earlier submission.
|
|
213
|
+
*
|
|
214
|
+
* the agent is instructed to call create_pull_request_review exactly once per
|
|
215
|
+
* Review-mode session (see action/modes.ts), but in practice it sometimes
|
|
216
|
+
* submits twice — once with substantive feedback, then again with the
|
|
217
|
+
* canonical "No new issues found." body when the prompt's branch logic
|
|
218
|
+
* re-classifies non-blocking observations. the second submission is
|
|
219
|
+
* always redundant: the first review is the record, and the duplicate just
|
|
220
|
+
* adds noise to the PR.
|
|
221
|
+
*
|
|
222
|
+
* legitimate follow-up reviews after new commits ARE allowed: the
|
|
223
|
+
* new-commits-mid-review path advances toolState.checkoutSha past the
|
|
224
|
+
* previously reviewed sha, and a subsequent checkout_pr advances it again.
|
|
225
|
+
* any call where checkoutSha has moved past the prior reviewedSha is a real
|
|
226
|
+
* follow-up and goes through. anything else — same sha, or no checkoutSha
|
|
227
|
+
* to compare against — is a duplicate.
|
|
228
|
+
*/
|
|
229
|
+
export function duplicateReviewDecision(params: {
|
|
230
|
+
existing: { id: number; reviewedSha: string | undefined } | undefined;
|
|
231
|
+
currentCheckoutSha: string | undefined;
|
|
232
|
+
}): DuplicateReviewDecision | null {
|
|
233
|
+
const existing = params.existing;
|
|
234
|
+
if (!existing) return null;
|
|
235
|
+
// checkoutSha advanced past the prior reviewed sha — legitimate follow-up
|
|
236
|
+
// (e.g. after checkout_pr re-fetched new commits the agent was nudged to
|
|
237
|
+
// pull). only treat as a duplicate when we cannot prove the SHA moved.
|
|
238
|
+
if (
|
|
239
|
+
params.currentCheckoutSha &&
|
|
240
|
+
existing.reviewedSha &&
|
|
241
|
+
params.currentCheckoutSha !== existing.reviewedSha
|
|
242
|
+
) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind: "already-submitted",
|
|
247
|
+
reviewId: existing.id,
|
|
248
|
+
reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call (call \`checkout_pr\` again first if new commits were pushed)`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* decide whether to skip a review submission before any network call.
|
|
254
|
+
*
|
|
255
|
+
* GitHub rejects `event: "COMMENT"` reviews with no body and no inline comments
|
|
256
|
+
* with HTTP 422 "Unprocessable Entity". two paths produce that shape:
|
|
257
|
+
*
|
|
258
|
+
* 1. `!approved` + empty body/comments: agent's "no issues found" result.
|
|
259
|
+
* skipping preserves the agent's intent (nothing to post is a fine
|
|
260
|
+
* outcome for a review run) without a spurious 422.
|
|
261
|
+
* 2. `approved` + `!prApproveEnabled` + empty body/comments: the runtime
|
|
262
|
+
* downgrades APPROVE to COMMENT when prApproveEnabled is off, and the
|
|
263
|
+
* resulting empty-COMMENT is exactly the shape GitHub 422s. skipping
|
|
264
|
+
* here surfaces the cause (downgrade + nothing to say) instead of an
|
|
265
|
+
* opaque 422 the agent can't recover from.
|
|
266
|
+
*
|
|
267
|
+
* legitimate bare approvals (`approved` + `prApproveEnabled`, no body/comments)
|
|
268
|
+
* are never skipped — GitHub accepts empty APPROVE reviews and the approval
|
|
269
|
+
* stamp itself is the review's content.
|
|
270
|
+
*/
|
|
271
|
+
export function reviewSkipDecision(params: {
|
|
272
|
+
approved: boolean;
|
|
273
|
+
body: string | null | undefined;
|
|
274
|
+
hasComments: boolean;
|
|
275
|
+
prApproveEnabled: boolean;
|
|
276
|
+
}): ReviewSkipDecision | null {
|
|
277
|
+
if (params.body || params.hasComments) return null;
|
|
278
|
+
if (!params.approved) {
|
|
279
|
+
return {
|
|
280
|
+
kind: "no-issues",
|
|
281
|
+
reason: "no issues found — nothing to post",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (!params.prApproveEnabled) {
|
|
285
|
+
return {
|
|
286
|
+
kind: "empty-downgraded-approve",
|
|
287
|
+
reason:
|
|
288
|
+
"approve requested but prApproveEnabled is disabled; no feedback body or comments to post as a COMMENT review instead",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function formatDroppedCommentsNote(dropped: DroppedComment[]): string {
|
|
295
|
+
const renderEntry = (d: DroppedComment): string => {
|
|
296
|
+
const range =
|
|
297
|
+
d.startLine != null && d.startLine !== d.line ? `${d.startLine}-${d.line}` : `${d.line}`;
|
|
298
|
+
return `- \`${d.path}:${range}\` (${d.side}) — ${d.reason}`;
|
|
299
|
+
};
|
|
300
|
+
const shown = dropped.slice(0, MAX_DROPPED_COMMENT_LINES).map(renderEntry);
|
|
301
|
+
const remainder = dropped.length - shown.length;
|
|
302
|
+
if (remainder > 0) shown.push(`- …and ${remainder} more dropped comment(s) not shown`);
|
|
303
|
+
return (
|
|
304
|
+
`\n\n---\n\n` +
|
|
305
|
+
`**Note:** ${dropped.length} inline comment(s) dropped because they did not anchor to lines inside the PR diff:\n` +
|
|
306
|
+
shown.join("\n")
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// one-shot review tool
|
|
311
|
+
export const CreatePullRequestReview = type({
|
|
312
|
+
pull_number: type.number.describe("The pull request number to review"),
|
|
313
|
+
body: type.string
|
|
314
|
+
.describe(
|
|
315
|
+
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array.",
|
|
316
|
+
)
|
|
317
|
+
.optional(),
|
|
318
|
+
approved: type.boolean
|
|
319
|
+
.describe(
|
|
320
|
+
"Set to true to submit as an approval. Use for `> ✅ No new issues found.` reviews where the PR is mergeable as-is and nothing in the body warrants code changes — approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> ℹ️ ...` (minor suggestions inline), `> [!IMPORTANT]` (recommended changes), and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported.",
|
|
321
|
+
)
|
|
322
|
+
.optional(),
|
|
323
|
+
commit_id: type.string
|
|
324
|
+
.describe(
|
|
325
|
+
"Optional SHA of the commit being reviewed. Defaults to latest. Must be the FULL 40-character SHA — abbreviated SHAs are rejected by GitHub with `422 Unprocessable Entity`. The PR-synchronize event payload's `head_sha` is already full-length.",
|
|
326
|
+
)
|
|
327
|
+
.optional(),
|
|
328
|
+
comments: type({
|
|
329
|
+
path: type.string.describe(
|
|
330
|
+
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff.",
|
|
331
|
+
),
|
|
332
|
+
line: type.number.describe(
|
|
333
|
+
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format. Must sit inside a `@@` hunk in the PR diff — anchors on context-only or untouched lines are dropped silently (the rest of the review still posts; dropped entries are reported under `droppedComments` in the response).",
|
|
334
|
+
),
|
|
335
|
+
side: type
|
|
336
|
+
.enumerated("LEFT", "RIGHT")
|
|
337
|
+
.describe(
|
|
338
|
+
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT.",
|
|
339
|
+
)
|
|
340
|
+
.optional(),
|
|
341
|
+
body: type.string
|
|
342
|
+
.describe("Explanatory comment text (optional if suggestion is provided)")
|
|
343
|
+
.optional(),
|
|
344
|
+
suggestion: type.string
|
|
345
|
+
.describe(
|
|
346
|
+
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code.",
|
|
347
|
+
)
|
|
348
|
+
.optional(),
|
|
349
|
+
start_line: type.number
|
|
350
|
+
.describe(
|
|
351
|
+
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces. Both `start_line` and `line` must sit inside the same `@@` hunk — a `start_line` outside the hunk causes the whole comment to be dropped even when `line` is valid. If you need to comment on context just above/below a hunk, shrink the range to a single line that is provably modified.",
|
|
352
|
+
)
|
|
353
|
+
.optional(),
|
|
354
|
+
})
|
|
355
|
+
.array()
|
|
356
|
+
.describe(
|
|
357
|
+
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead.",
|
|
358
|
+
)
|
|
359
|
+
.optional(),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|
363
|
+
return tool({
|
|
364
|
+
name: "create_pull_request_review",
|
|
365
|
+
description:
|
|
366
|
+
"Submit a review for an existing pull request. " +
|
|
367
|
+
'Example: `create_pull_request_review({ pull_number: 1234, body: "LGTM", approved: true, comments: [{ path: "src/api.ts", line: 42, body: "nit: rename" }] })`. ' +
|
|
368
|
+
"Each call creates a permanent, visible review on the PR — NEVER submit test or diagnostic reviews. " +
|
|
369
|
+
"Reviews with no body AND no comments are silently skipped (nothing to post). " +
|
|
370
|
+
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
|
371
|
+
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
|
372
|
+
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
|
373
|
+
"The first submission may error once with a one-time diff-coverage nudge listing unread TOC regions — retry with the same arguments and the pre-flight will not block again. " +
|
|
374
|
+
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
|
375
|
+
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
|
376
|
+
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
|
377
|
+
" Comments anchored outside a diff hunk are dropped automatically (with a note appended to the review body) — the rest of the review still posts.",
|
|
378
|
+
parameters: CreatePullRequestReview,
|
|
379
|
+
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
|
380
|
+
// SECURITY: a review (especially an APPROVE, which can satisfy a
|
|
381
|
+
// required-reviews branch-protection gate) must target the PR this run is
|
|
382
|
+
// scoped to or one it opened — never an arbitrary PR an injected agent
|
|
383
|
+
// names. mirrors the cross-PR guard on push_branch (mcp/git.ts).
|
|
384
|
+
assertTargetInScope(ctx, pull_number, "submit a review on");
|
|
385
|
+
|
|
386
|
+
if (body) body = fixDoubleEscapedString(body);
|
|
387
|
+
|
|
388
|
+
// set issue context (PRs are issues)
|
|
389
|
+
ctx.toolState.issueNumber = pull_number;
|
|
390
|
+
|
|
391
|
+
// guard against duplicate review submissions in the same session.
|
|
392
|
+
// see duplicateReviewDecision for the rationale — short version: the
|
|
393
|
+
// agent occasionally submits twice (substantive review + canonical
|
|
394
|
+
// "no issues found" follow-up) and the second is always redundant.
|
|
395
|
+
// legit re-reviews after new commits are still allowed because
|
|
396
|
+
// checkout_pr advances toolState.checkoutSha past the prior reviewedSha.
|
|
397
|
+
const dup = duplicateReviewDecision({
|
|
398
|
+
existing: ctx.toolState.review,
|
|
399
|
+
currentCheckoutSha: ctx.toolState.checkoutSha,
|
|
400
|
+
});
|
|
401
|
+
if (dup) {
|
|
402
|
+
log.info(`skipping duplicate review submission: ${dup.reason}`);
|
|
403
|
+
return {
|
|
404
|
+
success: true,
|
|
405
|
+
skipped: true,
|
|
406
|
+
reason: dup.reason,
|
|
407
|
+
reviewId: dup.reviewId,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// skip empty COMMENT reviews before any GitHub call. see reviewSkipDecision
|
|
412
|
+
// for the cases (no-issues vs empty-downgraded-approve) and why GitHub 422s
|
|
413
|
+
// the shape we'd otherwise POST.
|
|
414
|
+
const skip = reviewSkipDecision({
|
|
415
|
+
approved: approved ?? false,
|
|
416
|
+
body,
|
|
417
|
+
hasComments: comments.length > 0,
|
|
418
|
+
prApproveEnabled: ctx.prApproveEnabled,
|
|
419
|
+
});
|
|
420
|
+
if (skip) {
|
|
421
|
+
log.info(`skipping review submission: ${skip.reason}`);
|
|
422
|
+
return { success: true, skipped: true, reason: skip.reason };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled.
|
|
426
|
+
// by this point we already returned if the downgrade would produce an
|
|
427
|
+
// empty COMMENT (the skip above), so every downgrade that reaches here
|
|
428
|
+
// carries either a body or inline comments.
|
|
429
|
+
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
|
430
|
+
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
|
431
|
+
log.info("prApproveEnabled is disabled — downgrading APPROVE to COMMENT");
|
|
432
|
+
event = "COMMENT";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
|
436
|
+
owner: ctx.repo.owner,
|
|
437
|
+
repo: ctx.repo.name,
|
|
438
|
+
pull_number,
|
|
439
|
+
event,
|
|
440
|
+
};
|
|
441
|
+
let latestHeadSha: string | undefined;
|
|
442
|
+
if (commit_id) {
|
|
443
|
+
params.commit_id = commit_id;
|
|
444
|
+
} else {
|
|
445
|
+
const pr = await ctx.octokit.rest.pulls.get({
|
|
446
|
+
owner: ctx.repo.owner,
|
|
447
|
+
repo: ctx.repo.name,
|
|
448
|
+
pull_number,
|
|
449
|
+
});
|
|
450
|
+
latestHeadSha = pr.data.head.sha;
|
|
451
|
+
// anchor to checkout sha so line numbers match the diff the agent analyzed
|
|
452
|
+
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
|
453
|
+
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
|
454
|
+
log.info(
|
|
455
|
+
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
|
|
456
|
+
`(HEAD is now ${latestHeadSha.slice(0, 7)})`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
runDiffCoveragePreflight({ ctx });
|
|
462
|
+
|
|
463
|
+
type ReviewComment = NonNullable<typeof params.comments>[number];
|
|
464
|
+
const reviewComments = comments.map((comment) => {
|
|
465
|
+
let commentBody = fixDoubleEscapedString(comment.body || "");
|
|
466
|
+
if (comment.suggestion !== undefined) {
|
|
467
|
+
const suggestionBlock = `\`\`\`suggestion\n${comment.suggestion}\n\`\`\``;
|
|
468
|
+
commentBody = commentBody ? `${commentBody}\n\n${suggestionBlock}` : suggestionBlock;
|
|
469
|
+
}
|
|
470
|
+
const side = comment.side || "RIGHT";
|
|
471
|
+
const reviewComment: ReviewComment = {
|
|
472
|
+
path: comment.path,
|
|
473
|
+
line: comment.line,
|
|
474
|
+
body: commentBody,
|
|
475
|
+
side,
|
|
476
|
+
};
|
|
477
|
+
if (comment.start_line != null && comment.start_line !== comment.line) {
|
|
478
|
+
reviewComment.start_line = comment.start_line;
|
|
479
|
+
reviewComment.start_side = side;
|
|
480
|
+
}
|
|
481
|
+
return reviewComment;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// pre-validate inline comments against the current PR diff. drop any
|
|
485
|
+
// comment that does not anchor to a line inside a hunk, rather than
|
|
486
|
+
// letting GitHub 422 and sink the whole review.
|
|
487
|
+
let droppedComments: DroppedComment[] = [];
|
|
488
|
+
if (reviewComments.length > 0) {
|
|
489
|
+
const commentableMap = await buildCommentableMap(ctx, pull_number);
|
|
490
|
+
const validation = validateInlineComments(reviewComments, commentableMap);
|
|
491
|
+
droppedComments = validation.dropped;
|
|
492
|
+
if (droppedComments.length > 0) {
|
|
493
|
+
log.info(
|
|
494
|
+
`dropping ${droppedComments.length}/${reviewComments.length} inline comment(s) that do not anchor to PR diff lines`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
// always reassign so all-dropped reviews leave params.comments empty
|
|
498
|
+
// instead of carrying the original invalid set (which would 422).
|
|
499
|
+
params.comments = validation.valid;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// if we dropped comments, surface them in the review body so the
|
|
503
|
+
// author (and the agent, on retry) can see what was skipped.
|
|
504
|
+
if (droppedComments.length > 0) {
|
|
505
|
+
const note = formatDroppedCommentsNote(droppedComments);
|
|
506
|
+
body = body ? body + note : note.replace(/^\n\n/, "");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// after dropping, an empty non-approve review has nothing left to post.
|
|
510
|
+
if (!approved && !body && !params.comments?.length) {
|
|
511
|
+
log.info("review has no body and all inline comments were dropped — skipping submission");
|
|
512
|
+
return {
|
|
513
|
+
success: true,
|
|
514
|
+
skipped: true,
|
|
515
|
+
reason: "all inline comments were invalid — nothing to post",
|
|
516
|
+
droppedComments,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// no body → single-step createReview (no footer needed)
|
|
521
|
+
// has body → pending + submit so we can build footer with Fix links using review ID
|
|
522
|
+
//
|
|
523
|
+
// wrap the submission in `retry` so GitHub's transient 422 "internal
|
|
524
|
+
// error" body (distinct from anchor / body-length / suggestion 422s,
|
|
525
|
+
// which all cite the specific cause) clears on its own instead of
|
|
526
|
+
// surfacing through the generic 422 handler — that framing sent the
|
|
527
|
+
// agent dropping valid inline comments chasing a non-issue.
|
|
528
|
+
// `shouldRetry` scopes retries to the transient body only, so real
|
|
529
|
+
// validation 422s still fail fast.
|
|
530
|
+
let result: Awaited<ReturnType<typeof ctx.octokit.rest.pulls.createReview>>;
|
|
531
|
+
try {
|
|
532
|
+
result = await retry(
|
|
533
|
+
() =>
|
|
534
|
+
body
|
|
535
|
+
? createAndSubmitWithFooter(ctx, params, {
|
|
536
|
+
body,
|
|
537
|
+
approved: approved ?? false,
|
|
538
|
+
hasComments: (params.comments?.length ?? 0) > 0,
|
|
539
|
+
})
|
|
540
|
+
: createReviewWithStrandedRecovery(ctx, params),
|
|
541
|
+
{
|
|
542
|
+
delaysMs: TRANSIENT_REVIEW_RETRY_DELAYS_MS,
|
|
543
|
+
shouldRetry: isTransientReviewError,
|
|
544
|
+
label: "review submission",
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
} catch (err: unknown) {
|
|
548
|
+
// GitHub's transient 422 "internal error" is distinct from anchor /
|
|
549
|
+
// body-length / suggestion validation failures — framing it with the
|
|
550
|
+
// generic "likely causes (1)(2)(3)" prompt sends the agent dropping
|
|
551
|
+
// comments that were never the problem. after bounded in-tool retry
|
|
552
|
+
// we surface a dedicated message that tells the agent to wait-and-
|
|
553
|
+
// retry or fall back to a body-only review.
|
|
554
|
+
if (isTransientReviewError(err)) {
|
|
555
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
556
|
+
throw new Error(
|
|
557
|
+
`GitHub returned a transient 422 "internal error" on the reviews endpoint after ${TRANSIENT_REVIEW_RETRY_DELAYS_MS.length + 1} attempts. ` +
|
|
558
|
+
`This is a GitHub-side issue, not a problem with your review content. ` +
|
|
559
|
+
`Do NOT modify or drop inline comments — their content is not the cause. ` +
|
|
560
|
+
`Wait ~30 seconds and call this tool once more with the SAME arguments. ` +
|
|
561
|
+
`If it still fails, submit a body-only review (move all inline feedback into \`body\` as text) so nothing is lost. ` +
|
|
562
|
+
`GitHub said: ${rawMsg}`,
|
|
563
|
+
{ cause: err },
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
|
567
|
+
|
|
568
|
+
const details = params.comments.map((c) => {
|
|
569
|
+
const line = c.line ?? 0;
|
|
570
|
+
const startLine = c.start_line ?? line;
|
|
571
|
+
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
|
572
|
+
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
|
|
573
|
+
});
|
|
574
|
+
// a 422 on createReview-with-comments is USUALLY about comment
|
|
575
|
+
// anchors, but could also be about body length, invalid suggestion
|
|
576
|
+
// blocks, etc. include the verbatim GitHub error so the agent can
|
|
577
|
+
// diagnose non-anchor 422s without us having to enumerate every
|
|
578
|
+
// possible GitHub validation rule.
|
|
579
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
580
|
+
const checkoutRef = formatMcpToolRef(ctx.agentId, "checkout_pr");
|
|
581
|
+
throw new Error(
|
|
582
|
+
`GitHub rejected the review with 422 even after pre-validation. ` +
|
|
583
|
+
`Likely causes (check "GitHub said" below to narrow down): ` +
|
|
584
|
+
`(1) new commits pushed after pre-validation — call \`${checkoutRef}\` again to refresh the diff snapshot, then resubmit; ` +
|
|
585
|
+
`(2) the review body exceeded GitHub's ~65KB limit — shorten it and retry; ` +
|
|
586
|
+
`(3) a \`suggestion\` block is malformed (missing backticks, extra backticks, or wrong indentation) — inspect the affected comments below. ` +
|
|
587
|
+
`If none apply, move the failing comments into the review body as text so the rest still posts. ` +
|
|
588
|
+
`Affected comments: ${details.join(", ")}. ` +
|
|
589
|
+
`GitHub said: ${rawMsg}`,
|
|
590
|
+
{ cause: err },
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
|
594
|
+
if (!result.data.id) {
|
|
595
|
+
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
|
|
596
|
+
}
|
|
597
|
+
const reviewId = result.data.id;
|
|
598
|
+
const reviewNodeId = result.data.node_id;
|
|
599
|
+
log.info(`» created review ${reviewId} on pull request #${pull_number}`);
|
|
600
|
+
|
|
601
|
+
// reviewedSha = what the agent actually reviewed (checkout SHA), not the
|
|
602
|
+
// submission anchor (current HEAD). this ensures postReviewCleanup dispatches
|
|
603
|
+
// a follow-up if the agent doesn't handle new commits inline.
|
|
604
|
+
const actuallyReviewedSha = ctx.toolState.checkoutSha ?? params.commit_id;
|
|
605
|
+
ctx.toolState.review = {
|
|
606
|
+
id: reviewId,
|
|
607
|
+
nodeId: reviewNodeId,
|
|
608
|
+
reviewedSha: actuallyReviewedSha,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
ctx.toolState.wasUpdated = true;
|
|
612
|
+
|
|
613
|
+
// a submitted review obsoletes the progress comment — the review IS the
|
|
614
|
+
// durable artifact. owned here (not in main.ts) so cleanup is atomic with
|
|
615
|
+
// submission and survives any path out of the run (success, timeout,
|
|
616
|
+
// crash). deleteProgressComment sets progressComment = null, so a later
|
|
617
|
+
// report_progress call short-circuits to a no-op.
|
|
618
|
+
// best-effort: a cleanup failure must not turn a successful review into
|
|
619
|
+
// a tool-call failure visible to the agent.
|
|
620
|
+
await deleteProgressComment(ctx).catch((err) => {
|
|
621
|
+
log.debug(`progress comment cleanup after review failed: ${err}`);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// detect commits pushed since checkout and guide the agent to review them
|
|
625
|
+
// inline instead of dispatching a separate workflow run
|
|
626
|
+
if (
|
|
627
|
+
ctx.toolState.checkoutSha &&
|
|
628
|
+
latestHeadSha &&
|
|
629
|
+
latestHeadSha !== ctx.toolState.checkoutSha
|
|
630
|
+
) {
|
|
631
|
+
const fromSha = ctx.toolState.checkoutSha;
|
|
632
|
+
const toSha = latestHeadSha;
|
|
633
|
+
// store old checkoutSha as beforeSha so the next checkout_pr computes an incremental diff
|
|
634
|
+
ctx.toolState.beforeSha = fromSha;
|
|
635
|
+
// advance checkoutSha so the next review submission tracks correctly (just in case, checkout_pr will overwrite it again)
|
|
636
|
+
ctx.toolState.checkoutSha = toSha;
|
|
637
|
+
|
|
638
|
+
log.info(
|
|
639
|
+
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
success: true,
|
|
644
|
+
reviewId,
|
|
645
|
+
html_url: result.data.html_url,
|
|
646
|
+
state: result.data.state,
|
|
647
|
+
user: result.data.user?.login,
|
|
648
|
+
submitted_at: result.data.submitted_at,
|
|
649
|
+
droppedComments: droppedComments.length > 0 ? droppedComments : undefined,
|
|
650
|
+
newCommits: {
|
|
651
|
+
from: fromSha,
|
|
652
|
+
to: toSha,
|
|
653
|
+
instructions:
|
|
654
|
+
`new commits were pushed while you were reviewing. ` +
|
|
655
|
+
`call \`${formatMcpToolRef(ctx.agentId, "checkout_pr")}\` again to fetch the latest version — it will compute the incremental diff automatically. ` +
|
|
656
|
+
`submit another review covering only the new changes. do not repeat feedback from your previous review.`,
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
success: true,
|
|
663
|
+
reviewId,
|
|
664
|
+
html_url: result.data.html_url,
|
|
665
|
+
state: result.data.state,
|
|
666
|
+
user: result.data.user?.login,
|
|
667
|
+
submitted_at: result.data.submitted_at,
|
|
668
|
+
droppedComments: droppedComments.length > 0 ? droppedComments : undefined,
|
|
669
|
+
};
|
|
670
|
+
}),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function runDiffCoveragePreflight(params: { ctx: ToolContext }): void {
|
|
675
|
+
const coverageState = params.ctx.toolState.diffCoverage;
|
|
676
|
+
if (!coverageState) {
|
|
677
|
+
log.debug("diff coverage pre-flight skipped: no diffCoverage state present in toolState");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (coverageState.coveragePreflightRan) {
|
|
681
|
+
log.debug("diff coverage pre-flight skipped: already ran in this session");
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
coverageState.coveragePreflightRan = true;
|
|
686
|
+
log.debug(
|
|
687
|
+
`diff coverage pre-flight start: diffPath=${coverageState.diffPath}, totalLines=${coverageState.totalLines}, tocEntries=${coverageState.tocEntries.length}, coveredRanges=${coverageState.coveredRanges.length}`,
|
|
688
|
+
);
|
|
689
|
+
const breakdown = getDiffCoverageBreakdown({ state: coverageState });
|
|
690
|
+
const unread: Array<{ path: string; ranges: string; unreadLines: number }> = [];
|
|
691
|
+
let unreadLines = 0;
|
|
692
|
+
for (const file of breakdown.files) {
|
|
693
|
+
if (file.unreadRanges.length === 0) continue;
|
|
694
|
+
const rangesText = file.unreadRanges
|
|
695
|
+
.map((range) => `${range.startLine}-${range.endLine}`)
|
|
696
|
+
.join(", ");
|
|
697
|
+
const fileUnreadLines = countLinesInRanges({ ranges: file.unreadRanges });
|
|
698
|
+
unread.push({ path: file.filename, ranges: rangesText, unreadLines: fileUnreadLines });
|
|
699
|
+
unreadLines += fileUnreadLines;
|
|
700
|
+
}
|
|
701
|
+
coverageState.lastBreakdown = renderDiffCoverageBreakdown({
|
|
702
|
+
diffPath: coverageState.diffPath,
|
|
703
|
+
breakdown,
|
|
704
|
+
});
|
|
705
|
+
log.debug(
|
|
706
|
+
`diff coverage pre-flight breakdown: coveredLines=${breakdown.coveredLines}, unreadLines=${unreadLines}`,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
if (unreadLines === 0) {
|
|
710
|
+
log.debug("diff coverage pre-flight passed: no unread regions");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
log.info(
|
|
715
|
+
`diff coverage pre-flight nudge: unread lines=${unreadLines}, unread files=${unread.length}`,
|
|
716
|
+
);
|
|
717
|
+
const unreadText = unread
|
|
718
|
+
.map((entry) => `- ${entry.path} (${entry.unreadLines} lines, ${entry.ranges})`)
|
|
719
|
+
.join("\n");
|
|
720
|
+
throw new Error(
|
|
721
|
+
`diff coverage pre-flight: some TOC regions were not read before review submission. ` +
|
|
722
|
+
`this is a one-time nudge — read the ranges below from ${coverageState.diffPath} on a best-effort basis, then call create_pull_request_review again. ` +
|
|
723
|
+
`you are NOT obligated to read generated artifacts (lockfiles like pnpm-lock.yaml / package-lock.json / yarn.lock / Cargo.lock; codegen output like *.gen.*, *.pb.go, *.generated.*; snapshot/fixture dirs like __snapshots__/; migration metadata like drizzle/meta/, prisma migration SQL). ` +
|
|
724
|
+
`if every unread region is generated, retry immediately without reading. ` +
|
|
725
|
+
`this pre-flight will not block again in this review session.\n\n` +
|
|
726
|
+
`unread TOC regions:\n${unreadText}\n\n` +
|
|
727
|
+
`${coverageState.lastBreakdown}`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
type FooterOpts = { body: string; approved: boolean; hasComments: boolean };
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* clear a pending review draft stranded on the PR by a prior hard-killed run
|
|
735
|
+
* (workflow timeout, OOM) so the next createReview can succeed.
|
|
736
|
+
*
|
|
737
|
+
* GitHub enforces one-pending-review-per-user-per-PR. if the previous process
|
|
738
|
+
* died between createReview(PENDING) and submitReview, the draft remains and
|
|
739
|
+
* the next run's createReview 422s with "already has a pending review".
|
|
740
|
+
* listReviews only exposes PENDING reviews to their author, so filtering on
|
|
741
|
+
* state === "PENDING" is already scoped to the authed token's own draft.
|
|
742
|
+
*
|
|
743
|
+
* if `originalErr` is not a pending-review 422, or no leftover is found, this
|
|
744
|
+
* function rethrows `originalErr` so the caller surfaces the original failure.
|
|
745
|
+
* delete failures with 404 (draft already gone) or 422 (draft submitted by a
|
|
746
|
+
* concurrent caller) are swallowed — the caller's retry will succeed in both
|
|
747
|
+
* cases. any other delete error is rethrown unchanged.
|
|
748
|
+
*
|
|
749
|
+
* known limitation: if two runs on the SAME PR share the authed token and
|
|
750
|
+
* overlap in time, the loser's createReview 422s on the winner's still-active
|
|
751
|
+
* draft. recovery would then delete the winner's active draft and the
|
|
752
|
+
* winner's submitReview would 404. this is not distinguishable from a
|
|
753
|
+
* genuinely-stranded draft via the review object alone (PENDING reviews
|
|
754
|
+
* expose no created_at timestamp, and both reviews are authored by the same
|
|
755
|
+
* bot user). rely on workflow-level concurrency controls (e.g. a concurrency
|
|
756
|
+
* key keyed to the PR number) to prevent overlap.
|
|
757
|
+
*/
|
|
758
|
+
export async function clearStrandedPendingReview(
|
|
759
|
+
ctx: ToolContext,
|
|
760
|
+
params: { owner: string; repo: string; pull_number: number; originalErr: unknown },
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const originalErr = params.originalErr;
|
|
763
|
+
const msg = originalErr instanceof Error ? originalErr.message.toLowerCase() : "";
|
|
764
|
+
if (getHttpStatus(originalErr) !== 422 || !msg.includes("pending review")) throw originalErr;
|
|
765
|
+
// if listReviews itself fails (5xx, rate limit, etc), surface the ORIGINAL
|
|
766
|
+
// 422 rather than the listing failure — "pending review conflict" is the
|
|
767
|
+
// real blocker the caller needs to see. hiding it behind a transient 502
|
|
768
|
+
// sent agents chasing phantom server errors instead of retrying the
|
|
769
|
+
// conflict. log the listing failure for diagnosis but do not mask.
|
|
770
|
+
const reviews = await ctx.octokit
|
|
771
|
+
.paginate(ctx.octokit.rest.pulls.listReviews, {
|
|
772
|
+
owner: params.owner,
|
|
773
|
+
repo: params.repo,
|
|
774
|
+
pull_number: params.pull_number,
|
|
775
|
+
per_page: 100,
|
|
776
|
+
})
|
|
777
|
+
.catch((listErr: unknown) => {
|
|
778
|
+
// surface at info so operators not running at debug still see that
|
|
779
|
+
// recovery was attempted (and why) before the original 422 bubbles up.
|
|
780
|
+
log.info(
|
|
781
|
+
`» listReviews failed during pending-review cleanup, surfacing original 422: ${listErr instanceof Error ? listErr.message : String(listErr)}`,
|
|
782
|
+
);
|
|
783
|
+
throw originalErr;
|
|
784
|
+
});
|
|
785
|
+
const leftover = reviews.find((r) => r.state === "PENDING");
|
|
786
|
+
if (!leftover?.id) throw originalErr;
|
|
787
|
+
log.info(
|
|
788
|
+
`» clearing leftover pending review ${leftover.id} (likely stranded by a killed prior run)`,
|
|
789
|
+
);
|
|
790
|
+
try {
|
|
791
|
+
await ctx.octokit.rest.pulls.deletePendingReview({
|
|
792
|
+
owner: params.owner,
|
|
793
|
+
repo: params.repo,
|
|
794
|
+
pull_number: params.pull_number,
|
|
795
|
+
review_id: leftover.id,
|
|
796
|
+
});
|
|
797
|
+
} catch (cleanupErr) {
|
|
798
|
+
const cleanupStatus = getHttpStatus(cleanupErr);
|
|
799
|
+
if (cleanupStatus !== 404 && cleanupStatus !== 422) throw cleanupErr;
|
|
800
|
+
log.debug(`» delete of leftover pending ${leftover.id} no-op (status ${cleanupStatus})`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* single-step createReview (event != PENDING) with stranded-draft recovery.
|
|
806
|
+
* the body path goes through createAndSubmitWithFooter which already recovers
|
|
807
|
+
* from a stranded PENDING draft at its own createReview call. the no-body path
|
|
808
|
+
* used to call createReview directly with no recovery — so a PR whose previous
|
|
809
|
+
* body-path run crashed between createReview(PENDING) and submitReview would
|
|
810
|
+
* permanently 422 any subsequent no-body review (approve-with-no-feedback or
|
|
811
|
+
* comments-only) until a body-path run happened to clear the draft.
|
|
812
|
+
*/
|
|
813
|
+
export async function createReviewWithStrandedRecovery(
|
|
814
|
+
ctx: ToolContext,
|
|
815
|
+
params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"],
|
|
816
|
+
): Promise<Awaited<ReturnType<typeof ctx.octokit.rest.pulls.createReview>>> {
|
|
817
|
+
try {
|
|
818
|
+
return await ctx.octokit.rest.pulls.createReview(params);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
await clearStrandedPendingReview(ctx, {
|
|
821
|
+
owner: params.owner,
|
|
822
|
+
repo: params.repo,
|
|
823
|
+
pull_number: params.pull_number,
|
|
824
|
+
originalErr: err,
|
|
825
|
+
});
|
|
826
|
+
return await ctx.octokit.rest.pulls.createReview(params);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function createAndSubmitWithFooter(
|
|
831
|
+
ctx: ToolContext,
|
|
832
|
+
params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"],
|
|
833
|
+
opts: FooterOpts,
|
|
834
|
+
) {
|
|
835
|
+
// create as PENDING (strip event) so we get the review ID before publishing
|
|
836
|
+
const { event: _, ...pendingParams } = params;
|
|
837
|
+
let pending: Awaited<ReturnType<typeof ctx.octokit.rest.pulls.createReview>>;
|
|
838
|
+
try {
|
|
839
|
+
pending = await ctx.octokit.rest.pulls.createReview(pendingParams);
|
|
840
|
+
} catch (err) {
|
|
841
|
+
await clearStrandedPendingReview(ctx, {
|
|
842
|
+
owner: params.owner,
|
|
843
|
+
repo: params.repo,
|
|
844
|
+
pull_number: params.pull_number,
|
|
845
|
+
originalErr: err,
|
|
846
|
+
});
|
|
847
|
+
pending = await ctx.octokit.rest.pulls.createReview(pendingParams);
|
|
848
|
+
}
|
|
849
|
+
if (!pending.data.id) {
|
|
850
|
+
throw new Error(`createReview returned invalid data: ${JSON.stringify(pending.data)}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// once the pending draft exists, GitHub only allows one pending review per
|
|
854
|
+
// user per PR — so ANY failure between here and successful submit must
|
|
855
|
+
// clean up, not just a submitReview throw. getApiUrl() can throw if
|
|
856
|
+
// API_URL is misconfigured, and future footer-building changes could
|
|
857
|
+
// introduce new throw paths. keep the whole body wrapped.
|
|
858
|
+
try {
|
|
859
|
+
// Fix buttons are suppressed on approving reviews — those are mergeable
|
|
860
|
+
// by definition (the `> ✅ No new issues found.` tier, with no inline
|
|
861
|
+
// comments), so dispatching a fix run would be a UX trap.
|
|
862
|
+
const customParts: string[] = [];
|
|
863
|
+
if (!opts.approved) {
|
|
864
|
+
const apiUrl = getApiUrl();
|
|
865
|
+
if (opts.hasComments) {
|
|
866
|
+
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${params.pull_number}?action=fix&review_id=${pending.data.id}`;
|
|
867
|
+
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${params.pull_number}?action=fix-approved&review_id=${pending.data.id}`;
|
|
868
|
+
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
|
|
869
|
+
} else {
|
|
870
|
+
const fixUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${params.pull_number}?action=fix&review_id=${pending.data.id}`;
|
|
871
|
+
customParts.push(`[Fix it ➔](${fixUrl})`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const footer = buildTerramendFooter({
|
|
876
|
+
customParts,
|
|
877
|
+
model: ctx.toolState.model,
|
|
878
|
+
fallbackFrom: ctx.toolState.modelFallback?.from,
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
return await ctx.octokit.rest.pulls.submitReview({
|
|
882
|
+
owner: params.owner,
|
|
883
|
+
repo: params.repo,
|
|
884
|
+
pull_number: params.pull_number,
|
|
885
|
+
review_id: pending.data.id,
|
|
886
|
+
event: params.event!,
|
|
887
|
+
body: opts.body + footer,
|
|
888
|
+
});
|
|
889
|
+
} catch (err) {
|
|
890
|
+
// anything failed after the pending draft was created. leaving the draft
|
|
891
|
+
// on the PR would cause the agent's retry to fail with "already has a
|
|
892
|
+
// pending review" (GitHub's one-pending-per-user-per-PR limit). best-effort
|
|
893
|
+
// cleanup so retries start from a clean slate. the cleanup itself may
|
|
894
|
+
// 404/422 (review already submitted by a concurrent caller, or the PR
|
|
895
|
+
// was closed mid-flight) — log and swallow those so the original error
|
|
896
|
+
// isn't masked.
|
|
897
|
+
try {
|
|
898
|
+
await ctx.octokit.rest.pulls.deletePendingReview({
|
|
899
|
+
owner: params.owner,
|
|
900
|
+
repo: params.repo,
|
|
901
|
+
pull_number: params.pull_number,
|
|
902
|
+
review_id: pending.data.id,
|
|
903
|
+
});
|
|
904
|
+
log.debug(`» deleted leftover pending review ${pending.data.id} after failure`);
|
|
905
|
+
} catch (cleanupErr) {
|
|
906
|
+
log.debug(
|
|
907
|
+
`» failed to delete pending review ${pending.data.id}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
throw err;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* report the review node ID so the WorkflowRun is marked as "review submitted".
|
|
916
|
+
* exported for use in main.ts post-agent cleanup.
|
|
917
|
+
*/
|
|
918
|
+
export async function reportReviewNodeId(
|
|
919
|
+
ctx: ToolContext,
|
|
920
|
+
params: { nodeId: string },
|
|
921
|
+
): Promise<void> {
|
|
922
|
+
await patchWorkflowRunFields(ctx, { reviewNodeId: params.nodeId });
|
|
923
|
+
}
|