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,1042 @@
1
+ // cspell:ignore retarget retargeted
2
+
3
+ /**
4
+ * Everything that reads or writes GitHub or `git`, and nothing that decides.
5
+ * The API client and the `git` runner are shared with the other commands
6
+ * (`tools/scripts/lib/`).
7
+ */
8
+
9
+ import {
10
+ isSafeRefName,
11
+ parseClaimComment,
12
+ parseSetAsideComment,
13
+ requirementsOfRules,
14
+ SET_ASIDE_COMMENT_SCAN,
15
+ SKIP_CI_LABEL,
16
+ type CheckRunReport,
17
+ type Claim,
18
+ type NativeStackEntry,
19
+ type ReviewState,
20
+ type RulesetRequirements,
21
+ } from 'pr-report-core';
22
+ import { Arr, isRecord, Result } from 'ts-data-forge';
23
+ import * as t from 'ts-fortress';
24
+ import {
25
+ addLabel,
26
+ git,
27
+ graphql,
28
+ graphqlQuery,
29
+ resolveApiContext,
30
+ rest,
31
+ sh,
32
+ type ApiContext,
33
+ type GraphqlVariables,
34
+ } from '../lib/index.mjs';
35
+ import {
36
+ issueOrderOf,
37
+ unlinkedClosingIssues,
38
+ type ClosingIssueAnswer,
39
+ } from './merge-after.mjs';
40
+ import {
41
+ PullRequestSchema,
42
+ type BaseChange,
43
+ type IssueOrder,
44
+ type OwnSetAsideComment,
45
+ type PullRequest,
46
+ } from './types.mjs';
47
+ import { headShaFromListing } from './util.mjs';
48
+
49
+ /**
50
+ * The credential and the repository every call here takes, and the branch
51
+ * the queue lands on. `repoOption` is `--repo owner/name`, when given.
52
+ */
53
+ export const checkPreflight = async (
54
+ repoOption: string | undefined,
55
+ ): Promise<
56
+ Result<Readonly<{ api: ApiContext; defaultBranch: string }>, string>
57
+ > => {
58
+ const api = await resolveApiContext(repoOption);
59
+
60
+ if (Result.isErr(api)) {
61
+ return api;
62
+ }
63
+
64
+ const repository = await rest(
65
+ api.value,
66
+ 'GET',
67
+ '',
68
+ t.record({ default_branch: t.string() }),
69
+ );
70
+
71
+ if (Result.isErr(repository)) {
72
+ return Result.err(`cannot read the default branch: ${repository.value}`);
73
+ }
74
+
75
+ const name = repository.value.default_branch;
76
+
77
+ if (!isSafeRefName(name)) {
78
+ return Result.err(
79
+ `unexpected default branch name: ${JSON.stringify(name)}`,
80
+ );
81
+ }
82
+
83
+ return Result.ok({ api: api.value, defaultBranch: name });
84
+ };
85
+
86
+ /**
87
+ * The open pull requests, newest first, at most {@link OPEN_LIMIT} of them
88
+ * as `gh pr list` read. GraphQL, because `mergeStateStatus` is not in REST.
89
+ */
90
+ export const listPullRequests = async (
91
+ api: ApiContext,
92
+ ): Promise<Result<readonly PullRequest[], string>> =>
93
+ Result.map(
94
+ await askRepositoryOf(
95
+ api,
96
+ [
97
+ 'query($owner: String!, $name: String!) {',
98
+ ' repository(owner: $owner, name: $name) {',
99
+ ` pullRequests(states: OPEN, first: ${OPEN_LIMIT}, orderBy: { field: CREATED_AT, direction: DESC }) {`,
100
+ ` nodes { ${PULL_REQUEST_FIELDS} }`,
101
+ ' }',
102
+ ' }',
103
+ '}',
104
+ ].join('\n'),
105
+ {},
106
+ t.record({
107
+ pullRequests: t.record({ nodes: t.array(PullRequestNodeSchema) }),
108
+ }),
109
+ ),
110
+ ({ pullRequests }) => pullRequests.nodes.map(pullRequestOfNode),
111
+ );
112
+
113
+ export const viewPullRequest = async (
114
+ api: ApiContext,
115
+ prNumber: number,
116
+ ): Promise<Result<PullRequest, string>> =>
117
+ Result.map(
118
+ await askRepositoryOf(
119
+ api,
120
+ [
121
+ 'query($owner: String!, $name: String!, $number: Int!) {',
122
+ ' repository(owner: $owner, name: $name) {',
123
+ ` pullRequest(number: $number) { ${PULL_REQUEST_FIELDS} }`,
124
+ ' }',
125
+ '}',
126
+ ].join('\n'),
127
+ { number: prNumber },
128
+ t.record({ pullRequest: PullRequestNodeSchema }),
129
+ ),
130
+ ({ pullRequest }) => pullRequestOfNode(pullRequest),
131
+ );
132
+
133
+ /**
134
+ * `REVIEW_REQUIRED`, `CHANGES_REQUESTED`, `APPROVED`, or empty when the
135
+ * rules ask for no review. Read only when a green pull request will not
136
+ * merge, to say what holds it.
137
+ */
138
+ export const viewReviewDecision = async (
139
+ api: ApiContext,
140
+ prNumber: number,
141
+ ): Promise<Result<string, string>> =>
142
+ Result.map(
143
+ await askRepositoryOf(
144
+ api,
145
+ [
146
+ 'query($owner: String!, $name: String!, $number: Int!) {',
147
+ ' repository(owner: $owner, name: $name) {',
148
+ ' pullRequest(number: $number) { reviewDecision }',
149
+ ' }',
150
+ '}',
151
+ ].join('\n'),
152
+ { number: prNumber },
153
+ t.record({
154
+ pullRequest: t.record({
155
+ reviewDecision: t.union([t.string(), t.nullType]),
156
+ }),
157
+ }),
158
+ ),
159
+ ({ pullRequest }) => pullRequest.reviewDecision ?? '',
160
+ );
161
+
162
+ /**
163
+ * A pull request as GraphQL answers it, in the shape every module here reads
164
+ * ({@link PullRequestSchema}): only the labels differ, a connection there and
165
+ * a list here.
166
+ */
167
+ export const pullRequestOfNode = (
168
+ node: t.TypeOf<typeof PullRequestNodeSchema>,
169
+ ): PullRequest =>
170
+ ({
171
+ ...node,
172
+ labels: node.labels.nodes,
173
+ }) as const;
174
+
175
+ /** How many open pull requests one survey reads, as many as `gh` did. */
176
+ const OPEN_LIMIT = 100;
177
+
178
+ /**
179
+ * The fields of {@link PullRequestSchema}, read off it so that a field added
180
+ * there is asked for here; `labels` and `autoMergeRequest` are objects, and
181
+ * name what of them is read.
182
+ */
183
+ const PULL_REQUEST_FIELDS = Object.keys(PullRequestSchema.shape)
184
+ .map((key) =>
185
+ key === 'labels'
186
+ ? 'labels(first: 100) { nodes { name } }'
187
+ : key === 'autoMergeRequest'
188
+ ? 'autoMergeRequest { enabledAt }'
189
+ : key,
190
+ )
191
+ .join(' ');
192
+
193
+ const PullRequestNodeSchema = t.record({
194
+ ...PullRequestSchema.shape,
195
+ labels: t.record({ nodes: PullRequestSchema.shape.labels }),
196
+ });
197
+
198
+ /**
199
+ * The tip of `branch` on `origin`, by its whole ref name
200
+ * (`headShaFromListing`): the default branch read as the version pull
201
+ * request's branch is a set-aside recorded against a base that never moves.
202
+ */
203
+ export const remoteSha = async (
204
+ branch: string,
205
+ ): Promise<Result<string, string>> => {
206
+ const listed = await git(
207
+ `git ls-remote origin ${sh(`refs/heads/${branch}`)}`,
208
+ );
209
+
210
+ if (Result.isErr(listed)) {
211
+ return listed;
212
+ }
213
+
214
+ const sha = headShaFromListing(listed.value, branch);
215
+
216
+ return sha === undefined
217
+ ? Result.err(`origin has no branch named ${branch}`)
218
+ : Result.ok(sha);
219
+ };
220
+
221
+ /**
222
+ * What GitHub enforces on the branch, every ruleset that applies to it
223
+ * together — rather than what `repo-settings/` declares, which changes
224
+ * nothing until it is applied.
225
+ */
226
+ export const readBranchRules = async (
227
+ api: ApiContext,
228
+ branch: string,
229
+ ): Promise<Result<RulesetRequirements, string>> => {
230
+ // One path segment, whatever the name holds: `release/1.x` included. The
231
+ // rule's premise, building a query string, is not what this is.
232
+ // eslint-disable-next-line no-restricted-globals
233
+ const segment = encodeURIComponent(branch);
234
+
235
+ return Result.map(
236
+ await rest(api, 'GET', `/rules/branches/${segment}`, t.array(t.unknown())),
237
+ requirementsOfRules,
238
+ );
239
+ };
240
+
241
+ /**
242
+ * `CODEOWNERS` on the default branch, and what `pr-report-core`'s
243
+ * `review-hold.mts` reads about each of `numbers`, in one GraphQL request.
244
+ * `undefined` for a repository without `.github/CODEOWNERS`, the one of the
245
+ * three places GitHub looks that this repository uses.
246
+ *
247
+ * Every number goes in as a variable, and each pull request comes back under
248
+ * its own alias.
249
+ */
250
+ export const readReviewStates = async (
251
+ api: ApiContext,
252
+ numbers: readonly number[],
253
+ defaultBranch: string,
254
+ ): Promise<
255
+ Result<
256
+ Readonly<{
257
+ codeOwners: string | undefined;
258
+ states: ReadonlyMap<number, ReviewState>;
259
+ }>,
260
+ string
261
+ >
262
+ > => {
263
+ const query = [
264
+ `query ReviewStates(${['$owner: String!', '$name: String!', '$codeOwners: String!', ...numbers.map((n) => `$pr_${n}: Int!`)].join(', ')}) {`,
265
+ ' repository(owner: $owner, name: $name) {',
266
+ ' codeOwners: object(expression: $codeOwners) { ... on Blob { text } }',
267
+ ...numbers.map(
268
+ (n) => ` pr_${n}: pullRequest(number: $pr_${n}) { ...ReviewState }`,
269
+ ),
270
+ ' }',
271
+ '}',
272
+ 'fragment ReviewState on PullRequest {',
273
+ ' author { login }',
274
+ ' latestOpinionatedReviews(first: 20, writersOnly: true) { nodes { state author { login } } }',
275
+ ` files(first: ${PAGE_SIZE}) { pageInfo { hasNextPage } nodes { path } }`,
276
+ // A pull request with more conversations than one page is counted from
277
+ // the first page. Undercounting only ever holds less, and holding less
278
+ // is what happened before this was read at all.
279
+ ` reviewThreads(first: ${PAGE_SIZE}) { nodes { isResolved } }`,
280
+ '}',
281
+ ].join('\n');
282
+
283
+ const answered = await askRepositoryOf(
284
+ api,
285
+ query,
286
+ {
287
+ codeOwners: `${defaultBranch}:${CODE_OWNERS_PATH}`,
288
+ ...Object.fromEntries(numbers.map((n) => [`pr_${n}`, n] as const)),
289
+ },
290
+ t.unknown(),
291
+ );
292
+
293
+ if (Result.isErr(answered)) {
294
+ return answered;
295
+ }
296
+
297
+ const repository = answered.value;
298
+
299
+ if (!isRecord(repository)) {
300
+ return Result.err('GitHub answered without the repository');
301
+ }
302
+
303
+ const codeOwners = CodeOwnersBlobSchema.validate(repository['codeOwners']);
304
+
305
+ const nodes = numbers.map(
306
+ (n) => [n, ReviewStateNodeSchema.validate(repository[`pr_${n}`])] as const,
307
+ );
308
+
309
+ const invalid = Arr.generate(function* () {
310
+ for (const [n, node] of nodes) {
311
+ if (Result.isErr(node)) {
312
+ yield `#${n}: ${t.validationErrorsToMessages(node.value).join('\n')}`;
313
+ }
314
+ }
315
+ });
316
+
317
+ if (Arr.isNonEmpty(invalid)) {
318
+ return Result.err(invalid.join('\n'));
319
+ }
320
+
321
+ const states: ReadonlyMap<number, ReviewState> = new Map(
322
+ Arr.generate(function* () {
323
+ for (const [n, node] of nodes) {
324
+ if (Result.isOk(node)) {
325
+ yield [n, reviewStateOf(node.value)] as const;
326
+ }
327
+ }
328
+ }),
329
+ );
330
+
331
+ return Result.ok({
332
+ codeOwners:
333
+ Result.isOk(codeOwners) && codeOwners.value !== null
334
+ ? codeOwners.value.text
335
+ : undefined,
336
+ states,
337
+ });
338
+ };
339
+
340
+ /**
341
+ * What the issues the open pull requests close add to the merge order
342
+ * (`merge-after.mts`'s `issueOrderOf`): one GraphQL request for what GitHub
343
+ * links and how it marked the bodies, and one more for the keyword-closed
344
+ * issues it did not link, only when there are any.
345
+ */
346
+ export const readIssueOrder = async (
347
+ api: ApiContext,
348
+ numbers: readonly number[],
349
+ ): Promise<Result<IssueOrder, string>> => {
350
+ if (!Arr.isNonEmpty(numbers)) {
351
+ return Result.ok({ closes: new Map(), startAfter: new Map() });
352
+ }
353
+
354
+ const pulls = await askRepository(
355
+ api,
356
+ 'ClosingIssues',
357
+ numbers.map((n) => ({
358
+ alias: `pr_${n}`,
359
+ number: n,
360
+ field: `pullRequest(number: $pr_${n}) { bodyHTML closingIssuesReferences(first: 10) { nodes { ${CLOSING_ISSUE_FIELDS} repository { nameWithOwner } } } }`,
361
+ })),
362
+ );
363
+
364
+ if (Result.isErr(pulls)) {
365
+ return pulls;
366
+ }
367
+
368
+ const { repo, fields } = pulls.value;
369
+
370
+ const own = `${repo.owner}/${repo.name}`.toLowerCase();
371
+
372
+ const read = Arr.generate(function* () {
373
+ for (const n of numbers) {
374
+ const node = ClosingIssuesNodeSchema.validate(fields(`pr_${n}`));
375
+
376
+ if (Result.isOk(node)) {
377
+ yield {
378
+ number: n,
379
+ bodyHtml: node.value.bodyHTML,
380
+ linked: node.value.closingIssuesReferences.nodes
381
+ .filter(
382
+ (issue) => issue.repository.nameWithOwner.toLowerCase() === own,
383
+ )
384
+ .map((issue) => closingIssueAnswer(own, issue)),
385
+ };
386
+ }
387
+ }
388
+ });
389
+
390
+ const unlinked = unlinkedClosingIssues(repo, read);
391
+
392
+ if (!Arr.isNonEmpty(unlinked)) {
393
+ return Result.ok(issueOrderOf(repo, read, new Map()));
394
+ }
395
+
396
+ const issues = await askRepository(
397
+ api,
398
+ 'UnlinkedIssues',
399
+ unlinked.map((n) => ({
400
+ alias: `issue_${n}`,
401
+ number: n,
402
+ field: `issue(number: $issue_${n}) { ${CLOSING_ISSUE_FIELDS} }`,
403
+ })),
404
+ );
405
+
406
+ if (Result.isErr(issues)) {
407
+ return issues;
408
+ }
409
+
410
+ const described = new Map(
411
+ Arr.generate(function* () {
412
+ for (const n of unlinked) {
413
+ const node = ClosingIssueSchema.validate(
414
+ issues.value.fields(`issue_${n}`),
415
+ );
416
+
417
+ if (Result.isOk(node)) {
418
+ yield [n, closingIssueAnswer(own, node.value)] as const;
419
+ }
420
+ }
421
+ }),
422
+ );
423
+
424
+ return Result.ok(issueOrderOf(repo, read, described));
425
+ };
426
+
427
+ /**
428
+ * What an issue waits for is its `Start-After:` and the issues GitHub
429
+ * records as blocking it, read as one (`pr-report-core`'s `waitsFor`).
430
+ */
431
+ const CLOSING_ISSUE_FIELDS =
432
+ 'number state body blockedBy(first: 20) { nodes { number repository { nameWithOwner } } }';
433
+
434
+ const BlockedBySchema = t.record({
435
+ nodes: t.array(
436
+ t.record({
437
+ number: t.number(),
438
+ repository: t.record({ nameWithOwner: t.string() }),
439
+ }),
440
+ ),
441
+ });
442
+
443
+ const ClosingIssueSchema = t.record({
444
+ number: t.number(),
445
+ state: t.string(),
446
+ body: t.string(),
447
+ blockedBy: BlockedBySchema,
448
+ });
449
+
450
+ /** An issue as read, with only `own`'s issues among what blocks it. */
451
+ const closingIssueAnswer = (
452
+ own: string,
453
+ { number, state, body, blockedBy }: t.TypeOf<typeof ClosingIssueSchema>,
454
+ ): ClosingIssueAnswer =>
455
+ ({
456
+ number,
457
+ state,
458
+ body,
459
+ blockedBy: blockedBy.nodes
460
+ .filter(
461
+ ({ repository }) => repository.nameWithOwner.toLowerCase() === own,
462
+ )
463
+ .map((node) => node.number),
464
+ }) as const;
465
+
466
+ const ClosingIssuesNodeSchema = t.record({
467
+ bodyHTML: t.string(),
468
+ closingIssuesReferences: t.record({
469
+ nodes: t.array(
470
+ t.record({
471
+ number: t.number(),
472
+ state: t.string(),
473
+ body: t.string(),
474
+ blockedBy: BlockedBySchema,
475
+ repository: t.record({ nameWithOwner: t.string() }),
476
+ }),
477
+ ),
478
+ }),
479
+ });
480
+
481
+ /**
482
+ * One GraphQL request of aliased fields under the repository of the working
483
+ * directory, each taking one number as `$<alias>`, with the repository's own
484
+ * name read back so that a body's references can be told apart from another
485
+ * repository's.
486
+ */
487
+ const askRepository = async (
488
+ api: ApiContext,
489
+ operationName: string,
490
+ parts: readonly Readonly<{ alias: string; number: number; field: string }>[],
491
+ ): Promise<
492
+ Result<
493
+ Readonly<{
494
+ repo: Readonly<{ owner: string; name: string }>;
495
+ fields: (alias: string) => unknown;
496
+ }>,
497
+ string
498
+ >
499
+ > => {
500
+ const query = [
501
+ `query ${operationName}(${['$owner: String!', '$name: String!', ...parts.map(({ alias }) => `$${alias}: Int!`)].join(', ')}) {`,
502
+ ' repository(owner: $owner, name: $name) {',
503
+ ' nameWithOwner',
504
+ ...parts.map(({ alias, field }) => ` ${alias}: ${field}`),
505
+ ' }',
506
+ '}',
507
+ ].join('\n');
508
+
509
+ const answered = await askRepositoryOf(
510
+ api,
511
+ query,
512
+ Object.fromEntries(
513
+ parts.map(({ alias, number }) => [alias, number] as const),
514
+ ),
515
+ t.unknown(),
516
+ );
517
+
518
+ if (Result.isErr(answered)) {
519
+ return answered;
520
+ }
521
+
522
+ const repository = answered.value;
523
+
524
+ const nameWithOwner = isRecord(repository)
525
+ ? repository['nameWithOwner']
526
+ : undefined;
527
+
528
+ const [owner, name] =
529
+ typeof nameWithOwner === 'string'
530
+ ? nameWithOwner.split('/')
531
+ : ([] as const);
532
+
533
+ if (owner === undefined || name === undefined || !isRecord(repository)) {
534
+ return Result.err('GitHub answered without the repository');
535
+ }
536
+
537
+ return Result.ok({
538
+ repo: { owner, name },
539
+ fields: (alias) => repository[alias],
540
+ });
541
+ };
542
+
543
+ /**
544
+ * One GraphQL request whose `$owner` and `$name` are the repository's, to be
545
+ * declared by `query`, resolving to what it answered under `repository`.
546
+ */
547
+ const askRepositoryOf = async <A,>(
548
+ api: ApiContext,
549
+ query: string,
550
+ variables: GraphqlVariables,
551
+ schema: t.Type<A>,
552
+ ): Promise<Result<A, string>> =>
553
+ Result.map(
554
+ await graphqlQuery(
555
+ api,
556
+ query,
557
+ { owner: api.repo.owner, name: api.repo.name, ...variables },
558
+ t.record({ repository: schema }),
559
+ ),
560
+ ({ repository }) => repository,
561
+ );
562
+
563
+ const CODE_OWNERS_PATH = '.github/CODEOWNERS';
564
+
565
+ /** One page of a list inside a pull request. */
566
+ const PAGE_SIZE = 100;
567
+
568
+ const CodeOwnersBlobSchema = t.union([
569
+ t.record({ text: t.string() }),
570
+ t.nullType,
571
+ ]);
572
+
573
+ const LoginSchema = t.union([t.record({ login: t.string() }), t.nullType]);
574
+
575
+ const ReviewStateNodeSchema = t.record({
576
+ author: LoginSchema,
577
+ latestOpinionatedReviews: t.record({
578
+ nodes: t.array(t.record({ state: t.string(), author: LoginSchema })),
579
+ }),
580
+ files: t.record({
581
+ pageInfo: t.record({ hasNextPage: t.boolean() }),
582
+ nodes: t.array(t.record({ path: t.string() })),
583
+ }),
584
+ reviewThreads: t.record({
585
+ nodes: t.array(t.record({ isResolved: t.boolean() })),
586
+ }),
587
+ });
588
+
589
+ const reviewStateOf = (
590
+ node: t.TypeOf<typeof ReviewStateNodeSchema>,
591
+ ): ReviewState =>
592
+ ({
593
+ author: node.author?.login ?? '',
594
+ approvers: Arr.generate(function* () {
595
+ for (const { state, author } of node.latestOpinionatedReviews.nodes) {
596
+ if (state === 'APPROVED' && author !== null) {
597
+ yield author.login;
598
+ }
599
+ }
600
+ }),
601
+ files: node.files.nodes.map(({ path }) => path),
602
+ filesComplete: !node.files.pageInfo.hasNextPage,
603
+ unresolvedConversations: node.reviewThreads.nodes.filter(
604
+ ({ isResolved }) => !isResolved,
605
+ ).length,
606
+ }) as const;
607
+
608
+ export const addSkipCiLabel = async (
609
+ api: ApiContext,
610
+ prNumber: number,
611
+ ): Promise<Result<undefined, string>> => addLabel(api, prNumber, SKIP_CI_LABEL);
612
+
613
+ /**
614
+ * This account's set-aside comment on each of `numbers` that has one. See
615
+ * {@link readOwnComments}.
616
+ */
617
+ export const readSetAsideComments = async (
618
+ api: ApiContext,
619
+ numbers: readonly number[],
620
+ ): Promise<Result<ReadonlyMap<number, OwnSetAsideComment>, string>> =>
621
+ Result.map(await readOwnComments(api, numbers), ({ setAside }) => setAside);
622
+
623
+ /**
624
+ * This account's set-aside comment, and the standing claim a session wrote
625
+ * (`claims.mts`), on each of `numbers` that has one, in one GraphQL request:
626
+ * every number goes in as a variable and each pull request comes back under
627
+ * its own alias. `pr-report-core`'s `set-aside.mts` says why only
628
+ * `viewerDidAuthor` counts; of several of a kind, the oldest is the one.
629
+ */
630
+ export const readOwnComments = async (
631
+ api: ApiContext,
632
+ numbers: readonly number[],
633
+ ): Promise<
634
+ Result<
635
+ Readonly<{
636
+ setAside: ReadonlyMap<number, OwnSetAsideComment>;
637
+ claims: ReadonlyMap<number, Claim>;
638
+ }>,
639
+ string
640
+ >
641
+ > => {
642
+ if (!Arr.isNonEmpty(numbers)) {
643
+ return Result.ok({ setAside: new Map(), claims: new Map() });
644
+ }
645
+
646
+ const query = [
647
+ `query SetAsideComments(${['$owner: String!', '$name: String!', ...numbers.map((n) => `$pr_${n}: Int!`)].join(', ')}) {`,
648
+ ' repository(owner: $owner, name: $name) {',
649
+ ...numbers.map(
650
+ (n) =>
651
+ ` pr_${n}: pullRequest(number: $pr_${n}) { comments(last: ${SET_ASIDE_COMMENT_SCAN}) { nodes { databaseId body viewerDidAuthor } } }`,
652
+ ),
653
+ ' }',
654
+ '}',
655
+ ].join('\n');
656
+
657
+ const answered = await askRepositoryOf(
658
+ api,
659
+ query,
660
+ Object.fromEntries(numbers.map((n) => [`pr_${n}`, n] as const)),
661
+ t.unknown(),
662
+ );
663
+
664
+ if (Result.isErr(answered)) {
665
+ return answered;
666
+ }
667
+
668
+ const repository = answered.value;
669
+
670
+ if (!isRecord(repository)) {
671
+ return Result.err('GitHub answered without the repository');
672
+ }
673
+
674
+ const nodes = numbers.map(
675
+ (n) => [n, CommentsNodeSchema.validate(repository[`pr_${n}`])] as const,
676
+ );
677
+
678
+ const invalid = Arr.generate(function* () {
679
+ for (const [n, node] of nodes) {
680
+ if (Result.isErr(node)) {
681
+ yield `#${n}: ${t.validationErrorsToMessages(node.value).join('\n')}`;
682
+ }
683
+ }
684
+ });
685
+
686
+ if (Arr.isNonEmpty(invalid)) {
687
+ return Result.err(invalid.join('\n'));
688
+ }
689
+
690
+ const own = Arr.generate(function* () {
691
+ for (const [n, node] of nodes) {
692
+ if (Result.isOk(node)) {
693
+ yield [
694
+ n,
695
+ node.value.comments.nodes.filter(
696
+ ({ viewerDidAuthor }) => viewerDidAuthor,
697
+ ),
698
+ ] as const;
699
+ }
700
+ }
701
+ });
702
+
703
+ return Result.ok({
704
+ setAside: new Map(
705
+ Arr.generate(function* () {
706
+ for (const [n, comments] of own) {
707
+ const found = ownSetAsideComment(comments);
708
+
709
+ if (found !== undefined) {
710
+ yield [n, found] as const;
711
+ }
712
+ }
713
+ }),
714
+ ),
715
+ claims: new Map(
716
+ Arr.generate(function* () {
717
+ for (const [n, comments] of own) {
718
+ const says = Arr.generate(function* () {
719
+ for (const { body } of comments) {
720
+ const claim = parseClaimComment(body);
721
+
722
+ if (claim !== undefined) {
723
+ yield claim;
724
+ }
725
+ }
726
+ }).at(0);
727
+
728
+ if (says?.kind === 'standing') {
729
+ yield [n, says.claim] as const;
730
+ }
731
+ }
732
+ }),
733
+ ),
734
+ });
735
+ };
736
+
737
+ const ownSetAsideComment = (
738
+ comments: readonly t.TypeOf<typeof CommentSchema>[],
739
+ ): OwnSetAsideComment | undefined =>
740
+ Arr.generate(function* () {
741
+ for (const { databaseId, body } of comments) {
742
+ const says = parseSetAsideComment(body);
743
+
744
+ if (databaseId !== null && says !== undefined) {
745
+ yield { databaseId, says };
746
+ }
747
+ }
748
+ }).at(0);
749
+
750
+ const CommentSchema = t.record({
751
+ databaseId: t.union([t.number(), t.nullType]),
752
+ body: t.string(),
753
+ viewerDidAuthor: t.boolean(),
754
+ });
755
+
756
+ const CommentsNodeSchema = t.record({
757
+ comments: t.record({ nodes: t.array(CommentSchema) }),
758
+ });
759
+
760
+ /**
761
+ * Writes `body` as the pull request's set-aside comment: over `existing`,
762
+ * this account's own, when there is one — a pull request has one, edited in
763
+ * place — and as a new comment otherwise.
764
+ */
765
+ export const writeSetAsideCommentOn = async (
766
+ api: ApiContext,
767
+ prNumber: number,
768
+ existing: number | undefined,
769
+ body: string,
770
+ ): Promise<Result<undefined, string>> =>
771
+ Result.map(
772
+ await rest(
773
+ api,
774
+ existing === undefined ? 'POST' : 'PATCH',
775
+ existing === undefined
776
+ ? `/issues/${prNumber}/comments`
777
+ : `/issues/comments/${existing}`,
778
+ t.unknown(),
779
+ { body: { body } },
780
+ ),
781
+ () => undefined,
782
+ );
783
+
784
+ /**
785
+ * Every change of the pull request's base its timeline records, oldest
786
+ * first; `stack.mts` reads which branch GitHub moved it off.
787
+ *
788
+ * GraphQL, because the REST timeline does not say which branches a change of
789
+ * base was between.
790
+ */
791
+ export const readBaseChanges = async (
792
+ api: ApiContext,
793
+ prNumber: number,
794
+ ): Promise<Result<readonly BaseChange[], string>> =>
795
+ Result.map(
796
+ await askRepositoryOf(
797
+ api,
798
+ TIMELINE_QUERY,
799
+ { number: prNumber },
800
+ TimelineSchema,
801
+ ),
802
+ ({ pullRequest }) =>
803
+ pullRequest.timelineItems.nodes.map(
804
+ (node) =>
805
+ ({
806
+ from: node.previousRefName ?? '',
807
+ to: node.currentRefName ?? '',
808
+ }) as const,
809
+ ),
810
+ );
811
+
812
+ const TIMELINE_QUERY = [
813
+ 'query($owner: String!, $name: String!, $number: Int!) {',
814
+ ' repository(owner: $owner, name: $name) {',
815
+ ' pullRequest(number: $number) {',
816
+ ' timelineItems(last: 100, itemTypes: [BASE_REF_CHANGED_EVENT]) {',
817
+ ' nodes {',
818
+ ' ... on BaseRefChangedEvent { previousRefName currentRefName }',
819
+ ' }',
820
+ ' }',
821
+ ' }',
822
+ ' }',
823
+ '}',
824
+ ].join(' ');
825
+
826
+ const TimelineNodeSchema = t.record({
827
+ previousRefName: t.optional(t.union([t.string(), t.nullType])),
828
+ currentRefName: t.optional(t.union([t.string(), t.nullType])),
829
+ });
830
+
831
+ const TimelineSchema = t.record({
832
+ pullRequest: t.record({
833
+ timelineItems: t.record({
834
+ nodes: t.array(TimelineNodeSchema),
835
+ }),
836
+ }),
837
+ });
838
+
839
+ /**
840
+ * The pull request's place in one of GitHub's native stacks, or `undefined`
841
+ * when it is in none. GraphQL, because REST does not know them.
842
+ */
843
+ export const readNativeStack = async (
844
+ api: ApiContext,
845
+ prNumber: number,
846
+ ): Promise<Result<NativeStackEntry | undefined, string>> =>
847
+ Result.map(
848
+ await askRepositoryOf(
849
+ api,
850
+ NATIVE_STACK_QUERY,
851
+ { number: prNumber },
852
+ NativeStackSchema,
853
+ ),
854
+ ({ pullRequest: { stackEntry } }) =>
855
+ stackEntry === null
856
+ ? undefined
857
+ : {
858
+ stack: stackEntry.stack.number,
859
+ position: stackEntry.position,
860
+ size: stackEntry.stack.size,
861
+ },
862
+ );
863
+
864
+ const NATIVE_STACK_QUERY = [
865
+ 'query($owner: String!, $name: String!, $number: Int!) {',
866
+ ' repository(owner: $owner, name: $name) {',
867
+ ' pullRequest(number: $number) {',
868
+ ' stackEntry { position stack { number size } }',
869
+ ' }',
870
+ ' }',
871
+ '}',
872
+ ].join(' ');
873
+
874
+ const NativeStackSchema = t.record({
875
+ pullRequest: t.record({
876
+ stackEntry: t.union([
877
+ t.record({
878
+ position: t.number(),
879
+ stack: t.record({ number: t.number(), size: t.number() }),
880
+ }),
881
+ t.nullType,
882
+ ]),
883
+ }),
884
+ });
885
+
886
+ /**
887
+ * The head the last merged pull request from `branch` was merged at, and the
888
+ * commit its merge made, or `undefined` when none was — the layer a
889
+ * retargeted pull request was stacked on, whose commits it may still carry.
890
+ */
891
+ export const mergedHeadOf = async (
892
+ api: ApiContext,
893
+ branch: string,
894
+ ): Promise<Result<MergedHead | undefined, string>> =>
895
+ Result.map(
896
+ await rest(api, 'GET', '/pulls', t.array(MergedPullSchema), {
897
+ query: {
898
+ state: 'closed',
899
+ head: `${api.repo.owner}:${branch}`,
900
+ sort: 'updated',
901
+ direction: 'desc',
902
+ per_page: '5',
903
+ },
904
+ }),
905
+ latestMergedHead,
906
+ );
907
+
908
+ /**
909
+ * The last merged of `pulls`, closed pull requests from one branch, or
910
+ * `undefined` when none of them merged.
911
+ */
912
+ export const latestMergedHead = (
913
+ pulls: readonly t.TypeOf<typeof MergedPullSchema>[],
914
+ ): MergedHead | undefined => {
915
+ const [latest] = pulls
916
+ .filter(
917
+ (pull): pull is typeof pull & Readonly<{ merged_at: string }> =>
918
+ pull.merged_at !== null,
919
+ )
920
+ .toSorted((a, b) => b.merged_at.localeCompare(a.merged_at));
921
+
922
+ return latest === undefined
923
+ ? undefined
924
+ : {
925
+ number: latest.number,
926
+ headSha: latest.head.sha,
927
+ mergeCommit: latest.merge_commit_sha ?? undefined,
928
+ };
929
+ };
930
+
931
+ export type MergedHead = Readonly<{
932
+ number: number;
933
+ headSha: string;
934
+ /** The squash commit on the base; GitHub may not have it for a moment. */
935
+ mergeCommit: string | undefined;
936
+ }>;
937
+
938
+ const MergedPullSchema = t.record({
939
+ number: t.number(),
940
+ head: t.record({ sha: t.string() }),
941
+ merge_commit_sha: t.union([t.string(), t.nullType]),
942
+ merged_at: t.union([t.string(), t.nullType]),
943
+ });
944
+
945
+ /**
946
+ * Arms auto-merge, squash being the one method the ruleset allows. The
947
+ * mutation refuses when nothing holds the pull request, where
948
+ * `gh pr merge --auto` would merge it on the spot.
949
+ */
950
+ export const armAutoMerge = async (
951
+ api: ApiContext,
952
+ nodeId: string,
953
+ ): Promise<Result<undefined, string>> =>
954
+ graphql(api, ARM_MUTATION, { id: nodeId });
955
+
956
+ const ARM_MUTATION =
957
+ 'mutation($id: ID!) { enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH }) { clientMutationId } }';
958
+
959
+ /**
960
+ * Every check run GitHub Actions reported on one commit, every job of every
961
+ * workflow rather than only the required aggregates, so that a failure can
962
+ * be traced to the matrix entry under the aggregate. Other apps' runs — the
963
+ * codecov ones — are left out: nothing here can act on them, and none is
964
+ * required.
965
+ */
966
+ export const listCheckRuns = async (
967
+ api: ApiContext,
968
+ headSha: string,
969
+ ): Promise<Result<readonly CheckRunReport[], string>> => {
970
+ if (!SHA.test(headSha)) {
971
+ return Result.err(`unexpected head SHA: ${JSON.stringify(headSha)}`);
972
+ }
973
+
974
+ const mut_collected: CheckRunReport[] = [];
975
+
976
+ for (const page of Arr.seq(MAX_CHECK_RUN_PAGES)) {
977
+ const answered = await rest(
978
+ api,
979
+ 'GET',
980
+ `/commits/${headSha}/check-runs`,
981
+ CheckRunPageSchema,
982
+ {
983
+ query: { per_page: PAGE_SIZE.toString(), page: (page + 1).toString() },
984
+ },
985
+ );
986
+
987
+ if (Result.isErr(answered)) {
988
+ return answered;
989
+ }
990
+
991
+ for (const run of answered.value.check_runs) {
992
+ if (run.app?.slug === 'github-actions') {
993
+ mut_collected.push({
994
+ id: run.id,
995
+ checkSuiteId: run.check_suite.id,
996
+ name: run.name,
997
+ status: run.status,
998
+ conclusion: run.conclusion ?? undefined,
999
+ // Not read: a fix is planned once the checks have failed, not
1000
+ // while a newer round is running.
1001
+ round: undefined,
1002
+ });
1003
+ }
1004
+ }
1005
+
1006
+ if (
1007
+ Arr.isEmpty(answered.value.check_runs) ||
1008
+ (page + 1) * PAGE_SIZE >= answered.value.total_count
1009
+ ) {
1010
+ return Result.ok(mut_collected);
1011
+ }
1012
+ }
1013
+
1014
+ return Result.err(
1015
+ `${headSha} has more than ${PAGE_SIZE * MAX_CHECK_RUN_PAGES} check runs, which is more than this reads`,
1016
+ );
1017
+ };
1018
+
1019
+ const MAX_CHECK_RUN_PAGES = 10;
1020
+
1021
+ const CheckRunPageSchema = t.record({
1022
+ total_count: t.number(),
1023
+ check_runs: t.array(
1024
+ t.record({
1025
+ id: t.number(),
1026
+ check_suite: t.record({ id: t.number() }),
1027
+ app: t.union([t.record({ slug: t.string() }), t.nullType]),
1028
+ name: t.string(),
1029
+ status: t.string(),
1030
+ conclusion: t.union([t.string(), t.nullType]),
1031
+ }),
1032
+ ),
1033
+ });
1034
+
1035
+ export const removeWorktree = async (worktreeDir: string): Promise<void> => {
1036
+ // Both fail harmlessly when there is nothing to remove.
1037
+ await git(`git worktree remove --force ${sh(worktreeDir)}`);
1038
+
1039
+ await git('git worktree prune');
1040
+ };
1041
+
1042
+ const SHA = /^[0-9a-f]{40}$/u;