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,371 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import * as core from "@actions/core";
3
+ import { type } from "arktype";
4
+ import type { AuthorPermission, PayloadEvent } from "#app/external";
5
+ import { BUILTIN_MODE_NAMES } from "#app/modes";
6
+ import { log } from "#app/utils/cli";
7
+ import { parseRemediationCommand } from "#app/utils/remediationCommand";
8
+ import type { RepoSettings } from "#app/utils/runContext";
9
+ import { validateCompatibility } from "#app/utils/versioning";
10
+ import packageJson from "#package.json" with { type: "json" };
11
+
12
+ // tool permission enum types for inputs
13
+ const ShellPermissionInput = type.enumerated("disabled", "restricted", "enabled");
14
+ const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
15
+
16
+ // schema for JSON payload passed via prompt (internal dispatch invocation)
17
+ // note: permissions are intentionally NOT included here to prevent injection attacks
18
+ // permissions are derived from event.authorPermission instead
19
+ export const JsonPayload = type({
20
+ "~terramend": "true",
21
+ version: "string",
22
+ "model?": "string | undefined",
23
+ prompt: "string",
24
+ "triggerer?": "string | undefined",
25
+
26
+ "eventInstructions?": "string",
27
+ "previousRunsNote?": "string",
28
+ "event?": "object",
29
+ "timeout?": "string | undefined",
30
+ "progressComment?": type({
31
+ id: "string",
32
+ type: "'issue' | 'review'",
33
+ }).or("undefined"),
34
+ "generateSummary?": "boolean | undefined",
35
+ });
36
+
37
+ // permission levels that indicate collaborator status (have push access)
38
+ const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
39
+
40
+ // check if the event author has collaborator-level permissions
41
+ function isCollaborator(event: PayloadEvent): boolean {
42
+ const perm = event.authorPermission;
43
+ return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
44
+ }
45
+
46
+ // inputs schema - action inputs from core.getInput()
47
+ // note: tool permissions use .or("undefined") because getInput() || undefined
48
+ // explicitly sets the property to undefined when empty, which is different from
49
+ // the property being absent. arktype's "prop?" means "optional to include" but
50
+ // if included, must match the type - so we need to explicitly allow undefined.
51
+ export const Inputs = type({
52
+ prompt: "string",
53
+ "model?": type.string.or("undefined"),
54
+ "mode?": type.string.or("undefined"),
55
+ "timeout?": type.string.or("undefined"),
56
+ "push?": PushPermissionInput.or("undefined"),
57
+ "shell?": ShellPermissionInput.or("undefined"),
58
+ "cwd?": type.string.or("undefined"),
59
+ "output_schema?": type.string.or("undefined"),
60
+ // Terraform remediation config (all optional; defaults applied downstream)
61
+ "scan_scope?": type.string.or("undefined"),
62
+ "severity_threshold?": type.string.or("undefined"),
63
+ "max_prs?": type.string.or("undefined"),
64
+ "allowed_paths?": type.string.or("undefined"),
65
+ "base_branch?": type.string.or("undefined"),
66
+ "allow_replace?": type.string.or("undefined"),
67
+ "protected_paths?": type.string.or("undefined"),
68
+ "autonomy_threshold?": type.string.or("undefined"),
69
+ "gitleaks?": type.string.or("undefined"),
70
+ "cost_increase_block_usd?": type.string.or("undefined"),
71
+ "module_catalogue?": type.string.or("undefined"),
72
+ "terratest?": type.string.or("undefined"),
73
+ "terraform_mcp?": type.string.or("undefined"),
74
+ "review_instructions?": type.string.or("undefined"),
75
+ "fp_filtering_instructions?": type.string.or("undefined"),
76
+ });
77
+
78
+ export type Inputs = typeof Inputs.infer;
79
+
80
+ function isPayloadEvent(value: unknown): value is PayloadEvent {
81
+ return typeof value === "object" && value !== null && "trigger" in value;
82
+ }
83
+
84
+ function resolveCwd(cwd: string | undefined): string | undefined {
85
+ const workspace = process.env.GITHUB_WORKSPACE;
86
+ if (!cwd) return workspace;
87
+ if (isAbsolute(cwd)) return cwd;
88
+ return workspace ? resolve(workspace, cwd) : cwd;
89
+ }
90
+
91
+ export type ResolvedPromptInput = string | typeof JsonPayload.infer;
92
+
93
+ export function resolvePromptInput(): ResolvedPromptInput {
94
+ const prompt = core.getInput("prompt", { required: true });
95
+
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(prompt);
99
+ } catch {
100
+ // JSON parse error is fine (plain text prompt)
101
+ return prompt;
102
+ }
103
+
104
+ if (!parsed || typeof parsed !== "object" || !("~terramend" in parsed)) {
105
+ // if it doesn't look like a terramend payload, return the plain text prompt
106
+ return prompt;
107
+ }
108
+
109
+ // validation errors should propagate
110
+ const jsonPayload = JsonPayload.assert(parsed);
111
+ validateCompatibility(jsonPayload.version, packageJson.version);
112
+ return jsonPayload;
113
+ }
114
+
115
+ function resolveNonPromptInputs() {
116
+ return Inputs.omit("prompt").assert({
117
+ model: core.getInput("model") || undefined,
118
+ mode: core.getInput("mode") || undefined,
119
+ timeout: core.getInput("timeout") || undefined,
120
+ cwd: core.getInput("cwd") || undefined,
121
+ push: core.getInput("push") || undefined,
122
+ shell: core.getInput("shell") || undefined,
123
+ scan_scope: core.getInput("scan_scope") || undefined,
124
+ severity_threshold: core.getInput("severity_threshold") || undefined,
125
+ max_prs: core.getInput("max_prs") || undefined,
126
+ allowed_paths: core.getInput("allowed_paths") || undefined,
127
+ base_branch: core.getInput("base_branch") || undefined,
128
+ allow_replace: core.getInput("allow_replace") || undefined,
129
+ protected_paths: core.getInput("protected_paths") || undefined,
130
+ autonomy_threshold: core.getInput("autonomy_threshold") || undefined,
131
+ gitleaks: core.getInput("gitleaks") || undefined,
132
+ cost_increase_block_usd: core.getInput("cost_increase_block_usd") || undefined,
133
+ module_catalogue: core.getInput("module_catalogue") || undefined,
134
+ terratest: core.getInput("terratest") || undefined,
135
+ terraform_mcp: core.getInput("terraform_mcp") || undefined,
136
+ review_instructions: core.getInput("review_instructions") || undefined,
137
+ fp_filtering_instructions: core.getInput("fp_filtering_instructions") || undefined,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Canonicalize the `mode` input against the built-in mode names
143
+ * (case-insensitive). Returns the canonical name (e.g. "remediate" →
144
+ * "Remediate") when it matches a built-in mode, letting CI pin a mode
145
+ * deterministically. Unknown non-empty values warn and return undefined so the
146
+ * agent falls back to prompt-driven `select_mode` — mirroring how an unknown
147
+ * `model` slug degrades to auto-select rather than hard-failing the run.
148
+ */
149
+ export function parseMode(raw: string | undefined): string | undefined {
150
+ const v = raw?.trim();
151
+ if (!v) return undefined;
152
+ const match = BUILTIN_MODE_NAMES.find((name) => name.toLowerCase() === v.toLowerCase());
153
+ if (!match) {
154
+ log.warning(
155
+ `» unknown mode "${v}" — agent will select a mode (valid: ${BUILTIN_MODE_NAMES.join(", ")})`,
156
+ );
157
+ return undefined;
158
+ }
159
+ return match;
160
+ }
161
+
162
+ /** parse scan_scope; "diff" or "full" (default). */
163
+ function parseScanScope(raw: string | undefined): "diff" | "full" | undefined {
164
+ const v = raw?.trim().toLowerCase();
165
+ return v === "diff" || v === "full" ? v : undefined;
166
+ }
167
+
168
+ const SEVERITY_VALUES = new Set(["critical", "high", "medium", "low", "info"]);
169
+
170
+ /** parse the severity_threshold input; undefined when unset or not a valid level. */
171
+ function parseSeverityThreshold(raw: string | undefined): string | undefined {
172
+ if (!raw) return undefined;
173
+ const v = raw.trim().toLowerCase();
174
+ return SEVERITY_VALUES.has(v) ? v : undefined;
175
+ }
176
+
177
+ /** parse max_prs; a positive integer, else undefined (downstream default is 1). */
178
+ function parseMaxPrs(raw: string | undefined): number | undefined {
179
+ if (!raw) return undefined;
180
+ const n = Number.parseInt(raw.trim(), 10);
181
+ return Number.isInteger(n) && n > 0 ? n : undefined;
182
+ }
183
+
184
+ /** parse cost_increase_block_usd; a positive number of dollars/month, else
185
+ * undefined (no cost escalation). */
186
+ function parseCostIncreaseBlock(raw: string | undefined): number | undefined {
187
+ if (!raw) return undefined;
188
+ const n = Number.parseFloat(raw.trim());
189
+ return Number.isFinite(n) && n > 0 ? n : undefined;
190
+ }
191
+
192
+ /** parse a comma-separated glob list (allowed_paths / protected_paths);
193
+ * undefined when unset or empty after trimming. */
194
+ function parseGlobList(raw: string | undefined): string[] | undefined {
195
+ if (!raw) return undefined;
196
+ const globs = raw
197
+ .split(",")
198
+ .map((g) => g.trim())
199
+ .filter(Boolean);
200
+ return globs.length > 0 ? globs : undefined;
201
+ }
202
+
203
+ /** parse a boolean-ish action input ("true"/"1"/"yes" → true). undefined/unset
204
+ * and any other value → false. */
205
+ function parseBooleanInput(raw: string | undefined): boolean {
206
+ if (!raw) return false;
207
+ return ["true", "1", "yes", "on"].includes(raw.trim().toLowerCase());
208
+ }
209
+
210
+ /** parse the base_branch override; trims and strips a leading `refs/heads/`,
211
+ * undefined when unset (downstream resolves the run-start branch / default). */
212
+ export function parseBaseBranch(raw: string | undefined): string | undefined {
213
+ const v = raw?.trim().replace(/^refs\/heads\//, "");
214
+ return v || undefined;
215
+ }
216
+
217
+ /** parse the comma-separated allow_replace list — resource addresses (or globs,
218
+ * or `*`/`all`) permitted to be destroyed/replaced; undefined when unset. */
219
+ export function parseAllowReplace(raw: string | undefined): string[] | undefined {
220
+ if (!raw) return undefined;
221
+ const entries = raw
222
+ .split(",")
223
+ .map((a) => a.trim())
224
+ .filter(Boolean);
225
+ return entries.length > 0 ? entries : undefined;
226
+ }
227
+
228
+ const isTerramend = (actor: string | null | undefined): boolean => {
229
+ actor = actor?.replace("[bot]", "");
230
+ return !!actor && (actor === "terramend" || actor === "terramenddev");
231
+ };
232
+
233
+ export function resolvePayload(
234
+ resolvedPromptInput: ResolvedPromptInput,
235
+ repoSettings: RepoSettings,
236
+ ) {
237
+ const [prompt, jsonPayload] =
238
+ typeof resolvedPromptInput !== "string"
239
+ ? [resolvedPromptInput.prompt, resolvedPromptInput]
240
+ : [resolvedPromptInput, undefined];
241
+
242
+ const inputs = resolveNonPromptInputs();
243
+
244
+ // resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
245
+ const rawEvent = jsonPayload?.event;
246
+ const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
247
+
248
+ const model = jsonPayload?.model ?? inputs.model ?? repoSettings.model ?? undefined;
249
+
250
+ // determine shell permission - strictest setting wins
251
+ // precedence: disabled > restricted > enabled
252
+ // non-collaborators always get at least "restricted"
253
+ const isNonCollaborator = !isCollaborator(event);
254
+ const repoShell = repoSettings.shell ?? "restricted";
255
+ const inputShell = inputs.shell;
256
+
257
+ // resolve shell: start with repo setting, then apply restrictions
258
+ let resolvedShell = repoShell;
259
+
260
+ // input can only make it stricter (disabled > restricted > enabled)
261
+ if (inputShell === "disabled") {
262
+ resolvedShell = "disabled";
263
+ } else if (inputShell === "restricted" && resolvedShell === "enabled") {
264
+ resolvedShell = "restricted";
265
+ }
266
+
267
+ // non-collaborators get at least "restricted" (can't have "enabled")
268
+ if (isNonCollaborator && resolvedShell === "enabled") {
269
+ resolvedShell = "restricted";
270
+ }
271
+
272
+ // build payload - precedence: inputs > repoSettings > fallbacks
273
+ // note: modes are NOT in payload - they come from repoSettings in main()
274
+ return {
275
+ "~terramend": true as const,
276
+ version: jsonPayload?.version ?? packageJson.version,
277
+ model,
278
+ // deterministic mode pin for CI (action input only — not accepted from the
279
+ // JSON payload, which is the internal dispatch surface). undefined → the
280
+ // agent chooses via select_mode as before.
281
+ mode: parseMode(inputs.mode),
282
+ prompt,
283
+ triggerer:
284
+ jsonPayload?.triggerer ??
285
+ // it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
286
+ (!isTerramend(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
287
+ eventInstructions: jsonPayload?.eventInstructions,
288
+ previousRunsNote: jsonPayload?.previousRunsNote,
289
+ event,
290
+ timeout: inputs.timeout ?? jsonPayload?.timeout,
291
+ cwd: resolveCwd(inputs.cwd),
292
+ progressComment: jsonPayload?.progressComment,
293
+ generateSummary: jsonPayload?.generateSummary,
294
+
295
+ // permissions: inputs > repoSettings > fallbacks
296
+ push: inputs.push ?? repoSettings.push ?? "restricted",
297
+ shell: resolvedShell,
298
+
299
+ // Terraform remediation config — consumed by mcp/terraform.ts + the
300
+ // Remediate mode. Defaults are applied at the consumer, not here, so
301
+ // "unset" stays distinguishable from an explicit value.
302
+ scanScope: parseScanScope(inputs.scan_scope),
303
+ severityThreshold: parseSeverityThreshold(inputs.severity_threshold),
304
+ maxPrs: parseMaxPrs(inputs.max_prs),
305
+ allowedPaths: parseGlobList(inputs.allowed_paths),
306
+ // §2.7 — globs the fixer must never auto-modify (inverse of allowed_paths).
307
+ protectedPaths: parseGlobList(inputs.protected_paths),
308
+ // §3.9 — minimum severity at which a security concern escalates to a human.
309
+ autonomyThreshold: parseSeverityThreshold(inputs.autonomy_threshold),
310
+ // §2.8 — opt in to the external gitleaks engine on top of the built-in
311
+ // secret scanner (best-effort; degrades to built-in only when absent).
312
+ gitleaks: parseBooleanInput(inputs.gitleaks),
313
+ // §4.16-next — monthly $ increase at/above which a fix is escalated to a
314
+ // human (needs-human). undefined disables cost escalation.
315
+ costIncreaseBlockUsd: parseCostIncreaseBlock(inputs.cost_increase_block_usd),
316
+ // §4.14 + module catalogue — operator-approved modules a fix/generation
317
+ // should prefer; raw string, structured by `parseModuleCatalogue` in the
318
+ // `list_modules` tool.
319
+ moduleCatalogue: inputs.module_catalogue,
320
+ // §28 — opt in to scaffolding a Go Terratest smoke test + a native
321
+ // `*.tftest.hcl` (both plan the module directly) when generating a reusable
322
+ // module; also widens the push allow-list so the test files can be written.
323
+ terratest: parseBooleanInput(inputs.terratest),
324
+ // P2.2 — opt in to HashiCorp's terraform-mcp-server as a second MCP server
325
+ // for the agent (live Terraform Registry knowledge: current module versions
326
+ // and provider argument shapes). Requires docker on the runner; degrades
327
+ // green with a note when absent. See utils/terraformMcp.ts.
328
+ terraformMcp: parseBooleanInput(inputs.terraform_mcp),
329
+ // §3.12 — a `@terramend fix …` command parsed from the triggering comment
330
+ // body (the prompt), scoping the run to a specific concern/severity/file.
331
+ // null when the prompt isn't a recognised command.
332
+ remediationCommand: parseRemediationCommand(prompt),
333
+ // explicit base-branch override; when unset the effective base is resolved
334
+ // at PR time (run-start branch → repo default) — see resolveBaseBranch.
335
+ baseBranch: parseBaseBranch(inputs.base_branch),
336
+ // resource addresses the operator allows the remediation to destroy/replace
337
+ // — consumed by the destroy-block guardrail (mcp/guardrails.ts). Unset means
338
+ // no destructive change to a stateful resource is permitted.
339
+ allowReplace: parseAllowReplace(inputs.allow_replace),
340
+ // §5.5 — org-specific review policy + FP precedents from the workflow file
341
+ // (owner-controlled, same trust surface as repo settings). merged into the
342
+ // Review mode instructions in main() via mergeReviewModeInstructions.
343
+ reviewInstructions: inputs.review_instructions,
344
+ fpFilteringInstructions: inputs.fp_filtering_instructions,
345
+ };
346
+ }
347
+
348
+ export type ResolvedPayload = ReturnType<typeof resolvePayload>;
349
+
350
+ /**
351
+ * Parse and validate the optional `output_schema` action input. Returns the
352
+ * parsed object when present, or `undefined` when absent. Throws on invalid
353
+ * JSON or non-object payloads — these are workflow-author errors that should
354
+ * surface immediately, not silently degrade to "no schema".
355
+ */
356
+ export function resolveOutputSchema(): Record<string, unknown> | undefined {
357
+ const raw = core.getInput("output_schema");
358
+ if (!raw) return undefined;
359
+
360
+ let parsed: unknown;
361
+ try {
362
+ parsed = JSON.parse(raw);
363
+ } catch {
364
+ throw new Error(`invalid output_schema: not valid JSON`);
365
+ }
366
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
367
+ throw new Error(`invalid output_schema: must be a JSON object`);
368
+ }
369
+ log.info("» structured output schema provided — output will be required");
370
+ return parsed as Record<string, unknown>;
371
+ }
@@ -0,0 +1,51 @@
1
+ /** stdlib-only Terramend API fetch for entryPost.ts (no node_modules). */
2
+
3
+ type PostApiFetchOptions = {
4
+ path: string;
5
+ method?: string | undefined;
6
+ headers?: Record<string, string> | undefined;
7
+ body?: string | undefined;
8
+ };
9
+
10
+ function getApiUrl(): string {
11
+ return process.env.API_URL || "https://terramend.com";
12
+ }
13
+
14
+ export async function postApiFetch(options: PostApiFetchOptions): Promise<Response> {
15
+ const url = new URL(options.path, getApiUrl());
16
+
17
+ const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
18
+ if (bypassSecret) {
19
+ url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
20
+ }
21
+
22
+ const headers: Record<string, string> = {
23
+ ...options.headers,
24
+ };
25
+
26
+ if (bypassSecret) {
27
+ headers["x-vercel-protection-bypass"] = bypassSecret;
28
+ }
29
+
30
+ if (!options.body) {
31
+ for (const key of Object.keys(headers)) {
32
+ if (key.toLowerCase() === "content-type") delete headers[key];
33
+ }
34
+ }
35
+
36
+ const controller = new AbortController();
37
+ const timeoutId = setTimeout(() => controller.abort(), 30_000);
38
+
39
+ try {
40
+ const init: RequestInit = {
41
+ method: options.method ?? "GET",
42
+ headers,
43
+ signal: controller.signal,
44
+ };
45
+ if (options.body) init.body = options.body;
46
+
47
+ return await fetch(url, init);
48
+ } finally {
49
+ clearTimeout(timeoutId);
50
+ }
51
+ }
@@ -0,0 +1,224 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { ToolContext } from "#app/mcp/server";
7
+ import {
8
+ fetchPreviousSnapshot,
9
+ persistSummary,
10
+ readSummaryFile,
11
+ SUMMARY_FILE_NAME,
12
+ SUMMARY_SCAFFOLD,
13
+ seedSummaryFile,
14
+ summaryFilePath,
15
+ } from "#app/utils/prSummary";
16
+
17
+ vi.mock("#app/utils/apiFetch", () => ({
18
+ apiFetch: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("#app/utils/cli", () => ({
22
+ log: {
23
+ info: vi.fn(),
24
+ debug: vi.fn(),
25
+ warning: vi.fn(),
26
+ error: vi.fn(),
27
+ success: vi.fn(),
28
+ },
29
+ }));
30
+
31
+ vi.mock("#app/utils/patchWorkflowRunFields", () => ({
32
+ patchWorkflowRunFields: vi.fn(async () => undefined),
33
+ }));
34
+
35
+ import { apiFetch } from "#app/utils/apiFetch";
36
+ import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
37
+
38
+ const apiFetchMock = vi.mocked(apiFetch);
39
+ const patchMock = vi.mocked(patchWorkflowRunFields);
40
+
41
+ const TEMP = mkdtempSync(join(tmpdir(), "terramend-pr-summary-"));
42
+
43
+ afterAll(() => {
44
+ rmSync(TEMP, { recursive: true, force: true });
45
+ });
46
+
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ // long enough to clear the 60-char minimum snapshot length
52
+ const LONG_SNAPSHOT = `# PR summary\n\n${"meaningful cross-run context. ".repeat(4)}`.trim();
53
+
54
+ let dirCounter = 0;
55
+ function freshDir(): string {
56
+ dirCounter += 1;
57
+ return join(TEMP, `run-${dirCounter}`);
58
+ }
59
+
60
+ describe("summaryFilePath", () => {
61
+ it("joins the tmpdir with the well-known file name", () => {
62
+ expect(summaryFilePath("/tmp/run")).toBe(join("/tmp/run", SUMMARY_FILE_NAME));
63
+ });
64
+ });
65
+
66
+ describe("seedSummaryFile", () => {
67
+ it("seeds with the scaffold on first runs (no previous snapshot)", async () => {
68
+ const dir = freshDir();
69
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: null });
70
+ expect(path).toBe(summaryFilePath(dir));
71
+ await expect(readFile(path, "utf8")).resolves.toBe(SUMMARY_SCAFFOLD);
72
+ });
73
+
74
+ it("seeds with the previous snapshot when it is substantive", async () => {
75
+ const dir = freshDir();
76
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
77
+ await expect(readFile(path, "utf8")).resolves.toBe(LONG_SNAPSHOT);
78
+ });
79
+
80
+ it("falls back to the scaffold when the previous snapshot is too short", async () => {
81
+ const dir = freshDir();
82
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: "tiny" });
83
+ await expect(readFile(path, "utf8")).resolves.toBe(SUMMARY_SCAFFOLD);
84
+ });
85
+ });
86
+
87
+ describe("readSummaryFile", () => {
88
+ it("returns null when the file does not exist", async () => {
89
+ await expect(readSummaryFile(join(TEMP, "missing.md"))).resolves.toBeNull();
90
+ });
91
+
92
+ it("returns null when the content is below the sanity minimum", async () => {
93
+ const dir = freshDir();
94
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: null });
95
+ // the scaffold itself is above the minimum; overwrite with something tiny
96
+ const { writeFile } = await import("node:fs/promises");
97
+ await writeFile(path, " short ", "utf8");
98
+ await expect(readSummaryFile(path)).resolves.toBeNull();
99
+ });
100
+
101
+ it("returns the trimmed content for valid snapshots", async () => {
102
+ const dir = freshDir();
103
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: `${LONG_SNAPSHOT}\n\n` });
104
+ await expect(readSummaryFile(path)).resolves.toBe(LONG_SNAPSHOT);
105
+ });
106
+
107
+ it("caps oversized snapshots at the maximum length", async () => {
108
+ const dir = freshDir();
109
+ const huge = "x".repeat(40_000);
110
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: huge });
111
+ const read = await readSummaryFile(path);
112
+ expect(read).toHaveLength(32_768);
113
+ expect(huge.startsWith(read ?? "")).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("fetchPreviousSnapshot", () => {
118
+ function ctxWith(token: string | undefined): ToolContext {
119
+ return {
120
+ githubInstallationToken: token,
121
+ repo: { owner: "acme", name: "infra" },
122
+ } as unknown as ToolContext;
123
+ }
124
+
125
+ it("returns null without an installation token", async () => {
126
+ await expect(fetchPreviousSnapshot(ctxWith(undefined), 7)).resolves.toBeNull();
127
+ expect(apiFetchMock).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("returns null on non-ok responses", async () => {
131
+ apiFetchMock.mockResolvedValueOnce({ ok: false } as Response);
132
+ await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
133
+ });
134
+
135
+ it("returns the snapshot from the API response", async () => {
136
+ apiFetchMock.mockResolvedValueOnce({
137
+ ok: true,
138
+ json: async () => ({ snapshot: "previous summary" }),
139
+ } as Response);
140
+
141
+ await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBe("previous summary");
142
+ expect(apiFetchMock).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ path: "/api/repo/acme/infra/pr/7/summary-comment",
145
+ method: "GET",
146
+ headers: { authorization: "Bearer tok" },
147
+ }),
148
+ );
149
+ });
150
+
151
+ it("returns null for empty or missing snapshots", async () => {
152
+ apiFetchMock.mockResolvedValueOnce({
153
+ ok: true,
154
+ json: async () => ({ snapshot: "" }),
155
+ } as Response);
156
+ await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
157
+
158
+ apiFetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response);
159
+ await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
160
+ });
161
+
162
+ it("returns null when the API call throws", async () => {
163
+ apiFetchMock.mockRejectedValueOnce(new Error("network down"));
164
+ await expect(fetchPreviousSnapshot(ctxWith("tok"), 7)).resolves.toBeNull();
165
+ });
166
+ });
167
+
168
+ describe("persistSummary", () => {
169
+ function ctxWith(toolState: Record<string, unknown>): ToolContext {
170
+ return { toolState } as unknown as ToolContext;
171
+ }
172
+
173
+ it("does nothing when no summary file was seeded", async () => {
174
+ await persistSummary(ctxWith({}));
175
+ expect(patchMock).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("does nothing when persistence was already attempted", async () => {
179
+ await persistSummary(ctxWith({ summaryFilePath: "/tmp/x.md", summaryPersistAttempted: true }));
180
+ expect(patchMock).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it("skips the PATCH when the file is missing or invalid", async () => {
184
+ const toolState: Record<string, unknown> = {
185
+ summaryFilePath: join(TEMP, "never-written.md"),
186
+ };
187
+ await persistSummary(ctxWith(toolState));
188
+ expect(toolState.summaryPersistAttempted).toBe(true);
189
+ expect(patchMock).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("skips the PATCH when the agent never edited the seed", async () => {
193
+ const dir = freshDir();
194
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
195
+ await persistSummary(ctxWith({ summaryFilePath: path, summarySeed: `${LONG_SNAPSHOT}\n` }));
196
+ expect(patchMock).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("persists the snapshot when the agent edited the file", async () => {
200
+ const dir = freshDir();
201
+ const edited = `${LONG_SNAPSHOT}\n\n## What changed\n\n- reviewed the auth flow end to end`;
202
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: edited });
203
+ const ctx = ctxWith({ summaryFilePath: path, summarySeed: SUMMARY_SCAFFOLD });
204
+
205
+ await persistSummary(ctx);
206
+
207
+ expect(patchMock).toHaveBeenCalledWith(ctx, { summarySnapshot: edited });
208
+ });
209
+
210
+ it("persists even without a recorded seed", async () => {
211
+ const dir = freshDir();
212
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
213
+ await persistSummary(ctxWith({ summaryFilePath: path }));
214
+ expect(patchMock).toHaveBeenCalledTimes(1);
215
+ });
216
+
217
+ it("swallows PATCH failures (best-effort)", async () => {
218
+ const dir = freshDir();
219
+ const path = await seedSummaryFile({ tmpdir: dir, previousSnapshot: LONG_SNAPSHOT });
220
+ patchMock.mockRejectedValueOnce(new Error("api down"));
221
+
222
+ await expect(persistSummary(ctxWith({ summaryFilePath: path }))).resolves.toBeUndefined();
223
+ });
224
+ });