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,936 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Unwrap the ToolResult envelope so the CreatePullRequestReviewTool tests can
4
+ // assert on the raw object the tool returns (and see thrown errors as
5
+ // rejections instead of encoded error text).
6
+ vi.mock("#app/mcp/shared", async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import("#app/mcp/shared")>();
8
+ return {
9
+ ...actual,
10
+ execute: <T, R>(fn: (params: T) => Promise<R>): ((params: T) => Promise<R>) => fn,
11
+ };
12
+ });
13
+
14
+ import {
15
+ buildCommentableMap,
16
+ type CommentableLines,
17
+ CreatePullRequestReviewTool,
18
+ clearStrandedPendingReview,
19
+ commentableLinesForFile,
20
+ createReviewWithStrandedRecovery,
21
+ type DroppedComment,
22
+ duplicateReviewDecision,
23
+ formatDroppedCommentsNote,
24
+ isTransientReviewError,
25
+ MAX_DROPPED_COMMENT_LINES,
26
+ type ReviewCommentInput,
27
+ reviewSkipDecision,
28
+ validateInlineComments,
29
+ } from "#app/mcp/review";
30
+ import type { ToolContext } from "#app/mcp/server";
31
+
32
+ describe("commentableLinesForFile", () => {
33
+ it("returns empty sets for missing patches (binary or no changes)", () => {
34
+ const result = commentableLinesForFile(undefined);
35
+ expect(result.LEFT.size).toBe(0);
36
+ expect(result.RIGHT.size).toBe(0);
37
+ });
38
+
39
+ it("collects added lines on RIGHT, removed lines on LEFT, context on both", () => {
40
+ const patch = ["@@ -10,3 +10,4 @@", " ctx1", "-old", "+new", "+new2", " ctx2"].join("\n");
41
+ const { LEFT, RIGHT } = commentableLinesForFile(patch);
42
+ expect([...LEFT].sort((a, b) => a - b)).toEqual([10, 11, 12]);
43
+ expect([...RIGHT].sort((a, b) => a - b)).toEqual([10, 11, 12, 13]);
44
+ });
45
+
46
+ it("handles multiple hunks", () => {
47
+ const patch = ["@@ -1,2 +1,2 @@", " a", "-b", "+B", "@@ -20,1 +20,2 @@", " x", "+y"].join("\n");
48
+ const { LEFT, RIGHT } = commentableLinesForFile(patch);
49
+ expect(RIGHT.has(2)).toBe(true); // +B
50
+ expect(RIGHT.has(21)).toBe(true); // +y
51
+ expect(LEFT.has(2)).toBe(true); // -b
52
+ expect(LEFT.has(20)).toBe(true); // context x
53
+ expect(RIGHT.has(20)).toBe(true); // context x
54
+ });
55
+
56
+ it("ignores the 'no newline at end of file' marker", () => {
57
+ const patch = ["@@ -1,1 +1,1 @@", "-old", "\", "+new"].join("\n");
58
+ const { LEFT, RIGHT } = commentableLinesForFile(patch);
59
+ expect(LEFT.has(1)).toBe(true);
60
+ expect(RIGHT.has(1)).toBe(true);
61
+ expect(LEFT.size).toBe(1);
62
+ expect(RIGHT.size).toBe(1);
63
+ });
64
+
65
+ it("parses hunk headers without explicit counts", () => {
66
+ // single-line hunks can omit ",<count>"
67
+ const patch = ["@@ -5 +5 @@", "-old", "+new"].join("\n");
68
+ const { LEFT, RIGHT } = commentableLinesForFile(patch);
69
+ expect(LEFT.has(5)).toBe(true);
70
+ expect(RIGHT.has(5)).toBe(true);
71
+ });
72
+ });
73
+
74
+ function buildMap(entries: Array<[string, string]>): Map<string, CommentableLines> {
75
+ const map = new Map<string, CommentableLines>();
76
+ for (const [file, patch] of entries) {
77
+ map.set(file, commentableLinesForFile(patch));
78
+ }
79
+ return map;
80
+ }
81
+
82
+ describe("validateInlineComments", () => {
83
+ const patch = ["@@ -10,2 +10,3 @@", " ctx", "-old", "+new", "+new2"].join("\n");
84
+ const diffMap = buildMap([["src/foo.ts", patch]]);
85
+
86
+ const base = (overrides: Partial<ReviewCommentInput>): ReviewCommentInput => ({
87
+ path: "src/foo.ts",
88
+ line: 11,
89
+ side: "RIGHT",
90
+ body: "LGTM",
91
+ ...overrides,
92
+ });
93
+
94
+ it("keeps comments anchored to added lines on RIGHT", () => {
95
+ const result = validateInlineComments([base({ line: 12 })], diffMap);
96
+ expect(result.valid).toHaveLength(1);
97
+ expect(result.dropped).toHaveLength(0);
98
+ });
99
+
100
+ it("keeps comments anchored to removed lines on LEFT", () => {
101
+ const result = validateInlineComments([base({ line: 11, side: "LEFT" })], diffMap);
102
+ expect(result.valid).toHaveLength(1);
103
+ expect(result.dropped).toHaveLength(0);
104
+ });
105
+
106
+ it("drops comments on files not in the diff", () => {
107
+ const result = validateInlineComments([base({ path: "other/bar.ts" })], diffMap);
108
+ expect(result.valid).toHaveLength(0);
109
+ expect(result.dropped).toHaveLength(1);
110
+ expect(result.dropped[0]!.reason).toContain("file not in PR diff");
111
+ });
112
+
113
+ it("distinguishes binary/no-patch files from files with hunks", () => {
114
+ // file present in the PR but with no patch data (binary file).
115
+ const binaryMap = buildMap([
116
+ ["src/foo.ts", patch],
117
+ ["assets/logo.png", undefined as unknown as string],
118
+ ]);
119
+ const result = validateInlineComments([base({ path: "assets/logo.png", line: 1 })], binaryMap);
120
+ expect(result.valid).toHaveLength(0);
121
+ expect(result.dropped).toHaveLength(1);
122
+ expect(result.dropped[0]!.reason).toContain("no textual diff");
123
+ expect(result.dropped[0]!.reason).not.toContain("not inside a diff hunk");
124
+ });
125
+
126
+ it("drops comments on lines outside diff hunks", () => {
127
+ const result = validateInlineComments([base({ line: 500 })], diffMap);
128
+ expect(result.valid).toHaveLength(0);
129
+ expect(result.dropped).toHaveLength(1);
130
+ expect(result.dropped[0]!.reason).toContain("line 500");
131
+ expect(result.dropped[0]!.reason).toContain("RIGHT");
132
+ });
133
+
134
+ it("drops comments whose side mismatches the hunk (added line on LEFT)", () => {
135
+ // line 12 is "+new" — only in RIGHT. Asking for it on LEFT should drop.
136
+ const result = validateInlineComments([base({ line: 12, side: "LEFT" })], diffMap);
137
+ expect(result.valid).toHaveLength(0);
138
+ expect(result.dropped).toHaveLength(1);
139
+ });
140
+
141
+ it("drops multi-line comments where start_line is out of range", () => {
142
+ const result = validateInlineComments([base({ line: 12, start_line: 3 })], diffMap);
143
+ expect(result.valid).toHaveLength(0);
144
+ expect(result.dropped).toHaveLength(1);
145
+ expect(result.dropped[0]!.reason).toContain("start_line 3");
146
+ });
147
+
148
+ it("keeps multi-line comments fully inside a hunk", () => {
149
+ const result = validateInlineComments([base({ line: 12, start_line: 11 })], diffMap);
150
+ expect(result.valid).toHaveLength(1);
151
+ expect(result.dropped).toHaveLength(0);
152
+ });
153
+
154
+ it("drops inverted ranges (start_line > line) with a precise reason", () => {
155
+ // both 11 and 12 anchor in the hunk, but GitHub 422s with "invalid line
156
+ // numbers" when start_line > line. dropping locally avoids the opaque
157
+ // remote failure and tells the agent exactly what to fix.
158
+ const result = validateInlineComments([base({ line: 11, start_line: 12 })], diffMap);
159
+ expect(result.valid).toHaveLength(0);
160
+ expect(result.dropped).toHaveLength(1);
161
+ expect(result.dropped[0]!.reason).toMatch(/start_line 12 is after line 11/);
162
+ expect(result.dropped[0]!.reason).toMatch(/start_line <= line/);
163
+ });
164
+
165
+ it("partitions a batch — valid and invalid comments survive independently", () => {
166
+ const result = validateInlineComments(
167
+ [base({ line: 12 }), base({ line: 9999 }), base({ path: "missing.ts" })],
168
+ diffMap,
169
+ );
170
+ expect(result.valid).toHaveLength(1);
171
+ expect(result.dropped).toHaveLength(2);
172
+ });
173
+
174
+ it("defaults side to RIGHT when omitted", () => {
175
+ const result = validateInlineComments([{ path: "src/foo.ts", line: 12, body: "" }], diffMap);
176
+ expect(result.valid).toHaveLength(1);
177
+ });
178
+ });
179
+
180
+ describe("formatDroppedCommentsNote", () => {
181
+ it("renders single-line dropped entries with `path:line`", () => {
182
+ const dropped: DroppedComment[] = [
183
+ {
184
+ path: "src/foo.ts",
185
+ line: 42,
186
+ side: "RIGHT",
187
+ reason: "line 42 (RIGHT) is not inside a diff hunk",
188
+ },
189
+ ];
190
+ const note = formatDroppedCommentsNote(dropped);
191
+ expect(note).toContain("**Note:** 1 inline comment(s) dropped");
192
+ expect(note).toContain("`src/foo.ts:42` (RIGHT)");
193
+ expect(note).toContain("line 42 (RIGHT) is not inside a diff hunk");
194
+ });
195
+
196
+ it("renders multi-line dropped entries with `path:start-end`", () => {
197
+ const dropped: DroppedComment[] = [
198
+ {
199
+ path: "src/bar.ts",
200
+ line: 20,
201
+ startLine: 15,
202
+ side: "LEFT",
203
+ reason: "start_line 15 (LEFT) is not inside a diff hunk",
204
+ },
205
+ ];
206
+ const note = formatDroppedCommentsNote(dropped);
207
+ expect(note).toContain("`src/bar.ts:15-20` (LEFT)");
208
+ });
209
+
210
+ it("falls back to single-line format when startLine equals line", () => {
211
+ const dropped: DroppedComment[] = [
212
+ { path: "src/baz.ts", line: 7, startLine: 7, side: "RIGHT", reason: "file not in PR diff" },
213
+ ];
214
+ const note = formatDroppedCommentsNote(dropped);
215
+ expect(note).toContain("`src/baz.ts:7` (RIGHT)");
216
+ expect(note).not.toContain("7-7");
217
+ });
218
+
219
+ it("caps detail lines and reports the remainder so body stays under GitHub's size limit", () => {
220
+ const overflow = MAX_DROPPED_COMMENT_LINES + 7;
221
+ const dropped: DroppedComment[] = Array.from({ length: overflow }, (_, i) => ({
222
+ path: `src/file${i}.ts`,
223
+ line: i + 1,
224
+ side: "RIGHT" as const,
225
+ reason: "file not in PR diff",
226
+ }));
227
+ const note = formatDroppedCommentsNote(dropped);
228
+ expect(note).toContain(`**Note:** ${overflow} inline comment(s) dropped`);
229
+ // still reports the full count in the header
230
+ expect(note).toContain(`${overflow} inline comment(s)`);
231
+ // first entry shown, last entry elided
232
+ expect(note).toContain("`src/file0.ts:1` (RIGHT)");
233
+ expect(note).not.toContain(`src/file${overflow - 1}.ts`);
234
+ expect(note).toContain("…and 7 more dropped comment(s) not shown");
235
+ });
236
+
237
+ it("does not add a truncation line when drops fit under the cap", () => {
238
+ const dropped: DroppedComment[] = Array.from({ length: MAX_DROPPED_COMMENT_LINES }, (_, i) => ({
239
+ path: `src/f${i}.ts`,
240
+ line: i + 1,
241
+ side: "RIGHT" as const,
242
+ reason: "file not in PR diff",
243
+ }));
244
+ const note = formatDroppedCommentsNote(dropped);
245
+ expect(note).not.toContain("more dropped comment(s) not shown");
246
+ });
247
+ });
248
+
249
+ describe("reviewSkipDecision", () => {
250
+ // GitHub 422s `event: "COMMENT"` reviews with no body + no comments
251
+ // ("{\"message\":\"Unprocessable Entity\",\"errors\":[\"\"]}"). verified
252
+ // empirically against repos/terramend/preview-546-run-issues-fixes/pulls/1
253
+ // with and without commit_id set. the skip function must return a decision
254
+ // for every shape that lands on that API call.
255
+
256
+ it("skips with 'no-issues' when !approved + empty body + no comments", () => {
257
+ const decision = reviewSkipDecision({
258
+ approved: false,
259
+ body: "",
260
+ hasComments: false,
261
+ prApproveEnabled: true,
262
+ });
263
+ expect(decision?.kind).toBe("no-issues");
264
+ expect(decision?.reason).toContain("nothing to post");
265
+ });
266
+
267
+ it("treats null body the same as empty string", () => {
268
+ const decision = reviewSkipDecision({
269
+ approved: false,
270
+ body: null,
271
+ hasComments: false,
272
+ prApproveEnabled: true,
273
+ });
274
+ expect(decision?.kind).toBe("no-issues");
275
+ });
276
+
277
+ it("treats undefined body the same as empty string", () => {
278
+ const decision = reviewSkipDecision({
279
+ approved: false,
280
+ body: undefined,
281
+ hasComments: false,
282
+ prApproveEnabled: true,
283
+ });
284
+ expect(decision?.kind).toBe("no-issues");
285
+ });
286
+
287
+ it("skips with 'empty-downgraded-approve' when approved + !prApproveEnabled + empty", () => {
288
+ // this is the F3 regression case — agent requests APPROVE, runtime
289
+ // downgrades to COMMENT (prApproveEnabled off), and the empty COMMENT
290
+ // 422s at GitHub. before this fix, the tool returned a stranded-success
291
+ // shape that didn't map to any persisted review.
292
+ const decision = reviewSkipDecision({
293
+ approved: true,
294
+ body: "",
295
+ hasComments: false,
296
+ prApproveEnabled: false,
297
+ });
298
+ expect(decision?.kind).toBe("empty-downgraded-approve");
299
+ expect(decision?.reason).toContain("prApproveEnabled is disabled");
300
+ });
301
+
302
+ it("does NOT skip legitimate bare APPROVE (approved + prApproveEnabled + empty)", () => {
303
+ // GitHub accepts empty APPROVE reviews — the stamp itself is the content.
304
+ // skipping here would silently drop agents' real approvals.
305
+ const decision = reviewSkipDecision({
306
+ approved: true,
307
+ body: "",
308
+ hasComments: false,
309
+ prApproveEnabled: true,
310
+ });
311
+ expect(decision).toBeNull();
312
+ });
313
+
314
+ it("does NOT skip when body is present (no-issues path)", () => {
315
+ const decision = reviewSkipDecision({
316
+ approved: false,
317
+ body: "found some issues",
318
+ hasComments: false,
319
+ prApproveEnabled: true,
320
+ });
321
+ expect(decision).toBeNull();
322
+ });
323
+
324
+ it("does NOT skip when body is present (downgrade path)", () => {
325
+ // approved+!prApproveEnabled with a body becomes a real COMMENT review
326
+ // (downgrade + body). GitHub accepts those; don't skip.
327
+ const decision = reviewSkipDecision({
328
+ approved: true,
329
+ body: "nits follow",
330
+ hasComments: false,
331
+ prApproveEnabled: false,
332
+ });
333
+ expect(decision).toBeNull();
334
+ });
335
+
336
+ it("does NOT skip when comments are present (no-issues path)", () => {
337
+ const decision = reviewSkipDecision({
338
+ approved: false,
339
+ body: "",
340
+ hasComments: true,
341
+ prApproveEnabled: true,
342
+ });
343
+ expect(decision).toBeNull();
344
+ });
345
+
346
+ it("does NOT skip when comments are present (downgrade path)", () => {
347
+ const decision = reviewSkipDecision({
348
+ approved: true,
349
+ body: "",
350
+ hasComments: true,
351
+ prApproveEnabled: false,
352
+ });
353
+ expect(decision).toBeNull();
354
+ });
355
+ });
356
+
357
+ describe("duplicateReviewDecision", () => {
358
+ // regression: colinhacks/zod#5897 had two reviews submitted from the same
359
+ // workflow run 8 seconds apart — a substantive review followed by an empty
360
+ // "No new issues found." follow-up. the agent re-classified the first
361
+ // review's non-blocking observations as "no actionable issues" and
362
+ // submitted the canonical body per modes.ts. this guard makes the second
363
+ // call a no-op without burning a GitHub API call or polluting the PR.
364
+
365
+ it("allows the first submission when no prior review exists", () => {
366
+ const decision = duplicateReviewDecision({
367
+ existing: undefined,
368
+ currentCheckoutSha: "sha1",
369
+ });
370
+ expect(decision).toBeNull();
371
+ });
372
+
373
+ it("blocks a second submission when checkoutSha matches the prior reviewedSha", () => {
374
+ // exact reproduction of the zod#5897 shape: same session, same checked-out
375
+ // SHA, second create_pull_request_review call.
376
+ const decision = duplicateReviewDecision({
377
+ existing: { id: 100, reviewedSha: "sha1" },
378
+ currentCheckoutSha: "sha1",
379
+ });
380
+ expect(decision?.kind).toBe("already-submitted");
381
+ expect(decision?.reviewId).toBe(100);
382
+ expect(decision?.reason).toContain("already submitted");
383
+ expect(decision?.reason).toContain("checkout_pr");
384
+ });
385
+
386
+ it("allows a follow-up when checkoutSha advanced past the prior reviewedSha", () => {
387
+ // the new-commits-mid-review path advances toolState.checkoutSha to the
388
+ // new HEAD before returning, and the agent is told to call checkout_pr
389
+ // again — both paths leave checkoutSha != reviewedSha. those are real
390
+ // follow-up reviews and must go through.
391
+ const decision = duplicateReviewDecision({
392
+ existing: { id: 100, reviewedSha: "sha-old" },
393
+ currentCheckoutSha: "sha-new",
394
+ });
395
+ expect(decision).toBeNull();
396
+ });
397
+
398
+ it("blocks when checkoutSha is missing — cannot prove the SHA moved", () => {
399
+ // if the agent never called checkout_pr, we have no anchor to compare
400
+ // against. assume duplicate rather than letting a second review through
401
+ // — the prior review still satisfies the agent's intent.
402
+ const decision = duplicateReviewDecision({
403
+ existing: { id: 100, reviewedSha: "sha1" },
404
+ currentCheckoutSha: undefined,
405
+ });
406
+ expect(decision?.kind).toBe("already-submitted");
407
+ });
408
+
409
+ it("blocks when prior reviewedSha is missing — cannot prove the SHA moved", () => {
410
+ // belt-and-suspenders: if for any reason the prior review didn't capture
411
+ // a reviewedSha, treat the second call as a duplicate to be safe.
412
+ const decision = duplicateReviewDecision({
413
+ existing: { id: 100, reviewedSha: undefined },
414
+ currentCheckoutSha: "sha1",
415
+ });
416
+ expect(decision?.kind).toBe("already-submitted");
417
+ });
418
+ });
419
+
420
+ // --- network-boundary helpers (fake octokit) --------------------------------
421
+
422
+ function octokitErr(status: number, message: string): Error & { status: number } {
423
+ return Object.assign(new Error(message), { status });
424
+ }
425
+
426
+ const TRANSIENT_422 = () => octokitErr(422, "An internal error occurred, please try again.");
427
+
428
+ function makeCtx(over: Record<string, unknown> = {}): ToolContext {
429
+ return {
430
+ agentId: "claude",
431
+ repo: { owner: "octo", name: "repo" },
432
+ prApproveEnabled: true,
433
+ payload: {},
434
+ toolState: {},
435
+ octokit: {},
436
+ ...over,
437
+ } as unknown as ToolContext;
438
+ }
439
+
440
+ describe("isTransientReviewError", () => {
441
+ it("matches only GitHub's generic 422 'internal error' body", () => {
442
+ expect(isTransientReviewError(TRANSIENT_422())).toBe(true);
443
+ });
444
+
445
+ it("rejects a 422 that cites a specific cause", () => {
446
+ expect(isTransientReviewError(octokitErr(422, "Validation Failed: invalid line"))).toBe(false);
447
+ });
448
+
449
+ it("rejects other statuses and non-error values", () => {
450
+ expect(
451
+ isTransientReviewError(octokitErr(500, "An internal error occurred, please try again.")),
452
+ ).toBe(false);
453
+ expect(isTransientReviewError(new Error("plain"))).toBe(false);
454
+ expect(isTransientReviewError(null)).toBe(false);
455
+ expect(isTransientReviewError("string")).toBe(false);
456
+ });
457
+ });
458
+
459
+ describe("clearStrandedPendingReview", () => {
460
+ const params = (originalErr: unknown) => ({
461
+ owner: "octo",
462
+ repo: "repo",
463
+ pull_number: 7,
464
+ originalErr,
465
+ });
466
+
467
+ it("rethrows the original error when it is not a pending-review 422", async () => {
468
+ const original = octokitErr(500, "server exploded");
469
+ await expect(clearStrandedPendingReview(makeCtx(), params(original))).rejects.toBe(original);
470
+
471
+ const wrong422 = octokitErr(422, "Validation Failed");
472
+ await expect(clearStrandedPendingReview(makeCtx(), params(wrong422))).rejects.toBe(wrong422);
473
+ });
474
+
475
+ it("deletes the leftover PENDING review and resolves", async () => {
476
+ const deletePendingReview = vi.fn().mockResolvedValue({});
477
+ const ctx = makeCtx({
478
+ octokit: {
479
+ paginate: vi.fn().mockResolvedValue([
480
+ { id: 1, state: "COMMENTED" },
481
+ { id: 9, state: "PENDING" },
482
+ ]),
483
+ rest: { pulls: { listReviews: vi.fn(), deletePendingReview } },
484
+ },
485
+ });
486
+
487
+ const original = octokitErr(422, "User can only have one pending review per pull request");
488
+ await expect(clearStrandedPendingReview(ctx, params(original))).resolves.toBeUndefined();
489
+ expect(deletePendingReview).toHaveBeenCalledWith(
490
+ expect.objectContaining({ pull_number: 7, review_id: 9 }),
491
+ );
492
+ });
493
+
494
+ it("rethrows the original when no PENDING leftover exists", async () => {
495
+ const ctx = makeCtx({
496
+ octokit: {
497
+ paginate: vi.fn().mockResolvedValue([{ id: 1, state: "COMMENTED" }]),
498
+ rest: { pulls: { listReviews: vi.fn(), deletePendingReview: vi.fn() } },
499
+ },
500
+ });
501
+ const original = octokitErr(422, "already has a pending review");
502
+ await expect(clearStrandedPendingReview(ctx, params(original))).rejects.toBe(original);
503
+ });
504
+
505
+ it("surfaces the original 422 when listReviews itself fails", async () => {
506
+ const ctx = makeCtx({
507
+ octokit: {
508
+ paginate: vi.fn().mockRejectedValue(octokitErr(502, "bad gateway")),
509
+ rest: { pulls: { listReviews: vi.fn(), deletePendingReview: vi.fn() } },
510
+ },
511
+ });
512
+ const original = octokitErr(422, "already has a pending review");
513
+ await expect(clearStrandedPendingReview(ctx, params(original))).rejects.toBe(original);
514
+ });
515
+
516
+ it("swallows a 404/422 from the delete but rethrows anything else", async () => {
517
+ const mk = (deleteErr: Error) =>
518
+ makeCtx({
519
+ octokit: {
520
+ paginate: vi.fn().mockResolvedValue([{ id: 9, state: "PENDING" }]),
521
+ rest: {
522
+ pulls: {
523
+ listReviews: vi.fn(),
524
+ deletePendingReview: vi.fn().mockRejectedValue(deleteErr),
525
+ },
526
+ },
527
+ },
528
+ });
529
+ const original = octokitErr(422, "already has a pending review");
530
+
531
+ await expect(
532
+ clearStrandedPendingReview(mk(octokitErr(404, "gone")), params(original)),
533
+ ).resolves.toBeUndefined();
534
+ await expect(
535
+ clearStrandedPendingReview(mk(octokitErr(422, "already submitted")), params(original)),
536
+ ).resolves.toBeUndefined();
537
+
538
+ const hardErr = octokitErr(500, "delete exploded");
539
+ await expect(clearStrandedPendingReview(mk(hardErr), params(original))).rejects.toBe(hardErr);
540
+ });
541
+ });
542
+
543
+ describe("createReviewWithStrandedRecovery", () => {
544
+ const params = {
545
+ owner: "octo",
546
+ repo: "repo",
547
+ pull_number: 7,
548
+ event: "COMMENT",
549
+ } as Parameters<typeof createReviewWithStrandedRecovery>[1];
550
+
551
+ it("passes a first-try success straight through", async () => {
552
+ const response = { data: { id: 1 } };
553
+ const createReview = vi.fn().mockResolvedValue(response);
554
+ const ctx = makeCtx({ octokit: { rest: { pulls: { createReview } } } });
555
+
556
+ await expect(createReviewWithStrandedRecovery(ctx, params)).resolves.toBe(response);
557
+ expect(createReview).toHaveBeenCalledTimes(1);
558
+ });
559
+
560
+ it("clears a stranded pending draft and retries once", async () => {
561
+ const response = { data: { id: 2 } };
562
+ const createReview = vi
563
+ .fn()
564
+ .mockRejectedValueOnce(octokitErr(422, "user already has a pending review"))
565
+ .mockResolvedValueOnce(response);
566
+ const deletePendingReview = vi.fn().mockResolvedValue({});
567
+ const ctx = makeCtx({
568
+ octokit: {
569
+ paginate: vi.fn().mockResolvedValue([{ id: 9, state: "PENDING" }]),
570
+ rest: { pulls: { createReview, listReviews: vi.fn(), deletePendingReview } },
571
+ },
572
+ });
573
+
574
+ await expect(createReviewWithStrandedRecovery(ctx, params)).resolves.toBe(response);
575
+ expect(createReview).toHaveBeenCalledTimes(2);
576
+ expect(deletePendingReview).toHaveBeenCalledTimes(1);
577
+ });
578
+ });
579
+
580
+ describe("buildCommentableMap", () => {
581
+ it("reuses the checkout snapshot when PR number and sha both match", async () => {
582
+ const cached = buildMap([["src/foo.ts", "@@ -1 +1 @@\n+x"]]);
583
+ const paginate = vi.fn();
584
+ const ctx = makeCtx({
585
+ octokit: { paginate, rest: { pulls: { listFiles: vi.fn() } } },
586
+ toolState: {
587
+ commentableLinesByFile: cached,
588
+ commentableLinesPullNumber: 5,
589
+ commentableLinesCheckoutSha: "sha1",
590
+ checkoutSha: "sha1",
591
+ },
592
+ });
593
+
594
+ await expect(buildCommentableMap(ctx, 5)).resolves.toBe(cached);
595
+ expect(paginate).not.toHaveBeenCalled();
596
+ });
597
+
598
+ it("refetches when the cache was built for a different sha", async () => {
599
+ const cached = buildMap([["stale.ts", "@@ -1 +1 @@\n+x"]]);
600
+ const paginate = vi.fn().mockResolvedValue([
601
+ { filename: "src/foo.ts", patch: "@@ -1 +1,2 @@\n+a\n+b" },
602
+ { filename: "assets/logo.png" }, // no patch — binary
603
+ ]);
604
+ const ctx = makeCtx({
605
+ octokit: { paginate, rest: { pulls: { listFiles: vi.fn() } } },
606
+ toolState: {
607
+ commentableLinesByFile: cached,
608
+ commentableLinesPullNumber: 5,
609
+ commentableLinesCheckoutSha: "old-sha",
610
+ checkoutSha: "new-sha",
611
+ },
612
+ });
613
+
614
+ const map = await buildCommentableMap(ctx, 5);
615
+ expect(map).not.toBe(cached);
616
+ expect(map.get("src/foo.ts")?.RIGHT.has(2)).toBe(true);
617
+ expect(map.get("assets/logo.png")?.RIGHT.size).toBe(0);
618
+ });
619
+ });
620
+
621
+ describe("CreatePullRequestReviewTool", () => {
622
+ const FULL_SHA = "a".repeat(40);
623
+ const patch = ["@@ -10,2 +10,3 @@", " ctx", "-old", "+new", "+new2"].join("\n");
624
+
625
+ type RawResult = Record<string, unknown>;
626
+ function runTool(
627
+ toolDef: { execute: unknown },
628
+ params: Record<string, unknown>,
629
+ ): Promise<RawResult> {
630
+ const fn = toolDef.execute as (p: Record<string, unknown>) => Promise<RawResult>;
631
+ return fn(params);
632
+ }
633
+
634
+ /** toolState pre-seeded with a commentable-lines snapshot for PR 5 @ sha1. */
635
+ const snapshotState = (over: Record<string, unknown> = {}) => ({
636
+ commentableLinesByFile: buildMap([["src/foo.ts", patch]]),
637
+ commentableLinesPullNumber: 5,
638
+ commentableLinesCheckoutSha: "sha1",
639
+ checkoutSha: "sha1",
640
+ ...over,
641
+ });
642
+
643
+ const reviewResponse = (id: number) => ({
644
+ data: {
645
+ id,
646
+ node_id: `node-${id}`,
647
+ html_url: `https://github.test/review/${id}`,
648
+ state: "COMMENTED",
649
+ user: { login: "terramend[bot]" },
650
+ submitted_at: "2026-06-10T00:00:00Z",
651
+ },
652
+ });
653
+
654
+ afterEach(() => {
655
+ vi.useRealTimers();
656
+ });
657
+
658
+ it("short-circuits a duplicate submission in the same session", async () => {
659
+ const createReview = vi.fn();
660
+ const ctx = makeCtx({
661
+ toolState: { review: { id: 100, reviewedSha: "sha1" }, checkoutSha: "sha1" },
662
+ octokit: { rest: { pulls: { createReview } } },
663
+ });
664
+
665
+ const result = await runTool(CreatePullRequestReviewTool(ctx), {
666
+ pull_number: 5,
667
+ body: "second call",
668
+ });
669
+
670
+ expect(result).toMatchObject({ success: true, skipped: true, reviewId: 100 });
671
+ expect(createReview).not.toHaveBeenCalled();
672
+ expect(ctx.toolState.issueNumber).toBe(5);
673
+ });
674
+
675
+ it("skips an empty non-approve review before any GitHub call", async () => {
676
+ const ctx = makeCtx({ octokit: {} });
677
+ const result = await runTool(CreatePullRequestReviewTool(ctx), { pull_number: 5 });
678
+ expect(result).toMatchObject({ success: true, skipped: true });
679
+ expect(result.reason).toMatch(/nothing to post/);
680
+ });
681
+
682
+ it("drops invalid comments and posts the dropped-comments note as the review body", async () => {
683
+ const createReview = vi.fn().mockResolvedValue({ data: { id: 60 } });
684
+ const submitReview = vi.fn().mockResolvedValue(reviewResponse(60));
685
+ const ctx = makeCtx({
686
+ toolState: snapshotState(),
687
+ octokit: { rest: { pulls: { createReview, submitReview } } },
688
+ });
689
+
690
+ const result = await runTool(CreatePullRequestReviewTool(ctx), {
691
+ pull_number: 5,
692
+ commit_id: FULL_SHA,
693
+ comments: [{ path: "not-in-diff.ts", line: 1, body: "x" }],
694
+ });
695
+
696
+ expect(result).toMatchObject({ success: true, reviewId: 60 });
697
+ expect(result.droppedComments).toHaveLength(1);
698
+ // the dropped set is surfaced in the posted body, and the invalid comment
699
+ // never reaches GitHub.
700
+ const submitted = submitReview.mock.calls[0]?.[0] as { body: string };
701
+ expect(submitted.body).toContain("**Note:** 1 inline comment(s) dropped");
702
+ expect(createReview.mock.calls[0]?.[0]).toMatchObject({ comments: [] });
703
+ });
704
+
705
+ it("submits a comments-only review, rendering suggestions and multi-line ranges", async () => {
706
+ const createReview = vi.fn().mockResolvedValue(reviewResponse(42));
707
+ const ctx = makeCtx({
708
+ toolState: snapshotState(),
709
+ octokit: { rest: { pulls: { createReview } } },
710
+ });
711
+
712
+ const result = await runTool(CreatePullRequestReviewTool(ctx), {
713
+ pull_number: 5,
714
+ commit_id: FULL_SHA,
715
+ comments: [
716
+ { path: "src/foo.ts", line: 12, start_line: 11, suggestion: " fixed();", body: "tidy" },
717
+ ],
718
+ });
719
+
720
+ expect(result).toMatchObject({ success: true, reviewId: 42, state: "COMMENTED" });
721
+ expect(result.droppedComments).toBeUndefined();
722
+ expect(ctx.toolState.review).toEqual({ id: 42, nodeId: "node-42", reviewedSha: "sha1" });
723
+ expect(ctx.toolState.wasUpdated).toBe(true);
724
+
725
+ expect(createReview).toHaveBeenCalledTimes(1);
726
+ const sent = createReview.mock.calls[0]?.[0] as {
727
+ event: string;
728
+ commit_id: string;
729
+ comments: Array<Record<string, unknown>>;
730
+ };
731
+ expect(sent.event).toBe("COMMENT");
732
+ expect(sent.commit_id).toBe(FULL_SHA);
733
+ expect(sent.comments[0]).toMatchObject({
734
+ path: "src/foo.ts",
735
+ line: 12,
736
+ start_line: 11,
737
+ side: "RIGHT",
738
+ start_side: "RIGHT",
739
+ });
740
+ expect(String(sent.comments[0]?.body)).toContain("tidy\n\n```suggestion\n fixed();\n```");
741
+ });
742
+
743
+ it("submits a body review via pending+submit, appending the Fix-it footer", async () => {
744
+ const createReview = vi.fn().mockResolvedValue({ data: { id: 7 } });
745
+ const submitReview = vi.fn().mockResolvedValue(reviewResponse(7));
746
+ const pulls = {
747
+ createReview,
748
+ submitReview,
749
+ get: vi.fn().mockResolvedValue({ data: { head: { sha: "sha1" } } }),
750
+ };
751
+ const ctx = makeCtx({
752
+ toolState: snapshotState(),
753
+ octokit: { rest: { pulls } },
754
+ });
755
+
756
+ const result = await runTool(CreatePullRequestReviewTool(ctx), {
757
+ pull_number: 5,
758
+ body: "Found two issues.",
759
+ });
760
+
761
+ expect(result).toMatchObject({ success: true, reviewId: 7 });
762
+ // pending draft created WITHOUT the event…
763
+ expect(createReview.mock.calls[0]?.[0]).not.toHaveProperty("event");
764
+ // …then submitted with it, body + footer affordance included.
765
+ const submitted = submitReview.mock.calls[0]?.[0] as { event: string; body: string };
766
+ expect(submitted.event).toBe("COMMENT");
767
+ expect(submitted.body).toContain("Found two issues.");
768
+ expect(submitted.body).toContain("Fix it ➔");
769
+ });
770
+
771
+ it("downgrades APPROVE to COMMENT when prApproveEnabled is off (no Fix footer)", async () => {
772
+ const createReview = vi.fn().mockResolvedValue({ data: { id: 8 } });
773
+ const submitReview = vi.fn().mockResolvedValue(reviewResponse(8));
774
+ const ctx = makeCtx({
775
+ prApproveEnabled: false,
776
+ toolState: snapshotState(),
777
+ octokit: {
778
+ rest: {
779
+ pulls: {
780
+ createReview,
781
+ submitReview,
782
+ get: vi.fn().mockResolvedValue({ data: { head: { sha: "sha1" } } }),
783
+ },
784
+ },
785
+ },
786
+ });
787
+
788
+ await runTool(CreatePullRequestReviewTool(ctx), {
789
+ pull_number: 5,
790
+ body: "nits follow",
791
+ approved: true,
792
+ });
793
+
794
+ const submitted = submitReview.mock.calls[0]?.[0] as { event: string; body: string };
795
+ expect(submitted.event).toBe("COMMENT");
796
+ // approving reviews suppress Fix buttons even after the downgrade.
797
+ expect(submitted.body).not.toContain("Fix it ➔");
798
+ });
799
+
800
+ it("cleans up the pending draft when the submit step fails", async () => {
801
+ const createReview = vi.fn().mockResolvedValue({ data: { id: 7 } });
802
+ const submitErr = octokitErr(500, "submit exploded");
803
+ const submitReview = vi.fn().mockRejectedValue(submitErr);
804
+ const deletePendingReview = vi.fn().mockResolvedValue({});
805
+ const ctx = makeCtx({
806
+ toolState: snapshotState(),
807
+ octokit: {
808
+ rest: {
809
+ pulls: {
810
+ createReview,
811
+ submitReview,
812
+ deletePendingReview,
813
+ get: vi.fn().mockResolvedValue({ data: { head: { sha: "sha1" } } }),
814
+ },
815
+ },
816
+ },
817
+ });
818
+
819
+ await expect(
820
+ runTool(CreatePullRequestReviewTool(ctx), { pull_number: 5, body: "B" }),
821
+ ).rejects.toBe(submitErr);
822
+ expect(deletePendingReview).toHaveBeenCalledWith(expect.objectContaining({ review_id: 7 }));
823
+ });
824
+
825
+ it("reports new commits pushed mid-review and advances the checkout sha", async () => {
826
+ const createReview = vi.fn().mockResolvedValue(reviewResponse(43));
827
+ const ctx = makeCtx({
828
+ toolState: snapshotState(),
829
+ octokit: {
830
+ rest: {
831
+ pulls: {
832
+ createReview,
833
+ get: vi.fn().mockResolvedValue({ data: { head: { sha: "sha2" } } }),
834
+ },
835
+ },
836
+ },
837
+ });
838
+
839
+ const result = await runTool(CreatePullRequestReviewTool(ctx), {
840
+ pull_number: 5,
841
+ comments: [{ path: "src/foo.ts", line: 12, body: "x" }],
842
+ });
843
+
844
+ expect(result).toMatchObject({ success: true, reviewId: 43 });
845
+ expect(result.newCommits).toMatchObject({ from: "sha1", to: "sha2" });
846
+ expect(ctx.toolState.beforeSha).toBe("sha1");
847
+ expect(ctx.toolState.checkoutSha).toBe("sha2");
848
+ // the review anchors to the sha the agent actually analyzed.
849
+ expect(createReview.mock.calls[0]?.[0]).toMatchObject({ commit_id: "sha1" });
850
+ });
851
+
852
+ it("enriches a non-transient 422 with the affected comments and GitHub's message", async () => {
853
+ const createReview = vi.fn().mockRejectedValue(octokitErr(422, "Validation Failed: line"));
854
+ const ctx = makeCtx({
855
+ toolState: snapshotState(),
856
+ octokit: { rest: { pulls: { createReview } } },
857
+ });
858
+
859
+ await expect(
860
+ runTool(CreatePullRequestReviewTool(ctx), {
861
+ pull_number: 5,
862
+ commit_id: FULL_SHA,
863
+ comments: [{ path: "src/foo.ts", line: 12, body: "x" }],
864
+ }),
865
+ ).rejects.toThrow(/Affected comments: src\/foo\.ts:12 \(RIGHT\).*Validation Failed: line/s);
866
+ });
867
+
868
+ it("retries GitHub's transient 422 in-tool and surfaces dedicated guidance after exhaustion", async () => {
869
+ vi.useFakeTimers();
870
+ const createReview = vi.fn().mockImplementation(() => Promise.reject(TRANSIENT_422()));
871
+ const ctx = makeCtx({
872
+ toolState: snapshotState(),
873
+ octokit: { rest: { pulls: { createReview } } },
874
+ });
875
+
876
+ const pending = runTool(CreatePullRequestReviewTool(ctx), {
877
+ pull_number: 5,
878
+ commit_id: FULL_SHA,
879
+ comments: [{ path: "src/foo.ts", line: 12, body: "x" }],
880
+ }).then(
881
+ () => {
882
+ throw new Error("expected the tool to throw");
883
+ },
884
+ (err: unknown) => err,
885
+ );
886
+ await vi.advanceTimersByTimeAsync(10_000);
887
+ const err = await pending;
888
+
889
+ expect(String(err)).toMatch(/transient 422 "internal error".*after 3 attempts/s);
890
+ expect(String(err)).toMatch(/Do NOT modify or drop inline comments/);
891
+ expect(createReview).toHaveBeenCalledTimes(3);
892
+ });
893
+
894
+ it("nudges once about unread diff TOC regions, then lets the retry through", async () => {
895
+ const createReview = vi.fn().mockResolvedValue(reviewResponse(50));
896
+ const ctx = makeCtx({
897
+ toolState: snapshotState({
898
+ diffCoverage: {
899
+ diffPath: "/tmp/pr.diff",
900
+ totalLines: 10,
901
+ tocEntries: [{ filename: "src/foo.ts", startLine: 1, endLine: 10 }],
902
+ coveredRanges: [],
903
+ coveragePreflightRan: false,
904
+ },
905
+ }),
906
+ octokit: { rest: { pulls: { createReview } } },
907
+ });
908
+ const params = {
909
+ pull_number: 5,
910
+ commit_id: FULL_SHA,
911
+ comments: [{ path: "src/foo.ts", line: 12, body: "x" }],
912
+ };
913
+
914
+ await expect(runTool(CreatePullRequestReviewTool(ctx), params)).rejects.toThrow(
915
+ /diff coverage pre-flight.*src\/foo\.ts \(10 lines, 1-10\)/s,
916
+ );
917
+ // one-time nudge: the same call goes through on retry.
918
+ const result = await runTool(CreatePullRequestReviewTool(ctx), params);
919
+ expect(result).toMatchObject({ success: true, reviewId: 50 });
920
+ });
921
+
922
+ it("refuses to submit/approve a review on a PR outside the run's scope", async () => {
923
+ const createReview = vi.fn();
924
+ const ctx = makeCtx({
925
+ payload: { event: { trigger: "pull_request_opened", is_pr: true, issue_number: 5 } },
926
+ toolState: { createdTargets: new Set<number>() },
927
+ octokit: { rest: { pulls: { createReview } } },
928
+ });
929
+
930
+ await expect(
931
+ runTool(CreatePullRequestReviewTool(ctx), { pull_number: 6, approved: true, body: "lgtm" }),
932
+ ).rejects.toThrow(/scoped to #5; refusing to submit a review on #6/);
933
+ // the guard fires before any GitHub call — no review is ever created.
934
+ expect(createReview).not.toHaveBeenCalled();
935
+ });
936
+ });