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.
Files changed (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. 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
+ }