@intentius/chant 0.124.40 → 0.124.42

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 (346) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +347 -35
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
package/src/pr-forge.ts CHANGED
@@ -361,6 +361,13 @@ export interface PrForge {
361
361
  authors(pr: number): Promise<string[]>;
362
362
  /** The merged pull request that produced `sha`, or null. */
363
363
  pullRequestFor(sha: string): Promise<number | null>;
364
+ /**
365
+ * Whether pull request `pr` (merge request `pr` on GitLab) is still open
366
+ * (#4293): neither merged nor closed. A pull request the forge does not
367
+ * know reads as not open. Target locks (../lifecycle/target-lock.ts) read
368
+ * a lock whose holder is not open as released.
369
+ */
370
+ pullRequestOpen?(pr: number): Promise<boolean>;
364
371
  /**
365
372
  * Open a pull request (a merge request on GitLab) from `head` into `base`,
366
373
  * or into the default branch when `base` is not given. Over REST on every
@@ -377,6 +384,10 @@ export interface PrForge {
377
384
  editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
378
385
  /** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
379
386
  addLabels(pr: number, labels: readonly string[]): Promise<void>;
387
+ /** The names of the labels pull request `pr` carries (#4292), for `ci.labels`. */
388
+ labels?(pr: number): Promise<string[]>;
389
+ /** Whether pull request `pr` is open, closed unmerged, or merged (#4298), for `pr-env sweep`. */
390
+ pullRequestState?(pr: number): Promise<"open" | "closed" | "merged">;
380
391
  /**
381
392
  * Merge pull request `pr` only if its head is still `opts.sha`. A moved
382
393
  * head, a conflict or a branch rule the forge enforces comes back as
@@ -391,6 +402,81 @@ export interface PrForge {
391
402
  openPullRequests?(base: string): Promise<OpenPullRequest[]>;
392
403
  /** GitHub and Forgejo (#4095): the commit `branch` points at, or undefined when there is no such branch. */
393
404
  branchHead?(branch: string): Promise<string | undefined>;
405
+ /**
406
+ * When a scheduled Op's generated job last ran from its schedule or by hand
407
+ * (#4289). GitHub reads the workflow `workflow` names and its `schedule` and
408
+ * `workflow_dispatch` runs. Forgejo reads the repository's runs and keeps
409
+ * the ones from that workflow file. GitLab reads the active pipeline
410
+ * schedules whose `CHANT_SCHEDULED_OP` variable is `op` and their last
411
+ * pipelines. Throws a {@link ForgeApiError} when the forge will not say.
412
+ */
413
+ scheduledRuns?(query: ScheduledRunsQuery): Promise<ScheduledRuns>;
414
+ /**
415
+ * The four checks an apply before merge requires (#4301, ws-106), each
416
+ * about pull request `pr` at `head`, the commit the job checked out. Each
417
+ * fails when the forge's head for `pr` is another commit. `approved`:
418
+ * someone other than the pull request's authors ({@link authors}) approved
419
+ * `head`, and no standing review asks for changes. On GitLab an approval
420
+ * counts when it came after the merge request's latest push, read from its
421
+ * system notes, and never from the token's own user.
422
+ */
423
+ prApproved?(pr: number, head: string): Promise<PrRequireResult>;
424
+ /** The forge says `pr` merges as it stands: no conflicts, and on GitHub no branch protection blocking it. Pending while the forge works it out. */
425
+ prMergeable?(pr: number, head: string): Promise<PrRequireResult>;
426
+ /** `head` contains the tip of the branch `pr` merges into. */
427
+ prUndiverged?(pr: number, head: string): Promise<PrRequireResult>;
428
+ /**
429
+ * Every status and check reported on `head` passed, and at least one did.
430
+ * Statuses named in `ignore` are left out, such as the apply's own.
431
+ * Pending while one still runs or none has reported.
432
+ */
433
+ prChecksPassed?(pr: number, head: string, ignore?: readonly string[]): Promise<PrRequireResult>;
434
+ }
435
+
436
+ /** What one of the requires checks (#4301) found. */
437
+ export interface PrRequireResult {
438
+ passed: boolean;
439
+ /** Not settled yet: the forge is still working it out, or a check still runs. Asking again may pass. */
440
+ pending?: boolean;
441
+ /** One line for a person. */
442
+ reason: string;
443
+ }
444
+
445
+ const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
446
+ const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
447
+ const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
448
+ const moved = (what: string, head: string, now: string | undefined): PrRequireResult => refuse(`${what}'s head is ${short(now)}, not ${short(head)}, the commit this job checked out`);
449
+
450
+ /** The verdict over statuses and checks: failing and still-running names, and how many reported. */
451
+ function checksVerdict(what: string, head: string, failing: string[], waiting: string[], count: number): PrRequireResult {
452
+ const names = (l: string[]) => [...new Set(l)].sort().join(", ");
453
+ if (failing.length > 0) return refuse(`${names(failing)} failed on ${short(head)}`);
454
+ if (waiting.length > 0) return refuse(`${names(waiting)} still running on ${short(head)}`, true);
455
+ if (count === 0) return refuse(`no check has reported on ${what}'s head ${short(head)} yet`, true);
456
+ return pass(`${count} check${count === 1 ? "" : "s"} passed on ${short(head)}`);
457
+ }
458
+
459
+ /** Which scheduled Op job {@link PrForge.scheduledRuns} asks about. */
460
+ export interface ScheduledRunsQuery {
461
+ /** The Op's name, the value of GitLab's `CHANT_SCHEDULED_OP` selector. */
462
+ op: string;
463
+ /** GitHub and Forgejo: the Op's workflow file name, such as `drift-watch.yml`. */
464
+ workflow: string;
465
+ }
466
+
467
+ /** What a forge says about a scheduled Op job's runs (#4289). */
468
+ export interface ScheduledRuns {
469
+ /** When the job last ran from its schedule or by hand (ISO 8601), if it ever did. */
470
+ last?: string;
471
+ /** GitHub: the workflow is off, and why (`disabled_inactivity`, `disabled_manually`). */
472
+ disabled?: string;
473
+ /** GitLab: no active pipeline schedule selects the Op. */
474
+ noSchedule?: boolean;
475
+ }
476
+
477
+ /** The newest of some ISO 8601 times, ignoring what does not parse. */
478
+ function newestTime(times: readonly (string | undefined)[]): string | undefined {
479
+ return times.filter((t): t is string => typeof t === "string" && !Number.isNaN(Date.parse(t))).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
394
480
  }
