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,896 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Octokit } from "@octokit/rest";
|
|
4
|
+
import { type } from "arktype";
|
|
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 { resolveBodyAssets } from "#app/utils/body";
|
|
9
|
+
import { stripExistingFooter } from "#app/utils/buildTerramendFooter";
|
|
10
|
+
import { log } from "#app/utils/log";
|
|
11
|
+
|
|
12
|
+
// GraphQL query to fetch all review threads for a PR with full comment history
|
|
13
|
+
export const REVIEW_THREADS_QUERY = `
|
|
14
|
+
query ($owner: String!, $name: String!, $prNumber: Int!) {
|
|
15
|
+
repository(owner: $owner, name: $name) {
|
|
16
|
+
pullRequest(number: $prNumber) {
|
|
17
|
+
reviewThreads(first: 100) {
|
|
18
|
+
nodes {
|
|
19
|
+
id
|
|
20
|
+
path
|
|
21
|
+
line
|
|
22
|
+
startLine
|
|
23
|
+
diffSide
|
|
24
|
+
isResolved
|
|
25
|
+
isOutdated
|
|
26
|
+
comments(first: 50) {
|
|
27
|
+
nodes {
|
|
28
|
+
fullDatabaseId
|
|
29
|
+
body
|
|
30
|
+
bodyHTML
|
|
31
|
+
createdAt
|
|
32
|
+
diffHunk
|
|
33
|
+
line
|
|
34
|
+
startLine
|
|
35
|
+
originalLine
|
|
36
|
+
originalStartLine
|
|
37
|
+
author { login }
|
|
38
|
+
pullRequestReview {
|
|
39
|
+
databaseId
|
|
40
|
+
author { login }
|
|
41
|
+
}
|
|
42
|
+
reactionGroups {
|
|
43
|
+
content
|
|
44
|
+
reactors(first: 10) {
|
|
45
|
+
totalCount
|
|
46
|
+
nodes {
|
|
47
|
+
... on Actor { login }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
export type ReviewThreadComment = {
|
|
61
|
+
fullDatabaseId: string | null;
|
|
62
|
+
body: string;
|
|
63
|
+
bodyHTML: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
diffHunk: string;
|
|
66
|
+
line: number | null;
|
|
67
|
+
startLine: number | null;
|
|
68
|
+
originalLine: number | null;
|
|
69
|
+
originalStartLine: number | null;
|
|
70
|
+
author: { login: string } | null;
|
|
71
|
+
pullRequestReview: {
|
|
72
|
+
databaseId: number | null;
|
|
73
|
+
author: { login: string } | null;
|
|
74
|
+
} | null;
|
|
75
|
+
reactionGroups: Array<{
|
|
76
|
+
content: string;
|
|
77
|
+
reactors: { totalCount?: number; nodes: Array<{ login: string } | null> | null } | null;
|
|
78
|
+
}> | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type ReviewThread = {
|
|
82
|
+
id: string;
|
|
83
|
+
path: string;
|
|
84
|
+
line: number | null;
|
|
85
|
+
startLine: number | null;
|
|
86
|
+
diffSide: "LEFT" | "RIGHT";
|
|
87
|
+
isResolved: boolean;
|
|
88
|
+
isOutdated: boolean;
|
|
89
|
+
comments: {
|
|
90
|
+
nodes: (ReviewThreadComment | null)[] | null;
|
|
91
|
+
} | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type ReviewThreadsQueryResponse = {
|
|
95
|
+
repository: {
|
|
96
|
+
pullRequest: {
|
|
97
|
+
reviewThreads: {
|
|
98
|
+
nodes: (ReviewThread | null)[] | null;
|
|
99
|
+
} | null;
|
|
100
|
+
} | null;
|
|
101
|
+
} | null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function countLines(str: string): number {
|
|
105
|
+
let count = 1;
|
|
106
|
+
let index = -1;
|
|
107
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: assignment in while condition is intentional for indexOf loop pattern
|
|
108
|
+
while ((index = str.indexOf("\n", index + 1)) !== -1) {
|
|
109
|
+
count++;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// extract exactly the commented line range from diffHunk, plus context
|
|
115
|
+
const CONTEXT_PADDING = 3;
|
|
116
|
+
|
|
117
|
+
function extractCommentedLines(
|
|
118
|
+
diffHunk: string,
|
|
119
|
+
startLine: number | null,
|
|
120
|
+
endLine: number | null,
|
|
121
|
+
side: "LEFT" | "RIGHT",
|
|
122
|
+
): string {
|
|
123
|
+
const lines = diffHunk.split("\n");
|
|
124
|
+
if (lines.length <= 1) return diffHunk;
|
|
125
|
+
|
|
126
|
+
const header = lines[0]!;
|
|
127
|
+
const contentLines = lines.slice(1);
|
|
128
|
+
|
|
129
|
+
// parse header: @@ -old_start,old_count +new_start,new_count @@
|
|
130
|
+
const headerMatch = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
131
|
+
if (!headerMatch) return diffHunk;
|
|
132
|
+
|
|
133
|
+
const hunkOldStart = parseInt(headerMatch[1]!, 10);
|
|
134
|
+
const hunkNewStart = parseInt(headerMatch[2]!, 10);
|
|
135
|
+
|
|
136
|
+
// LEFT = old file (deletions), RIGHT = new file (additions)
|
|
137
|
+
const hunkStart = side === "LEFT" ? hunkOldStart : hunkNewStart;
|
|
138
|
+
const commentStart = startLine ?? endLine ?? hunkStart;
|
|
139
|
+
const commentEnd = endLine ?? commentStart;
|
|
140
|
+
|
|
141
|
+
// walk through diff lines, tracking line numbers for both old and new files
|
|
142
|
+
// - lines: old file only (LEFT)
|
|
143
|
+
// + lines: new file only (RIGHT)
|
|
144
|
+
// context lines: both files
|
|
145
|
+
type DiffLine = { text: string; lineNum: number | null };
|
|
146
|
+
const diffLines: DiffLine[] = [];
|
|
147
|
+
let oldLineNum = hunkOldStart;
|
|
148
|
+
let newLineNum = hunkNewStart;
|
|
149
|
+
|
|
150
|
+
for (const line of contentLines) {
|
|
151
|
+
const prefix = line[0];
|
|
152
|
+
if (prefix === "-") {
|
|
153
|
+
// deletion - only has old line number
|
|
154
|
+
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : null });
|
|
155
|
+
oldLineNum++;
|
|
156
|
+
} else if (prefix === "+") {
|
|
157
|
+
// addition - only has new line number
|
|
158
|
+
diffLines.push({ text: line, lineNum: side === "RIGHT" ? newLineNum : null });
|
|
159
|
+
newLineNum++;
|
|
160
|
+
} else {
|
|
161
|
+
// context - has both line numbers
|
|
162
|
+
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : newLineNum });
|
|
163
|
+
oldLineNum++;
|
|
164
|
+
newLineNum++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// find lines for comment range with context
|
|
169
|
+
const targetStart = commentStart - CONTEXT_PADDING;
|
|
170
|
+
const targetEnd = commentEnd;
|
|
171
|
+
|
|
172
|
+
const result: string[] = [];
|
|
173
|
+
let truncatedBefore = 0;
|
|
174
|
+
|
|
175
|
+
for (const [i, dl] of diffLines.entries()) {
|
|
176
|
+
// include if: within target range, OR it's an "other side" line adjacent to included lines
|
|
177
|
+
const inRange = dl.lineNum !== null && dl.lineNum >= targetStart && dl.lineNum <= targetEnd;
|
|
178
|
+
// include opposite-side lines if they're between included lines
|
|
179
|
+
const adjacentOtherSide = dl.lineNum === null && result.length > 0 && i < diffLines.length - 1;
|
|
180
|
+
|
|
181
|
+
if (inRange || adjacentOtherSide) {
|
|
182
|
+
result.push(dl.text);
|
|
183
|
+
} else if (result.length === 0) {
|
|
184
|
+
truncatedBefore++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (truncatedBefore > 0) {
|
|
189
|
+
return `${header}\n... (${truncatedBefore} lines above) ...\n${result.join("\n")}`;
|
|
190
|
+
}
|
|
191
|
+
return `${header}\n${result.join("\n")}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// parsed hunk from a unified diff
|
|
195
|
+
export type ParsedHunk = {
|
|
196
|
+
header: string;
|
|
197
|
+
oldStart: number;
|
|
198
|
+
oldCount: number;
|
|
199
|
+
newStart: number;
|
|
200
|
+
newCount: number;
|
|
201
|
+
content: string[];
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// parse a full file patch into individual hunks
|
|
205
|
+
export function parseFilePatches(patch: string): ParsedHunk[] {
|
|
206
|
+
const hunks: ParsedHunk[] = [];
|
|
207
|
+
const lines = patch.split("\n");
|
|
208
|
+
|
|
209
|
+
let currentHunk: ParsedHunk | null = null;
|
|
210
|
+
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
213
|
+
if (hunkMatch) {
|
|
214
|
+
if (currentHunk) hunks.push(currentHunk);
|
|
215
|
+
currentHunk = {
|
|
216
|
+
header: line,
|
|
217
|
+
oldStart: parseInt(hunkMatch[1]!, 10),
|
|
218
|
+
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
|
|
219
|
+
newStart: parseInt(hunkMatch[3]!, 10),
|
|
220
|
+
newCount: parseInt(hunkMatch[4] ?? "1", 10),
|
|
221
|
+
content: [],
|
|
222
|
+
};
|
|
223
|
+
} else if (currentHunk) {
|
|
224
|
+
currentHunk.content.push(line);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (currentHunk) hunks.push(currentHunk);
|
|
228
|
+
|
|
229
|
+
return hunks;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// find hunks that overlap with a line range (for LEFT or RIGHT side)
|
|
233
|
+
function findOverlappingHunks(
|
|
234
|
+
hunks: ParsedHunk[],
|
|
235
|
+
startLine: number,
|
|
236
|
+
endLine: number,
|
|
237
|
+
side: "LEFT" | "RIGHT",
|
|
238
|
+
): ParsedHunk[] {
|
|
239
|
+
return hunks.filter((hunk) => {
|
|
240
|
+
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
|
241
|
+
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
|
242
|
+
const hunkEnd = hunkStart + hunkCount - 1;
|
|
243
|
+
|
|
244
|
+
// check for overlap: ranges overlap if start1 <= end2 && start2 <= end1
|
|
245
|
+
return startLine <= hunkEnd && hunkStart <= endLine;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// extract diff content from multiple hunks for a comment range
|
|
250
|
+
function extractFromFilePatches(
|
|
251
|
+
hunks: ParsedHunk[],
|
|
252
|
+
startLine: number,
|
|
253
|
+
endLine: number,
|
|
254
|
+
side: "LEFT" | "RIGHT",
|
|
255
|
+
): string {
|
|
256
|
+
const overlapping = findOverlappingHunks(hunks, startLine, endLine, side);
|
|
257
|
+
|
|
258
|
+
if (overlapping.length === 0) {
|
|
259
|
+
return `(no diff hunks found for lines ${startLine}-${endLine})`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (overlapping.length === 1) {
|
|
263
|
+
// single hunk - use existing extraction logic
|
|
264
|
+
const hunk = overlapping[0]!;
|
|
265
|
+
const fullHunk = `${hunk.header}\n${hunk.content.join("\n")}`;
|
|
266
|
+
return extractCommentedLines(fullHunk, startLine, endLine, side);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// multiple hunks - combine them with gap indicators
|
|
270
|
+
const result: string[] = [];
|
|
271
|
+
let prevHunkEnd = 0;
|
|
272
|
+
|
|
273
|
+
for (const [i, hunk] of overlapping.entries()) {
|
|
274
|
+
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
|
275
|
+
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
|
276
|
+
const hunkEnd = hunkStart + hunkCount - 1;
|
|
277
|
+
|
|
278
|
+
// add gap indicator if there's a gap between hunks
|
|
279
|
+
if (i > 0 && hunkStart > prevHunkEnd + 1) {
|
|
280
|
+
const gapSize = hunkStart - prevHunkEnd - 1;
|
|
281
|
+
result.push(`\n... (${gapSize} unchanged lines) ...\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// add the hunk header and content
|
|
285
|
+
result.push(hunk.header);
|
|
286
|
+
result.push(...hunk.content);
|
|
287
|
+
|
|
288
|
+
prevHunkEnd = hunkEnd;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const GetReviewComments = type({
|
|
295
|
+
pull_number: type.number.describe("The pull request number"),
|
|
296
|
+
review_id: type.number.describe("The review ID to get comments for"),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolean {
|
|
300
|
+
if (!comment.reactionGroups) return false;
|
|
301
|
+
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
|
302
|
+
if (!thumbsUp?.reactors?.nodes) return false;
|
|
303
|
+
const needle = username.toLowerCase();
|
|
304
|
+
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === needle);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
|
308
|
+
const comments = thread.comments?.nodes ?? [];
|
|
309
|
+
return comments.some((c) => c && hasThumbsUpFrom(c, username));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* §5.7 — render 👍/👎 totals as a `reactions=` tag attribute so the agent sees
|
|
314
|
+
* human feedback on its findings inline with the thread (a 👎 on a terramend
|
|
315
|
+
* root comment is false-positive signal; see the IncrementalReview prompt).
|
|
316
|
+
* Only the thumbs contents — the other six reaction types carry no
|
|
317
|
+
* accept/reject semantics — and only when at least one is nonzero, so the
|
|
318
|
+
* common no-reaction case adds zero tokens.
|
|
319
|
+
*/
|
|
320
|
+
export function formatReactionCounts(comment: ReviewThreadComment): string {
|
|
321
|
+
const count = (content: string): number =>
|
|
322
|
+
comment.reactionGroups?.find((g) => g.content === content)?.reactors?.totalCount ?? 0;
|
|
323
|
+
const up = count("THUMBS_UP");
|
|
324
|
+
const down = count("THUMBS_DOWN");
|
|
325
|
+
if (up === 0 && down === 0) return "";
|
|
326
|
+
const parts = [up > 0 ? `👍${up}` : null, down > 0 ? `👎${down}` : null].filter(Boolean);
|
|
327
|
+
return ` reactions=${parts.join(",")}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* formats thread blocks into markdown with TOC and line numbers.
|
|
332
|
+
* extracted for testability.
|
|
333
|
+
*/
|
|
334
|
+
export function formatReviewThreads(
|
|
335
|
+
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>,
|
|
336
|
+
header: { pullNumber: number; reviewId: number; reviewer: string; reviewBody?: string },
|
|
337
|
+
) {
|
|
338
|
+
// header section takes: title (1) + blank (1) + "## TOC" (1) + blank (1) + N TOC entries + blank (1) + "---" (1) + blank (1)
|
|
339
|
+
const tocHeaderLines = 4;
|
|
340
|
+
const tocFooterLines = 3;
|
|
341
|
+
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
|
|
342
|
+
|
|
343
|
+
// account for review body section if present
|
|
344
|
+
const reviewBodyLines: string[] = [];
|
|
345
|
+
if (header.reviewBody) {
|
|
346
|
+
reviewBodyLines.push("## Review Body", "", header.reviewBody, "");
|
|
347
|
+
currentLine += reviewBodyLines.reduce((sum, line) => sum + countLines(line), 0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const tocEntries: string[] = [];
|
|
351
|
+
const threadLines: string[] = [];
|
|
352
|
+
|
|
353
|
+
for (const block of threadBlocks) {
|
|
354
|
+
const startLine = currentLine;
|
|
355
|
+
const actualLineCount = block.content.reduce((sum, line) => sum + countLines(line), 0);
|
|
356
|
+
const endLine = currentLine + actualLineCount - 1;
|
|
357
|
+
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
|
|
358
|
+
threadLines.push(...block.content);
|
|
359
|
+
currentLine += actualLineCount;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const lines: string[] = [];
|
|
363
|
+
lines.push(
|
|
364
|
+
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`,
|
|
365
|
+
);
|
|
366
|
+
lines.push("");
|
|
367
|
+
if (threadBlocks.length > 0) {
|
|
368
|
+
lines.push("## TOC");
|
|
369
|
+
lines.push("");
|
|
370
|
+
lines.push(...tocEntries);
|
|
371
|
+
lines.push("");
|
|
372
|
+
}
|
|
373
|
+
lines.push(...reviewBodyLines);
|
|
374
|
+
lines.push("---");
|
|
375
|
+
lines.push("");
|
|
376
|
+
lines.push(...threadLines);
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
toc: tocEntries.join("\n"),
|
|
380
|
+
content: lines.join("\n"),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* builds thread blocks from review threads and file patches.
|
|
386
|
+
* extracted for testability.
|
|
387
|
+
*/
|
|
388
|
+
export function buildThreadBlocks(
|
|
389
|
+
threads: ReviewThread[],
|
|
390
|
+
filePatchMap: Map<string, ParsedHunk[]>,
|
|
391
|
+
reviewId: number,
|
|
392
|
+
) {
|
|
393
|
+
// sort threads by file path, then by line number
|
|
394
|
+
threads.sort((a, b) => {
|
|
395
|
+
const pathCmp = a.path.localeCompare(b.path);
|
|
396
|
+
if (pathCmp !== 0) return pathCmp;
|
|
397
|
+
const aLine = a.startLine ?? a.line ?? 0;
|
|
398
|
+
const bLine = b.startLine ?? b.line ?? 0;
|
|
399
|
+
return aLine - bLine;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
|
403
|
+
|
|
404
|
+
for (const thread of threads) {
|
|
405
|
+
const allComments = (thread.comments?.nodes ?? []).filter(
|
|
406
|
+
(c): c is ReviewThreadComment => c !== null,
|
|
407
|
+
);
|
|
408
|
+
if (allComments.length === 0) continue;
|
|
409
|
+
|
|
410
|
+
// get line info from thread, or fall back to first comment's line info
|
|
411
|
+
const firstComment = allComments[0];
|
|
412
|
+
const line =
|
|
413
|
+
thread.line ?? firstComment?.line ?? firstComment?.originalLine ?? thread.startLine ?? 0;
|
|
414
|
+
const startLine =
|
|
415
|
+
thread.startLine ?? firstComment?.startLine ?? firstComment?.originalStartLine ?? line;
|
|
416
|
+
const lineRange = startLine === line ? `${line}` : `${startLine}-${line}`;
|
|
417
|
+
const block: string[] = [];
|
|
418
|
+
|
|
419
|
+
// header with file:line range and status
|
|
420
|
+
const status = thread.isResolved ? " [RESOLVED]" : thread.isOutdated ? " [OUTDATED]" : "";
|
|
421
|
+
block.push(`## ${thread.path}:${lineRange}${status}`);
|
|
422
|
+
block.push("");
|
|
423
|
+
|
|
424
|
+
// show all comments in the thread (full conversation history)
|
|
425
|
+
for (const comment of allComments) {
|
|
426
|
+
const author = comment.author?.login ?? "unknown";
|
|
427
|
+
const isTargetReview = comment.pullRequestReview?.databaseId === reviewId;
|
|
428
|
+
const marker = isTargetReview ? " *" : "";
|
|
429
|
+
|
|
430
|
+
block.push(
|
|
431
|
+
`\`\`\`\`comment author=${author} id=${comment.fullDatabaseId ?? "unknown"} review=${comment.pullRequestReview?.databaseId ?? "unknown"} thread=${thread.id}${formatReactionCounts(comment)}${marker}`,
|
|
432
|
+
);
|
|
433
|
+
block.push(comment.body || "(no comment body)");
|
|
434
|
+
block.push("````");
|
|
435
|
+
block.push("");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// diff context
|
|
439
|
+
const fileHunks = filePatchMap.get(thread.path);
|
|
440
|
+
const firstCommentWithHunk = allComments.find((c) => c.diffHunk);
|
|
441
|
+
let diffContent: string | null = null;
|
|
442
|
+
|
|
443
|
+
if (fileHunks && fileHunks.length > 0) {
|
|
444
|
+
const overlapping = findOverlappingHunks(fileHunks, startLine, line, thread.diffSide);
|
|
445
|
+
if (overlapping.length > 0) {
|
|
446
|
+
diffContent = extractFromFilePatches(fileHunks, startLine, line, thread.diffSide);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!diffContent && firstCommentWithHunk) {
|
|
451
|
+
diffContent = extractCommentedLines(
|
|
452
|
+
firstCommentWithHunk.diffHunk,
|
|
453
|
+
startLine,
|
|
454
|
+
line,
|
|
455
|
+
thread.diffSide,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (diffContent) {
|
|
460
|
+
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
|
461
|
+
block.push(diffContent);
|
|
462
|
+
block.push("```");
|
|
463
|
+
block.push("");
|
|
464
|
+
} else {
|
|
465
|
+
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
|
466
|
+
block.push(`(no diff context available - comment on unchanged lines)`);
|
|
467
|
+
block.push("```");
|
|
468
|
+
block.push("");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
threadBlocks.push({ path: thread.path, lineRange, content: block });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return threadBlocks;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* `truncated` is true when GitHub returned a full page of threads (first: 100)
|
|
479
|
+
* or any thread returned a full page of comments (first: 50), meaning some
|
|
480
|
+
* review feedback is NOT in `threads`. Surfaced to the agent so it knows the
|
|
481
|
+
* thread set it's addressing may be incomplete rather than silently dropping
|
|
482
|
+
* the overflow (the cap is only logged for the operator).
|
|
483
|
+
*/
|
|
484
|
+
async function getReviewThreads(
|
|
485
|
+
input: GetReviewDataInput,
|
|
486
|
+
): Promise<{ threads: ReviewThread[]; truncated: boolean }> {
|
|
487
|
+
const response = await input.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
|
488
|
+
owner: input.owner,
|
|
489
|
+
name: input.name,
|
|
490
|
+
prNumber: input.pullNumber,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
494
|
+
|
|
495
|
+
let truncated = false;
|
|
496
|
+
if (allThreads.length >= 100) {
|
|
497
|
+
truncated = true;
|
|
498
|
+
log.warning(
|
|
499
|
+
`PR ${input.owner}/${input.name}#${input.pullNumber}: reviewThreads returned 100 results (limit reached, some threads may be missing)`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
for (const thread of allThreads) {
|
|
503
|
+
if (thread?.comments?.nodes && thread.comments.nodes.length >= 50) {
|
|
504
|
+
truncated = true;
|
|
505
|
+
log.warning(
|
|
506
|
+
`PR ${input.owner}/${input.name}#${input.pullNumber}: review thread at ${thread.path}:${thread.line} has 50 comments (limit reached, some comments may be missing)`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
|
512
|
+
if (!thread?.comments?.nodes) return false;
|
|
513
|
+
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === input.reviewId);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (!input.approvedBy) {
|
|
517
|
+
return { threads: threadsForReview, truncated };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const username = input.approvedBy;
|
|
521
|
+
return {
|
|
522
|
+
threads: threadsForReview.filter((thread) => threadHasThumbsUpFrom(thread, username)),
|
|
523
|
+
truncated,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface GetReviewDataInput {
|
|
528
|
+
octokit: Octokit;
|
|
529
|
+
owner: string;
|
|
530
|
+
name: string;
|
|
531
|
+
pullNumber: number;
|
|
532
|
+
reviewId: number;
|
|
533
|
+
approvedBy?: string | undefined;
|
|
534
|
+
tmpdir: string;
|
|
535
|
+
githubToken: string;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// pure formatter: takes already-fetched GitHub responses and produces the
|
|
539
|
+
// review data the MCP tool returns. extracted from getReviewData so tests
|
|
540
|
+
// can drive it from checked-in fixtures without live API access.
|
|
541
|
+
//
|
|
542
|
+
// `prFiles` may be empty when `threads` is empty — callers that hit the
|
|
543
|
+
// network should skip the listFiles call in that case as a perf
|
|
544
|
+
// optimization. when both are empty and `review.body` is also empty, the
|
|
545
|
+
// formatter returns undefined just like getReviewData.
|
|
546
|
+
export interface FormatReviewDataInput {
|
|
547
|
+
review: ReviewResponse;
|
|
548
|
+
threads: ReviewThread[];
|
|
549
|
+
prFiles: ReviewPrFile[];
|
|
550
|
+
pullNumber: number;
|
|
551
|
+
reviewId: number;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export type ReviewResponse = {
|
|
555
|
+
body: string | null | undefined;
|
|
556
|
+
user: { login: string } | null | undefined;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
export type ReviewPrFile = {
|
|
560
|
+
filename: string;
|
|
561
|
+
patch?: string | undefined;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
export function formatReviewData(input: FormatReviewDataInput):
|
|
565
|
+
| {
|
|
566
|
+
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
|
567
|
+
reviewer: string;
|
|
568
|
+
formatted: { toc: string; content: string };
|
|
569
|
+
}
|
|
570
|
+
| undefined {
|
|
571
|
+
const rawReviewBody = input.review.body;
|
|
572
|
+
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
|
573
|
+
const reviewer = input.review.user?.login ?? "unknown";
|
|
574
|
+
|
|
575
|
+
if (input.threads.length === 0 && !reviewBody) return undefined;
|
|
576
|
+
|
|
577
|
+
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
|
578
|
+
|
|
579
|
+
if (input.threads.length > 0) {
|
|
580
|
+
const filePatchMap = new Map<string, ParsedHunk[]>();
|
|
581
|
+
for (const file of input.prFiles) {
|
|
582
|
+
if (file.patch) {
|
|
583
|
+
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
threadBlocks = buildThreadBlocks(input.threads, filePatchMap, input.reviewId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const formatted = formatReviewThreads(threadBlocks, {
|
|
590
|
+
pullNumber: input.pullNumber,
|
|
591
|
+
reviewId: input.reviewId,
|
|
592
|
+
reviewer,
|
|
593
|
+
reviewBody,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return { threadBlocks, reviewer, formatted };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function getReviewData(input: GetReviewDataInput): Promise<
|
|
600
|
+
| {
|
|
601
|
+
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
|
602
|
+
reviewer: string;
|
|
603
|
+
formatted: { toc: string; content: string };
|
|
604
|
+
truncated: boolean;
|
|
605
|
+
}
|
|
606
|
+
| undefined
|
|
607
|
+
> {
|
|
608
|
+
const [review, threadsResult] = await Promise.all([
|
|
609
|
+
input.octokit.rest.pulls.getReview({
|
|
610
|
+
owner: input.owner,
|
|
611
|
+
repo: input.name,
|
|
612
|
+
pull_number: input.pullNumber,
|
|
613
|
+
review_id: input.reviewId,
|
|
614
|
+
headers: { accept: "application/vnd.github.full+json" },
|
|
615
|
+
}),
|
|
616
|
+
getReviewThreads(input),
|
|
617
|
+
]);
|
|
618
|
+
const { threads, truncated } = threadsResult;
|
|
619
|
+
|
|
620
|
+
// skip listFiles when there are no threads — prFiles is only used for
|
|
621
|
+
// building thread blocks, and an empty array short-circuits below.
|
|
622
|
+
const prFiles =
|
|
623
|
+
threads.length > 0
|
|
624
|
+
? await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
|
|
625
|
+
owner: input.owner,
|
|
626
|
+
repo: input.name,
|
|
627
|
+
pull_number: input.pullNumber,
|
|
628
|
+
per_page: 100,
|
|
629
|
+
})
|
|
630
|
+
: [];
|
|
631
|
+
|
|
632
|
+
if (review.data.body) {
|
|
633
|
+
review.data.body =
|
|
634
|
+
(await resolveBodyAssets({
|
|
635
|
+
body: review.data.body,
|
|
636
|
+
bodyHtml: review.data.body_html,
|
|
637
|
+
tmpdir: input.tmpdir,
|
|
638
|
+
githubToken: input.githubToken,
|
|
639
|
+
})) ?? review.data.body;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for (const thread of threads) {
|
|
643
|
+
for (const comment of thread.comments?.nodes ?? []) {
|
|
644
|
+
if (comment?.body) {
|
|
645
|
+
comment.body =
|
|
646
|
+
(await resolveBodyAssets({
|
|
647
|
+
body: comment.body,
|
|
648
|
+
bodyHtml: comment.bodyHTML,
|
|
649
|
+
tmpdir: input.tmpdir,
|
|
650
|
+
githubToken: input.githubToken,
|
|
651
|
+
})) ?? comment.body;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const formatted = formatReviewData({
|
|
657
|
+
review: review.data,
|
|
658
|
+
threads,
|
|
659
|
+
prFiles,
|
|
660
|
+
pullNumber: input.pullNumber,
|
|
661
|
+
reviewId: input.reviewId,
|
|
662
|
+
});
|
|
663
|
+
if (!formatted) return undefined;
|
|
664
|
+
return { ...formatted, truncated };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function GetReviewCommentsTool(ctx: ToolContext) {
|
|
668
|
+
return tool({
|
|
669
|
+
name: "get_review_comments",
|
|
670
|
+
description:
|
|
671
|
+
"Get review comments for a pull request review with full thread context. " +
|
|
672
|
+
"Example: `get_review_comments({ pull_number: 1234, review_id: 567890 })`. " +
|
|
673
|
+
"Automatically filters to approved comments when applicable. " +
|
|
674
|
+
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
|
675
|
+
parameters: GetReviewComments,
|
|
676
|
+
execute: execute(async (params) => {
|
|
677
|
+
// auto-filter to approved comments when the event has approved_only set
|
|
678
|
+
const approvedBy =
|
|
679
|
+
ctx.payload.event.trigger === "fix_review" && ctx.payload.event.approved_only
|
|
680
|
+
? ctx.payload.triggerer
|
|
681
|
+
: undefined;
|
|
682
|
+
|
|
683
|
+
const result = await getReviewData({
|
|
684
|
+
octokit: ctx.octokit,
|
|
685
|
+
owner: ctx.repo.owner,
|
|
686
|
+
name: ctx.repo.name,
|
|
687
|
+
pullNumber: params.pull_number,
|
|
688
|
+
reviewId: params.review_id,
|
|
689
|
+
approvedBy,
|
|
690
|
+
tmpdir: ctx.tmpdir,
|
|
691
|
+
githubToken: ctx.githubInstallationToken,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
if (!result) {
|
|
695
|
+
return {
|
|
696
|
+
review_id: params.review_id,
|
|
697
|
+
pull_number: params.pull_number,
|
|
698
|
+
reviewer: "unknown",
|
|
699
|
+
threadCount: 0,
|
|
700
|
+
commentsPath: null,
|
|
701
|
+
toc: null,
|
|
702
|
+
instructions: approvedBy
|
|
703
|
+
? `no threads with 👍 from ${approvedBy}`
|
|
704
|
+
: "no threads found for this review",
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const { threadBlocks, reviewer, formatted, truncated } = result;
|
|
709
|
+
|
|
710
|
+
const tempDir = process.env.TERRAMEND_TEMP_DIR;
|
|
711
|
+
if (!tempDir) {
|
|
712
|
+
throw new Error("TERRAMEND_TEMP_DIR not set");
|
|
713
|
+
}
|
|
714
|
+
const filename = `review-${params.review_id}-threads.md`;
|
|
715
|
+
const commentsPath = join(tempDir, filename);
|
|
716
|
+
writeFileSync(commentsPath, formatted.content);
|
|
717
|
+
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
|
|
718
|
+
|
|
719
|
+
const truncationNote = truncated
|
|
720
|
+
? ` WARNING: this PR has more review threads/comments than a single fetch returns, so the set above is INCOMPLETE — ` +
|
|
721
|
+
`some threads or comments are not included. Address what's here, but do not treat the list as exhaustive.`
|
|
722
|
+
: "";
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
review_id: params.review_id,
|
|
726
|
+
pull_number: params.pull_number,
|
|
727
|
+
reviewer,
|
|
728
|
+
threadCount: threadBlocks.length,
|
|
729
|
+
truncated,
|
|
730
|
+
commentsPath,
|
|
731
|
+
toc: formatted.toc,
|
|
732
|
+
instructions:
|
|
733
|
+
`the file at commentsPath contains ${threadBlocks.length} review threads with full conversation history. ` +
|
|
734
|
+
`comments marked with * are from the target review (${params.review_id}). ` +
|
|
735
|
+
`the TOC shows each thread's file:line and the line number where it appears in the file. ` +
|
|
736
|
+
`to read a specific thread, use: grep -A 50 "^## <file:line>" ${commentsPath} ` +
|
|
737
|
+
`(replace <file:line> with the path from the TOC, e.g. "^## action/utils/foo.ts:42"). ` +
|
|
738
|
+
`address each thread in order, working through one file at a time.` +
|
|
739
|
+
truncationNote,
|
|
740
|
+
};
|
|
741
|
+
}),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export const ListPullRequestReviews = type({
|
|
746
|
+
pull_number: type.number.describe("The pull request number to list reviews for"),
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
export function ListPullRequestReviewsTool(ctx: ToolContext) {
|
|
750
|
+
return tool({
|
|
751
|
+
name: "list_pull_request_reviews",
|
|
752
|
+
description:
|
|
753
|
+
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments. " +
|
|
754
|
+
"Example: `list_pull_request_reviews({ pull_number: 1234 })`.",
|
|
755
|
+
parameters: ListPullRequestReviews,
|
|
756
|
+
execute: execute(async (params) => {
|
|
757
|
+
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
|
758
|
+
owner: ctx.repo.owner,
|
|
759
|
+
repo: ctx.repo.name,
|
|
760
|
+
pull_number: params.pull_number,
|
|
761
|
+
headers: { accept: "application/vnd.github.full+json" },
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const processedReviews = await Promise.all(
|
|
765
|
+
reviews.map(async (review) => ({
|
|
766
|
+
id: review.id,
|
|
767
|
+
node_id: review.node_id,
|
|
768
|
+
body: await resolveBodyAssets({
|
|
769
|
+
body: review.body,
|
|
770
|
+
bodyHtml: review.body_html,
|
|
771
|
+
tmpdir: ctx.tmpdir,
|
|
772
|
+
githubToken: ctx.githubInstallationToken,
|
|
773
|
+
}),
|
|
774
|
+
state: review.state,
|
|
775
|
+
user: review.user?.login,
|
|
776
|
+
submitted_at: review.submitted_at,
|
|
777
|
+
commit_id: review.commit_id,
|
|
778
|
+
html_url: review.html_url,
|
|
779
|
+
})),
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
pull_number: params.pull_number,
|
|
784
|
+
reviews: processedReviews,
|
|
785
|
+
count: processedReviews.length,
|
|
786
|
+
};
|
|
787
|
+
}),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const RESOLVE_REVIEW_THREAD_MUTATION = `
|
|
792
|
+
mutation($threadId: ID!) {
|
|
793
|
+
resolveReviewThread(input: {threadId: $threadId}) {
|
|
794
|
+
thread {
|
|
795
|
+
id
|
|
796
|
+
isResolved
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
`;
|
|
801
|
+
|
|
802
|
+
// resolve a thread node id to the PR it belongs to, so resolve_review_thread can
|
|
803
|
+
// be scope-bound (a thread id is opaque, unlike a bare PR number).
|
|
804
|
+
const REVIEW_THREAD_PR_QUERY = `
|
|
805
|
+
query($id: ID!) {
|
|
806
|
+
node(id: $id) {
|
|
807
|
+
... on PullRequestReviewThread {
|
|
808
|
+
pullRequest { number }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
`;
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* SECURITY: bind resolve_review_thread to the run's scoped PR (or one it
|
|
816
|
+
* created). The thread id is opaque, so look up its PR first. Skips the check
|
|
817
|
+
* for standalone runs (no triggering issue/PR) and, deliberately, when the
|
|
818
|
+
* lookup can't determine a PR number — resolving a thread is low-risk and
|
|
819
|
+
* reversible, so a transient API/permission hiccup must not block a legitimate
|
|
820
|
+
* resolve. A definite out-of-scope match still throws.
|
|
821
|
+
*/
|
|
822
|
+
async function assertThreadInScope(ctx: ToolContext, threadId: string): Promise<void> {
|
|
823
|
+
if (ctx.payload?.event?.issue_number === undefined) return;
|
|
824
|
+
let prNumber: number | undefined;
|
|
825
|
+
try {
|
|
826
|
+
const lookup = await ctx.octokit.graphql<{
|
|
827
|
+
node?: { pullRequest?: { number?: number } } | null;
|
|
828
|
+
}>(REVIEW_THREAD_PR_QUERY, { id: threadId });
|
|
829
|
+
prNumber = lookup.node?.pullRequest?.number;
|
|
830
|
+
} catch (e) {
|
|
831
|
+
log.debug(
|
|
832
|
+
`resolve_review_thread scope lookup failed (allowing): ${e instanceof Error ? e.message : String(e)}`,
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (typeof prNumber === "number") {
|
|
837
|
+
assertTargetInScope(ctx, prNumber, "resolve a review thread on");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export const ResolveReviewThread = type({
|
|
842
|
+
thread_id: type.string.describe("The GraphQL node ID of the review thread to resolve"),
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
export function ResolveReviewThreadTool(ctx: ToolContext) {
|
|
846
|
+
return tool({
|
|
847
|
+
name: "resolve_review_thread",
|
|
848
|
+
description:
|
|
849
|
+
"Mark a review thread as resolved using GitHub's GraphQL API. " +
|
|
850
|
+
"Only call this after addressing the review feedback, implementing fixes, testing them, and posting a reply. " +
|
|
851
|
+
"Do not resolve threads that are already resolved, threads where no action was taken, or threads where you disagree with the feedback.",
|
|
852
|
+
parameters: ResolveReviewThread,
|
|
853
|
+
execute: execute(async (params) => {
|
|
854
|
+
await assertThreadInScope(ctx, params.thread_id);
|
|
855
|
+
try {
|
|
856
|
+
const response = await ctx.octokit.graphql<{
|
|
857
|
+
resolveReviewThread: {
|
|
858
|
+
thread: {
|
|
859
|
+
id: string;
|
|
860
|
+
isResolved: boolean;
|
|
861
|
+
};
|
|
862
|
+
};
|
|
863
|
+
}>(RESOLVE_REVIEW_THREAD_MUTATION, {
|
|
864
|
+
threadId: params.thread_id,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const thread = response.resolveReviewThread.thread;
|
|
868
|
+
log.info(`» resolved review thread ${thread.id}`);
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
thread_id: thread.id,
|
|
872
|
+
is_resolved: thread.isResolved,
|
|
873
|
+
success: true,
|
|
874
|
+
message: "Thread resolved successfully",
|
|
875
|
+
};
|
|
876
|
+
} catch (error) {
|
|
877
|
+
// handle common error cases gracefully
|
|
878
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
879
|
+
const isResolved =
|
|
880
|
+
errorMessage.includes("already resolved") || errorMessage.includes("isResolved");
|
|
881
|
+
|
|
882
|
+
const message = isResolved
|
|
883
|
+
? `thread ${params.thread_id} was already resolved`
|
|
884
|
+
: `failed to resolve thread ${params.thread_id}: ${errorMessage}`;
|
|
885
|
+
log.info(message);
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
thread_id: params.thread_id,
|
|
889
|
+
is_resolved: isResolved,
|
|
890
|
+
success: isResolved,
|
|
891
|
+
message,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
}
|