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