395
481
 
396
482
  /** A GitHub or Forgejo client. */
@@ -581,6 +667,11 @@ export interface GithubForgeOptions {
581
667
  fetch?: ForgeFetch;
582
668
  }
583
669
 
670
+ /** GitLab's merge statuses while it works out whether a merge request can merge (#4301). */
671
+ const GITLAB_CHECKING = new Set(["checking", "unchecked", "preparing", "approvals_syncing", "cannot_be_merged_recheck"]);
672
+ /** GitLab's commit statuses still running. */
673
+ const GITLAB_RUNNING = new Set(["created", "waiting_for_resource", "preparing", "pending", "running", "scheduled"]);
674
+
584
675
  /** GitHub keeps a status description to 140 characters; Forgejo follows. */
585
676
  const GITHUB_DESCRIPTION = 140;
586
677
  /** GitLab keeps a status description to 255 characters. */
@@ -650,6 +741,33 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
650
741
  ...(url ? { target_url: url } : {}),
651
742
  });
652
743
  };
744
+ const authorsOf = async (pr: number): Promise<string[]> => {
745
+ const logins = new Set<string>();
746
+ const add = (v: unknown) => {
747
+ if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
748
+ };
749
+ const opened = await api.call("GET", `${repo}/pulls/${pr}`);
750
+ if (isObject(opened)) add(opened.user);
751
+ for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
752
+ if (!isObject(c)) continue;
753
+ add(c.author);
754
+ add(c.committer);
755
+ }
756
+ return [...logins].sort();
757
+ };
758
+ const branchTip = async (branch: string): Promise<string | undefined> => {
759
+ let b: unknown;
760
+ try {
761
+ b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
762
+ } catch (err) {
763
+ if (err instanceof ForgeApiError && err.status === 404) return undefined;
764
+ throw err;
765
+ }
766
+ // GitHub names the commit `sha`, Forgejo `id`.
767
+ const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
768
+ return commit ? str(commit.sha) ?? str(commit.id) : undefined;
769
+ };
770
+ const headOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.head) ? str(v.head.sha) : undefined);
653
771
  return {
654
772
  kind,
655
773
  host,
@@ -677,19 +795,93 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
677
795
  }
678
796
  return [...standing].filter(([, state]) => state === "APPROVED").map(([login]) => login).sort();
