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,187 @@
1
+ /**
2
+ * What a repository says about itself in `.github/unblock-prs.json`: the
3
+ * parts of the run that depend on how its CI is laid out.
4
+ *
5
+ * Only the auto-fix is here. The labels, `Merge-After:` and the version pull
6
+ * request's branch (`changeset-release/<base>`) are conventions the PR
7
+ * Manager page reads too, from repositories it has no configuration for, so
8
+ * they are fixed in `pr-report-core` rather than set per repository. A
9
+ * repository without the file gets everything but the auto-fix.
10
+ *
11
+ * The file's JSON Schema, `config.schema.json` beside this module, is
12
+ * generated from {@link ConfigFileSchema}; a test holds it to the output.
13
+ */
14
+
15
+ import * as fs from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import { Arr, Result } from 'ts-data-forge';
18
+ import * as t from 'ts-fortress';
19
+ import { readJsonFile, repoRoot } from '../lib/index.mjs';
20
+
21
+ /** Where the file is read from, relative to the repository root. */
22
+ export const CONFIG_PATH = '.github/unblock-prs.json';
23
+
24
+ /**
25
+ * The repository's configuration, or `undefined` when it has none. A file
26
+ * that is there but wrong stops the run: guessing would run commands nobody
27
+ * wrote.
28
+ */
29
+ export const readConfig = async (
30
+ root?: string,
31
+ ): Promise<Result<Config | undefined, string>> => {
32
+ const file = path.join(root ?? (await repoRoot()), CONFIG_PATH);
33
+
34
+ if (!(await exists(file))) {
35
+ return Result.ok(undefined);
36
+ }
37
+
38
+ const read = await readJsonFile(file, ConfigFileSchema);
39
+
40
+ return Result.isErr(read) ? read : configOf(read.value, file);
41
+ };
42
+
43
+ /** {@link readConfig}'s validation, after the JSON has been parsed. */
44
+ export const configOf = (
45
+ file: ConfigFile,
46
+ source: string,
47
+ ): Result<Config, string> => {
48
+ if (file.autoFix === undefined) {
49
+ return Result.ok({ autoFix: undefined });
50
+ }
51
+
52
+ const { ignore, before, prepare, fixers, after } = file.autoFix;
53
+
54
+ const patterns = [
55
+ ...ignore.map((pattern, i) => [`autoFix.ignore[${i}]`, pattern] as const),
56
+ ...prepare.map(
57
+ ({ context }, i) => [`autoFix.prepare[${i}].context`, context] as const,
58
+ ),
59
+ ...fixers.map(
60
+ ({ context }, i) => [`autoFix.fixers[${i}].context`, context] as const,
61
+ ),
62
+ ] as const;
63
+
64
+ const invalid = patterns
65
+ .map(([where, pattern]) => [where, compile(pattern)] as const)
66
+ .filter(([, compiled]) => Result.isErr(compiled))
67
+ .map(([where]) => where);
68
+
69
+ if (Arr.isNonEmpty(invalid)) {
70
+ return Result.err(
71
+ `${source}: not a regular expression: ${invalid.join(', ')}`,
72
+ );
73
+ }
74
+
75
+ return Result.ok({
76
+ autoFix: {
77
+ ignore: ignore.map(compileValid),
78
+ before,
79
+ prepare: prepare.map(({ context, run }) => ({
80
+ context: compileValid(context),
81
+ run,
82
+ })),
83
+ fixers: fixers.map(({ context, run, stage }) => ({
84
+ context: compileValid(context),
85
+ run,
86
+ stage,
87
+ })),
88
+ after,
89
+ },
90
+ });
91
+ };
92
+
93
+ /** Declared before its first use, which runs at load: a string `configOf` compiles; `regex` is all the schema can say about it. */
94
+ const regex = (): t.Type<string> => t.annotate(t.string(), { format: 'regex' });
95
+
96
+ /**
97
+ * The file as written. The descriptions are the file's documentation: they are
98
+ * what `config.schema.json`, generated from this by `gen:config-schema`,
99
+ * shows in an editor.
100
+ */
101
+ export const ConfigFileSchema = t.annotate(
102
+ t.strictRecord({
103
+ $schema: t.optional(t.string()),
104
+ autoFix: t.optional(
105
+ t.annotate(
106
+ t.strictRecord({
107
+ ignore: t.annotate(t.array(regex()), {
108
+ description:
109
+ 'Regular expressions for failed contexts that are only the echo of another, such as an aggregate job, and are not read.',
110
+ }),
111
+ before: t.annotate(t.array(t.string()), {
112
+ description:
113
+ 'Commands run first, always: what CI does before any job (an install).',
114
+ }),
115
+ prepare: t.annotate(
116
+ t.array(t.strictRecord({ context: regex(), run: t.string() })),
117
+ {
118
+ description:
119
+ 'Commands run after `before` when a context being fixed matches `context`, each command once: what CI gives some jobs and not others (a build).',
120
+ },
121
+ ),
122
+ fixers: t.annotate(
123
+ t.array(
124
+ t.strictRecord({
125
+ context: t.annotate(regex(), {
126
+ description:
127
+ "A regular expression for the failed context's name.",
128
+ }),
129
+ run: t.annotate(t.string(), {
130
+ description:
131
+ "The command, where $1 to $9 are the context's capture groups, shell-quoted.",
132
+ }),
133
+ stage: t.annotate(t.number(), {
134
+ description:
135
+ 'Lower stages run first, and within a stage by command.',
136
+ }),
137
+ }),
138
+ ),
139
+ {
140
+ description:
141
+ 'Every failed context not ignored has to match one of these, the first that matches deciding, or nothing is fixed.',
142
+ },
143
+ ),
144
+ after: t.annotate(t.array(t.string()), {
145
+ description:
146
+ 'Commands run last, always: settling what the fixers wrote (a formatter).',
147
+ }),
148
+ }),
149
+ {
150
+ description:
151
+ "How to fix a failure that is only a fixer's diff: a job that runs a command and then asserts a clean tree. Absent: a failure is never fixed, only reported.",
152
+ },
153
+ ),
154
+ ),
155
+ }),
156
+ {
157
+ title: 'unblock-prs configuration',
158
+ description:
159
+ "What a repository says about itself in .github/unblock-prs.json: the parts of an unblock-prs run that depend on how its CI is laid out. Labels, Merge-After: and the version pull request's branch are fixed conventions, not set here.",
160
+ },
161
+ );
162
+
163
+ export type ConfigFile = t.TypeOf<typeof ConfigFileSchema>;
164
+
165
+ export type Config = Readonly<{
166
+ autoFix: AutoFixConfig | undefined;
167
+ }>;
168
+
169
+ export type AutoFixConfig = Readonly<{
170
+ ignore: readonly RegExp[];
171
+ before: readonly string[];
172
+ prepare: readonly Readonly<{ context: RegExp; run: string }>[];
173
+ fixers: readonly Readonly<{ context: RegExp; run: string; stage: number }>[];
174
+ after: readonly string[];
175
+ }>;
176
+
177
+ const exists = async (file: string): Promise<boolean> =>
178
+ Result.isOk(await Result.fromPromise(fs.access(file)));
179
+
180
+ const compile = (pattern: string): Result<RegExp, unknown> =>
181
+ // eslint-disable-next-line security/detect-non-literal-regexp -- the repository's own configuration
182
+ Result.fromThrowable(() => new RegExp(pattern, 'u'));
183
+
184
+ /** Only after {@link compile} has accepted every pattern. */
185
+ const compileValid = (pattern: string): RegExp =>
186
+ // eslint-disable-next-line security/detect-non-literal-regexp -- the repository's own configuration
187
+ new RegExp(pattern, 'u');
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "unblock-prs configuration",
4
+ "description": "What a repository says about itself in .github/unblock-prs.json: the parts of an unblock-prs run that depend on how its CI is laid out. Labels, Merge-After: and the version pull request's branch are fixed conventions, not set here.",
5
+ "type": "object",
6
+ "properties": {
7
+ "$schema": { "type": "string" },
8
+ "autoFix": {
9
+ "description": "How to fix a failure that is only a fixer's diff: a job that runs a command and then asserts a clean tree. Absent: a failure is never fixed, only reported.",
10
+ "type": "object",
11
+ "properties": {
12
+ "ignore": {
13
+ "description": "Regular expressions for failed contexts that are only the echo of another, such as an aggregate job, and are not read.",
14
+ "type": "array",
15
+ "items": { "type": "string", "format": "regex" }
16
+ },
17
+ "before": {
18
+ "description": "Commands run first, always: what CI does before any job (an install).",
19
+ "type": "array",
20
+ "items": { "type": "string" }
21
+ },
22
+ "prepare": {
23
+ "description": "Commands run after `before` when a context being fixed matches `context`, each command once: what CI gives some jobs and not others (a build).",
24
+ "type": "array",
25
+ "items": {
26
+ "type": "object",
27
+ "properties": {
28
+ "context": { "type": "string", "format": "regex" },
29
+ "run": { "type": "string" }
30
+ },
31
+ "required": ["context", "run"],
32
+ "additionalProperties": false
33
+ }
34
+ },
35
+ "fixers": {
36
+ "description": "Every failed context not ignored has to match one of these, the first that matches deciding, or nothing is fixed.",
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "properties": {
41
+ "context": {
42
+ "description": "A regular expression for the failed context's name.",
43
+ "type": "string",
44
+ "format": "regex"
45
+ },
46
+ "run": {
47
+ "description": "The command, where $1 to $9 are the context's capture groups, shell-quoted.",
48
+ "type": "string"
49
+ },
50
+ "stage": {
51
+ "description": "Lower stages run first, and within a stage by command.",
52
+ "type": "number"
53
+ }
54
+ },
55
+ "required": ["context", "run", "stage"],
56
+ "additionalProperties": false
57
+ }
58
+ },
59
+ "after": {
60
+ "description": "Commands run last, always: settling what the fixers wrote (a formatter).",
61
+ "type": "array",
62
+ "items": { "type": "string" }
63
+ }
64
+ },
65
+ "required": ["ignore", "before", "prepare", "fixers", "after"],
66
+ "additionalProperties": false
67
+ }
68
+ },
69
+ "required": [],
70
+ "additionalProperties": false
71
+ }
@@ -0,0 +1,139 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { Result } from 'ts-data-forge';
5
+ import * as t from 'ts-fortress';
6
+ import { planAutoFix } from './auto-fix.mjs';
7
+ import {
8
+ CONFIG_PATH,
9
+ ConfigFileSchema,
10
+ configOf,
11
+ readConfig,
12
+ type ConfigFile,
13
+ } from './config.mjs';
14
+
15
+ describe(readConfig, () => {
16
+ test('is undefined for a repository without the file', async () => {
17
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'unblock-prs-'));
18
+
19
+ const read = await readConfig(root);
20
+
21
+ await fs.rm(root, { recursive: true });
22
+
23
+ assert.isTrue(Result.isOk(read));
24
+
25
+ assert.isUndefined(read.value);
26
+ });
27
+
28
+ test("reads this repository's own file", async () => {
29
+ const read = await readConfig();
30
+
31
+ assert.isTrue(Result.isOk(read));
32
+
33
+ assert.isDefined(read.value?.autoFix);
34
+ });
35
+
36
+ test('stops at a file that is there but misspelt', async () => {
37
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'unblock-prs-'));
38
+
39
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
40
+ await fs.mkdir(path.join(root, '.github'));
41
+
42
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
43
+ await fs.writeFile(
44
+ path.join(root, CONFIG_PATH),
45
+ JSON.stringify({ autofix: {} }),
46
+ );
47
+
48
+ const read = await readConfig(root);
49
+
50
+ await fs.rm(root, { recursive: true });
51
+
52
+ assert.isTrue(Result.isErr(read));
53
+ });
54
+ });
55
+
56
+ describe(configOf, () => {
57
+ test('names every pattern that is not a regular expression', () => {
58
+ const config = configOf(
59
+ autoFixFile({
60
+ ignore: ['('],
61
+ fixers: [{ context: '[', run: 'x', stage: 0 }],
62
+ }),
63
+ 'test.json',
64
+ );
65
+
66
+ assert.isTrue(Result.isErr(config));
67
+
68
+ expect(config.value).toContain('autoFix.ignore[0]');
69
+
70
+ expect(config.value).toContain('autoFix.fixers[0].context');
71
+ });
72
+
73
+ test('leaves the auto-fix off when the file has none', () => {
74
+ const config = configOf({ $schema: undefined, autoFix: undefined }, 'x');
75
+
76
+ assert.isTrue(Result.isOk(config));
77
+
78
+ assert.isUndefined(config.value.autoFix);
79
+ });
80
+ });
81
+
82
+ describe('a fixer command', () => {
83
+ test("shell-quotes the context's capture groups", () => {
84
+ const config = configOf(
85
+ autoFixFile({
86
+ fixers: [
87
+ { context: String.raw`^fix \((.+)\)$`, run: 'run $1', stage: 0 },
88
+ ],
89
+ }),
90
+ 'test.json',
91
+ );
92
+
93
+ assert.isTrue(Result.isOk(config));
94
+
95
+ const plan = planAutoFix(
96
+ new Map([["fix (a'; rm -rf /)", 'failed']]),
97
+ config.value.autoFix,
98
+ );
99
+
100
+ assert.isTrue(Result.isOk(plan));
101
+
102
+ assert.deepStrictEqual(plan.value.commands, [
103
+ String.raw`run 'a'\''; rm -rf /'`,
104
+ ]);
105
+ });
106
+ });
107
+
108
+ describe('config.schema.json', () => {
109
+ test('is what gen:config-schema writes from ConfigFileSchema', async () => {
110
+ const generated = t.toJsonSchema(ConfigFileSchema);
111
+
112
+ assert.isTrue(Result.isOk(generated));
113
+
114
+ const committed: unknown = JSON.parse(
115
+ await fs.readFile(
116
+ path.join(import.meta.dirname, 'config.schema.json'),
117
+ 'utf8',
118
+ ),
119
+ );
120
+
121
+ // Run `pnpm run gen:config-schema` in this package when this fails.
122
+ assert.deepStrictEqual(committed, generated.value);
123
+ });
124
+ });
125
+
126
+ const autoFixFile = (
127
+ autoFix: Partial<NonNullable<ConfigFile['autoFix']>>,
128
+ ): ConfigFile =>
129
+ ({
130
+ $schema: undefined,
131
+ autoFix: {
132
+ ignore: [],
133
+ before: [],
134
+ prepare: [],
135
+ fixers: [],
136
+ after: [],
137
+ ...autoFix,
138
+ },
139
+ }) as const;
@@ -0,0 +1,33 @@
1
+ /** How long the loop waits, and how long it waits before giving up. */
2
+
3
+ /** How many times to re-list while GitHub still reports `UNKNOWN`. */
4
+ export const UNKNOWN_STATE_RETRIES = 6;
5
+
6
+ export const UNKNOWN_STATE_RETRY_MS = 10_000;
7
+
8
+ /**
9
+ * How long to wait before surveying again after a rebase that changed
10
+ * nothing, which means the merge state the survey acted on was stale.
11
+ */
12
+ export const STALE_STATE_PAUSE_MS = 30_000;
13
+
14
+ /**
15
+ * How many consecutive polls a pull request may sit with every required
16
+ * context green, and GitHub saying nothing holds the merge, before it is
17
+ * written off as held by something a rebase cannot fix. Triage keeps back
18
+ * the two such things it can read, a missing code-owner approval and an
19
+ * unresolved conversation, so this is what is left: auto-merge armed by
20
+ * someone who may not merge, or a rule nothing here reads.
21
+ */
22
+ export const GREEN_POLLS_BEFORE_GIVING_UP = 3;
23
+
24
+ /**
25
+ * The same, for a pull request GitHub still calls `BLOCKED` while every
26
+ * required context reads green. That usually means the ruleset wants
27
+ * something the checks do not cover, but it is also what a check run that has
28
+ * just been superseded looks like for a moment, so it is given longer.
29
+ */
30
+ export const BLOCKED_GREEN_POLLS_BEFORE_GIVING_UP = 8;
31
+
32
+ /** How many consecutive polling errors end a watch. */
33
+ export const MAX_CONSECUTIVE_POLL_ERRORS = 5;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The pull requests picked last: ones a watch saw green and still open, held
3
+ * by something no check reports and triage could not name.
4
+ *
5
+ * `pr-report-core`'s `review-hold.mts` keeps the causes it can read — an
6
+ * owner's approval, an unresolved conversation — from being picked at all. What
7
+ * is left is a pull request whose checks all passed and which did not merge for
8
+ * a reason nothing here can see, and the likeliest thing about it is that it
9
+ * will not merge next time either. A `not-merging` skip record already keeps it
10
+ * from being picked until the base moves; the base moving is what makes it
11
+ * `BEHIND` again, and without this it would then be rebased first, run a full
12
+ * matrix, and sit green once more ahead of everything behind it.
13
+ *
14
+ * So it is picked after everything else, the version pull request included,
15
+ * rather than not at all: a demotion is a guess, and the guess is wrong for
16
+ * a pull request that was waiting on something which has since cleared.
17
+ */
18
+
19
+ import { Arr } from 'ts-data-forge';
20
+ import type { Demotions, PullRequest, WatchOutcome } from './types.mjs';
21
+
22
+ /**
23
+ * Whether the pull request is demoted. A head other than the one recorded is
24
+ * someone else's push — this run's own pushes move the record with them, see
25
+ * {@link followHead} — and a push is the author doing something about it.
26
+ */
27
+ export const isDemoted = (demoted: Demotions, pr: PullRequest): boolean =>
28
+ demoted.get(pr.number) === pr.headRefOid;
29
+
30
+ /**
31
+ * What a watch that ended on `head` leaves demoted: the pull request itself
32
+ * if it sat green without merging, and otherwise whatever was demoted before,
33
+ * carried to the head the watch ended on.
34
+ */
35
+ export const afterWatch = (
36
+ demoted: Demotions,
37
+ prNumber: number,
38
+ head: string,
39
+ outcome: WatchOutcome,
40
+ ): Demotions =>
41
+ outcome === 'not-merging'
42
+ ? new Map([...demoted, [prNumber, head]])
43
+ : followHead(demoted, prNumber, head);
44
+
45
+ /**
46
+ * Carries a demotion to a head this run pushed. A rebase is not the author
47
+ * doing anything, so it must not read as one on the next survey.
48
+ */
49
+ export const followHead = (
50
+ demoted: Demotions,
51
+ prNumber: number,
52
+ head: string,
53
+ ): Demotions =>
54
+ demoted.has(prNumber) ? new Map([...demoted, [prNumber, head]]) : demoted;
55
+
56
+ /**
57
+ * Drops demotions of pull requests that are gone or that someone else has
58
+ * pushed to. The base moving is deliberately not among them — see the top
59
+ * of this file.
60
+ */
61
+ export const pruneDemotions = (
62
+ demoted: Demotions,
63
+ pullRequests: readonly PullRequest[],
64
+ ): Demotions =>
65
+ new Map(
66
+ Arr.generate(function* () {
67
+ for (const pr of pullRequests) {
68
+ if (isDemoted(demoted, pr)) {
69
+ yield [pr.number, pr.headRefOid] as const;
70
+ }
71
+ }
72
+ }),
73
+ );
@@ -0,0 +1,100 @@
1
+ import {
2
+ afterWatch,
3
+ followHead,
4
+ isDemoted,
5
+ pruneDemotions,
6
+ } from './demotions.mjs';
7
+ import type { Demotions, PullRequest } from './types.mjs';
8
+
9
+ describe(afterWatch, () => {
10
+ const none: Demotions = new Map();
11
+
12
+ test('demotes a pull request that sat green without merging', () => {
13
+ assert.deepStrictEqual(
14
+ afterWatch(none, 7, 'head', 'not-merging'),
15
+ new Map([[7, 'head']]),
16
+ );
17
+ });
18
+
19
+ test('demotes nothing for any other ending', () => {
20
+ for (const outcome of [
21
+ 'merged',
22
+ 'checks-failed',
23
+ 'timeout',
24
+ 'behind-again',
25
+ ] as const) {
26
+ assert.deepStrictEqual(afterWatch(none, 7, 'head', outcome), none);
27
+ }
28
+ });
29
+
30
+ // The watch may end on a head this run pushed — a fixer's diff amended
31
+ // onto the branch — and a demotion that stayed on the old head would be
32
+ // cleared by the next survey as if someone else had pushed.
33
+ test('keeps a demotion on the head the watch ended on', () => {
34
+ assert.deepStrictEqual(
35
+ afterWatch(new Map([[7, 'old']]), 7, 'new', 'checks-failed'),
36
+ new Map([[7, 'new']]),
37
+ );
38
+ });
39
+ });
40
+
41
+ describe(followHead, () => {
42
+ test('moves a demotion to the head this run pushed', () => {
43
+ assert.deepStrictEqual(
44
+ followHead(new Map([[7, 'before-rebase']]), 7, 'after-rebase'),
45
+ new Map([[7, 'after-rebase']]),
46
+ );
47
+ });
48
+
49
+ test('demotes nothing that was not demoted', () => {
50
+ assert.deepStrictEqual(
51
+ followHead(new Map([[7, 'head']]), 8, 'other'),
52
+ new Map([[7, 'head']]),
53
+ );
54
+ });
55
+ });
56
+
57
+ describe(pruneDemotions, () => {
58
+ const demoted: Demotions = new Map([
59
+ [1, 'a'.repeat(40)],
60
+ [2, 'a'.repeat(40)],
61
+ [3, 'a'.repeat(40)],
62
+ ]);
63
+
64
+ // The base moving is not among what clears it: that is exactly when a
65
+ // pull request that sat green is rebased again, and the point of the
66
+ // demotion is that it waits behind the rest when that happens.
67
+ test('keeps a demotion for as long as nobody else pushes', () => {
68
+ const kept = pruneDemotions(demoted, [
69
+ pullRequest({ number: 1 }),
70
+ pullRequest({ number: 2, headRefOid: 'd'.repeat(40) }),
71
+ ]);
72
+
73
+ assert.deepStrictEqual(kept, new Map([[1, 'a'.repeat(40)]]));
74
+
75
+ assert.isTrue(isDemoted(kept, pullRequest({ number: 1 })));
76
+
77
+ assert.isFalse(
78
+ isDemoted(kept, pullRequest({ number: 1, headRefOid: 'd'.repeat(40) })),
79
+ );
80
+ });
81
+ });
82
+
83
+ const pullRequest = (
84
+ fields: Partial<PullRequest> & Readonly<{ number: number }>,
85
+ ): PullRequest =>
86
+ ({
87
+ id: `PR_${fields.number}`,
88
+ title: `pull request #${fields.number}`,
89
+ body: '',
90
+ state: 'OPEN',
91
+ headRefName: `feature/${fields.number}`,
92
+ headRefOid: 'a'.repeat(40),
93
+ baseRefName: 'main',
94
+ isCrossRepository: false,
95
+ isDraft: false,
96
+ mergeStateStatus: 'BEHIND',
97
+ autoMergeRequest: {},
98
+ labels: [],
99
+ ...fields,
100
+ }) as const;