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,615 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import * as core from "@actions/core";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { apiFetch } from "#app/utils/apiFetch";
7
+ import {
8
+ acquireNewToken,
9
+ createOctokit,
10
+ ensureGitHubToken,
11
+ parseRepoContext,
12
+ type UsageSummary,
13
+ writeGitHubUsageSummaryToFile,
14
+ } from "#app/utils/github";
15
+
16
+ vi.mock("#app/utils/apiFetch", () => ({
17
+ apiFetch: vi.fn(),
18
+ }));
19
+
20
+ const retryState = vi.hoisted(() => ({
21
+ lastOptions: undefined as
22
+ | { label?: string; shouldRetry?: (error: unknown) => boolean }
23
+ | undefined,
24
+ }));
25
+
26
+ vi.mock("#app/utils/retry", () => ({
27
+ retry: vi.fn(
28
+ async (
29
+ fn: () => Promise<unknown>,
30
+ options?: { label?: string; shouldRetry?: (error: unknown) => boolean },
31
+ ) => {
32
+ retryState.lastOptions = options;
33
+ return fn();
34
+ },
35
+ ),
36
+ }));
37
+
38
+ vi.mock("@actions/core", () => ({
39
+ getIDToken: vi.fn(async () => "oidc-token"),
40
+ }));
41
+
42
+ vi.mock("node:fs/promises", () => ({
43
+ writeFile: vi.fn(async () => undefined),
44
+ rename: vi.fn(async () => undefined),
45
+ }));
46
+
47
+ const apiFetchMock = vi.mocked(apiFetch);
48
+
49
+ function jsonResponse(body: unknown, init?: { status?: number; statusText?: string }): Response {
50
+ const status = init?.status ?? 200;
51
+ return {
52
+ ok: status >= 200 && status < 300,
53
+ status,
54
+ statusText: init?.statusText ?? "OK",
55
+ json: async () => body,
56
+ text: async () => JSON.stringify(body),
57
+ } as unknown as Response;
58
+ }
59
+
60
+ function stubNoOidcEnv(): void {
61
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_URL", "");
62
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "");
63
+ vi.stubEnv("GITHUB_ACTIONS", "");
64
+ vi.stubEnv("GITHUB_APP_ID", "");
65
+ vi.stubEnv("GITHUB_PRIVATE_KEY", "");
66
+ }
67
+
68
+ function stubOidcEnv(): void {
69
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://token.example");
70
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "req-token");
71
+ }
72
+
73
+ const savedGithubToken = process.env.GITHUB_TOKEN;
74
+
75
+ beforeEach(() => {
76
+ retryState.lastOptions = undefined;
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.clearAllMocks();
81
+ vi.unstubAllEnvs();
82
+ vi.unstubAllGlobals();
83
+ if (savedGithubToken === undefined) {
84
+ delete process.env.GITHUB_TOKEN;
85
+ } else {
86
+ process.env.GITHUB_TOKEN = savedGithubToken;
87
+ }
88
+ });
89
+
90
+ describe("parseRepoContext", () => {
91
+ it("parses owner and name from GITHUB_REPOSITORY", () => {
92
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
93
+ expect(parseRepoContext()).toEqual({ owner: "acme", name: "widgets" });
94
+ });
95
+
96
+ it("throws when GITHUB_REPOSITORY is unset", () => {
97
+ vi.stubEnv("GITHUB_REPOSITORY", "");
98
+ expect(() => parseRepoContext()).toThrow("GITHUB_REPOSITORY environment variable is required");
99
+ });
100
+
101
+ it("throws on a malformed value", () => {
102
+ vi.stubEnv("GITHUB_REPOSITORY", "no-slash");
103
+ expect(() => parseRepoContext()).toThrow("Invalid GITHUB_REPOSITORY format: no-slash");
104
+ });
105
+
106
+ it("throws when the repo part is empty", () => {
107
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/");
108
+ expect(() => parseRepoContext()).toThrow("Invalid GITHUB_REPOSITORY format");
109
+ });
110
+ });
111
+
112
+ describe("acquireNewToken — OIDC path", () => {
113
+ it("exchanges the OIDC token and appends the target repo to the repos param", async () => {
114
+ stubOidcEnv();
115
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
116
+ apiFetchMock.mockResolvedValue(jsonResponse({ token: "installation-token" }));
117
+
118
+ await expect(acquireNewToken()).resolves.toBe("installation-token");
119
+
120
+ expect(core.getIDToken).toHaveBeenCalledWith("terramend-api");
121
+ expect(apiFetchMock).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ path: "/api/github/installation-token?repos=widgets",
124
+ method: "POST",
125
+ headers: expect.objectContaining({ Authorization: "Bearer oidc-token" }),
126
+ }),
127
+ );
128
+ });
129
+
130
+ it("merges explicit repos with the target repo and sends permissions in the body", async () => {
131
+ stubOidcEnv();
132
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
133
+ apiFetchMock.mockResolvedValue(jsonResponse({ token: "t" }));
134
+
135
+ await acquireNewToken({ repos: ["other"], permissions: { contents: "write" } });
136
+
137
+ expect(apiFetchMock).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ path: "/api/github/installation-token?repos=other,widgets",
140
+ body: JSON.stringify({ permissions: { contents: "write" } }),
141
+ }),
142
+ );
143
+ });
144
+
145
+ it("omits the repos param when no repos are known", async () => {
146
+ stubOidcEnv();
147
+ vi.stubEnv("GITHUB_REPOSITORY", "");
148
+ apiFetchMock.mockResolvedValue(jsonResponse({ token: "t" }));
149
+
150
+ await acquireNewToken();
151
+
152
+ expect(apiFetchMock).toHaveBeenCalledWith(
153
+ expect.objectContaining({ path: "/api/github/installation-token" }),
154
+ );
155
+ });
156
+
157
+ it("surfaces the server-provided error message on a non-2xx response", async () => {
158
+ stubOidcEnv();
159
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
160
+ apiFetchMock.mockResolvedValue(
161
+ jsonResponse({ error: "app not installed — visit https://example/install" }, { status: 404 }),
162
+ );
163
+
164
+ await expect(acquireNewToken()).rejects.toThrow(
165
+ "app not installed — visit https://example/install",
166
+ );
167
+ });
168
+
169
+ it("falls back to a generic message when the error field is not a string", async () => {
170
+ stubOidcEnv();
171
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
172
+ apiFetchMock.mockResolvedValue(
173
+ jsonResponse({ error: 42 }, { status: 503, statusText: "Service Unavailable" }),
174
+ );
175
+
176
+ await expect(acquireNewToken()).rejects.toThrow(
177
+ "Token exchange failed: 503 Service Unavailable",
178
+ );
179
+ });
180
+
181
+ it("falls back to a generic message when the error body is not JSON", async () => {
182
+ stubOidcEnv();
183
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
184
+ apiFetchMock.mockResolvedValue({
185
+ ok: false,
186
+ status: 502,
187
+ statusText: "Bad Gateway",
188
+ json: async () => {
189
+ throw new Error("not json");
190
+ },
191
+ } as unknown as Response);
192
+
193
+ await expect(acquireNewToken()).rejects.toThrow("Token exchange failed: 502 Bad Gateway");
194
+ });
195
+
196
+ it("aborts the exchange via the 30s timeout controller", async () => {
197
+ vi.useFakeTimers();
198
+ try {
199
+ stubOidcEnv();
200
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
201
+ apiFetchMock.mockImplementation(
202
+ (options) =>
203
+ new Promise<Response>((_resolve, reject) => {
204
+ options.signal?.addEventListener("abort", () => {
205
+ const abortError = new Error("aborted");
206
+ abortError.name = "AbortError";
207
+ reject(abortError);
208
+ });
209
+ }),
210
+ );
211
+
212
+ const pending = expect(acquireNewToken()).rejects.toThrow(
213
+ "Token exchange timed out after 30000ms",
214
+ );
215
+ await vi.advanceTimersByTimeAsync(30_000);
216
+ await pending;
217
+ } finally {
218
+ vi.useRealTimers();
219
+ }
220
+ });
221
+
222
+ it("maps AbortError to a timeout message", async () => {
223
+ stubOidcEnv();
224
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
225
+ const abortError = new Error("aborted");
226
+ abortError.name = "AbortError";
227
+ apiFetchMock.mockRejectedValue(abortError);
228
+
229
+ await expect(acquireNewToken()).rejects.toThrow("Token exchange timed out after 30000ms");
230
+ });
231
+
232
+ it("retries 5xx and 429 token-exchange failures but not 4xx", async () => {
233
+ stubOidcEnv();
234
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
235
+
236
+ const errorForStatus = async (status: number): Promise<unknown> => {
237
+ apiFetchMock.mockResolvedValueOnce(jsonResponse({ error: `status ${status}` }, { status }));
238
+ return acquireNewToken().then(
239
+ () => {
240
+ throw new Error("expected acquireNewToken to reject");
241
+ },
242
+ (error: unknown) => error,
243
+ );
244
+ };
245
+
246
+ const notFound = await errorForStatus(404);
247
+ const serverError = await errorForStatus(500);
248
+ const rateLimited = await errorForStatus(429);
249
+
250
+ const shouldRetry = retryState.lastOptions?.shouldRetry;
251
+ if (!shouldRetry) throw new Error("expected retry options to have been captured");
252
+
253
+ expect(shouldRetry(notFound)).toBe(false);
254
+ expect(shouldRetry(serverError)).toBe(true);
255
+ expect(shouldRetry(rateLimited)).toBe(true);
256
+ });
257
+
258
+ it("retries plain network errors by message, but not unrelated errors", async () => {
259
+ stubOidcEnv();
260
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
261
+ apiFetchMock.mockResolvedValue(jsonResponse({ token: "t" }));
262
+ await acquireNewToken();
263
+
264
+ const shouldRetry = retryState.lastOptions?.shouldRetry;
265
+ if (!shouldRetry) throw new Error("expected retry options to have been captured");
266
+
267
+ expect(shouldRetry(new Error("fetch failed"))).toBe(true);
268
+ expect(shouldRetry(new Error("Token exchange timed out after 30000ms"))).toBe(true);
269
+ expect(shouldRetry(new Error("read ECONNRESET"))).toBe(true);
270
+ expect(shouldRetry(new Error("connect ETIMEDOUT"))).toBe(true);
271
+ expect(shouldRetry(new Error("schema validation failed"))).toBe(false);
272
+ expect(shouldRetry("not an error")).toBe(false);
273
+ });
274
+ });
275
+
276
+ describe("acquireNewToken — no OIDC", () => {
277
+ it("explains the missing id-token permission when running in GitHub Actions", async () => {
278
+ stubNoOidcEnv();
279
+ vi.stubEnv("GITHUB_ACTIONS", "true");
280
+
281
+ await expect(acquireNewToken()).rejects.toThrow(
282
+ "missing `permissions: id-token: write` on the Terramend workflow job",
283
+ );
284
+ });
285
+
286
+ it("requires GitHub App credentials for local development", async () => {
287
+ stubNoOidcEnv();
288
+
289
+ await expect(acquireNewToken()).rejects.toThrow(
290
+ "GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set",
291
+ );
292
+ });
293
+ });
294
+
295
+ describe("acquireNewToken — GitHub App path (local dev)", () => {
296
+ const { privateKey } = generateKeyPairSync("rsa", {
297
+ modulusLength: 2048,
298
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
299
+ publicKeyEncoding: { type: "spki", format: "pem" },
300
+ });
301
+
302
+ function stubAppEnv(): void {
303
+ stubNoOidcEnv();
304
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
305
+ vi.stubEnv("GITHUB_APP_ID", "12345");
306
+ // encode with literal \n to exercise the unescaping in acquireTokenViaGitHubApp
307
+ vi.stubEnv("GITHUB_PRIVATE_KEY", privateKey.replace(/\n/g, "\\n"));
308
+ }
309
+
310
+ it("signs a JWT, finds the installation with repo access, and mints a token", async () => {
311
+ stubAppEnv();
312
+ const tokensByInstallation: Record<string, string[]> = {
313
+ "11": ["tmp-11"],
314
+ "22": ["tmp-22", "final-22"],
315
+ };
316
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
317
+ const path = new URL(url).pathname;
318
+ if (path === "/app/installations") {
319
+ const headers = init?.headers as Record<string, string>;
320
+ // a real three-segment JWT must be presented
321
+ expect(headers.Authorization).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
322
+ return jsonResponse([
323
+ { id: 11, account: { login: "someone-else", type: "User" } },
324
+ { id: 22, account: { login: "acme", type: "Organization" } },
325
+ ]);
326
+ }
327
+ const tokenMatch = path.match(/^\/app\/installations\/(\d+)\/access_tokens$/);
328
+ if (tokenMatch) {
329
+ const queue = tokensByInstallation[tokenMatch[1] ?? ""] ?? [];
330
+ const token = queue.shift();
331
+ return jsonResponse({ token, expires_at: "soon" });
332
+ }
333
+ if (path === "/installation/repositories") {
334
+ const headers = init?.headers as Record<string, string>;
335
+ if (headers.Authorization === "token tmp-22") {
336
+ return jsonResponse({ repositories: [{ owner: { login: "ACME" }, name: "Widgets" }] });
337
+ }
338
+ return jsonResponse({ repositories: [{ owner: { login: "someone-else" }, name: "x" }] });
339
+ }
340
+ throw new Error(`unexpected fetch: ${url}`);
341
+ });
342
+ vi.stubGlobal("fetch", fetchMock);
343
+
344
+ await expect(acquireNewToken({ permissions: { contents: "write" } })).resolves.toBe("final-22");
345
+
346
+ // the final mint carries the requested permissions in the body
347
+ const finalCall = fetchMock.mock.calls.at(-1);
348
+ if (!finalCall) throw new Error("expected a final access_tokens call");
349
+ expect(finalCall[1]?.body).toBe(JSON.stringify({ permissions: { contents: "write" } }));
350
+ });
351
+
352
+ it("throws when no installation has access to the target repository", async () => {
353
+ stubAppEnv();
354
+ const fetchMock = vi.fn(async (url: string) => {
355
+ const path = new URL(url).pathname;
356
+ if (path === "/app/installations") {
357
+ return jsonResponse([{ id: 11, account: { login: "someone-else", type: "User" } }]);
358
+ }
359
+ if (path.endsWith("/access_tokens")) {
360
+ return jsonResponse({ token: "tmp", expires_at: "soon" });
361
+ }
362
+ if (path === "/installation/repositories") {
363
+ // checkRepositoryAccess swallows this failure and reports no access
364
+ return jsonResponse({ message: "boom" }, { status: 500, statusText: "Server Error" });
365
+ }
366
+ throw new Error(`unexpected fetch: ${url}`);
367
+ });
368
+ vi.stubGlobal("fetch", fetchMock);
369
+
370
+ await expect(acquireNewToken()).rejects.toThrow(
371
+ "No installation found with access to acme/widgets",
372
+ );
373
+ });
374
+
375
+ it("skips installations whose token mint fails and keeps searching", async () => {
376
+ stubAppEnv();
377
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
378
+ const path = new URL(url).pathname;
379
+ if (path === "/app/installations") {
380
+ return jsonResponse([
381
+ { id: 11, account: { login: "a", type: "User" } },
382
+ { id: 22, account: { login: "acme", type: "Organization" } },
383
+ ]);
384
+ }
385
+ if (path === "/app/installations/11/access_tokens") {
386
+ return jsonResponse({ message: "nope" }, { status: 403, statusText: "Forbidden" });
387
+ }
388
+ if (path === "/app/installations/22/access_tokens") {
389
+ return jsonResponse({ token: "t22", expires_at: "soon" });
390
+ }
391
+ if (path === "/installation/repositories") {
392
+ const headers = init?.headers as Record<string, string>;
393
+ expect(headers.Authorization).toBe("token t22");
394
+ return jsonResponse({ repositories: [{ owner: { login: "acme" }, name: "widgets" }] });
395
+ }
396
+ throw new Error(`unexpected fetch: ${url}`);
397
+ });
398
+ vi.stubGlobal("fetch", fetchMock);
399
+
400
+ await expect(acquireNewToken()).resolves.toBe("t22");
401
+ });
402
+
403
+ it("ensureGitHubToken exports the minted app token when no token exists", async () => {
404
+ stubAppEnv();
405
+ vi.stubEnv("GITHUB_TOKEN", "");
406
+ vi.stubEnv("GH_TOKEN", "");
407
+ const tokens = ["tmp-22", "final-22"];
408
+ const fetchMock = vi.fn(async (url: string) => {
409
+ const path = new URL(url).pathname;
410
+ if (path === "/app/installations") {
411
+ return jsonResponse([{ id: 22, account: { login: "acme", type: "Organization" } }]);
412
+ }
413
+ if (path === "/app/installations/22/access_tokens") {
414
+ return jsonResponse({ token: tokens.shift(), expires_at: "soon" });
415
+ }
416
+ if (path === "/installation/repositories") {
417
+ return jsonResponse({ repositories: [{ owner: { login: "acme" }, name: "widgets" }] });
418
+ }
419
+ throw new Error(`unexpected fetch: ${url}`);
420
+ });
421
+ vi.stubGlobal("fetch", fetchMock);
422
+
423
+ await ensureGitHubToken();
424
+
425
+ expect(process.env.GITHUB_TOKEN).toBe("final-22");
426
+ });
427
+ });
428
+
429
+ describe("ensureGitHubToken", () => {
430
+ it("always mints a fresh token when OIDC is available", async () => {
431
+ stubOidcEnv();
432
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/widgets");
433
+ vi.stubEnv("GITHUB_TOKEN", "stale-runner-token");
434
+ apiFetchMock.mockResolvedValue(jsonResponse({ token: "fresh-token" }));
435
+
436
+ await ensureGitHubToken();
437
+
438
+ expect(process.env.GITHUB_TOKEN).toBe("fresh-token");
439
+ });
440
+
441
+ it("keeps an existing GITHUB_TOKEN when OIDC is unavailable", async () => {
442
+ stubNoOidcEnv();
443
+ vi.stubEnv("GITHUB_TOKEN", "existing");
444
+
445
+ await ensureGitHubToken();
446
+
447
+ expect(process.env.GITHUB_TOKEN).toBe("existing");
448
+ expect(apiFetchMock).not.toHaveBeenCalled();
449
+ });
450
+
451
+ it("accepts GH_TOKEN as an existing credential", async () => {
452
+ stubNoOidcEnv();
453
+ vi.stubEnv("GITHUB_TOKEN", "");
454
+ vi.stubEnv("GH_TOKEN", "gh-existing");
455
+
456
+ await ensureGitHubToken();
457
+
458
+ expect(apiFetchMock).not.toHaveBeenCalled();
459
+ });
460
+
461
+ it("propagates acquisition failures when no token source exists", async () => {
462
+ stubNoOidcEnv();
463
+ vi.stubEnv("GITHUB_TOKEN", "");
464
+ vi.stubEnv("GH_TOKEN", "");
465
+
466
+ await expect(ensureGitHubToken()).rejects.toThrow(
467
+ "GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set",
468
+ );
469
+ });
470
+ });
471
+
472
+ describe("writeGitHubUsageSummaryToFile", () => {
473
+ async function writtenSummary(path: string): Promise<UsageSummary> {
474
+ await writeGitHubUsageSummaryToFile(path);
475
+ const call = vi.mocked(writeFile).mock.calls.at(-1);
476
+ if (!call) throw new Error("expected writeFile to have been called");
477
+ return JSON.parse(String(call[1])) as UsageSummary;
478
+ }
479
+
480
+ it("writes the summary atomically via a temp file in the same directory", async () => {
481
+ const target = join("out", "usage.json");
482
+ const summary = await writtenSummary(target);
483
+
484
+ const expectedTmp = join(dirname(target), `.usage-summary-${process.pid}.tmp`);
485
+ expect(writeFile).toHaveBeenCalledWith(expectedTmp, expect.any(String));
486
+ expect(rename).toHaveBeenCalledWith(expectedTmp, target);
487
+ expect(summary.version).toBe(1);
488
+ expect(summary.github.core).toMatchObject({ requestCount: expect.any(Number) });
489
+ expect(summary.github.graphql).toMatchObject({ requestCount: expect.any(Number) });
490
+ });
491
+
492
+ it("tracks octokit request usage from rate-limit headers, including error responses", async () => {
493
+ const target = join("out", "usage.json");
494
+ const before = await writtenSummary(target);
495
+
496
+ const octokit = createOctokit("test-token");
497
+
498
+ const okFetch = vi.fn(
499
+ async () =>
500
+ new Response("{}", {
501
+ status: 200,
502
+ headers: {
503
+ "content-type": "application/json",
504
+ "x-ratelimit-resource": "core",
505
+ "x-ratelimit-remaining": "42",
506
+ "x-ratelimit-reset": "100",
507
+ },
508
+ }),
509
+ );
510
+ await octokit.request("GET /zen", { request: { fetch: okFetch } });
511
+
512
+ const middle = await writtenSummary(target);
513
+ expect(middle.github.core.requestCount).toBe(before.github.core.requestCount + 1);
514
+ expect(middle.github.core.rateLimitRemaining).toBe(42);
515
+ expect(middle.github.core.rateLimitResetMs).toBe(100_000);
516
+
517
+ // a failing request still records usage from the error response headers
518
+ const errorFetch = vi.fn(
519
+ async () =>
520
+ new Response('{"message":"not found"}', {
521
+ status: 404,
522
+ headers: {
523
+ "content-type": "application/json",
524
+ "x-ratelimit-resource": "core",
525
+ "x-ratelimit-remaining": "7",
526
+ },
527
+ }),
528
+ );
529
+ await expect(
530
+ octokit.request("GET /missing", { request: { fetch: errorFetch } }),
531
+ ).rejects.toThrow();
532
+
533
+ // a response without the resource header is ignored
534
+ const headerlessFetch = vi.fn(
535
+ async () =>
536
+ new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
537
+ );
538
+ await octokit.request("GET /zen", { request: { fetch: headerlessFetch } });
539
+
540
+ // a resource without rate-limit headers counts the request but keeps nulls
541
+ const graphqlFetch = vi.fn(
542
+ async () =>
543
+ new Response("{}", {
544
+ status: 200,
545
+ headers: { "content-type": "application/json", "x-ratelimit-resource": "graphql" },
546
+ }),
547
+ );
548
+ await octokit.request("GET /zen", { request: { fetch: graphqlFetch } });
549
+
550
+ // a plain network failure carries no response and records nothing
551
+ const brokenFetch = vi.fn(async () => {
552
+ throw new Error("socket hang up");
553
+ });
554
+ await expect(
555
+ octokit.request("GET /zen", { request: { fetch: brokenFetch, retries: 0 } }),
556
+ ).rejects.toThrow();
557
+
558
+ const after = await writtenSummary(target);
559
+ expect(after.github.core.requestCount).toBe(before.github.core.requestCount + 2);
560
+ expect(after.github.core.rateLimitRemaining).toBe(7);
561
+ // reset header was absent on the error response — previous value sticks
562
+ expect(after.github.core.rateLimitResetMs).toBe(100_000);
563
+ expect(after.github.graphql.requestCount).toBe(before.github.graphql.requestCount + 1);
564
+ expect(after.github.graphql.rateLimitRemaining).toBeNull();
565
+ expect(after.github.graphql.rateLimitResetMs).toBeNull();
566
+ });
567
+
568
+ it("retries a primary rate-limited request once via the throttling plugin", async () => {
569
+ const octokit = createOctokit("test-token");
570
+ const fetchMock = vi
571
+ .fn<typeof fetch>()
572
+ .mockResolvedValueOnce(
573
+ new Response('{"message":"API rate limit exceeded"}', {
574
+ status: 403,
575
+ headers: {
576
+ "content-type": "application/json",
577
+ "retry-after": "0",
578
+ "x-ratelimit-remaining": "0",
579
+ "x-ratelimit-reset": "0",
580
+ },
581
+ }),
582
+ )
583
+ .mockResolvedValueOnce(
584
+ new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
585
+ );
586
+
587
+ const response = await octokit.request("GET /zen", { request: { fetch: fetchMock } });
588
+
589
+ expect(response.status).toBe(200);
590
+ expect(fetchMock).toHaveBeenCalledTimes(2);
591
+ });
592
+
593
+ it("retries a secondary rate-limited request once via the throttling plugin", {
594
+ timeout: 10_000,
595
+ }, async () => {
596
+ const octokit = createOctokit("test-token");
597
+ const fetchMock = vi
598
+ .fn<typeof fetch>()
599
+ .mockResolvedValueOnce(
600
+ new Response('{"message":"You have exceeded a secondary rate limit"}', {
601
+ status: 403,
602
+ // "0" is falsy and makes the plugin fall back to a 60s wait — use 1s
603
+ headers: { "content-type": "application/json", "retry-after": "1" },
604
+ }),
605
+ )
606
+ .mockResolvedValueOnce(
607
+ new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
608
+ );
609
+
610
+ const response = await octokit.request("GET /zen", { request: { fetch: fetchMock } });
611
+
612
+ expect(response.status).toBe(200);
613
+ expect(fetchMock).toHaveBeenCalledTimes(2);
614
+ });
615
+ });