679
797
  },
680
- async authors(pr) {
681
- const logins = new Set<string>();
682
- const add = (v: unknown) => {
683
- if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
684
- };
685
- const opened = await api.call("GET", `${repo}/pulls/${pr}`);
686
- if (isObject(opened)) add(opened.user);
687
- for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
688
- if (!isObject(c)) continue;
689
- add(c.author);
690
- add(c.committer);
798
+ authors: authorsOf,
799
+ async prApproved(pr, head) {
800
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
801
+ const at = headOf(got);
802
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
803
+ // A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
804
+ const standing = new Map<string, { state: string; commit?: string }>();
805
+ for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
806
+ const login = loginOf(r);
807
+ if (!login || !isObject(r) || typeof r.state !== "string") continue;
808
+ const state = r.dismissed === true ? "DISMISSED" : r.state.toUpperCase();
809
+ if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
810
+ const commit = str(r.commit_id);
811
+ standing.set(login, { state, ...(commit ? { commit } : {}) });
812
+ }
813
+ const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
814
+ if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
815
+ const authors = new Set(await authorsOf(pr));
816
+ const by = [...standing].filter(([l, r]) => r.state === "APPROVED" && r.commit === head && !authors.has(l)).map(([l]) => l).sort();
817
+ if (by.length === 0) return refuse(`no reviewer other than its authors approved its head ${short(head)}`);
818
+ return pass(`approved at ${short(head)} by ${by.join(", ")}`);
819
+ },
820
+ async prMergeable(pr, head) {
821
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
822
+ const at = headOf(got);
823
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
824
+ const m = isObject(got) ? got : {};
825
+ const into = isObject(m.base) ? str(m.base.ref) : undefined;
826
+ if (m.mergeable === false || m.mergeable_state === "dirty") return refuse(`the forge reports conflicts with ${into ?? "its base"}`);
827
+ if (m.mergeable !== true) return refuse("the forge has not worked out yet whether it can merge", true);
828
+ if (kind === "github" && m.mergeable_state === "blocked") return refuse(`branch protection on ${into ?? "its base"} blocks its merge`);
829
+ return pass(`it merges into ${into ?? "its base"} as it stands`);
830
+ },
831
+ async prUndiverged(pr, head) {
832
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
833
+ const at = headOf(got);
834
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
835
+ const into = isObject(got) && isObject(got.base) ? str(got.base.ref) : undefined;
836
+ if (!into) return refuse(`the forge names no branch pull request ${pr} merges into`);
837
+ if (kind === "github") {
838
+ // behind_by: the commits on the base branch that the head does not contain.
839
+ const cmp = await api.call("GET", `${repo}/compare/${encodeURIComponent(into)}...${head}`);
840
+ const behind = isObject(cmp) ? num(cmp.behind_by) : undefined;
841
+ if (behind === undefined) return refuse(`the forge did not say how far ${short(head)} is behind ${into}`);
842
+ return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
843
+ }
844
+ // Forgejo: the pull request's merge base is the base branch's tip exactly when the head contains it.
845
+ const tip = await branchTip(into);
846
+ const base = isObject(got) ? str(got.merge_base) : undefined;
847
+ if (!tip || !base) return refuse(`the forge did not say where ${into} is or where ${short(head)} left it`);
848
+ return base === tip ? pass(`${short(head)} contains ${into} at ${short(tip)}`) : refuse(`${short(head)} does not contain ${into} at ${short(tip)}`);
849
+ },
850
+ async prChecksPassed(_pr, head, ignore = []) {
851
+ const skip = new Set(ignore);
852
+ const failing: string[] = [];
853
+ const waiting: string[] = [];
854
+ let count = 0;
855
+ // The combined status holds each context's newest status.
856
+ const combined = await api.call("GET", `${repo}/commits/${head}/status`);
857
+ for (const st of isObject(combined) && Array.isArray(combined.statuses) ? combined.statuses : []) {
858
+ const context = isObject(st) ? str(st.context) : undefined;
859
+ if (!context || skip.has(context)) continue;
860
+ const state = str(st.state) ?? str(st.status) ?? "";
861
+ count++;
862
+ if (state === "failure" || state === "error" || state === "failed") failing.push(context);
863
+ else if (state === "pending" || state === "running" || state === "") waiting.push(context);
864
+ }
865
+ if (kind === "github") {
866
+ const runs = await api.call("GET", `${repo}/commits/${head}/check-runs?per_page=100`);
867
+ for (const run of isObject(runs) && Array.isArray(runs.check_runs) ? runs.check_runs : []) {
868
+ const name = (isObject(run) ? str(run.name) : undefined) ?? "a check";
869
+ if (skip.has(name) || !isObject(run)) continue;
870
+ count++;
871
+ if (run.status !== "completed") waiting.push(name);
872
+ else if (!["success", "neutral", "skipped"].includes(String(run.conclusion))) failing.push(name);
873
+ }
874
+ }
875
+ return checksVerdict(`pull request ${_pr}`, head, failing, waiting, count);
876
+ },
877
+ async pullRequestOpen(pr) {
878
+ try {
879
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
880
+ return isObject(got) && got.state === "open" && got.merged !== true;
881
+ } catch (err) {
882
+ if (err instanceof ForgeApiError && err.status === 404) return false;
883
+ throw err;
691
884
  }
692
- return [...logins].sort();
693
885
  },
