pr-merge-queue 0.0.0-stage → 0.0.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 (331) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +125 -2
  3. package/dist/claim-pr/claim-pr.d.mts +45 -0
  4. package/dist/claim-pr/claim-pr.d.mts.map +1 -0
  5. package/dist/claim-pr/claim-pr.mjs +217 -0
  6. package/dist/claim-pr/claim-pr.mjs.map +1 -0
  7. package/dist/claim-pr/index.d.mts +2 -0
  8. package/dist/claim-pr/index.d.mts.map +1 -0
  9. package/dist/claim-pr/index.mjs +2 -0
  10. package/dist/claim-pr/index.mjs.map +1 -0
  11. package/dist/cmd/claim-pr.d.mts +3 -0
  12. package/dist/cmd/claim-pr.d.mts.map +1 -0
  13. package/dist/cmd/claim-pr.mjs +20 -0
  14. package/dist/cmd/claim-pr.mjs.map +1 -0
  15. package/dist/cmd/open-pr.d.mts +3 -0
  16. package/dist/cmd/open-pr.d.mts.map +1 -0
  17. package/dist/cmd/open-pr.mjs +20 -0
  18. package/dist/cmd/open-pr.mjs.map +1 -0
  19. package/dist/cmd/pr-report.d.mts +3 -0
  20. package/dist/cmd/pr-report.d.mts.map +1 -0
  21. package/dist/cmd/pr-report.mjs +20 -0
  22. package/dist/cmd/pr-report.mjs.map +1 -0
  23. package/dist/cmd/push-fixed-pr.d.mts +3 -0
  24. package/dist/cmd/push-fixed-pr.d.mts.map +1 -0
  25. package/dist/cmd/push-fixed-pr.mjs +20 -0
  26. package/dist/cmd/push-fixed-pr.mjs.map +1 -0
  27. package/dist/cmd/report-unowned-prs.d.mts +3 -0
  28. package/dist/cmd/report-unowned-prs.d.mts.map +1 -0
  29. package/dist/cmd/report-unowned-prs.mjs +20 -0
  30. package/dist/cmd/report-unowned-prs.mjs.map +1 -0
  31. package/dist/cmd/unblock-prs.d.mts +3 -0
  32. package/dist/cmd/unblock-prs.d.mts.map +1 -0
  33. package/dist/cmd/unblock-prs.mjs +19 -0
  34. package/dist/cmd/unblock-prs.mjs.map +1 -0
  35. package/dist/entry-point.d.mts +3 -0
  36. package/dist/entry-point.d.mts.map +1 -0
  37. package/dist/entry-point.mjs +3 -0
  38. package/dist/entry-point.mjs.map +1 -0
  39. package/dist/lib/cli-args.d.mts +27 -0
  40. package/dist/lib/cli-args.d.mts.map +1 -0
  41. package/dist/lib/cli-args.mjs +55 -0
  42. package/dist/lib/cli-args.mjs.map +1 -0
  43. package/dist/lib/github-api.d.mts +153 -0
  44. package/dist/lib/github-api.d.mts.map +1 -0
  45. package/dist/lib/github-api.mjs +291 -0
  46. package/dist/lib/github-api.mjs.map +1 -0
  47. package/dist/lib/index.d.mts +7 -0
  48. package/dist/lib/index.d.mts.map +1 -0
  49. package/dist/lib/index.mjs +7 -0
  50. package/dist/lib/index.mjs.map +1 -0
  51. package/dist/lib/json.d.mts +12 -0
  52. package/dist/lib/json.d.mts.map +1 -0
  53. package/dist/lib/json.mjs +28 -0
  54. package/dist/lib/json.mjs.map +1 -0
  55. package/dist/lib/log.d.mts +10 -0
  56. package/dist/lib/log.d.mts.map +1 -0
  57. package/dist/lib/log.mjs +13 -0
  58. package/dist/lib/log.mjs.map +1 -0
  59. package/dist/lib/shell.d.mts +18 -0
  60. package/dist/lib/shell.d.mts.map +1 -0
  61. package/dist/lib/shell.mjs +42 -0
  62. package/dist/lib/shell.mjs.map +1 -0
  63. package/dist/lib/time.d.mts +17 -0
  64. package/dist/lib/time.d.mts.map +1 -0
  65. package/dist/lib/time.mjs +35 -0
  66. package/dist/lib/time.mjs.map +1 -0
  67. package/dist/open-pr/github.d.mts +72 -0
  68. package/dist/open-pr/github.d.mts.map +1 -0
  69. package/dist/open-pr/github.mjs +249 -0
  70. package/dist/open-pr/github.mjs.map +1 -0
  71. package/dist/open-pr/index.d.mts +6 -0
  72. package/dist/open-pr/index.d.mts.map +1 -0
  73. package/dist/open-pr/index.mjs +6 -0
  74. package/dist/open-pr/index.mjs.map +1 -0
  75. package/dist/open-pr/main.d.mts +36 -0
  76. package/dist/open-pr/main.d.mts.map +1 -0
  77. package/dist/open-pr/main.mjs +204 -0
  78. package/dist/open-pr/main.mjs.map +1 -0
  79. package/dist/open-pr/options.d.mts +20 -0
  80. package/dist/open-pr/options.d.mts.map +1 -0
  81. package/dist/open-pr/options.mjs +66 -0
  82. package/dist/open-pr/options.mjs.map +1 -0
  83. package/dist/open-pr/steps.d.mts +23 -0
  84. package/dist/open-pr/steps.d.mts.map +1 -0
  85. package/dist/open-pr/steps.mjs +38 -0
  86. package/dist/open-pr/steps.mjs.map +1 -0
  87. package/dist/open-pr/types.d.mts +179 -0
  88. package/dist/open-pr/types.d.mts.map +1 -0
  89. package/dist/open-pr/types.mjs +20 -0
  90. package/dist/open-pr/types.mjs.map +1 -0
  91. package/dist/pr-report/github.d.mts +59 -0
  92. package/dist/pr-report/github.d.mts.map +1 -0
  93. package/dist/pr-report/github.mjs +561 -0
  94. package/dist/pr-report/github.mjs.map +1 -0
  95. package/dist/pr-report/index.d.mts +8 -0
  96. package/dist/pr-report/index.d.mts.map +1 -0
  97. package/dist/pr-report/index.mjs +8 -0
  98. package/dist/pr-report/index.mjs.map +1 -0
  99. package/dist/pr-report/linked-issues.d.mts +15 -0
  100. package/dist/pr-report/linked-issues.d.mts.map +1 -0
  101. package/dist/pr-report/linked-issues.mjs +39 -0
  102. package/dist/pr-report/linked-issues.mjs.map +1 -0
  103. package/dist/pr-report/main.d.mts +42 -0
  104. package/dist/pr-report/main.d.mts.map +1 -0
  105. package/dist/pr-report/main.mjs +163 -0
  106. package/dist/pr-report/main.mjs.map +1 -0
  107. package/dist/pr-report/options.d.mts +36 -0
  108. package/dist/pr-report/options.d.mts.map +1 -0
  109. package/dist/pr-report/options.mjs +90 -0
  110. package/dist/pr-report/options.mjs.map +1 -0
  111. package/dist/pr-report/queue.d.mts +28 -0
  112. package/dist/pr-report/queue.d.mts.map +1 -0
  113. package/dist/pr-report/queue.mjs +74 -0
  114. package/dist/pr-report/queue.mjs.map +1 -0
  115. package/dist/pr-report/render.d.mts +14 -0
  116. package/dist/pr-report/render.d.mts.map +1 -0
  117. package/dist/pr-report/render.mjs +318 -0
  118. package/dist/pr-report/render.mjs.map +1 -0
  119. package/dist/pr-report/repo-settings.d.mts +12 -0
  120. package/dist/pr-report/repo-settings.d.mts.map +1 -0
  121. package/dist/pr-report/repo-settings.mjs +22 -0
  122. package/dist/pr-report/repo-settings.mjs.map +1 -0
  123. package/dist/push-fixed-pr/index.d.mts +2 -0
  124. package/dist/push-fixed-pr/index.d.mts.map +1 -0
  125. package/dist/push-fixed-pr/index.mjs +2 -0
  126. package/dist/push-fixed-pr/index.mjs.map +1 -0
  127. package/dist/push-fixed-pr/push-fixed-pr.d.mts +54 -0
  128. package/dist/push-fixed-pr/push-fixed-pr.d.mts.map +1 -0
  129. package/dist/push-fixed-pr/push-fixed-pr.mjs +172 -0
  130. package/dist/push-fixed-pr/push-fixed-pr.mjs.map +1 -0
  131. package/dist/report-unowned-prs/index.d.mts +2 -0
  132. package/dist/report-unowned-prs/index.d.mts.map +1 -0
  133. package/dist/report-unowned-prs/index.mjs +2 -0
  134. package/dist/report-unowned-prs/index.mjs.map +1 -0
  135. package/dist/report-unowned-prs/report-unowned-prs.d.mts +49 -0
  136. package/dist/report-unowned-prs/report-unowned-prs.d.mts.map +1 -0
  137. package/dist/report-unowned-prs/report-unowned-prs.mjs +127 -0
  138. package/dist/report-unowned-prs/report-unowned-prs.mjs.map +1 -0
  139. package/dist/tsconfig.json +1 -0
  140. package/dist/unblock-prs/auto-fix.d.mts +45 -0
  141. package/dist/unblock-prs/auto-fix.d.mts.map +1 -0
  142. package/dist/unblock-prs/auto-fix.mjs +207 -0
  143. package/dist/unblock-prs/auto-fix.mjs.map +1 -0
  144. package/dist/unblock-prs/checks.d.mts +307 -0
  145. package/dist/unblock-prs/checks.d.mts.map +1 -0
  146. package/dist/unblock-prs/checks.mjs +372 -0
  147. package/dist/unblock-prs/checks.mjs.map +1 -0
  148. package/dist/unblock-prs/claims.d.mts +21 -0
  149. package/dist/unblock-prs/claims.d.mts.map +1 -0
  150. package/dist/unblock-prs/claims.mjs +23 -0
  151. package/dist/unblock-prs/claims.mjs.map +1 -0
  152. package/dist/unblock-prs/config.d.mts +487 -0
  153. package/dist/unblock-prs/config.d.mts.map +1 -0
  154. package/dist/unblock-prs/config.mjs +119 -0
  155. package/dist/unblock-prs/config.mjs.map +1 -0
  156. package/dist/unblock-prs/constants.d.mts +28 -0
  157. package/dist/unblock-prs/constants.d.mts.map +1 -0
  158. package/dist/unblock-prs/constants.mjs +28 -0
  159. package/dist/unblock-prs/constants.mjs.map +1 -0
  160. package/dist/unblock-prs/demotions.d.mts +42 -0
  161. package/dist/unblock-prs/demotions.d.mts.map +1 -0
  162. package/dist/unblock-prs/demotions.mjs +50 -0
  163. package/dist/unblock-prs/demotions.mjs.map +1 -0
  164. package/dist/unblock-prs/github.d.mts +739 -0
  165. package/dist/unblock-prs/github.d.mts.map +1 -0
  166. package/dist/unblock-prs/github.mjs +626 -0
  167. package/dist/unblock-prs/github.mjs.map +1 -0
  168. package/dist/unblock-prs/index.d.mts +25 -0
  169. package/dist/unblock-prs/index.d.mts.map +1 -0
  170. package/dist/unblock-prs/index.mjs +25 -0
  171. package/dist/unblock-prs/index.mjs.map +1 -0
  172. package/dist/unblock-prs/labels.d.mts +49 -0
  173. package/dist/unblock-prs/labels.d.mts.map +1 -0
  174. package/dist/unblock-prs/labels.mjs +49 -0
  175. package/dist/unblock-prs/labels.mjs.map +1 -0
  176. package/dist/unblock-prs/main.d.mts +179 -0
  177. package/dist/unblock-prs/main.d.mts.map +1 -0
  178. package/dist/unblock-prs/main.mjs +640 -0
  179. package/dist/unblock-prs/main.mjs.map +1 -0
  180. package/dist/unblock-prs/merge-after.d.mts +46 -0
  181. package/dist/unblock-prs/merge-after.d.mts.map +1 -0
  182. package/dist/unblock-prs/merge-after.mjs +65 -0
  183. package/dist/unblock-prs/merge-after.mjs.map +1 -0
  184. package/dist/unblock-prs/options.d.mts +35 -0
  185. package/dist/unblock-prs/options.d.mts.map +1 -0
  186. package/dist/unblock-prs/options.mjs +88 -0
  187. package/dist/unblock-prs/options.mjs.map +1 -0
  188. package/dist/unblock-prs/owner.d.mts +32 -0
  189. package/dist/unblock-prs/owner.d.mts.map +1 -0
  190. package/dist/unblock-prs/owner.mjs +63 -0
  191. package/dist/unblock-prs/owner.mjs.map +1 -0
  192. package/dist/unblock-prs/quiet.d.mts +21 -0
  193. package/dist/unblock-prs/quiet.d.mts.map +1 -0
  194. package/dist/unblock-prs/quiet.mjs +42 -0
  195. package/dist/unblock-prs/quiet.mjs.map +1 -0
  196. package/dist/unblock-prs/rebase.d.mts +120 -0
  197. package/dist/unblock-prs/rebase.d.mts.map +1 -0
  198. package/dist/unblock-prs/rebase.mjs +576 -0
  199. package/dist/unblock-prs/rebase.mjs.map +1 -0
  200. package/dist/unblock-prs/release.d.mts +62 -0
  201. package/dist/unblock-prs/release.d.mts.map +1 -0
  202. package/dist/unblock-prs/release.mjs +94 -0
  203. package/dist/unblock-prs/release.mjs.map +1 -0
  204. package/dist/unblock-prs/set-aside-comment.d.mts +20 -0
  205. package/dist/unblock-prs/set-aside-comment.d.mts.map +1 -0
  206. package/dist/unblock-prs/set-aside-comment.mjs +93 -0
  207. package/dist/unblock-prs/set-aside-comment.mjs.map +1 -0
  208. package/dist/unblock-prs/set-aside-detail.d.mts +41 -0
  209. package/dist/unblock-prs/set-aside-detail.d.mts.map +1 -0
  210. package/dist/unblock-prs/set-aside-detail.mjs +56 -0
  211. package/dist/unblock-prs/set-aside-detail.mjs.map +1 -0
  212. package/dist/unblock-prs/skips.d.mts +85 -0
  213. package/dist/unblock-prs/skips.d.mts.map +1 -0
  214. package/dist/unblock-prs/skips.mjs +141 -0
  215. package/dist/unblock-prs/skips.mjs.map +1 -0
  216. package/dist/unblock-prs/stack.d.mts +98 -0
  217. package/dist/unblock-prs/stack.d.mts.map +1 -0
  218. package/dist/unblock-prs/stack.mjs +141 -0
  219. package/dist/unblock-prs/stack.mjs.map +1 -0
  220. package/dist/unblock-prs/triage.d.mts +28 -0
  221. package/dist/unblock-prs/triage.d.mts.map +1 -0
  222. package/dist/unblock-prs/triage.mjs +396 -0
  223. package/dist/unblock-prs/triage.mjs.map +1 -0
  224. package/dist/unblock-prs/types.d.mts +793 -0
  225. package/dist/unblock-prs/types.d.mts.map +1 -0
  226. package/dist/unblock-prs/types.mjs +26 -0
  227. package/dist/unblock-prs/types.mjs.map +1 -0
  228. package/dist/unblock-prs/util.d.mts +23 -0
  229. package/dist/unblock-prs/util.d.mts.map +1 -0
  230. package/dist/unblock-prs/util.mjs +60 -0
  231. package/dist/unblock-prs/util.mjs.map +1 -0
  232. package/dist/unblock-prs/version-pr.d.mts +57 -0
  233. package/dist/unblock-prs/version-pr.d.mts.map +1 -0
  234. package/dist/unblock-prs/version-pr.mjs +79 -0
  235. package/dist/unblock-prs/version-pr.mjs.map +1 -0
  236. package/dist/unblock-prs/watch.d.mts +17 -0
  237. package/dist/unblock-prs/watch.d.mts.map +1 -0
  238. package/dist/unblock-prs/watch.mjs +128 -0
  239. package/dist/unblock-prs/watch.mjs.map +1 -0
  240. package/package.json +118 -3
  241. package/src/claim-pr/claim-pr.mts +380 -0
  242. package/src/claim-pr/claim-pr.test.mts +176 -0
  243. package/src/claim-pr/index.mts +1 -0
  244. package/src/cmd/claim-pr.mts +32 -0
  245. package/src/cmd/open-pr.mts +28 -0
  246. package/src/cmd/pr-report.mts +28 -0
  247. package/src/cmd/push-fixed-pr.mts +36 -0
  248. package/src/cmd/report-unowned-prs.mts +37 -0
  249. package/src/cmd/unblock-prs.mts +24 -0
  250. package/src/entry-point.mts +3 -0
  251. package/src/lib/cli-args.mts +86 -0
  252. package/src/lib/cli-args.test.mts +92 -0
  253. package/src/lib/github-api.mts +463 -0
  254. package/src/lib/github-api.test.mts +40 -0
  255. package/src/lib/index.mts +6 -0
  256. package/src/lib/json.mts +43 -0
  257. package/src/lib/json.test.mts +34 -0
  258. package/src/lib/log.mts +19 -0
  259. package/src/lib/log.test.mts +45 -0
  260. package/src/lib/shell.mts +52 -0
  261. package/src/lib/time.mts +49 -0
  262. package/src/open-pr/README.md +237 -0
  263. package/src/open-pr/github.mts +444 -0
  264. package/src/open-pr/index.mts +5 -0
  265. package/src/open-pr/main.mts +327 -0
  266. package/src/open-pr/options.mts +101 -0
  267. package/src/open-pr/options.test.mts +67 -0
  268. package/src/open-pr/steps.mts +57 -0
  269. package/src/open-pr/steps.test.mts +59 -0
  270. package/src/open-pr/types.mts +42 -0
  271. package/src/pr-report/README.md +244 -0
  272. package/src/pr-report/github.mts +911 -0
  273. package/src/pr-report/index.mts +7 -0
  274. package/src/pr-report/linked-issues.mts +51 -0
  275. package/src/pr-report/linked-issues.test.mts +65 -0
  276. package/src/pr-report/main.mts +257 -0
  277. package/src/pr-report/options.mts +140 -0
  278. package/src/pr-report/queue.mts +125 -0
  279. package/src/pr-report/render.mts +428 -0
  280. package/src/pr-report/render.test.mts +379 -0
  281. package/src/pr-report/repo-settings.mts +34 -0
  282. package/src/push-fixed-pr/index.mts +1 -0
  283. package/src/push-fixed-pr/push-fixed-pr.mts +276 -0
  284. package/src/push-fixed-pr/push-fixed-pr.test.mts +228 -0
  285. package/src/report-unowned-prs/index.mts +1 -0
  286. package/src/report-unowned-prs/report-unowned-prs.mts +190 -0
  287. package/src/report-unowned-prs/report-unowned-prs.test.mts +87 -0
  288. package/src/unblock-prs/README.md +1616 -0
  289. package/src/unblock-prs/auto-fix.mts +334 -0
  290. package/src/unblock-prs/auto-fix.test.mts +159 -0
  291. package/src/unblock-prs/checks.mts +538 -0
  292. package/src/unblock-prs/checks.test.mts +507 -0
  293. package/src/unblock-prs/claims.mts +33 -0
  294. package/src/unblock-prs/claims.test.mts +64 -0
  295. package/src/unblock-prs/config.mts +187 -0
  296. package/src/unblock-prs/config.schema.json +71 -0
  297. package/src/unblock-prs/config.test.mts +139 -0
  298. package/src/unblock-prs/constants.mts +33 -0
  299. package/src/unblock-prs/demotions.mts +73 -0
  300. package/src/unblock-prs/demotions.test.mts +100 -0
  301. package/src/unblock-prs/github.mts +1042 -0
  302. package/src/unblock-prs/github.test.mts +84 -0
  303. package/src/unblock-prs/index.mts +24 -0
  304. package/src/unblock-prs/labels.mts +70 -0
  305. package/src/unblock-prs/main.mts +1051 -0
  306. package/src/unblock-prs/merge-after.mts +117 -0
  307. package/src/unblock-prs/merge-after.test.mts +120 -0
  308. package/src/unblock-prs/options.mts +157 -0
  309. package/src/unblock-prs/owner.mts +91 -0
  310. package/src/unblock-prs/owner.test.mts +114 -0
  311. package/src/unblock-prs/quiet.mts +62 -0
  312. package/src/unblock-prs/quiet.test.mts +175 -0
  313. package/src/unblock-prs/rebase.mts +962 -0
  314. package/src/unblock-prs/release.mts +149 -0
  315. package/src/unblock-prs/release.test.mts +209 -0
  316. package/src/unblock-prs/set-aside-comment.mts +143 -0
  317. package/src/unblock-prs/set-aside-comment.test.mts +148 -0
  318. package/src/unblock-prs/set-aside-detail.mts +82 -0
  319. package/src/unblock-prs/set-aside-detail.test.mts +131 -0
  320. package/src/unblock-prs/skips.mts +215 -0
  321. package/src/unblock-prs/skips.test.mts +280 -0
  322. package/src/unblock-prs/stack.mts +220 -0
  323. package/src/unblock-prs/stack.test.mts +449 -0
  324. package/src/unblock-prs/triage.mts +669 -0
  325. package/src/unblock-prs/triage.test.mts +100 -0
  326. package/src/unblock-prs/types.mts +358 -0
  327. package/src/unblock-prs/util.mts +78 -0
  328. package/src/unblock-prs/util.test.mts +26 -0
  329. package/src/unblock-prs/version-pr.mts +112 -0
  330. package/src/unblock-prs/version-pr.test.mts +78 -0
  331. package/src/unblock-prs/watch.mts +219 -0
