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,772 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ addFooter,
4
+ CreateCommentTool,
5
+ deleteProgressComment,
6
+ duplicateReplyDecision,
7
+ EditCommentTool,
8
+ ReplyToReviewCommentTool,
9
+ ReportProgressTool,
10
+ reportProgress,
11
+ } from "#app/mcp/comment";
12
+ import type { ToolContext } from "#app/mcp/server";
13
+ import { initToolState, type ToolState } from "#app/toolState";
14
+ import { TERRAMEND_DIVIDER } from "#app/utils/buildTerramendFooter";
15
+ import { log } from "#app/utils/cli";
16
+ import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
17
+
18
+ vi.mock("#app/utils/patchWorkflowRunFields", () => ({
19
+ patchWorkflowRunFields: vi.fn(async () => undefined),
20
+ }));
21
+
22
+ type ToolResultShape = { content: [{ type: "text"; text: string }]; isError?: boolean };
23
+
24
+ async function runTool(t: { execute?: unknown }, params: unknown): Promise<ToolResultShape> {
25
+ const exec = t.execute as (p: unknown, c: unknown) => Promise<ToolResultShape>;
26
+ return exec(params, {});
27
+ }
28
+
29
+ function makeOctokit() {
30
+ return {
31
+ rest: {
32
+ issues: {
33
+ createComment: vi.fn(async (_p: unknown) => ({
34
+ data: {
35
+ id: 101,
36
+ node_id: "NODE_101",
37
+ html_url: "https://gh/comment/101",
38
+ body: "created body" as string | null,
39
+ },
40
+ })),
41
+ updateComment: vi.fn(async (_p: unknown) => ({
42
+ data: {
43
+ id: 101,
44
+ node_id: "NODE_101" as string | undefined,
45
+ html_url: "https://gh/comment/101",
46
+ body: "updated body" as string | null,
47
+ updated_at: "2026-06-10T00:00:00Z",
48
+ },
49
+ })),
50
+ deleteComment: vi.fn(async (_p: unknown) => ({})),
51
+ },
52
+ pulls: {
53
+ createReplyForReviewComment: vi.fn(async (_p: unknown) => ({
54
+ data: {
55
+ id: 555,
56
+ html_url: "https://gh/review/555",
57
+ body: "reply body",
58
+ in_reply_to_id: 42,
59
+ },
60
+ })),
61
+ updateReviewComment: vi.fn(async (_p: unknown) => ({
62
+ data: {
63
+ id: 777,
64
+ node_id: "NODE_777",
65
+ html_url: "https://gh/review/777",
66
+ body: "review updated",
67
+ },
68
+ })),
69
+ deleteReviewComment: vi.fn(async (_p: unknown) => ({})),
70
+ },
71
+ },
72
+ };
73
+ }
74
+
75
+ function makeCtx(overrides?: { toolState?: Partial<ToolState>; event?: Record<string, unknown> }): {
76
+ ctx: ToolContext;
77
+ octokit: ReturnType<typeof makeOctokit>;
78
+ toolState: ToolState;
79
+ } {
80
+ const octokit = makeOctokit();
81
+ const toolState: ToolState = {
82
+ ...initToolState({ progressComment: undefined }),
83
+ ...overrides?.toolState,
84
+ };
85
+ const ctx = {
86
+ octokit,
87
+ repo: { owner: "octo", name: "repo" },
88
+ payload: { event: { ...overrides?.event } },
89
+ toolState,
90
+ runId: 1,
91
+ apiToken: "jwt",
92
+ tmpdir: "/tmp",
93
+ } as unknown as ToolContext;
94
+ return { ctx, octokit, toolState };
95
+ }
96
+
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ vi.stubEnv("API_URL", "https://terramend.dev");
100
+ });
101
+
102
+ afterEach(() => {
103
+ vi.unstubAllEnvs();
104
+ });
105
+
106
+ describe("addFooter", () => {
107
+ it("throws when <br/> is followed by a non-blank line", () => {
108
+ const { ctx } = makeCtx();
109
+ expect(() => addFooter(ctx, "before<br/>\nnext line")).toThrow(/blank line after <br\/> tags/);
110
+ });
111
+
112
+ it("accepts <br/> followed by a blank line and appends the footer", () => {
113
+ const { ctx } = makeCtx();
114
+ const result = addFooter(ctx, "before<br/>\n\nafter");
115
+ expect(result).toContain("before<br/>\n\nafter");
116
+ expect(result).toContain(TERRAMEND_DIVIDER);
117
+ expect(result).toContain("via Terramend");
118
+ });
119
+
120
+ it("strips an existing footer before appending a fresh one", () => {
121
+ const { ctx } = makeCtx();
122
+ const body = `hello world\n\n${TERRAMEND_DIVIDER}\n<sup>stale footer</sup>`;
123
+ const result = addFooter(ctx, body);
124
+ expect(result.startsWith("hello world")).toBe(true);
125
+ expect(result).not.toContain("stale footer");
126
+ expect(result.indexOf(TERRAMEND_DIVIDER)).toBe(result.lastIndexOf(TERRAMEND_DIVIDER));
127
+ });
128
+
129
+ it("repairs double-escaped newlines in the body", () => {
130
+ const { ctx } = makeCtx();
131
+ const result = addFooter(ctx, "line1\\nline2");
132
+ expect(result).toContain("line1\nline2");
133
+ expect(result).not.toContain("line1\\nline2");
134
+ });
135
+ });
136
+
137
+ describe("duplicateReplyDecision", () => {
138
+ it("returns null when there is no prior reply", () => {
139
+ expect(duplicateReplyDecision({ existing: undefined, bodyWithFooter: "x" })).toBeNull();
140
+ });
141
+
142
+ it("returns null when the prior reply has a different body", () => {
143
+ expect(
144
+ duplicateReplyDecision({
145
+ existing: { commentId: 1, url: "u", bodyWithFooter: "other" },
146
+ bodyWithFooter: "x",
147
+ }),
148
+ ).toBeNull();
149
+ });
150
+
151
+ it("flags an identical body to the same parent as a duplicate", () => {
152
+ const decision = duplicateReplyDecision({
153
+ existing: { commentId: 7, url: "https://gh/r/7", bodyWithFooter: "same" },
154
+ bodyWithFooter: "same",
155
+ });
156
+ expect(decision).toMatchObject({
157
+ kind: "already-replied",
158
+ commentId: 7,
159
+ url: "https://gh/r/7",
160
+ });
161
+ expect(decision?.reason).toMatch(/identical body/);
162
+ });
163
+ });
164
+
165
+ describe("CreateCommentTool", () => {
166
+ it("creates a regular comment with footer and marks wasUpdated", async () => {
167
+ const { ctx, octokit, toolState } = makeCtx();
168
+ const result = await runTool(CreateCommentTool(ctx), { issueNumber: 12, body: "hi there" });
169
+
170
+ expect(result.isError).toBeUndefined();
171
+ expect(result.content[0].text).toContain("success: true");
172
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
173
+ expect.objectContaining({ owner: "octo", repo: "repo", issue_number: 12 }),
174
+ );
175
+ const createBody = octokit.rest.issues.createComment.mock.calls[0]?.[0] as { body: string };
176
+ expect(createBody.body).toContain(TERRAMEND_DIVIDER);
177
+ expect(octokit.rest.issues.updateComment).not.toHaveBeenCalled();
178
+ expect(toolState.wasUpdated).toBe(true);
179
+ });
180
+
181
+ it("Plan type: creates, patches planCommentNodeId, then updates with implement link", async () => {
182
+ const { ctx, octokit } = makeCtx();
183
+ const result = await runTool(CreateCommentTool(ctx), {
184
+ issueNumber: 12,
185
+ body: "the plan",
186
+ type: "Plan",
187
+ });
188
+
189
+ expect(result.isError).toBeUndefined();
190
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { planCommentNodeId: "NODE_101" });
191
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
192
+ expect.objectContaining({ comment_id: 101 }),
193
+ );
194
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
195
+ expect(updateBody.body).toContain("Implement plan");
196
+ expect(updateBody.body).toContain("/trigger/octo/repo/12?action=implement&comment_id=101");
197
+ // the returned payload is the UPDATED comment (with the plan link), not the
198
+ // initial create — pin the fields so a regression to the create result (or
199
+ // an empty object) is caught.
200
+ expect(result.content[0].text).toContain("success: true");
201
+ expect(result.content[0].text).toContain("101");
202
+ expect(result.content[0].text).toContain("https://gh/comment/101");
203
+ });
204
+
205
+ it("Plan type without node_id skips the workflow-run patch", async () => {
206
+ const { ctx, octokit } = makeCtx();
207
+ octokit.rest.issues.createComment.mockResolvedValueOnce({
208
+ data: { id: 101, node_id: "", html_url: "https://gh/comment/101", body: "b" },
209
+ });
210
+ await runTool(CreateCommentTool(ctx), { issueNumber: 12, body: "the plan", type: "Plan" });
211
+
212
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
213
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalled();
214
+ });
215
+
216
+ it("surfaces addFooter validation failures as tool errors", async () => {
217
+ const { ctx, octokit } = makeCtx();
218
+ const result = await runTool(CreateCommentTool(ctx), {
219
+ issueNumber: 12,
220
+ body: "bad<br/>\nno blank line",
221
+ });
222
+
223
+ expect(result.isError).toBe(true);
224
+ expect(result.content[0].text).toMatch(/blank line after <br\/> tags/);
225
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
226
+ });
227
+ });
228
+
229
+ describe("EditCommentTool", () => {
230
+ it("updates the comment in place with a footer", async () => {
231
+ const { ctx, octokit } = makeCtx();
232
+ const result = await runTool(EditCommentTool(ctx), { commentId: 101, body: "new text" });
233
+
234
+ expect(result.isError).toBeUndefined();
235
+ expect(result.content[0].text).toContain("success: true");
236
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
237
+ expect.objectContaining({ owner: "octo", repo: "repo", comment_id: 101 }),
238
+ );
239
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
240
+ expect(updateBody.body).toContain("new text");
241
+ expect(updateBody.body).toContain(TERRAMEND_DIVIDER);
242
+ });
243
+
244
+ it("propagates API failures as tool errors", async () => {
245
+ const { ctx, octokit } = makeCtx();
246
+ octokit.rest.issues.updateComment.mockRejectedValueOnce(new Error("API down"));
247
+ const result = await runTool(EditCommentTool(ctx), { commentId: 101, body: "new text" });
248
+
249
+ expect(result.isError).toBe(true);
250
+ expect(result.content[0].text).toContain("API down");
251
+ });
252
+
253
+ it("refuses to edit a comment on an issue/PR outside the run's scope", async () => {
254
+ const getComment = vi.fn(async () => ({
255
+ data: { issue_url: "https://api.github.com/repos/octo/repo/issues/6" },
256
+ }));
257
+ const updateComment = vi.fn();
258
+ const ctx = {
259
+ octokit: { rest: { issues: { getComment, updateComment } } },
260
+ repo: { owner: "octo", name: "repo" },
261
+ payload: { event: { trigger: "pull_request_opened", issue_number: 5 } },
262
+ toolState: { createdTargets: new Set<number>() },
263
+ runId: 1,
264
+ apiToken: "jwt",
265
+ tmpdir: "/tmp",
266
+ } as unknown as ToolContext;
267
+
268
+ const result = await runTool(EditCommentTool(ctx), { commentId: 999, body: "x" });
269
+
270
+ expect(result.isError).toBe(true);
271
+ expect(result.content[0].text).toMatch(/scoped to #5; refusing to edit a comment on #6/);
272
+ expect(updateComment).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("allows editing a comment that belongs to the run's scoped issue", async () => {
276
+ const getComment = vi.fn(async () => ({
277
+ data: { issue_url: "https://api.github.com/repos/octo/repo/issues/5" },
278
+ }));
279
+ const updateComment = vi.fn(async () => ({
280
+ data: {
281
+ id: 101,
282
+ html_url: "https://gh/comment/101",
283
+ body: "updated",
284
+ updated_at: "2026-06-10T00:00:00Z",
285
+ },
286
+ }));
287
+ const ctx = {
288
+ octokit: { rest: { issues: { getComment, updateComment } } },
289
+ repo: { owner: "octo", name: "repo" },
290
+ payload: { event: { trigger: "pull_request_opened", issue_number: 5 } },
291
+ toolState: { createdTargets: new Set<number>() },
292
+ runId: 1,
293
+ apiToken: "jwt",
294
+ tmpdir: "/tmp",
295
+ } as unknown as ToolContext;
296
+
297
+ const result = await runTool(EditCommentTool(ctx), { commentId: 101, body: "x" });
298
+
299
+ expect(result.isError).toBeUndefined();
300
+ expect(updateComment).toHaveBeenCalledWith(expect.objectContaining({ comment_id: 101 }));
301
+ });
302
+ });
303
+
304
+ describe("ReplyToReviewCommentTool", () => {
305
+ it("posts a reply, marks wasUpdated, and records it for dedupe", async () => {
306
+ const { ctx, octokit, toolState } = makeCtx();
307
+ const result = await runTool(ReplyToReviewCommentTool(ctx), {
308
+ pull_number: 9,
309
+ comment_id: 42,
310
+ body: "Fixed by renaming.",
311
+ });
312
+
313
+ expect(result.isError).toBeUndefined();
314
+ expect(octokit.rest.pulls.createReplyForReviewComment).toHaveBeenCalledWith(
315
+ expect.objectContaining({ owner: "octo", repo: "repo", pull_number: 9, comment_id: 42 }),
316
+ );
317
+ expect(toolState.wasUpdated).toBe(true);
318
+ expect(toolState.reviewReplies?.get(42)).toMatchObject({
319
+ commentId: 555,
320
+ url: "https://gh/review/555",
321
+ });
322
+ });
323
+
324
+ it("skips an identical second reply to the same parent comment", async () => {
325
+ const { ctx, octokit } = makeCtx();
326
+ const tool = ReplyToReviewCommentTool(ctx);
327
+ const params = { pull_number: 9, comment_id: 42, body: "Fixed by renaming." };
328
+ await runTool(tool, params);
329
+ const second = await runTool(tool, params);
330
+
331
+ expect(second.isError).toBeUndefined();
332
+ expect(second.content[0].text).toContain("skipped: true");
333
+ expect(octokit.rest.pulls.createReplyForReviewComment).toHaveBeenCalledTimes(1);
334
+ });
335
+ });
336
+
337
+ describe("reportProgress", () => {
338
+ it("liveProgress writes do not record lastProgressBody or flip wasUpdated", async () => {
339
+ const { ctx, octokit, toolState } = makeCtx({
340
+ toolState: { progressComment: { id: 9, type: "issue" } },
341
+ });
342
+ const result = await reportProgress(ctx, { body: "live checklist", liveProgress: true });
343
+
344
+ expect(result.action).toBe("updated");
345
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
346
+ expect.objectContaining({ comment_id: 9 }),
347
+ );
348
+ expect(toolState.lastProgressBody).toBeUndefined();
349
+ expect(toolState.wasUpdated).toBeUndefined();
350
+ });
351
+
352
+ it("silent events are skipped but the body is still tracked for the summary", async () => {
353
+ const { ctx, octokit, toolState } = makeCtx({ event: { silent: true, issue_number: 7 } });
354
+ const result = await reportProgress(ctx, { body: "quiet" });
355
+
356
+ expect(result).toEqual({ body: "quiet", action: "skipped" });
357
+ expect(toolState.lastProgressBody).toBe("quiet");
358
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
359
+ });
360
+
361
+ it("updates an existing issue comment; plan mode patches planCommentNodeId", async () => {
362
+ const { ctx, octokit, toolState } = makeCtx({
363
+ toolState: { progressComment: { id: 9, type: "issue" }, selectedMode: "Plan" },
364
+ event: { issue_number: 7 },
365
+ });
366
+ const result = await reportProgress(ctx, { body: "progress" });
367
+
368
+ expect(result.action).toBe("updated");
369
+ expect(result.commentId).toBe(101);
370
+ expect(toolState.wasUpdated).toBe(true);
371
+ expect(toolState.lastProgressBody).toBe("progress");
372
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { planCommentNodeId: "NODE_101" });
373
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
374
+ expect(updateBody.body).toContain("Implement plan");
375
+ });
376
+
377
+ it("warns and falls through to the normal path when target_plan_comment has no stored id", async () => {
378
+ const { ctx, octokit } = makeCtx({
379
+ toolState: { progressComment: { id: 9, type: "issue" } },
380
+ event: { issue_number: 7 },
381
+ });
382
+ const warnSpy = vi.spyOn(log, "warning").mockImplementation(() => {});
383
+ try {
384
+ const result = await reportProgress(ctx, { body: "revise", target_plan_comment: true });
385
+
386
+ expect(warnSpy).toHaveBeenCalledWith(
387
+ expect.stringContaining("no existingPlanCommentId in tool state"),
388
+ );
389
+ // without a stored plan-comment id the flag is ignored: the run's own
390
+ // progress comment (id 9) is updated, not a plan comment.
391
+ expect(result.action).toBe("updated");
392
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
393
+ expect.objectContaining({ comment_id: 9 }),
394
+ );
395
+ } finally {
396
+ warnSpy.mockRestore();
397
+ }
398
+ });
399
+
400
+ it("updates an existing comment without plan extras outside Plan mode", async () => {
401
+ const { ctx, octokit } = makeCtx({
402
+ toolState: { progressComment: { id: 9, type: "issue" } },
403
+ event: { issue_number: 7 },
404
+ });
405
+ const result = await reportProgress(ctx, { body: "progress" });
406
+
407
+ expect(result.action).toBe("updated");
408
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
409
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
410
+ expect(updateBody.body).not.toContain("Implement plan");
411
+ });
412
+
413
+ it("skips when the progress comment was deliberately deleted (null)", async () => {
414
+ const { ctx, octokit } = makeCtx({
415
+ toolState: { progressComment: null },
416
+ event: { issue_number: 7 },
417
+ });
418
+ const result = await reportProgress(ctx, { body: "after delete" });
419
+
420
+ expect(result).toEqual({ body: "after delete", action: "skipped" });
421
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
422
+ expect(octokit.rest.issues.updateComment).not.toHaveBeenCalled();
423
+ });
424
+
425
+ it("skips when there is no issue/PR to comment on", async () => {
426
+ const { ctx, octokit, toolState } = makeCtx();
427
+ const result = await reportProgress(ctx, { body: "no target" });
428
+
429
+ expect(result).toEqual({ body: "no target", action: "skipped" });
430
+ expect(toolState.lastProgressBody).toBe("no target");
431
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("creates a fresh comment and retargets tool state when none exists", async () => {
435
+ const { ctx, octokit, toolState } = makeCtx({ event: { issue_number: 7 } });
436
+ const result = await reportProgress(ctx, { body: "first write" });
437
+
438
+ expect(result.action).toBe("created");
439
+ expect(result.commentId).toBe(101);
440
+ expect(toolState.progressComment).toEqual({ id: 101, type: "issue" });
441
+ expect(toolState.wasUpdated).toBe(true);
442
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
443
+ expect.objectContaining({ issue_number: 7 }),
444
+ );
445
+ expect(octokit.rest.issues.updateComment).not.toHaveBeenCalled();
446
+ });
447
+
448
+ it("liveProgress creation writes the comment without flipping wasUpdated", async () => {
449
+ const { ctx, octokit, toolState } = makeCtx({ event: { issue_number: 7 } });
450
+ octokit.rest.issues.createComment.mockResolvedValueOnce({
451
+ data: { id: 101, node_id: "NODE_101", html_url: "https://gh/comment/101", body: null },
452
+ });
453
+ const result = await reportProgress(ctx, { body: "live checklist", liveProgress: true });
454
+
455
+ expect(result.action).toBe("created");
456
+ expect(result.body).toBe("");
457
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1);
458
+ expect(toolState.progressComment).toEqual({ id: 101, type: "issue" });
459
+ expect(toolState.wasUpdated).toBeUndefined();
460
+ expect(toolState.lastProgressBody).toBeUndefined();
461
+ });
462
+
463
+ it("updates with an empty result body fall back to an empty string", async () => {
464
+ const { ctx, octokit } = makeCtx({
465
+ toolState: { progressComment: { id: 9, type: "issue" } },
466
+ event: { issue_number: 7 },
467
+ });
468
+ octokit.rest.issues.updateComment.mockResolvedValueOnce({
469
+ data: {
470
+ id: 9,
471
+ node_id: undefined,
472
+ html_url: "https://gh/comment/9",
473
+ body: null,
474
+ updated_at: "2026-06-10T00:00:00Z",
475
+ },
476
+ });
477
+ const result = await reportProgress(ctx, { body: "x" });
478
+
479
+ expect(result.action).toBe("updated");
480
+ expect(result.body).toBe("");
481
+ });
482
+
483
+ it("plan mode creation does create-then-update with the implement link", async () => {
484
+ const { ctx, octokit } = makeCtx({
485
+ toolState: { selectedMode: "Plan" },
486
+ event: { issue_number: 7 },
487
+ });
488
+ const result = await reportProgress(ctx, { body: "the plan" });
489
+
490
+ expect(result.action).toBe("created");
491
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1);
492
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledTimes(1);
493
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
494
+ expect(updateBody.body).toContain("Implement plan");
495
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { planCommentNodeId: "NODE_101" });
496
+ });
497
+
498
+ it("plan mode creation without a node_id skips the workflow-run patch", async () => {
499
+ const { ctx, octokit } = makeCtx({
500
+ toolState: { selectedMode: "Plan" },
501
+ event: { issue_number: 7 },
502
+ });
503
+ octokit.rest.issues.updateComment.mockResolvedValueOnce({
504
+ data: {
505
+ id: 101,
506
+ node_id: undefined,
507
+ html_url: "https://gh/comment/101",
508
+ body: null,
509
+ updated_at: "2026-06-10T00:00:00Z",
510
+ },
511
+ });
512
+ const result = await reportProgress(ctx, { body: "the plan" });
513
+
514
+ expect(result.action).toBe("created");
515
+ expect(result.body).toBe("");
516
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
517
+ });
518
+
519
+ it("404 on a stale review comment falls back to a fresh top-level comment", async () => {
520
+ const { ctx, octokit, toolState } = makeCtx({
521
+ toolState: { progressComment: { id: 33, type: "review" } },
522
+ event: { issue_number: 7 },
523
+ });
524
+ octokit.rest.pulls.updateReviewComment.mockRejectedValueOnce(new Error("Not Found"));
525
+ octokit.rest.issues.createComment.mockResolvedValueOnce({
526
+ data: { id: 101, node_id: "NODE_101", html_url: "https://gh/comment/101", body: null },
527
+ });
528
+ const result = await reportProgress(ctx, { body: "final answer" });
529
+
530
+ expect(result.action).toBe("created");
531
+ expect(result.commentId).toBe(101);
532
+ expect(result.body).toBe("");
533
+ expect(toolState.progressComment).toEqual({ id: 101, type: "issue" });
534
+ expect(toolState.wasUpdated).toBe(true);
535
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
536
+ expect.objectContaining({ issue_number: 7 }),
537
+ );
538
+ });
539
+
540
+ it("liveProgress 404 on a review comment rethrows instead of falling back", async () => {
541
+ const { ctx, octokit } = makeCtx({
542
+ toolState: { progressComment: { id: 33, type: "review" } },
543
+ event: { issue_number: 7 },
544
+ });
545
+ octokit.rest.pulls.updateReviewComment.mockRejectedValueOnce(new Error("Not Found"));
546
+
547
+ await expect(reportProgress(ctx, { body: "checklist", liveProgress: true })).rejects.toThrow(
548
+ "Not Found",
549
+ );
550
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
551
+ });
552
+
553
+ it("404 on an issue-type progress comment rethrows", async () => {
554
+ const { ctx, octokit } = makeCtx({
555
+ toolState: { progressComment: { id: 9, type: "issue" } },
556
+ event: { issue_number: 7 },
557
+ });
558
+ octokit.rest.issues.updateComment.mockRejectedValueOnce(new Error("Not Found"));
559
+
560
+ await expect(reportProgress(ctx, { body: "x" })).rejects.toThrow("Not Found");
561
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it("non-404 errors on a review comment rethrow", async () => {
565
+ const { ctx, octokit } = makeCtx({
566
+ toolState: { progressComment: { id: 33, type: "review" } },
567
+ event: { issue_number: 7 },
568
+ });
569
+ octokit.rest.pulls.updateReviewComment.mockRejectedValueOnce(new Error("rate limited"));
570
+
571
+ await expect(reportProgress(ctx, { body: "x" })).rejects.toThrow("rate limited");
572
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
573
+ });
574
+
575
+ it("404 with no issue number to fall back to rethrows", async () => {
576
+ const { ctx, octokit } = makeCtx({
577
+ toolState: { progressComment: { id: 33, type: "review" } },
578
+ });
579
+ octokit.rest.pulls.updateReviewComment.mockRejectedValueOnce(new Error("Not Found"));
580
+
581
+ await expect(reportProgress(ctx, { body: "x" })).rejects.toThrow("Not Found");
582
+ expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
583
+ });
584
+
585
+ it("target_plan_comment updates the stored plan comment with the implement link", async () => {
586
+ const { ctx, octokit, toolState } = makeCtx({
587
+ toolState: { existingPlanCommentId: 88, selectedMode: "Plan" },
588
+ event: { issue_number: 7 },
589
+ });
590
+ const result = await reportProgress(ctx, { body: "revised plan", target_plan_comment: true });
591
+
592
+ expect(result.action).toBe("updated");
593
+ expect(toolState.wasUpdated).toBe(true);
594
+ expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
595
+ expect.objectContaining({ comment_id: 88 }),
596
+ );
597
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
598
+ expect(updateBody.body).toContain("comment_id=88");
599
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { planCommentNodeId: "NODE_101" });
600
+ });
601
+
602
+ it("target_plan_comment without issue number omits the link and patch", async () => {
603
+ const { ctx, octokit } = makeCtx({
604
+ toolState: { existingPlanCommentId: 88 },
605
+ });
606
+ const result = await reportProgress(ctx, { body: "revised plan", target_plan_comment: true });
607
+
608
+ expect(result.action).toBe("updated");
609
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
610
+ const updateBody = octokit.rest.issues.updateComment.mock.calls[0]?.[0] as { body: string };
611
+ expect(updateBody.body).not.toContain("Implement plan");
612
+ });
613
+
614
+ it("target_plan_comment liveProgress writes do not flip wasUpdated", async () => {
615
+ const { ctx, octokit, toolState } = makeCtx({
616
+ toolState: { existingPlanCommentId: 88 },
617
+ });
618
+ octokit.rest.issues.updateComment.mockResolvedValueOnce({
619
+ data: {
620
+ id: 88,
621
+ node_id: undefined,
622
+ html_url: "https://gh/comment/88",
623
+ body: null,
624
+ updated_at: "2026-06-10T00:00:00Z",
625
+ },
626
+ });
627
+ const result = await reportProgress(ctx, {
628
+ body: "live",
629
+ target_plan_comment: true,
630
+ liveProgress: true,
631
+ });
632
+
633
+ expect(result.action).toBe("updated");
634
+ expect(result.body).toBe("");
635
+ expect(toolState.wasUpdated).toBeUndefined();
636
+ expect(toolState.lastProgressBody).toBeUndefined();
637
+ });
638
+
639
+ it("target_plan_comment without a stored plan comment warns and falls through", async () => {
640
+ const { ctx, octokit, toolState } = makeCtx({ event: { issue_number: 7 } });
641
+ const result = await reportProgress(ctx, { body: "plan", target_plan_comment: true });
642
+
643
+ expect(result.action).toBe("created");
644
+ expect(toolState.progressComment).toEqual({ id: 101, type: "issue" });
645
+ expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1);
646
+ });
647
+ });
648
+
649
+ describe("ReportProgressTool", () => {
650
+ function makeTodoTracker(collapsible: string) {
651
+ return {
652
+ cancel: vi.fn(),
653
+ settled: vi.fn(async () => undefined),
654
+ renderCollapsible: vi.fn(() => collapsible),
655
+ };
656
+ }
657
+
658
+ it("appends the completed task list and marks finalSummaryWritten", async () => {
659
+ const tracker = makeTodoTracker("<details>tasks</details>");
660
+ const { ctx, octokit, toolState } = makeCtx({
661
+ toolState: { todoTracker: tracker as unknown as ToolState["todoTracker"] },
662
+ event: { issue_number: 7 },
663
+ });
664
+ const result = await runTool(ReportProgressTool(ctx), { body: "done" });
665
+
666
+ expect(result.isError).toBeUndefined();
667
+ expect(tracker.cancel).toHaveBeenCalledTimes(1);
668
+ expect(tracker.settled).toHaveBeenCalledTimes(1);
669
+ expect(tracker.renderCollapsible).toHaveBeenCalledWith({ completeInProgress: true });
670
+ const createBody = octokit.rest.issues.createComment.mock.calls[0]?.[0] as { body: string };
671
+ expect(createBody.body).toContain("done\n\n<details>tasks</details>");
672
+ expect(toolState.finalSummaryWritten).toBe(true);
673
+ });
674
+
675
+ it("skips the collapsible when the tracker renders nothing", async () => {
676
+ const tracker = makeTodoTracker("");
677
+ const { ctx, octokit } = makeCtx({
678
+ toolState: { todoTracker: tracker as unknown as ToolState["todoTracker"] },
679
+ event: { issue_number: 7 },
680
+ });
681
+ await runTool(ReportProgressTool(ctx), { body: "done" });
682
+
683
+ const createBody = octokit.rest.issues.createComment.mock.calls[0]?.[0] as { body: string };
684
+ expect(createBody.body).not.toContain("<details>");
685
+ });
686
+
687
+ it("returns the no-comment message when reporting is skipped", async () => {
688
+ const { ctx, toolState } = makeCtx({ event: { silent: true } });
689
+ const result = await runTool(ReportProgressTool(ctx), { body: "quiet" });
690
+
691
+ expect(result.isError).toBeUndefined();
692
+ expect(result.content[0].text).toContain("no GitHub comment created");
693
+ expect(toolState.finalSummaryWritten).toBeUndefined();
694
+ });
695
+
696
+ it("plan revisions do not touch the todo tracker or finalSummaryWritten", async () => {
697
+ const tracker = makeTodoTracker("<details>tasks</details>");
698
+ const { ctx, toolState } = makeCtx({
699
+ toolState: {
700
+ todoTracker: tracker as unknown as ToolState["todoTracker"],
701
+ existingPlanCommentId: 88,
702
+ },
703
+ event: { issue_number: 7 },
704
+ });
705
+ const result = await runTool(ReportProgressTool(ctx), {
706
+ body: "revised",
707
+ target_plan_comment: true,
708
+ });
709
+
710
+ expect(result.isError).toBeUndefined();
711
+ expect(tracker.cancel).not.toHaveBeenCalled();
712
+ expect(toolState.finalSummaryWritten).toBeUndefined();
713
+ });
714
+ });
715
+
716
+ describe("deleteProgressComment", () => {
717
+ it("deletes an issue-type comment, nulls tool state, and returns true", async () => {
718
+ const { ctx, octokit, toolState } = makeCtx({
719
+ toolState: { progressComment: { id: 9, type: "issue" } },
720
+ });
721
+ await expect(deleteProgressComment(ctx)).resolves.toBe(true);
722
+
723
+ expect(octokit.rest.issues.deleteComment).toHaveBeenCalledWith(
724
+ expect.objectContaining({ comment_id: 9 }),
725
+ );
726
+ expect(toolState.progressComment).toBeNull();
727
+ });
728
+
729
+ it("routes review-type comments to the review endpoint", async () => {
730
+ const { ctx, octokit, toolState } = makeCtx({
731
+ toolState: { progressComment: { id: 33, type: "review" } },
732
+ });
733
+ await expect(deleteProgressComment(ctx)).resolves.toBe(true);
734
+
735
+ expect(octokit.rest.pulls.deleteReviewComment).toHaveBeenCalledWith(
736
+ expect.objectContaining({ comment_id: 33 }),
737
+ );
738
+ expect(octokit.rest.issues.deleteComment).not.toHaveBeenCalled();
739
+ expect(toolState.progressComment).toBeNull();
740
+ });
741
+
742
+ it("returns false when no progress comment exists", async () => {
743
+ const { ctx, octokit } = makeCtx();
744
+ await expect(deleteProgressComment(ctx)).resolves.toBe(false);
745
+ expect(octokit.rest.issues.deleteComment).not.toHaveBeenCalled();
746
+ });
747
+
748
+ it("returns false when the comment was already deliberately deleted", async () => {
749
+ const { ctx } = makeCtx({ toolState: { progressComment: null } });
750
+ await expect(deleteProgressComment(ctx)).resolves.toBe(false);
751
+ });
752
+
753
+ it("swallows a 404 (already deleted) and still nulls tool state", async () => {
754
+ const { ctx, octokit, toolState } = makeCtx({
755
+ toolState: { progressComment: { id: 9, type: "issue" } },
756
+ });
757
+ octokit.rest.issues.deleteComment.mockRejectedValueOnce(new Error("Not Found"));
758
+
759
+ await expect(deleteProgressComment(ctx)).resolves.toBe(true);
760
+ expect(toolState.progressComment).toBeNull();
761
+ });
762
+
763
+ it("rethrows non-404 errors and leaves tool state untouched", async () => {
764
+ const { ctx, octokit, toolState } = makeCtx({
765
+ toolState: { progressComment: { id: 9, type: "issue" } },
766
+ });
767
+ octokit.rest.issues.deleteComment.mockRejectedValueOnce(new Error("forbidden"));
768
+
769
+ await expect(deleteProgressComment(ctx)).rejects.toThrow("forbidden");
770
+ expect(toolState.progressComment).toEqual({ id: 9, type: "issue" });
771
+ });
772
+ });