694
886
  async pullRequestFor(sha) {
695
887
  if (kind === "forgejo") {
@@ -765,17 +957,27 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
765
957
  }
766
958
  return out.sort((a, b) => a.number - b.number);
767
959
  },
768
- async branchHead(branch) {
769
- let b: unknown;
770
- try {
771
- b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
772
- } catch (err) {
773
- if (err instanceof ForgeApiError && err.status === 404) return undefined;
774
- throw err;
960
+ branchHead: branchTip,
961
+ async scheduledRuns(q) {
962
+ const file = encodeURIComponent(q.workflow);
963
+ if (kind === "github") {
964
+ const workflow = await api.call("GET", `${repo}/actions/workflows/${file}`);
965
+ const runs = await Promise.all(["schedule", "workflow_dispatch"].map((event) => api.call("GET", `${repo}/actions/workflows/${file}/runs?event=${event}&per_page=1`)));
966
+ const last = newestTime(runs.flatMap((r) => (isObject(r) && Array.isArray(r.workflow_runs) ? r.workflow_runs.map((w) => (isObject(w) ? str(w.created_at) : undefined)) : [])));
967
+ const state = (isObject(workflow) ? str(workflow.state) : undefined) ?? "active";
968
+ return { ...(last ? { last } : {}), ...(state.startsWith("disabled") ? { disabled: state } : {}) };
775
969
  }
776
- // GitHub names the commit `sha`, Forgejo `id`.
777
- const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
778
- return commit ? str(commit.sha) ?? str(commit.id) : undefined;
970
+ // Forgejo lists a repository's runs newest first; the event and the workflow are read from each run, whatever the query honours.
971
+ const listed = await api.call("GET", `${repo}/actions/runs?event=schedule&limit=50`);
972
+ const runs = Array.isArray(listed) ? listed : isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : [];
973
+ const ours = runs.filter((r): r is Record<string, unknown> => {
974
+ if (!isObject(r)) return false;
975
+ const event = str(r.event) ?? str(r.trigger_event);
976
+ const workflow = str(r.workflow_id) ?? str(r.path);
977
+ return (event === "schedule" || event === "workflow_dispatch") && (workflow === undefined || workflow.endsWith(q.workflow));
978
+ });
979
+ const last = newestTime(ours.map((r) => str(r.created) ?? str(r.created_at) ?? str(r.started)));
980
+ return last ? { last } : {};
779
981
  },
780
982
  async editPullRequest(pr, change) {
781
983
  return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
@@ -784,6 +986,16 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
784
986
  if (labels.length === 0) return;
785
987
  await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
786
988
  },
989
+ async labels(pr) {
990
+ const listed = await api.list(`${repo}/issues/${pr}/labels`, pageParams, pageSize);
991
+ return listed.map((l) => (isObject(l) ? str(l.name) : undefined)).filter((n): n is string => n !== undefined);
992
+ },
993
+ async pullRequestState(pr) {
994
+ const p = await api.call("GET", `${repo}/pulls/${pr}`);
995
+ if (!isObject(p)) return "open";
996
+ if (p.merged === true || (typeof p.merged_at === "string" && p.merged_at !== "")) return "merged";
997
+ return p.state === "closed" ? "closed" : "open";
998
+ },
787
999
  async mergePullRequest(pr, m) {
788
1000
  const method = m.method ?? "merge";
789
1001
  let res: unknown;
@@ -915,6 +1127,22 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
915
1127
  await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
916
1128
  },
917
1129
  };
1130
+ const authorsOf = async (mr: number): Promise<string[]> => {
1131
+ const logins = new Set<string>();
1132
+ const merge = await api.call("GET", `${notes}/${mr}`);
1133
+ const author = isObject(merge) ? authorOf(merge) : undefined;
1134
+ if (author) logins.add(author);
1135
+ const emails = new Set<string>();
1136
+ for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
1137
+ if (!isObject(c)) continue;
1138
+ for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
1139
+ }
1140
+ for (const email of [...emails].sort()) {
1141
+ const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
1142
+ for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
1143
+ }
1144
+ return [...logins].sort();
1145
+ };
918
1146
  return {
919
1147
  kind: "gitlab",
920
1148
  host,
@@ -946,21 +1174,83 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
946
1174
  .filter((l): l is string => l !== undefined);
947
1175
  return [...new Set(logins)].sort();
948
1176
  },