@@ -0,0 +1,962 @@
1
+ // cspell:ignore unlabel unlabelling retarget retargeted
2
+
3
+ /**
4
+ * Moving a branch: onto the tip of the base, with the layers stacked on it
5
+ * carried along, and out from under `skip-ci` — or, for a layer the one below
6
+ * it merged from under, off that layer's commits and nothing more.
7
+ */
8
+
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import {
12
+ isSafeRefName,
13
+ MERGE_QUEUED_LABEL,
14
+ SKIP_CI_LABEL,
15
+ stackDescendants,
16
+ type QueueOwner,
17
+ } from 'pr-report-core';
18
+ import { Arr, isRecord, Result } from 'ts-data-forge';
19
+ import { git, log, removeLabel, sh, type ApiContext } from '../lib/index.mjs';
20
+ import {
21
+ armAutoMerge,
22
+ mergedHeadOf,
23
+ readBaseChanges,
24
+ remoteSha,
25
+ removeWorktree,
26
+ viewPullRequest,
27
+ type MergedHead,
28
+ } from './github.mjs';
29
+ import { isMergeQueued, isSkipCiLabelled } from './labels.mjs';
30
+ import {
31
+ describeCommandFailure,
32
+ describeConflict,
33
+ } from './set-aside-detail.mjs';
34
+ import { withSkip } from './skips.mjs';
35
+ import {
36
+ autoRebaseTargets,
37
+ restackable,
38
+ retargetedFrom,
39
+ retargetedLayers,
40
+ stackParentsOf,
41
+ } from './stack.mjs';
42
+ import type {
43
+ Advanced,
44
+ AdvancePlan,
45
+ BranchOf,
46
+ PullRequest,
47
+ RebaseFailure,
48
+ RetargetedLayer,
49
+ SkipRecords,
50
+ StackedOn,
51
+ } from './types.mjs';
52
+ import { lastLines, stopRequested } from './util.mjs';
53
+ import { isVersionPullRequest } from './version-pr.mjs';
54
+
55
+ /**
56
+ * Where a branch is rebased to, and what it is rebased from.
57
+ *
58
+ * `onto` is a revision: `origin/<default branch>` for a pull request's own
59
+ * turn, or the new head of the layer below for a restack. `upstream`, when
60
+ * given, is the commit below the branch's own commits, and makes the rebase
61
+ * `git rebase --onto <onto> <upstream>`: exactly the commits after it are
62
+ * replayed, whatever their patches look like upstream. `required` says what
63
+ * to do when the branch does not contain it: fail, or fall back to a plain
64
+ * rebase, which drops commits already upstream by their patches.
65
+ */
66
+ type RebaseTarget = Readonly<{
67
+ onto: string;
68
+ /** Refspecs to fetch from `origin` besides the branch itself. */
69
+ fetch: readonly string[];
70
+ upstream: Readonly<{ sha: string; required: boolean }> | undefined;
71
+ }>;
72
+
73
+ /**
74
+ * Rebases the pull request's branch in a throwaway worktree and
75
+ * force-pushes the result, expecting the remote branch to still be at the
76
+ * head the survey saw. Resolves to the new head, or to `moved` when the
77
+ * branch is no longer where the survey saw it.
78
+ *
79
+ * The worktree is detached, so the checkout this runs from is never touched,
80
+ * and it is removed on the way out of every path: the work inside it is a
81
+ * function of its own, and this one removes the worktree after it returns
82
+ * however it returned. A process killed mid-rebase leaves one behind, which
83
+ * is what the `removeWorktree` before `git worktree add` is for.
84
+ */
85
+ const rebaseAndPush = async (
86
+ pr: BranchOf,
87
+ target: RebaseTarget,
88
+ ): Promise<Result<Rebased, RebaseFailure>> => {
89
+ const branch = pr.headRefName;
90
+
91
+ const worktreeDir = path.join(os.tmpdir(), 'unblock-prs', `pr-${pr.number}`);
92
+
93
+ const rebaseFailed = (detail: string): Result<Rebased, RebaseFailure> =>
94
+ Result.err({ reason: 'rebase-failed', detail });
95
+
96
+ // A previous run may have been interrupted with this worktree in place.
97
+ await removeWorktree(worktreeDir);
98
+
99
+ const fetched = await git(
100
+ Arr.toUnshifted('git fetch --quiet origin')(
101
+ Arr.toPushed(target.fetch, branch).map(sh),
102
+ ).join(' '),
103
+ );
104
+
105
+ if (Result.isErr(fetched)) {
106
+ return rebaseFailed(`fetch failed: ${fetched.value}`);
107
+ }
108
+
109
+ const remoteHead = await git(`git rev-parse ${sh(`origin/${branch}`)}`);
110
+
111
+ if (Result.isErr(remoteHead)) {
112
+ return rebaseFailed(`cannot resolve origin/${branch}: ${remoteHead.value}`);
113
+ }
114
+
115
+ if (remoteHead.value.trim() !== pr.headRefOid) {
116
+ return Result.ok({ kind: 'moved' });
117
+ }
118
+
119
+ const added = await git(
120
+ `git worktree add --detach ${sh(worktreeDir)} ${sh(pr.headRefOid)}`,
121
+ );
122
+
123
+ if (Result.isErr(added)) {
124
+ return rebaseFailed(`checkout failed: ${added.value}`);
125
+ }
126
+
127
+ const result = await rebaseInWorktree(pr, target, worktreeDir);
128
+
129
+ await removeWorktree(worktreeDir);
130
+
131
+ return result;
132
+ };
133
+
134
+ /**
135
+ * The part that happens inside the worktree, so that removing it afterwards
136
+ * is one statement in the caller rather than a `finally`. Every path here
137
+ * resolves to a `Result` — `git` turns a failing command into one — so the
138
+ * caller's next line always runs.
139
+ */
140
+ const rebaseInWorktree = async (
141
+ pr: BranchOf,
142
+ target: RebaseTarget,
143
+ worktreeDir: string,
144
+ ): Promise<Result<Rebased, RebaseFailure>> => {
145
+ const branch = pr.headRefName;
146
+
147
+ const rebaseFailed = (
148
+ detail: string,
149
+ output?: string,
150
+ ): Result<Rebased, RebaseFailure> =>
151
+ Result.err({
152
+ reason: 'rebase-failed',
153
+ detail,
154
+ ...(output === undefined ? {} : { output }),
155
+ });
156
+
157
+ const { upstream } = target;
158
+
159
+ const contained =
160
+ upstream !== undefined &&
161
+ Result.isOk(
162
+ await git(
163
+ `git merge-base --is-ancestor ${sh(upstream.sha)} HEAD`,
164
+ worktreeDir,
165
+ ),
166
+ );
167
+
168
+ if (!contained && upstream?.required === true) {
169
+ return rebaseFailed(
170
+ `it does not contain ${upstream.sha.slice(0, 10)}, the head it was stacked on, so which commits are its own cannot be told`,
171
+ );
172
+ }
173
+
174
+ const rebased = await git(
175
+ contained
176
+ ? `git rebase --onto ${sh(target.onto)} ${sh(upstream.sha)}`
177
+ : `git rebase ${sh(target.onto)}`,
178
+ worktreeDir,
179
+ );
180
+
181
+ if (Result.isErr(rebased)) {
182
+ const conflicted = await git(
183
+ 'git diff --name-only --diff-filter=U',
184
+ worktreeDir,
185
+ );
186
+
187
+ await git('git rebase --abort', worktreeDir);
188
+
189
+ return rebaseFailed(
190
+ describeConflict(
191
+ Result.isOk(conflicted)
192
+ ? conflicted.value.split('\n').filter((line) => line !== '')
193
+ : [],
194
+ lastLines(rebased.value, 5),
195
+ target.onto,
196
+ ),
197
+ rebased.value,
198
+ );
199
+ }
200
+
201
+ const newHead = await git('git rev-parse HEAD', worktreeDir);
202
+
203
+ if (Result.isErr(newHead)) {
204
+ return rebaseFailed(`cannot read the rebased head: ${newHead.value}`);
205
+ }
206
+
207
+ const sha = newHead.value.trim();
208
+
209
+ if (sha === pr.headRefOid) {
210
+ return Result.ok({ kind: 'rebased', head: sha });
211
+ }
212
+
213
+ const ontoHead = await git(`git rev-parse ${sh(target.onto)}`, worktreeDir);
214
+
215
+ if (Result.isOk(ontoHead) && sha === ontoHead.value.trim()) {
216
+ // The rebase left the branch at what it was rebased onto: every commit
217
+ // on it had a patch already there, so `git rebase` skipped the lot.
218
+ // Pushing that would leave a pull request with no commits in it, which
219
+ // auto-merge will never merge, so leave the branch alone and say what
220
+ // happened.
221
+ return Result.err({
222
+ reason: 'already-in-base',
223
+ detail: `every commit is already in ${target.onto}; the pull request has nothing left to merge`,
224
+ });
225
+ }
226
+
227
+ const pushed = await pushLeased(branch, sha, pr.headRefOid, worktreeDir);
228
+
229
+ if (Result.isErr(pushed)) {
230
+ return Result.err({
231
+ reason: 'push-failed',
232
+ detail: `push refused: ${lastLines(pushed.value, 5)}`,
233
+ output: pushed.value,
234
+ });
235
+ }
236
+
237
+ return Result.ok(
238
+ pushed.value === 'moved'
239
+ ? { kind: 'moved' }
240
+ : { kind: 'rebased', head: sha },
241
+ );
242
+ };
243
+
244
+ /**
245
+ * Force-pushes `sha` to `branch`, leased against `lease`: `moved` when the
246
+ * lease lost to someone else's push, an error when the push was refused for
247
+ * any other reason. The worker's rebases and restacks push through here, and
248
+ * so does `push-fixed-pr`, which a session runs.
249
+ */
250
+ export const pushLeased = async (
251
+ branch: string,
252
+ sha: string,
253
+ lease: string,
254
+ cwd?: string,
255
+ ): Promise<Result<'moved' | 'pushed', string>> => {
256
+ // The lease names the SHA the survey saw, not the remote-tracking ref: a
257
+ // bare `--force-with-lease` compares against whatever the last fetch
258
+ // left, which is exactly the race this is meant to lose safely.
259
+ const pushed = await git(
260
+ `git push --force-with-lease=${sh(`${branch}:${lease}`)} origin ${sh(`${sha}:refs/heads/${branch}`)}`,
261
+ cwd,
262
+ );
263
+
264
+ if (Result.isOk(pushed)) {
265
+ return Result.ok('pushed');
266
+ }
267
+
268
+ // A lease that lost is someone else pushing in the time the rebase took,
269
+ // and that is not a reason to set the pull request aside.
270
+ const remoteHeadNow = await remoteSha(branch);
271
+
272
+ return Result.isOk(remoteHeadNow) && remoteHeadNow.value !== lease
273
+ ? Result.ok('moved')
274
+ : Result.err(pushed.value);
275
+ };
276
+
277
+ type Rebased = Readonly<{ kind: 'moved' } | { kind: 'rebased'; head: string }>;
278
+
279
+ /**
280
+ * Brings one pull request as close to merging as this script can: onto the
281
+ * tip of the base, with the layers stacked on it carried along, armed, and
282
+ * out from under `skip-ci`.
283
+ *
284
+ * The order is the point. While `skip-ci` is on, the push the rebase makes
285
+ * fires a `synchronize` whose every check workflow skips, so it costs
286
+ * nothing; removing the label afterwards fires `unlabeled`, and the matrix
287
+ * that starts then runs once, on the head that will actually be merged. Doing
288
+ * it the other way round starts a full matrix on the pre-rebase head and has
289
+ * the push cancel it through the concurrency group. Arming comes between
290
+ * the two, so that while it happens the label still holds the merge.
291
+ *
292
+ * A pull request GitHub moved off a stack is rebased with `--onto` from the
293
+ * head its merged parent was merged at, when it still contains that head —
294
+ * GitHub's own rebase on retargeting can fail, and then the branch still
295
+ * carries the parent's commits, whose patches match the squash commit only
296
+ * if nothing changed them on the way in. Otherwise the plain rebase drops
297
+ * them by patch, as it always has.
298
+ *
299
+ * A rebase that changes nothing is a failure only when there was no label to
300
+ * take off either: then GitHub's `BEHIND` or `DIRTY` was stale and the survey
301
+ * has to start over. A paused pull request that is already on top of the base
302
+ * is simply one whose only obstacle was the label.
303
+ *
304
+ * A branch that moves while this is moving it is someone else moving the same
305
+ * pull request — the skill runs beside this script, and a person may push —
306
+ * so it resolves to `moved` and leaves the rest to that someone. Recording it
307
+ * as a failure would set a pull request aside for being worked on, and trying
308
+ * the next candidate would start a second matrix beside the one the other
309
+ * writer is about to start.
310
+ *
311
+ * The version pull request is the exception: only the label comes off it.
312
+ * `changesets/action` rebuilds that branch from the tip of the base itself
313
+ * and force-pushes the result, so rebasing it here would be a second thing
314
+ * force-pushing one branch — and would carry the old version commit onto a
315
+ * tip whose changesets it never consumed. Triage has already established
316
+ * that the branch is on the tip; there is nothing left to move.
317
+ */
318
+ export const advance = async (
319
+ api: ApiContext,
320
+ pr: PullRequest,
321
+ defaultBranch: string,
322
+ plan: AdvancePlan,
323
+ ): Promise<Result<Advanced, RebaseFailure>> => {
324
+ if (isVersionPullRequest(pr, defaultBranch)) {
325
+ if (!isSkipCiLabelled(pr)) {
326
+ // Triage only ever offers this one while it is paused, so nothing to
327
+ // take off means the survey is a release behind.
328
+ return Result.ok({ kind: 'stale-merge-state' });
329
+ }
330
+
331
+ if (plan.arm) {
332
+ const armed = await armOnPick(api, pr, pr.headRefOid, defaultBranch);
333
+
334
+ if (Result.isErr(armed) || armed.value === 'moved') {
335
+ return Result.isErr(armed) ? armed : Result.ok({ kind: 'moved' });
336
+ }
337
+ }
338
+
339
+ const unlabelled = await removeSkipCiLabel(api, pr, pr.headRefOid);
340
+
341
+ return Result.isErr(unlabelled)
342
+ ? unlabelled
343
+ : Result.ok(
344
+ unlabelled.value === 'moved'
345
+ ? { kind: 'moved' }
346
+ : { kind: 'advanced', head: pr.headRefOid },
347
+ );
348
+ }
349
+
350
+ const merged = await stackedOnMerged(api, pr, defaultBranch);
351
+
352
+ const rebased = await rebaseAndPush(pr, {
353
+ onto: `origin/${defaultBranch}`,
354
+ fetch:
355
+ merged === undefined
356
+ ? [defaultBranch]
357
+ : [defaultBranch, `refs/pull/${merged.number}/head`],
358
+ upstream:
359
+ merged === undefined
360
+ ? undefined
361
+ : { sha: merged.headSha, required: false },
362
+ });
363
+
364
+ if (Result.isErr(rebased)) {
365
+ return rebased;
366
+ }
367
+
368
+ if (rebased.value.kind === 'moved') {
369
+ return Result.ok({ kind: 'moved' });
370
+ }
371
+
372
+ const { head } = rebased.value;
373
+
374
+ if (head !== pr.headRefOid) {
375
+ await restack(pr, head, plan.descendants);
376
+ }
377
+
378
+ if (plan.arm) {
379
+ const armed = await armOnPick(api, pr, head, defaultBranch);
380
+
381
+ if (Result.isErr(armed) || armed.value === 'moved') {
382
+ return Result.isErr(armed) ? armed : Result.ok({ kind: 'moved' });
383
+ }
384
+ }
385
+
386
+ if (!isSkipCiLabelled(pr)) {
387
+ return head === pr.headRefOid
388
+ ? Result.ok({ kind: 'stale-merge-state' })
389
+ : Result.ok({ kind: 'advanced', head });
390
+ }
391
+
392
+ const removed = await removeSkipCiLabel(api, pr, head);
393
+
394
+ return Result.isErr(removed)
395
+ ? removed
396
+ : Result.ok(
397
+ removed.value === 'moved'
398
+ ? { kind: 'moved' }
399
+ : { kind: 'advanced', head },
400
+ );
401
+ };
402
+
403
+ /**
404
+ * Rebases every pull request GitHub moved onto the default branch when the
405
+ * layer below it merged, and that still carries that layer's commits, off
406
+ * them: `--onto` from the head the layer merged at to the squash commit it
407
+ * merged as, carrying the layers stacked on it along. `stack.mts` says why
408
+ * this waits for no queue, and why the tree comes out as it went in.
409
+ *
410
+ * Nothing is armed or released: `skip-ci` stays as it is, and a push under
411
+ * it fires a `synchronize` whose every check skips. Resolves to whether any
412
+ * branch moved — by this, or by someone else under it — since either makes
413
+ * the heads the survey read stale.
414
+ */
415
+ export const restackRetargeted = async (
416
+ api: ApiContext,
417
+ before: StackedOn | undefined,
418
+ pullRequests: readonly PullRequest[],
419
+ defaultBranch: string,
420
+ dryRun: boolean,
421
+ ): Promise<boolean> => {
422
+ const stackParents = stackParentsOf(pullRequests, defaultBranch);
423
+
424
+ let mut_moved = false;
425
+
426
+ for (const layer of retargetedLayers(before, pullRequests, defaultBranch)) {
427
+ if (stopRequested()) {
428
+ break;
429
+ }
430
+
431
+ const outcome = await restackOffMerged(api, layer, defaultBranch, {
432
+ dryRun,
433
+ descendants: stackDescendants(stackParents, layer.pr.number).flatMap(
434
+ (number) => pullRequests.filter((pr) => pr.number === number),
435
+ ),
436
+ });
437
+
438
+ mut_moved ||= outcome !== 'untouched';
439
+ }
440
+
441
+ return mut_moved;
442
+ };
443
+
444
+ /**
445
+ * Rebases the pull requests that asked for it and are paused by `skip-ci`
446
+ * and behind the default branch onto it, carrying the layers stacked on each
447
+ * along, so that a stack waiting for review or for its turn keeps a diff of
448
+ * its own changes only. The push runs no checks: the label skips every workflow.
449
+ * `stack.mts` says which pull requests are eligible.
450
+ *
451
+ * A rebase that conflicts, or a push that is refused, is a set-aside like any
452
+ * other and is returned as one, tied to the head and the base it was tried at.
453
+ * Resolves to those records and whether any branch moved — by this, or by
454
+ * someone else under it — since either makes the survey stale.
455
+ */
456
+ export const restackBehind = async (
457
+ pullRequests: readonly PullRequest[],
458
+ defaultBranch: string,
459
+ context: Readonly<{
460
+ baseSha: string;
461
+ skipped: SkipRecords;
462
+ owners: ReadonlyMap<number, QueueOwner>;
463
+ dryRun: boolean;
464
+ }>,
465
+ ): Promise<Readonly<{ skipped: SkipRecords; moved: boolean }>> => {
466
+ const targets = autoRebaseTargets(pullRequests, defaultBranch, context);
467
+
468
+ if (!Arr.isNonEmpty(targets)) {
469
+ return { skipped: context.skipped, moved: false };
470
+ }
471
+
472
+ const fetched = await git(
473
+ Arr.toUnshifted('git fetch --quiet origin')(
474
+ Arr.toUnshifted(defaultBranch)(targets.map((pr) => pr.headRefName)).map(
475
+ sh,
476
+ ),
477
+ ).join(' '),
478
+ );
479
+
480
+ if (Result.isErr(fetched)) {
481
+ log(`Cannot fetch to see what is behind: ${lastLines(fetched.value, 2)}`);
482
+
483
+ return { skipped: context.skipped, moved: false };
484
+ }
485
+
486
+ const stackParents = stackParentsOf(pullRequests, defaultBranch);
487
+
488
+ let mut_skipped = context.skipped;
489
+
490
+ let mut_moved = false;
491
+
492
+ for (const pr of targets) {
493
+ if (stopRequested()) {
494
+ break;
495
+ }
496
+
497
+ // Ancestry rather than `mergeStateStatus`: `skip-ci` holds that at
498
+ // `BLOCKED` whether or not the branch is behind.
499
+ const upToDate = await git(
500
+ `git merge-base --is-ancestor ${sh(`origin/${defaultBranch}`)} ${sh(pr.headRefOid)}`,
501
+ );
502
+
503
+ if (Result.isOk(upToDate)) {
504
+ continue;
505
+ }
506
+
507
+ if (context.dryRun) {
508
+ log(`Would rebase #${pr.number} onto ${defaultBranch}; it is behind.`);
509
+
510
+ continue;
511
+ }
512
+
513
+ const rebased = await rebaseAndPush(pr, {
514
+ onto: `origin/${defaultBranch}`,
515
+ fetch: [defaultBranch],
516
+ upstream: undefined,
517
+ });
518
+
519
+ if (Result.isErr(rebased)) {
520
+ log(
521
+ `#${pr.number}: behind ${defaultBranch}, and not rebased — ${rebased.value.detail}`,
522
+ );
523
+
524
+ mut_skipped = withSkip(mut_skipped, {
525
+ number: pr.number,
526
+ headSha: pr.headRefOid,
527
+ baseSha: context.baseSha,
528
+ reason: rebased.value.reason,
529
+ detail: rebased.value.detail,
530
+ ...(rebased.value.output === undefined
531
+ ? {}
532
+ : { output: rebased.value.output }),
533
+ });
534
+
535
+ continue;
536
+ }
537
+
538
+ if (rebased.value.kind === 'moved') {
539
+ mut_moved = true;
540
+
541
+ continue;
542
+ }
543
+
544
+ const { head } = rebased.value;
545
+
546
+ if (head === pr.headRefOid) {
547
+ continue;
548
+ }
549
+
550
+ mut_moved = true;
551
+
552
+ log(
553
+ `#${pr.number}: was behind ${defaultBranch}; rebased at ${head.slice(0, 10)}.`,
554
+ );
555
+
556
+ await restack(
557
+ pr,
558
+ head,
559
+ stackDescendants(stackParents, pr.number).flatMap((number) =>
560
+ pullRequests.filter((layer) => layer.number === number),
561
+ ),
562
+ );
563
+ }
564
+
565
+ return { skipped: mut_skipped, moved: mut_moved };
566
+ };
567
+
568
+ /**
569
+ * Takes `skip-ci` off, having first asked GitHub whether the pull request
570
+ * still wants it. Removing the label starts a CI matrix, and the survey it
571
+ * was decided on is a rebase old by now.
572
+ */
573
+ const removeSkipCiLabel = async (
574
+ api: ApiContext,
575
+ pr: PullRequest,
576
+ head: string,
577
+ ): Promise<Result<'moved' | 'removed', RebaseFailure>> => {
578
+ const failed = (
579
+ detail: string,
580
+ output?: string,
581
+ ): Result<'moved' | 'removed', RebaseFailure> =>
582
+ Result.err({
583
+ reason: 'unlabel-failed',
584
+ detail,
585
+ ...(output === undefined ? {} : { output }),
586
+ });
587
+
588
+ const current = await viewPullRequest(api, pr.number);
589
+
590
+ if (Result.isErr(current)) {
591
+ return failed(`cannot re-read it before unlabelling: ${current.value}`);
592
+ }
593
+
594
+ if (current.value.state !== 'OPEN') {
595
+ return failed(`it is ${current.value.state} now`);
596
+ }
597
+
598
+ if (current.value.headRefOid !== head) {
599
+ return Result.ok('moved');
600
+ }
601
+
602
+ if (!isSkipCiLabelled(current.value)) {
603
+ // Someone took the label off in the meantime, which is the thing this was
604
+ // about to do.
605
+ return Result.ok('removed');
606
+ }
607
+
608
+ if (!isMergeQueued(current.value)) {
609
+ return failed(`${MERGE_QUEUED_LABEL} came off while it was rebasing`);
610
+ }
611
+
612
+ const removed = await removeLabel(api, pr.number, SKIP_CI_LABEL);
613
+
614
+ return Result.isErr(removed)
615
+ ? failed(
616
+ `cannot remove ${SKIP_CI_LABEL}: ${describeCommandFailure(removed.value)}`,
617
+ removed.value,
618
+ )
619
+ : Result.ok('removed');
620
+ };
621
+
622
+ /**
623
+ * The layer this pull request was stacked on, if GitHub moved it onto the
624
+ * default branch when that layer merged — and so the head below its own
625
+ * commits, if GitHub did not rebase it. Anything that cannot be read is
626
+ * `undefined`, and the rebase falls back to dropping commits by their
627
+ * patches, which a one-commit layer squash-merged unchanged also survives.
628
+ */
629
+ const stackedOnMerged = async (
630
+ api: ApiContext,
631
+ pr: PullRequest,
632
+ defaultBranch: string,
633
+ ): Promise<MergedHead | undefined> => {
634
+ const changes = await readBaseChanges(api, pr.number);
635
+
636
+ if (Result.isErr(changes)) {
637
+ log(
638
+ `#${pr.number}: cannot read whether it came off a stack; rebasing by patch. (${lastLines(changes.value, 2)})`,
639
+ );
640
+
641
+ return undefined;
642
+ }
643
+
644
+ const from = retargetedFrom(changes.value, defaultBranch);
645
+
646
+ if (from === undefined || !isSafeRefName(from)) {
647
+ return undefined;
648
+ }
649
+
650
+ const merged = await mergedHeadOf(api, from);
651
+
652
+ if (Result.isErr(merged)) {
653
+ log(
654
+ `#${pr.number}: cannot read what merged from ${from}; rebasing by patch. (${lastLines(merged.value, 2)})`,
655
+ );
656
+
657
+ return undefined;
658
+ }
659
+
660
+ return merged.value;
661
+ };
662
+
663
+ /**
664
+ * One layer of `restackRetargeted`. Whatever cannot be read or done is said
665
+ * and left: the layer is where GitHub put it, which is where it would be
666
+ * without this.
667
+ */
668
+ const restackOffMerged = async (
669
+ api: ApiContext,
670
+ { pr, from }: RetargetedLayer,
671
+ defaultBranch: string,
672
+ plan: Readonly<{ dryRun: boolean; descendants: readonly PullRequest[] }>,
673
+ ): Promise<'moved' | 'restacked' | 'untouched'> => {
674
+ const branch = from ?? (await retargetedFromTimeline(api, pr, defaultBranch));
675
+
676
+ if (branch === undefined || !isSafeRefName(branch)) {
677
+ return 'untouched';
678
+ }
679
+
680
+ const merged = await mergedHeadOf(api, branch);
681
+
682
+ if (Result.isErr(merged)) {
683
+ log(
684
+ `#${pr.number}: cannot read what merged from ${branch}, so not rebasing it off that. (${lastLines(merged.value, 2)})`,
685
+ );
686
+
687
+ return 'untouched';
688
+ }
689
+
690
+ // Nothing merged from that branch: the layer below was closed, or a person
691
+ // moved this one.
692
+ if (merged.value === undefined) {
693
+ return 'untouched';
694
+ }
695
+
696
+ const { number, headSha, mergeCommit } = merged.value;
697
+
698
+ const carried = await carries(pr, headSha, [
699
+ defaultBranch,
700
+ `refs/pull/${number}/head`,
701
+ ]);
702
+
703
+ if (Result.isErr(carried)) {
704
+ log(
705
+ `#${pr.number}: cannot tell whether it still carries #${number}'s commits. (${lastLines(carried.value, 2)})`,
706
+ );
707
+
708
+ return 'untouched';
709
+ }
710
+
711
+ // GitHub rebased it itself, or this already has.
712
+ if (!carried.value) {
713
+ return 'untouched';
714
+ }
715
+
716
+ if (mergeCommit === undefined) {
717
+ log(
718
+ `#${pr.number}: still carries #${number}'s commits, but GitHub does not say what #${number} merged as; leaving it.`,
719
+ );
720
+
721
+ return 'untouched';
722
+ }
723
+
724
+ if (plan.dryRun) {
725
+ log(
726
+ `Would rebase #${pr.number} off #${number}'s commits, onto the squash commit ${mergeCommit.slice(0, 10)}.`,
727
+ );
728
+
729
+ return 'untouched';
730
+ }
731
+
732
+ // `carries` has just fetched everything the rebase needs.
733
+ const rebased = await rebaseAndPush(pr, {
734
+ onto: mergeCommit,
735
+ fetch: [],
736
+ upstream: { sha: headSha, required: true },
737
+ });
738
+
739
+ if (Result.isErr(rebased)) {
740
+ log(
741
+ `#${pr.number}: still carries #${number}'s commits, and cannot be rebased off them — ${rebased.value.detail}`,
742
+ );
743
+
744
+ return 'untouched';
745
+ }
746
+
747
+ if (rebased.value.kind === 'moved') {
748
+ log(
749
+ `#${pr.number}: not rebased off #${number}'s commits — its branch moved since the survey, so someone else is moving it.`,
750
+ );
751
+
752
+ return 'moved';
753
+ }
754
+
755
+ const { head } = rebased.value;
756
+
757
+ log(
758
+ `#${pr.number}: came off #${number} still carrying its commits; rebased its own onto #${number}'s squash commit ${mergeCommit.slice(0, 10)}, at ${head.slice(0, 10)}.`,
759
+ );
760
+
761
+ await restack(pr, head, plan.descendants);
762
+
763
+ return 'restacked';
764
+ };
765
+
766
+ /**
767
+ * The branch the pull request's timeline says GitHub moved it off, if it
768
+ * did. A timeline that cannot be read says nothing.
769
+ */
770
+ const retargetedFromTimeline = async (
771
+ api: ApiContext,
772
+ pr: PullRequest,
773
+ defaultBranch: string,
774
+ ): Promise<string | undefined> => {
775
+ const changes = await readBaseChanges(api, pr.number);
776
+
777
+ if (Result.isErr(changes)) {
778
+ log(
779
+ `#${pr.number}: cannot read whether it came off a stack, so not rebasing it off one. (${lastLines(changes.value, 2)})`,
780
+ );
781
+
782
+ return undefined;
783
+ }
784
+
785
+ return retargetedFrom(changes.value, defaultBranch);
786
+ };
787
+
788
+ /**
789
+ * Whether the pull request's head, as the survey saw it, contains `sha`,
790
+ * having fetched the branch and `refspecs` to find out.
791
+ */
792
+ const carries = async (
793
+ pr: PullRequest,
794
+ sha: string,
795
+ refspecs: readonly string[],
796
+ ): Promise<Result<boolean, string>> => {
797
+ const fetched = await git(
798
+ Arr.toUnshifted('git fetch --quiet origin')(
799
+ Arr.toPushed(refspecs, pr.headRefName).map(sh),
800
+ ).join(' '),
801
+ );
802
+
803
+ if (Result.isErr(fetched)) {
804
+ return Result.err(fetched.value);
805
+ }
806
+
807
+ const contained = await git(
808
+ `git merge-base --is-ancestor ${sh(sha)} ${sh(pr.headRefOid)}`,
809
+ );
810
+
811
+ return Result.ok(Result.isOk(contained));
812
+ };
813
+
814
+ /**
815
+ * Carries every layer stacked on `pr` along after it moved from its old head
816
+ * to `head`: each is replayed with `--onto` from the head it was on to the
817
+ * one that replaced it, lowest layer first so that each finds its own new
818
+ * base already pushed. A layer that cannot be moved, or that someone else
819
+ * moved first, is reported and left where it is, and so is everything on
820
+ * it; none of this stops `pr` itself, whose turn it is. `push-fixed-pr`
821
+ * carries a session's layers along through here too.
822
+ */
823
+ export const restack = async (
824
+ pr: BranchOf,
825
+ head: string,
826
+ descendants: readonly BranchOf[],
827
+ ): Promise<void> => {
828
+ const mut_moved = new Map<string, Readonly<{ from: string; to: string }>>([
829
+ [pr.headRefName, { from: pr.headRefOid, to: head }],
830
+ ]);
831
+
832
+ for (const layer of descendants) {
833
+ const below = mut_moved.get(layer.baseRefName);
834
+
835
+ if (below === undefined) {
836
+ log(
837
+ `#${layer.number}: not restacked — the layer it is on, ${layer.baseRefName}, did not move.`,
838
+ );
839
+
840
+ continue;
841
+ }
842
+
843
+ const refused = restackable(layer);
844
+
845
+ if (refused !== undefined) {
846
+ log(`#${layer.number}: not restacked — ${refused}.`);
847
+
848
+ continue;
849
+ }
850
+
851
+ const moved = await rebaseAndPush(layer, {
852
+ onto: below.to,
853
+ fetch: [],
854
+ upstream: { sha: below.from, required: true },
855
+ });
856
+
857
+ if (Result.isErr(moved)) {
858
+ log(
859
+ `#${layer.number}: not restacked onto ${layer.baseRefName} — ${moved.value.detail}`,
860
+ );
861
+
862
+ continue;
863
+ }
864
+
865
+ if (moved.value.kind === 'moved') {
866
+ log(
867
+ `#${layer.number}: not restacked — its branch moved since the survey, so someone else is moving it.`,
868
+ );
869
+
870
+ continue;
871
+ }
872
+
873
+ log(
874
+ `#${layer.number}: restacked onto ${layer.baseRefName} at ${below.to.slice(0, 10)}.`,
875
+ );
876
+
877
+ mut_moved.set(layer.headRefName, {
878
+ from: layer.headRefOid,
879
+ to: moved.value.head,
880
+ });
881
+ }
882
+ };
883
+
884
+ /**
885
+ * Arms auto-merge on the pull request this cycle picked, having first asked
886
+ * whether it is still what triage saw: open, on the default branch, queued,
887
+ * at `head`. A head other than that is someone else moving it, as in
888
+ * `removeSkipCiLabel`.
889
+ *
890
+ * Called for one it rebases after the push and before `skip-ci` comes off, so
891
+ * that the label holds the merge until the matrix that decides it starts; and
892
+ * for one already up to date before it is watched.
893
+ *
894
+ * Nothing else arms one. `open-pr` opens a pull request with none, and the
895
+ * author queueing it with `merge-queued` is the request to land it. Arming at
896
+ * pick time rather than at opening is what keeps a stacked pull request
897
+ * unarmed while it is stacked: its base is another pull request's branch,
898
+ * which no ruleset covers, so armed there it would merge into that branch
899
+ * the moment nothing held it. It is picked only once GitHub has moved it onto
900
+ * the default branch, where the ruleset gates the merge.
901
+ *
902
+ * `merge-queued` is the only thing read as permission, so one that has lost
903
+ * its auto-merge — GitHub disarms it when the base changes, and a person may
904
+ * switch it off — is armed again when it is next picked. Holding one back is
905
+ * taking `merge-queued` off.
906
+ */
907
+ export const armOnPick = async (
908
+ api: ApiContext,
909
+ pr: PullRequest,
910
+ head: string,
911
+ defaultBranch: string,
912
+ ): Promise<Result<'armed' | 'moved', RebaseFailure>> => {
913
+ const failed = (
914
+ detail: string,
915
+ output?: string,
916
+ ): Result<'armed' | 'moved', RebaseFailure> =>
917
+ Result.err({
918
+ reason: 'arm-failed',
919
+ detail,
920
+ ...(output === undefined ? {} : { output }),
921
+ });
922
+
923
+ const current = await viewPullRequest(api, pr.number);
924
+
925
+ if (Result.isErr(current)) {
926
+ return failed(`cannot re-read it before arming: ${current.value}`);
927
+ }
928
+
929
+ if (current.value.state === 'OPEN' && current.value.headRefOid !== head) {
930
+ return Result.ok('moved');
931
+ }
932
+
933
+ const refused =
934
+ current.value.state !== 'OPEN'
935
+ ? (`it is ${current.value.state} now` as const)
936
+ : current.value.baseRefName !== defaultBranch
937
+ ? (`its base is ${current.value.baseRefName} now` as const)
938
+ : !isMergeQueued(current.value)
939
+ ? (`${MERGE_QUEUED_LABEL} came off before it was armed` as const)
940
+ : undefined;
941
+
942
+ if (refused !== undefined) {
943
+ return failed(`not arming auto-merge: ${refused}`);
944
+ }
945
+
946
+ if (isRecord(current.value.autoMergeRequest)) {
947
+ return Result.ok('armed');
948
+ }
949
+
950
+ const armed = await armAutoMerge(api, current.value.id);
951
+
952
+ if (Result.isErr(armed)) {
953
+ return failed(
954
+ `cannot arm auto-merge: ${describeCommandFailure(armed.value)}`,
955
+ armed.value,
956
+ );
957
+ }
958
+
959
+ log(`#${pr.number}: auto-merge armed.`);
960
+
961
+ return Result.ok('armed');
962
+ };