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,886 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
5
+ import { type } from "arktype";
6
+ import { rejectIfLeadingDash } from "#app/mcp/git";
7
+ import { commentableLinesForFile } from "#app/mcp/review";
8
+ import type { ToolContext } from "#app/mcp/server";
9
+ import { execute, tool } from "#app/mcp/shared";
10
+ import { log } from "#app/utils/cli";
11
+ import { countLines, createDiffCoverageState } from "#app/utils/diffCoverage";
12
+ import { $git, $gitFetchWithDeepen } from "#app/utils/gitAuth";
13
+ import { executeLifecycleHook } from "#app/utils/lifecycle";
14
+ import { computeIncrementalDiff } from "#app/utils/rangeDiff";
15
+ import { retry } from "#app/utils/retry";
16
+ import { $ } from "#app/utils/shell";
17
+
18
+ type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
19
+
20
+ export type FormatFilesResult = {
21
+ content: string;
22
+ toc: string;
23
+ };
24
+
25
+ export type FetchAndFormatPrDiffResult = FormatFilesResult & {
26
+ files: PullFile[];
27
+ };
28
+
29
+ /**
30
+ * formats PR files with explicit line numbers for each code line.
31
+ * preserves all original diff info (file headers, hunk headers) and adds:
32
+ * | OLD | NEW | TYPE | code
33
+ * returns both the formatted content and a TOC with line ranges per file.
34
+ */
35
+ export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
36
+ const output: string[] = [];
37
+ const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
38
+
39
+ // calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
40
+ const tocHeaderSize = 1 + files.length + 2;
41
+ let currentLine = tocHeaderSize + 1;
42
+
43
+ for (const file of files) {
44
+ const fileStartLine = currentLine;
45
+
46
+ // file header
47
+ output.push(`diff --git a/${file.filename} b/${file.filename}`);
48
+ output.push(`--- a/${file.filename}`);
49
+ output.push(`+++ b/${file.filename}`);
50
+ currentLine += 3;
51
+
52
+ if (!file.patch) {
53
+ output.push("(binary file or no changes)");
54
+ output.push("");
55
+ currentLine += 2;
56
+ tocEntries.push({
57
+ filename: file.filename,
58
+ startLine: fileStartLine,
59
+ endLine: currentLine - 1,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ // parse and format the patch with line numbers
65
+ const lines = file.patch.split("\n");
66
+ let oldLine = 0;
67
+ let newLine = 0;
68
+
69
+ for (const line of lines) {
70
+ // hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
71
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
72
+ if (hunkMatch) {
73
+ oldLine = parseInt(hunkMatch[1]!, 10);
74
+ newLine = parseInt(hunkMatch[2]!, 10);
75
+ output.push(line); // pass through unchanged
76
+ currentLine++;
77
+ continue;
78
+ }
79
+
80
+ // code lines within hunks
81
+ const changeType = line[0] || " ";
82
+ const code = line.slice(1);
83
+
84
+ if (changeType === "-") {
85
+ // removed line: show old line number, no new line number
86
+ output.push(`| ${padNum(oldLine)} | | - | ${code}`);
87
+ oldLine++;
88
+ } else if (changeType === "+") {
89
+ // added line: no old line number, show new line number
90
+ output.push(`| | ${padNum(newLine)} | + | ${code}`);
91
+ newLine++;
92
+ } else if (changeType === " " || changeType === "\\") {
93
+ // context line or ""
94
+ if (changeType === "\\") {
95
+ output.push(line); // pass through as-is
96
+ } else {
97
+ output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
98
+ oldLine++;
99
+ newLine++;
100
+ }
101
+ } else {
102
+ // unknown line type, pass through
103
+ output.push(line);
104
+ }
105
+ currentLine++;
106
+ }
107
+ output.push(""); // blank line between files
108
+ currentLine++;
109
+
110
+ tocEntries.push({
111
+ filename: file.filename,
112
+ startLine: fileStartLine,
113
+ endLine: currentLine - 1,
114
+ });
115
+ }
116
+
117
+ // build TOC. each entry includes the precomputed sha256 anchor used in
118
+ // github PR Files Changed URLs (#diff-<hex>), so the agent never needs to
119
+ // shell out to sha256sum.
120
+ const tocLines = [`## Files (${files.length})`];
121
+ for (const entry of tocEntries) {
122
+ const anchor = createHash("sha256").update(entry.filename).digest("hex");
123
+ tocLines.push(
124
+ `- ${entry.filename} → lines ${entry.startLine}-${entry.endLine} · diff-${anchor}`,
125
+ );
126
+ }
127
+ tocLines.push("");
128
+ tocLines.push("---");
129
+ tocLines.push("");
130
+
131
+ const toc = tocLines.join("\n");
132
+ const content = toc + output.join("\n");
133
+
134
+ return { content, toc };
135
+ }
136
+
137
+ function padNum(n: number): string {
138
+ return n.toString().padStart(4, " ");
139
+ }
140
+
141
+ export const CheckoutPr = type({
142
+ pull_number: type.number.describe("the pull request number to checkout"),
143
+ });
144
+
145
+ export type CheckoutPrResult = {
146
+ success: true;
147
+ number: number;
148
+ title: string;
149
+ body: string | null;
150
+ base: string;
151
+ localBranch: string;
152
+ remoteBranch: string;
153
+ isFork: boolean;
154
+ maintainerCanModify: boolean;
155
+ url: string;
156
+ headRepo: string;
157
+ diffPath: string;
158
+ incrementalDiffPath?: string | undefined;
159
+ toc: string;
160
+ commitCount: number;
161
+ commitLog: string;
162
+ /** true when commitLog was capped because the PR has more commits than we render */
163
+ commitLogTruncated: boolean;
164
+ /** true when commit metadata could not be computed (e.g. base ref unreachable after shallow fetch). commitCount/commitLog are zero/empty in that case, not "no commits". */
165
+ commitLogUnavailable: boolean;
166
+ /** non-fatal warning from the post-checkout lifecycle hook, if any */
167
+ hookWarning?: string | undefined;
168
+ instructions: string;
169
+ };
170
+
171
+ /**
172
+ * fetches PR files from GitHub and formats them with line numbers and TOC.
173
+ * this is the core diff formatting logic, extracted for testability.
174
+ */
175
+ export async function fetchAndFormatPrDiff(
176
+ ctx: ToolContext,
177
+ pullNumber: number,
178
+ ): Promise<FetchAndFormatPrDiffResult> {
179
+ const files = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
180
+ owner: ctx.repo.owner,
181
+ repo: ctx.repo.name,
182
+ pull_number: pullNumber,
183
+ per_page: 100,
184
+ });
185
+ return { ...formatFilesWithLineNumbers(files), files };
186
+ }
187
+
188
+ import { captureInitialHead, type GitContext } from "#app/utils/setup";
189
+
190
+ export type PrData = {
191
+ number: number;
192
+ headSha: string;
193
+ headRef: string;
194
+ headRepoFullName: string;
195
+ baseRef: string;
196
+ baseRepoFullName: string;
197
+ maintainerCanModify: boolean;
198
+ };
199
+
200
+ type EnsureBeforeShaParams = {
201
+ sha: string;
202
+ octokit: Octokit;
203
+ owner: string;
204
+ repo: string;
205
+ gitToken: string;
206
+ isShallow: boolean;
207
+ };
208
+
209
+ type CreateTempBranchParams = {
210
+ octokit: Octokit;
211
+ owner: string;
212
+ repo: string;
213
+ ref: string;
214
+ sha: string;
215
+ };
216
+
217
+ async function createTempBranch(params: CreateTempBranchParams) {
218
+ const response = await params.octokit.rest.git.createRef({
219
+ owner: params.owner,
220
+ repo: params.repo,
221
+ ref: `refs/heads/${params.ref}`,
222
+ sha: params.sha,
223
+ });
224
+ return {
225
+ data: response.data,
226
+ async [Symbol.asyncDispose]() {
227
+ try {
228
+ await params.octokit.rest.git.deleteRef({
229
+ owner: params.owner,
230
+ repo: params.repo,
231
+ ref: `heads/${params.ref}`,
232
+ });
233
+ log.debug(`» deleted temp branch ${params.ref}`);
234
+ } catch (e) {
235
+ log.debug(
236
+ `» failed to delete temp branch ${params.ref}: ${e instanceof Error ? e.message : String(e)}`,
237
+ );
238
+ }
239
+ },
240
+ };
241
+ }
242
+
243
+ async function ensureBeforeShaReachable(params: EnsureBeforeShaParams): Promise<boolean> {
244
+ try {
245
+ $("git", ["cat-file", "-t", params.sha], { log: false });
246
+ log.debug(`» before_sha ${params.sha.slice(0, 7)} is reachable`);
247
+ return true;
248
+ } catch {
249
+ // not available locally — create a temporary branch to fetch it
250
+ }
251
+
252
+ const tempBranch = `terramend/tmp/${params.sha.slice(0, 12)}`;
253
+ try {
254
+ log.debug(`» before_sha ${params.sha.slice(0, 7)} not reachable, creating temp branch...`);
255
+ await using _ref = await createTempBranch({
256
+ octokit: params.octokit,
257
+ owner: params.owner,
258
+ repo: params.repo,
259
+ sha: params.sha,
260
+ ref: tempBranch,
261
+ });
262
+ await $gitFetchWithDeepen(
263
+ ["--no-tags", ...(params.isShallow ? ["--depth=1"] : []), "origin", tempBranch],
264
+ { token: params.gitToken },
265
+ `before_sha temp branch ${tempBranch}`,
266
+ );
267
+ log.debug(`» fetched before_sha via temp branch ${tempBranch}`);
268
+ return true;
269
+ } catch (e) {
270
+ log.debug(`» failed to fetch before_sha: ${e instanceof Error ? e.message : String(e)}`);
271
+ return false;
272
+ }
273
+ }
274
+
275
+ type CheckoutPrBranchParams = GitContext & {
276
+ beforeSha?: string | undefined;
277
+ };
278
+
279
+ // stale lock files left over from a crashed/cancelled prior git process block
280
+ // every subsequent fetch with `Unable to create '<path>': File exists`. only
281
+ // sweep locks older than this threshold so we never race a concurrent
282
+ // legitimate git op that's holding the lock.
283
+ const STALE_LOCK_AGE_MS = 30_000;
284
+
285
+ // PR head refs (refs/pull/N/head) sometimes lag the pull_request.opened
286
+ // webhook by a few seconds. retry the missing-ref case with backoff
287
+ // before giving up — see issue #591.
288
+ const PULL_REF_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
289
+ const PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
290
+
291
+ const GIT_LOCK_PATHS = [
292
+ ".git/shallow.lock",
293
+ ".git/index.lock",
294
+ ".git/objects/maintenance.lock",
295
+ ] as const;
296
+
297
+ function cleanupStaleGitLocks(): void {
298
+ const now = Date.now();
299
+ for (const relPath of GIT_LOCK_PATHS) {
300
+ let mtimeMs: number;
301
+ try {
302
+ mtimeMs = statSync(relPath).mtimeMs;
303
+ } catch {
304
+ continue;
305
+ }
306
+ if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
307
+ try {
308
+ unlinkSync(relPath);
309
+ log.warning(`» removed stale ${relPath} from prior run`);
310
+ } catch (e) {
311
+ log.debug(
312
+ `» failed to remove stale ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Returns false when a PR's current state diverges from what we dispatched
320
+ * on (closed/merged, or head SHA differs from pr.headSha). Used to short-
321
+ * circuit the pull/N/head retry loop when the ref is missing because the
322
+ * PR has moved on, not because of a webhook race.
323
+ *
324
+ * Network failures here are treated as "still valid" — we'd rather burn the
325
+ * retry budget than wrongly abort on a transient API blip.
326
+ *
327
+ * Note: this answers "should we keep trying?", NOT "will the next fetch
328
+ * succeed?". `pulls.get` (REST API) and `pull/N/head` (git ref) are served
329
+ * by independent GitHub replicas with their own propagation lag, so
330
+ * `pulls.get` reporting an open PR with a matching head SHA does not
331
+ * guarantee the git ref is yet visible — and vice versa (see issue #591
332
+ * for the original webhook-vs-ref replication-lag context).
333
+ */
334
+ async function isPullRequestStillDispatchable(args: {
335
+ octokit: Octokit;
336
+ owner: string;
337
+ repo: string;
338
+ pr: PrData;
339
+ }): Promise<boolean> {
340
+ try {
341
+ const { data } = await args.octokit.rest.pulls.get({
342
+ owner: args.owner,
343
+ repo: args.repo,
344
+ pull_number: args.pr.number,
345
+ });
346
+ if (data.state !== "open") return false;
347
+ if (data.head.sha !== args.pr.headSha) return false;
348
+ return true;
349
+ } catch {
350
+ // lenient — don't abort on API hiccups
351
+ return true;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Throws the friendly clean-abort error when the PR has moved on since
357
+ * dispatch. Wraps `isPullRequestStillDispatchable` so the abort message
358
+ * lives in one place and is invoked from the inner `catch` around the
359
+ * `pull/N/head` fetch on every missing-ref failure.
360
+ */
361
+ async function abortIfPullRequestMoved(args: {
362
+ octokit: Octokit;
363
+ owner: string;
364
+ repo: string;
365
+ pr: PrData;
366
+ }): Promise<void> {
367
+ const stillValid = await isPullRequestStillDispatchable(args);
368
+ if (stillValid) return;
369
+ throw new Error(
370
+ `PR #${args.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout — re-trigger the run if this PR is still active.`,
371
+ );
372
+ }
373
+
374
+ /**
375
+ * Shared helper to checkout a PR branch and configure fork remotes.
376
+ * Assumes origin remote is already configured with authentication.
377
+ * Updates toolState.issueNumber, toolState.checkoutSha, and toolState.pushUrl (for fork PRs).
378
+ */
379
+ export async function checkoutPrBranch(
380
+ pr: PrData,
381
+ params: CheckoutPrBranchParams,
382
+ ): Promise<{ hookWarning?: string | undefined }> {
383
+ const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
384
+ log.info(`» checking out PR #${pr.number}...`);
385
+
386
+ // SECURITY: PR ref names come from GitHub and are attacker-controlled on
387
+ // forks (the PR author picks headRef freely, and baseRef could be a
388
+ // maliciously-named branch on the target repo). reject leading-dash names
389
+ // before they reach any git command — without this, a ref like
390
+ // "-upload-pack=evil" fed into `git fetch origin <ref>` would be parsed as
391
+ // a flag, not a refspec.
392
+ rejectIfLeadingDash(pr.baseRef, "PR base ref");
393
+ rejectIfLeadingDash(pr.headRef, "PR head ref");
394
+
395
+ // self-hosted runners and cancelled jobs frequently leave stale .git/*.lock
396
+ // files behind. without this sweep, the first fetch below aborts with
397
+ // `Unable to create '.git/shallow.lock': File exists` (originally surfaced
398
+ // by agents shelling out to `rm -f` here, issue #564). doing it server-side
399
+ // is the only safe place — the tool description now explicitly forbids the
400
+ // agent from removing lock files, because manual removal during an in-flight
401
+ // fetch kills the running fetch and creates an inescapable retry loop
402
+ // (issue #860).
403
+ cleanupStaleGitLocks();
404
+
405
+ const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
406
+
407
+ // always use pr-{number} as local branch name for consistency
408
+ // this avoids naming conflicts and makes push config simpler
409
+ const localBranch = `pr-${pr.number}`;
410
+
411
+ const isShallow =
412
+ $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
413
+
414
+ toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
415
+ const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
416
+
417
+ // fetch base branch so origin/<base> exists for diff operations.
418
+ // wrap with deepen-retry: on shallow clones (the actions/checkout default
419
+ // is depth=1), repos with deep PR ancestry can't reach the baseRef tip in
420
+ // a single round trip, surfacing as `Could not read <sha>` / `remote did
421
+ // not send all necessary objects` (issue #656).
422
+ log.debug(`» fetching base branch (${pr.baseRef})...`);
423
+ await $gitFetchWithDeepen(
424
+ ["--no-tags", "origin", pr.baseRef],
425
+ { token: gitToken },
426
+ `base branch ${pr.baseRef}`,
427
+ );
428
+
429
+ // alreadyOnBranch only matches for repeated checkout_pr calls for the same PR in one session
430
+ // (without the tip moving), or if an external setup already checked out the PR head.
431
+ // normal PR-triggered runs won't match here — actions/checkout lands on a synthesized
432
+ // merge commit whose SHA differs from pr.headSha.
433
+ //
434
+ // so the fetch+checkout block below will almost always execute, and the fetched HEAD
435
+ // might differ from pr.headSha. toolState.checkoutSha is set after to capture the actual SHA.
436
+ if (!alreadyOnBranch) {
437
+ // checkout base branch first to avoid "refusing to fetch into current branch" error
438
+ // -B creates or resets the branch to match origin/baseBranch
439
+ $("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
440
+
441
+ // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs).
442
+ // two transient classes wrap this fetch:
443
+ // - shallow-unreachable (`Could not read <sha>` etc.) — handled by the
444
+ // inner `$gitFetchWithDeepen` deepen-retry (one shot, see issue #656)
445
+ // - pull/N/head webhook race (`couldn't find remote ref pull/N/head`) —
446
+ // handled by the outer retry below (see issue #591)
447
+ log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
448
+ await retry(
449
+ async () => {
450
+ try {
451
+ await $gitFetchWithDeepen(
452
+ ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
453
+ { token: gitToken },
454
+ `PR #${pr.number}`,
455
+ );
456
+ } catch (e) {
457
+ // on the webhook race, check whether the PR still matches what we
458
+ // dispatched on. if it's been closed/merged or the head SHA moved,
459
+ // no amount of retrying will populate the expected ref — surface a
460
+ // clean abort error instead of burning the full retry budget.
461
+ const msg = e instanceof Error ? e.message : String(e);
462
+ if (PULL_REF_MISSING_PATTERN.test(msg)) {
463
+ await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
464
+ }
465
+ throw e;
466
+ }
467
+ },
468
+ {
469
+ delaysMs: PULL_REF_RETRY_DELAYS_MS,
470
+ label: `pull/${pr.number}/head fetch`,
471
+ shouldRetry: (e) =>
472
+ PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
473
+ },
474
+ );
475
+
476
+ // checkout the branch
477
+ $("git", ["checkout", localBranch], { log: false });
478
+ log.debug(`» checked out PR #${pr.number}`);
479
+ // make sure toolState.checkoutSha is set to the actual checked-out SHA (which might be different from pr.headSha)
480
+ toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
481
+ }
482
+
483
+ const beforeShaReachable = beforeSha
484
+ ? await ensureBeforeShaReachable({
485
+ sha: beforeSha,
486
+ octokit,
487
+ owner,
488
+ repo: name,
489
+ gitToken,
490
+ isShallow,
491
+ })
492
+ : false;
493
+
494
+ // compute deepen depth for shallow clones. actions/checkout uses depth=1
495
+ // by default, which breaks rebase/log because git can't find the merge base.
496
+ // use the GitHub compare API to fetch exactly enough history.
497
+ // computed after checkout so compareCommits uses the actual checked-out SHA.
498
+ if (isShallow) {
499
+ let deepenDepth = 0;
500
+ try {
501
+ // ahead_by = PR commits past merge base, behind_by = base commits past merge base.
502
+ // --deepen extends ALL shallow roots equally (can't deepen a single branch),
503
+ // so we need the max across both the PR head and before_sha to ensure all
504
+ // three points (base, head, before_sha) reach the merge base in a single deepen call.
505
+ const [prComparison, beforeShaComparison] = await Promise.all([
506
+ octokit.rest.repos.compareCommits({
507
+ owner,
508
+ repo: name,
509
+ base: pr.baseRef,
510
+ head: toolState.checkoutSha,
511
+ }),
512
+ beforeSha && beforeShaReachable
513
+ ? octokit.rest.repos.compareCommits({
514
+ owner,
515
+ repo: name,
516
+ base: pr.baseRef,
517
+ head: beforeSha,
518
+ })
519
+ : undefined,
520
+ ]);
521
+ deepenDepth =
522
+ Math.max(
523
+ prComparison.data.ahead_by,
524
+ prComparison.data.behind_by,
525
+ beforeShaComparison?.data.ahead_by ?? 0,
526
+ beforeShaComparison?.data.behind_by ?? 0,
527
+ ) + 10;
528
+ log.debug(
529
+ `» PR: ${prComparison.data.ahead_by} ahead / ${prComparison.data.behind_by} behind` +
530
+ (beforeShaComparison
531
+ ? `, before_sha: ${beforeShaComparison.data.ahead_by} ahead / ${beforeShaComparison.data.behind_by} behind`
532
+ : "") +
533
+ `, deepen by ${deepenDepth}`,
534
+ );
535
+ } catch {
536
+ deepenDepth = 1000;
537
+ log.debug(`» compare API failed, falling back to --deepen=${deepenDepth}`);
538
+ }
539
+ // deepen after both branches are fetched so the merge base is reachable from both sides
540
+ if (deepenDepth) {
541
+ log.debug(`» deepening by ${deepenDepth} to reach merge base...`);
542
+ await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
543
+ token: gitToken,
544
+ });
545
+ }
546
+ }
547
+
548
+ // configure push remote for this branch
549
+ // NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
550
+ // fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
551
+ if (isFork) {
552
+ const remoteName = `pr-${pr.number}`;
553
+ // SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
554
+ const forkUrl = `https://github.com/${pr.headRepoFullName}.git`;
555
+
556
+ // add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
557
+ try {
558
+ $("git", ["remote", "add", remoteName, forkUrl], { log: false });
559
+ log.debug(`» added remote '${remoteName}' for fork ${pr.headRepoFullName}`);
560
+ } catch {
561
+ // remote already exists, update its URL
562
+ $("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
563
+ log.debug(`» updated remote '${remoteName}' for fork ${pr.headRepoFullName}`);
564
+ }
565
+
566
+ // set branch push config so `git push` knows where to push
567
+ $("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
568
+ // set merge ref so git knows the remote branch name (may differ from local)
569
+ $("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
570
+ log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
571
+
572
+ // warn if maintainer can't modify (push will likely fail)
573
+ if (!pr.maintainerCanModify) {
574
+ log.warning(
575
+ `» fork PR has maintainer_can_modify=false - push operations will fail. ` +
576
+ `ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`,
577
+ );
578
+ }
579
+ } else {
580
+ // for same-repo PRs, push to origin
581
+ $("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
582
+ $("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
583
+ }
584
+
585
+ // update toolState
586
+ toolState.issueNumber = pr.number;
587
+ if (isFork) {
588
+ toolState.pushUrl = `https://github.com/${pr.headRepoFullName}.git`;
589
+ }
590
+
591
+ // store push destination so push_branch can use it directly
592
+ // git config is the primary mechanism, but toolState serves as a reliable fallback
593
+ // in case git config reads fail in certain environments
594
+ toolState.pushDest = {
595
+ remoteName: isFork ? `pr-${pr.number}` : "origin",
596
+ remoteBranch: pr.headRef,
597
+ localBranch,
598
+ };
599
+
600
+ // execute post-checkout lifecycle hook. soft-fail: surface the warning
601
+ // to the agent via the tool response instead of throwing, so a flaky or
602
+ // slightly-broken hook doesn't block checkout entirely.
603
+ const postCheckoutHook = await executeLifecycleHook({
604
+ event: "post-checkout",
605
+ script: params.postCheckoutScript,
606
+ normalizeWorkingTreeAfter: true,
607
+ });
608
+ return { hookWarning: postCheckoutHook.warning };
609
+ }
610
+
611
+ /**
612
+ * dedupes concurrent `checkout_pr` calls for the same PR. agents (notably
613
+ * Sonnet/Claude) occasionally emit duplicate parallel tool_use blocks for the
614
+ * same args in one turn; without this, both invocations race
615
+ * `checkoutPrBranch` against the same `.git/shallow.lock` and one fails with
616
+ * `File exists` (issue #642). cleared in `finally` so subsequent same-PR
617
+ * calls re-do the work normally.
618
+ */
619
+ const inFlightCheckouts = new Map<number, Promise<CheckoutPrResult>>();
620
+
621
+ type InitialHead = NonNullable<ToolContext["toolState"]["initialHead"]>;
622
+
623
+ function headsEqual(a: InitialHead, b: InitialHead): boolean {
624
+ if (a.kind === "branch" && b.kind === "branch") return a.name === b.name;
625
+ if (a.kind === "detached" && b.kind === "detached") return a.sha === b.sha;
626
+ return false;
627
+ }
628
+
629
+ function describeHead(h: InitialHead): string {
630
+ if (h.kind === "branch") return `branch \`${h.name}\``;
631
+ return `detached HEAD \`${h.sha}\``;
632
+ }
633
+
634
+ export function CheckoutPrTool(ctx: ToolContext) {
635
+ const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => {
636
+ const prResponse = await ctx.octokit.rest.pulls.get({
637
+ owner: ctx.repo.owner,
638
+ repo: ctx.repo.name,
639
+ pull_number,
640
+ });
641
+
642
+ const headRepo = prResponse.data.head.repo;
643
+ if (!headRepo) {
644
+ throw new Error(`PR #${pull_number} source repository was deleted`);
645
+ }
646
+
647
+ const pr: PrData = {
648
+ number: pull_number,
649
+ headSha: prResponse.data.head.sha,
650
+ headRef: prResponse.data.head.ref,
651
+ headRepoFullName: headRepo.full_name,
652
+ baseRef: prResponse.data.base.ref,
653
+ baseRepoFullName: prResponse.data.base.repo.full_name,
654
+ maintainerCanModify: prResponse.data.maintainer_can_modify,
655
+ };
656
+
657
+ const checkoutResult = await checkoutPrBranch(pr, {
658
+ octokit: ctx.octokit,
659
+ owner: ctx.repo.owner,
660
+ name: ctx.repo.name,
661
+ gitToken: ctx.gitToken,
662
+ toolState: ctx.toolState,
663
+ shell: ctx.payload.shell,
664
+ postCheckoutScript: ctx.postCheckoutScript,
665
+ beforeSha: ctx.toolState.beforeSha,
666
+ });
667
+
668
+ const tempDir = process.env.TERRAMEND_TEMP_DIR;
669
+ if (!tempDir) {
670
+ throw new Error(
671
+ "TERRAMEND_TEMP_DIR not set - checkout_pr must run in terramend action context",
672
+ );
673
+ }
674
+
675
+ const headShort = ctx.toolState.checkoutSha!.slice(0, 7);
676
+
677
+ // compute incremental diff if we have a beforeSha to compare against
678
+ let incrementalDiffPath: string | undefined;
679
+ if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
680
+ const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
681
+ const incremental = computeIncrementalDiff({
682
+ baseBranch: pr.baseRef,
683
+ beforeSha: ctx.toolState.beforeSha,
684
+ headSha: ctx.toolState.checkoutSha,
685
+ });
686
+ if (incremental) {
687
+ incrementalDiffPath = join(
688
+ tempDir,
689
+ `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`,
690
+ );
691
+ writeFileSync(incrementalDiffPath, incremental);
692
+ log.info(
693
+ `» incremental diff computed (${incremental.length} bytes) → ${incrementalDiffPath}`,
694
+ );
695
+ }
696
+ }
697
+
698
+ // fetch PR files and format with line numbers
699
+ const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
700
+ const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
701
+ log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
702
+ const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
703
+ writeFileSync(diffPath, formatResult.content);
704
+ log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
705
+ ctx.toolState.diffCoverage = createDiffCoverageState({
706
+ diffPath,
707
+ totalLines: countLines({ content: formatResult.content }),
708
+ toc: formatResult.toc,
709
+ previous: ctx.toolState.diffCoverage,
710
+ });
711
+ log.debug(
712
+ `» diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`,
713
+ );
714
+
715
+ // cache commentable-lines snapshot so review-time validation matches what
716
+ // GitHub will anchor to (commit_id=checkoutSha), even if the PR is updated
717
+ // between checkout and review.
718
+ const cached = new Map<string, ReturnType<typeof commentableLinesForFile>>();
719
+ for (const file of formatResult.files) {
720
+ cached.set(file.filename, commentableLinesForFile(file.patch));
721
+ }
722
+ ctx.toolState.commentableLinesByFile = cached;
723
+ ctx.toolState.commentableLinesPullNumber = pull_number;
724
+ ctx.toolState.commentableLinesCheckoutSha = ctx.toolState.checkoutSha;
725
+
726
+ const incrementalInstructions = incrementalDiffPath
727
+ ? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
728
+ `(computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, ` +
729
+ `then use diffPath for full PR context. do NOT skip the incremental diff.`
730
+ : "";
731
+
732
+ // commit metadata relative to the PR base (e.g. main). use origin/<base>
733
+ // because the local base ref may not exist after a shallow fetch. cap
734
+ // the log so a PR with thousands of commits doesn't blow up the tool
735
+ // response. if the base ref can't be resolved (e.g. shallow fetch that
736
+ // didn't pull down origin/<base>), degrade gracefully rather than
737
+ // failing the whole checkout_pr call over metadata.
738
+ const COMMIT_LOG_MAX = 200;
739
+ const baseRange = `origin/${pr.baseRef}..HEAD`;
740
+ let commitCount = 0;
741
+ let commitLog = "";
742
+ let commitLogUnavailable = false;
743
+ try {
744
+ commitCount = parseInt(
745
+ $("git", ["rev-list", "--count", baseRange], { log: false }).trim() || "0",
746
+ 10,
747
+ );
748
+ commitLog = $("git", ["log", "--oneline", `--max-count=${COMMIT_LOG_MAX}`, baseRange], {
749
+ log: false,
750
+ });
751
+ } catch (err) {
752
+ commitLogUnavailable = true;
753
+ log.debug(
754
+ `» unable to compute commit metadata for ${baseRange}: ${err instanceof Error ? err.message : String(err)}`,
755
+ );
756
+ }
757
+ const commitLogTruncated = commitCount > COMMIT_LOG_MAX;
758
+
759
+ const hookWarningInstructions = checkoutResult.hookWarning
760
+ ? ` HOOK WARNING: the post-checkout lifecycle hook reported a non-fatal failure (see hookWarning). ` +
761
+ `decide whether to retry based on the guidance in that field before proceeding.`
762
+ : "";
763
+
764
+ const commitLogInstructions = commitLogUnavailable
765
+ ? ` NOTE: commit metadata is partial (base ref unreachable, likely a shallow fetch). ` +
766
+ `commitCount/commitLog may be 0/empty or incomplete; treat them as "unknown" rather than "no commits", ` +
767
+ `and use \`git log\` directly if you need the full history.`
768
+ : commitLogTruncated
769
+ ? ` NOTE: commitLog was capped at ${COMMIT_LOG_MAX} entries out of ${commitCount} commits; ` +
770
+ `use \`git log\` directly if you need the full history.`
771
+ : "";
772
+
773
+ return {
774
+ success: true,
775
+ number: prResponse.data.number,
776
+ title: prResponse.data.title,
777
+ body: prResponse.data.body,
778
+ base: pr.baseRef,
779
+ localBranch: `pr-${pull_number}`,
780
+ remoteBranch: `refs/heads/${pr.headRef}`,
781
+ isFork: pr.headRepoFullName !== pr.baseRepoFullName,
782
+ maintainerCanModify: pr.maintainerCanModify,
783
+ url: prResponse.data.html_url,
784
+ headRepo: pr.headRepoFullName,
785
+ diffPath,
786
+ incrementalDiffPath,
787
+ toc: formatResult.toc,
788
+ commitCount,
789
+ commitLog,
790
+ commitLogTruncated,
791
+ commitLogUnavailable,
792
+ hookWarning: checkoutResult.hookWarning,
793
+ instructions:
794
+ `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
795
+ `use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. ` +
796
+ `for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
797
+ `review files selectively based on relevance rather than reading everything sequentially. ` +
798
+ `to inspect the PR's changed files, use diffPath — do NOT run \`git diff\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. ` +
799
+ `if you ever do need a branch-vs-base diff via the git tool, use \`git diff --merge-base <base>\` (single call, includes uncommitted edits) or three-dot \`git diff <base>...HEAD\` (committed-only). bare \`<base>\` and two-dot \`<base>..HEAD\` are symmetric and pull in the inverse of every commit landed on \`<base>\` since the branch forked — the git tool will reject those forms when the divergence is detected. \`$(...)\` subshells are NOT expanded by the git tool. ` +
800
+ `\`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes — but PR review content MUST come from diffPath. ` +
801
+ `before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. ` +
802
+ `retry the same create_pull_request_review call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session. ` +
803
+ `the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
804
+ `when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
805
+ incrementalInstructions +
806
+ hookWarningInstructions +
807
+ commitLogInstructions,
808
+ } satisfies CheckoutPrResult;
809
+ };
810
+
811
+ return tool({
812
+ name: "checkout_pr",
813
+ timeoutMs: 600_000,
814
+ description:
815
+ "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
816
+ "Returns diffPath pointing to the formatted diff file. " +
817
+ "Example: `checkout_pr({ pull_number: 1234 })`. " +
818
+ "Large repos can take several minutes — wait for the call to finish; do not treat a slow response as failure. " +
819
+ "If you see `MCP error -32001: Request timed out`, that is a client-side abort while the server's `git fetch` is still running in the background; retry the SAME call (it will share the in-flight result) and DO NOT touch `.git/*.lock` files — removing them kills the still-running fetch and creates an inescapable retry loop. " +
820
+ "Stale lock files from prior crashed runs are swept automatically by the tool itself before each fetch; you do not need to remove them by hand.",
821
+ parameters: CheckoutPr,
822
+ execute: execute(async ({ pull_number }) => {
823
+ const inFlight = inFlightCheckouts.get(pull_number);
824
+ if (inFlight) {
825
+ log.info(`» checkout_pr({pull_number:${pull_number}}) already in flight — sharing result`);
826
+ return inFlight;
827
+ }
828
+
829
+ // unconditional refusal: any dirty working tree blocks checkout_pr, even
830
+ // when HEAD is already on pr-N. no stashing, no live-HEAD escape hatch.
831
+ // shared-cwd subagents made "carry edits along" semantics dangerous
832
+ // (zed-industries/cloud, 2026-05-18) — forcing commit/discard before
833
+ // any PR-context op eliminates the entire carry-forward failure class.
834
+ const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
835
+ if (dirty) {
836
+ throw new Error(
837
+ `cannot checkout PR #${pull_number} while the working tree has uncommitted changes. ` +
838
+ `commit (then push if needed), or discard with \`git restore --staged --worktree .\` / \`git clean -fd\` before retrying. ` +
839
+ `this refusal is unconditional — even re-checking-out the PR you're already on is refused, ` +
840
+ `because shared-working-tree subagents make carry-forward edits unsafe. dirty paths:\n${dirty}`,
841
+ );
842
+ }
843
+
844
+ // initial-branch invariant: the only sanctioned HEAD positions for a
845
+ // checkout_pr call are (a) the run-entry HEAD captured by setupGit, or
846
+ // (b) `pr-${pull_number}` for idempotent same-PR re-checkout (e.g.
847
+ // re-fetch after the PR head moved). anything else means a subagent
848
+ // silently parked HEAD on another PR, which is the zed-industries/cloud
849
+ // (2026-05-18) cross-PR clobber shape. uses the same live probe (not
850
+ // toolState.issueNumber, poisonable per the PR #796 review) and
851
+ // discriminates branch vs detached so detached-entry runs don't get a
852
+ // trivial "any future detached state matches" carve-out.
853
+ const initialHead = ctx.toolState.initialHead;
854
+ if (initialHead) {
855
+ const currentHead = captureInitialHead(process.cwd());
856
+ const targetBranch = `pr-${pull_number}`;
857
+ const onTarget = currentHead.kind === "branch" && currentHead.name === targetBranch;
858
+ const onInitial = headsEqual(currentHead, initialHead);
859
+ if (!onTarget && !onInitial) {
860
+ const recoverCmd =
861
+ initialHead.kind === "branch"
862
+ ? `git checkout ${initialHead.name}`
863
+ : `git checkout ${initialHead.sha}`;
864
+ throw new Error(
865
+ `cannot checkout PR #${pull_number} from ${describeHead(currentHead)}. ` +
866
+ `the only sanctioned HEAD positions for checkout_pr are the run-entry HEAD ` +
867
+ `(${describeHead(initialHead)}) or the target PR's branch (\`${targetBranch}\`, idempotent re-checkout). ` +
868
+ `recover with \`${recoverCmd}\` first — if that would carry uncommitted ` +
869
+ `work along, commit or discard it (\`git restore --staged --worktree .\` / \`git clean -fd\`) before switching. ` +
870
+ `routing around this via the \`git\` tool's \`checkout\`/\`switch\` subcommands is not sanctioned: ` +
871
+ `this guard exists to prevent the shared-working-tree cross-PR clobber pattern from the ` +
872
+ `zed-industries/cloud (2026-05-18) incident.`,
873
+ );
874
+ }
875
+ }
876
+
877
+ const promise = runCheckout(pull_number);
878
+ inFlightCheckouts.set(pull_number, promise);
879
+ try {
880
+ return await promise;
881
+ } finally {
882
+ inFlightCheckouts.delete(pull_number);
883
+ }
884
+ }),
885
+ });
886
+ }