949
- async authors(mr) {
950
- const logins = new Set<string>();
951
- const merge = await api.call("GET", `${notes}/${mr}`);
952
- const author = isObject(merge) ? authorOf(merge) : undefined;
953
- if (author) logins.add(author);
954
- const emails = new Set<string>();
955
- for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
956
- if (!isObject(c)) continue;
957
- for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
1177
+ authors: authorsOf,
1178
+ async prApproved(mr, head) {
1179
+ const got = await api.call("GET", `${notes}/${mr}`);
1180
+ const at = isObject(got) ? str(got.sha) : undefined;
1181
+ if (at !== head) return moved(`merge request !${mr}`, head, at);
1182
+ // The token's own user approves for the pipeline, never as a reviewer.
1183
+ const me = await api.call("GET", "/user");
1184
+ const self = isObject(me) ? str(me.username) : undefined;
1185
+ if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
1186
+ // An approval counts once it came after the latest push: the newest version of the diff.
1187
+ const versions = await api.call("GET", `${notes}/${mr}/versions`);
1188
+ const since = Math.max(-Infinity, ...(Array.isArray(versions) ? versions : []).map((v) => Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "")).filter(Number.isFinite));
1189
+ const said = new Map<string, { what: string; at: number }>();
1190
+ for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
1191
+ if (!isObject(n) || n.system !== true) continue;
1192
+ const who = authorOf(n);
1193
+ const body = (str(n.body) ?? "").trim();
1194
+ const what = /^approved this merge request/.test(body) ? "approved" : /^unapproved this merge request/.test(body) ? "unapproved" : /^requested changes/.test(body) ? "changes" : undefined;
1195
+ const when = Date.parse(str(n.created_at) ?? "");
1196
+ if (!who || who === self || !what || !Number.isFinite(when)) continue;
1197
+ const before = said.get(who);
1198
+ if (!before || when >= before.at) said.set(who, { what, at: when });
1199
+ }
1200
+ const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
1201
+ if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
1202
+ const authors = new Set(await authorsOf(mr));
1203
+ const by = [...said].filter(([w, v]) => v.what === "approved" && v.at > since && !authors.has(w)).map(([w]) => w).sort();
1204
+ if (by.length === 0) return refuse(`no member other than its authors approved it after its latest push, ${short(head)}`);
1205
+ return pass(`approved after ${short(head)} by ${by.join(", ")}`);
1206
+ },
1207
+ async prMergeable(mr, head) {
1208
+ const got = await api.call("GET", `${notes}/${mr}`);
1209
+ const m = isObject(got) ? got : {};
1210
+ if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
1211
+ const status = str(m.detailed_merge_status) ?? (m.merge_status === "can_be_merged" ? "mergeable" : (str(m.merge_status) ?? ""));
1212
+ const into = str(m.target_branch) ?? "its target branch";
1213
+ if (status === "conflict" || m.has_conflicts === true) return refuse(`GitLab reports conflicts with ${into}`);
1214
+ if (GITLAB_CHECKING.has(status)) return refuse("GitLab has not worked out yet whether it can merge", true);
1215
+ if (status !== "mergeable") return refuse(`GitLab says ${status || "nothing"} about merging it`);
1216
+ return pass(`it merges into ${into} as it stands`);
1217
+ },
1218
+ async prUndiverged(mr, head) {
1219
+ const got = await api.call("GET", `${notes}/${mr}?include_diverged_commits_count=true`);
1220
+ const m = isObject(got) ? got : {};
1221
+ if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
1222
+ const into = str(m.target_branch) ?? "its target branch";
1223
+ const behind = num(m.diverged_commits_count);
1224
+ if (behind === undefined) return refuse(`GitLab did not say how far ${short(head)} is behind ${into}`);
1225
+ return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
1226
+ },
1227
+ async prChecksPassed(mr, head, ignore = []) {
1228
+ const skip = new Set(ignore);
1229
+ // Each name's newest status; a job allowed to fail holds nothing back.
1230
+ const newest = new Map<string, Record<string, unknown>>();
1231
+ for (const st of await api.list(`${project}/repository/commits/${head}/statuses?all=true`, pageParams, 100)) {
1232
+ const name = isObject(st) ? str(st.name) : undefined;
1233
+ if (!name || skip.has(name) || !isObject(st)) continue;
1234
+ const had = newest.get(name);
1235
+ if (!had || (num(st.id) ?? 0) > (num(had.id) ?? 0)) newest.set(name, st);
958
1236
  }
959
- for (const email of [...emails].sort()) {
960
- const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
961
- for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
1237
+ const failing: string[] = [];
1238
+ const waiting: string[] = [];
1239
+ for (const [name, st] of newest) {
1240
+ const state = str(st.status) ?? "";
1241
+ if ((state === "failed" || state === "canceled") && st.allow_failure !== true) failing.push(name);
1242
+ else if (GITLAB_RUNNING.has(state)) waiting.push(name);
1243
+ }
1244
+ return checksVerdict(`merge request !${mr}`, head, failing, waiting, newest.size);
1245
+ },
1246
+ async pullRequestOpen(mr) {
1247
+ try {
1248
+ const got = await api.call("GET", `${notes}/${mr}`);
1249
+ return isObject(got) && got.state === "opened";
1250
+ } catch (err) {
1251
+ if (err instanceof ForgeApiError && err.status === 404) return false;
1252
+ throw err;
962
1253
  }
963
- return [...logins].sort();
964
1254
  },
965
1255
  async pullRequestFor(sha) {
966
1256
  const mrs = await api.call("GET", `${project}/repository/commits/${sha}/merge_requests`);
@@ -984,6 +1274,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
984
1274
  .filter((m): m is PullRequest => m !== undefined)
985
1275
  .sort((a, b) => a.number - b.number)[0];
986
1276
  },
1277
+ async scheduledRuns(q) {
1278
+ // The variable the gitlab lexicon's generated Op file selects a scheduled job by.
1279
+ const selects = (d: unknown): boolean =>
1280
+ isObject(d) && Array.isArray(d.variables) && d.variables.some((v) => isObject(v) && v.key === "CHANT_SCHEDULED_OP" && v.value === q.op);
1281
+ const schedules = await api.list(`${project}/pipeline_schedules?scope=active`, pageParams, 100);
1282
+ // A schedule's variables are on each schedule, not the list.
1283
+ const details = await Promise.all(schedules.map((s) => api.call("GET", `${project}/pipeline_schedules/${isObject(s) ? num(s.id) : ""}`)));
1284
+ const ours = details.filter(selects);
1285
+ const ids = ours.map((d) => (isObject(d) && isObject(d.last_pipeline) ? num(d.last_pipeline.id) : undefined)).filter((n): n is number => n !== undefined && n > 0);
1286
+ const pipelines = await Promise.all(ids.map((id) => api.call("GET", `${project}/pipelines/${id}`)));
1287
+ const last = newestTime(pipelines.map((p) => (isObject(p) ? str(p.created_at) : undefined)));
1288
+ return { ...(last ? { last } : {}), ...(ours.length === 0 ? { noSchedule: true } : {}) };
1289
+ },
987
1290
  async editPullRequest(mr, change) {
988
1291
  return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
989
1292
  },
@@ -991,6 +1294,15 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
991
1294
  if (labels.length === 0) return;
992
1295
  await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
993
1296
  },
1297
+ async labels(mr) {
1298
+ const m = await api.call("GET", `${notes}/${mr}`);
1299
+ return isObject(m) && Array.isArray(m.labels) ? m.labels.filter((l): l is string => typeof l === "string") : [];
1300
+ },
1301
+ async pullRequestState(mr) {
1302
+ const m = await api.call("GET", `${notes}/${mr}`);
1303
+ if (!isObject(m)) return "open";
1304
+ return m.state === "merged" ? "merged" : m.state === "closed" ? "closed" : "open";
1305
+ },
994
1306
  async mergePullRequest(mr, m) {
995
1307
  const method = m.method ?? "merge";
996
1308
  if (method === "rebase") throw new Error("GitLab has no one-call rebase merge; use method merge or squash");
@@ -41,7 +41,7 @@ describe("the pull-request loop bundle", () => {
41
41
  "packages/core/src/pr-forge.ts",
42
42
  "packages/core/src/pr-loop.ts",
43
43
  ]);
44
- // About 65 KB today, after the pull request authors (#4000).
45
- expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(72 * 1024);
44
+ // About 89 KB today, after the hooks line (#4288), the blocked set (#4291), the scheduled-run reads (#4289) and the requires checks (#4301).
45
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
46
46
  });
47
47
  });
@@ -366,3 +366,61 @@ describe("a gate per wave on merge (#4003)", () => {
366
366
  expect(prStatusDescription({ ...waiting, waves: [wave1], refusal: "earlier-wave" })).toBe("refused: an earlier wave has not applied");
367
367
  });
368
368
  });
369
+
370
+ describe("stage hooks in the report and the note (#4288)", () => {
371
+ const hooked: PrReport = {
372
+ ...planReport,
373
+ status: "plan-failed",
374
+ message: "The before-plan hook lint exited 1, so nothing was planned or applied.",
375
+ hooks: [
376
+ { moment: "before-observe", name: "init", target: "a", exit: 0, outcome: "passed" },
377
+ { moment: "before-plan", name: "lint", exit: 1, outcome: "failed" },
378
+ ],
379
+ };
380
+
381
+ test("a report with hooks validates", () => {
382
+ expectValid(hooked);
383
+ });
384
+
385
+ test("the note counts the hooks that passed and names the one that failed", () => {
386
+ const note = renderPrNote(hooked);
387
+ expect(note).toContain("A hook failed, so nothing can apply.");
388
+ expect(note).toContain("Hooks: 1 passed; `lint` before-plan failed (exit 1).");
389
+ });
390
+ });
391
+
392
+ describe("the policy in the report and the note (#4294)", () => {
393
+ const policy = {
394
+ result: "denied" as const,
395
+ engine: "opa" as const,
396
+ base: "b".repeat(40),
397
+ denials: [{ rule: "main.deny_public", message: "bucket logs is public" }],
398
+ rules: ["main.deny_public"],
399
+ holds: [{ rule: "main.hold_iam", message: "an IAM role changes" }],
400
+ warnings: ["three deletes"],
401
+ overrideCommand: 'chant approve policy-override pr-7 --rule main.deny_public --reason "<why>"',
402
+ };
403
+ const denied: PrReport = { ...planReport, policy };
404
+
405
+ test("a report with a policy, and an apply the policy refused, validate", () => {
406
+ expectValid(denied);
407
+ expectValid({ ...denied, stage: "apply", status: "refused", refusal: "policy-denied", message: "The policy denied this plan." });
408
+ expectValid({ ...denied, policy: { ...policy, result: "error", denials: [], rules: undefined, holds: [], error: "opa did not run" }, stage: "apply", status: "refused", refusal: "policy-error" });
409
+ });
410
+
411
+ test("the note names the denial, the hold and the override command", () => {
412
+ const note = renderPrNote(denied);
413
+ expect(note).toContain("Policy (opa, read at `bbbbbbbbbbbb`) denied this plan:");
414
+ expect(note).toContain("- `main.deny_public`: bucket logs is public");
415
+ expect(note).toContain("holds this plan for a person's approval of its digest:");
416
+ expect(note).toContain(policy.overrideCommand);
417
+ expect(note).toContain("Policy warnings: three deletes");
418
+ });
419
+
420
+ test("a denied plan fails its status until an override counts; the apply's refusal says why", () => {
421
+ expect(prStatusState(denied)).toBe("failure");
422
+ expect(prStatusState({ ...denied, policy: { ...policy, override: { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "docs site", digest: "sha256:1" } } })).toBe("success");
423
+ expect(prStatusState({ ...denied, policy: { ...policy, result: "held", denials: [] } })).toBe("success");
424
+ expect(prStatusDescription({ ...denied, stage: "apply", status: "refused", refusal: "policy-denied" })).toBe("refused: the policy denied the plan (main.deny_public)");
425
+ });
426
+ });