@intentius/chant 0.124.39 → 0.124.41

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 (337) 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/component-endpoint.d.ts +20 -0
  11. package/dist/cli/component-endpoint.d.ts.map +1 -0
  12. package/dist/cli/component-endpoint.js +36 -0
  13. package/dist/cli/handlers/approve.d.ts +2 -1
  14. package/dist/cli/handlers/approve.d.ts.map +1 -1
  15. package/dist/cli/handlers/approve.js +20 -6
  16. package/dist/cli/handlers/build.d.ts.map +1 -1
  17. package/dist/cli/handlers/build.js +15 -1
  18. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  19. package/dist/cli/handlers/fan-out.js +26 -4
  20. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  21. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  22. package/dist/cli/handlers/gate-resume.js +15 -2
  23. package/dist/cli/handlers/lock.d.ts +8 -0
  24. package/dist/cli/handlers/lock.d.ts.map +1 -0
  25. package/dist/cli/handlers/lock.js +197 -0
  26. package/dist/cli/handlers/pr-env.d.ts +34 -0
  27. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  28. package/dist/cli/handlers/pr-env.js +436 -0
  29. package/dist/cli/handlers/pr.d.ts +74 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +514 -58
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +16 -2
  34. package/dist/cli/handlers/query.d.ts +6 -0
  35. package/dist/cli/handlers/query.d.ts.map +1 -0
  36. package/dist/cli/handlers/query.js +80 -0
  37. package/dist/cli/handlers/run.d.ts +4 -1
  38. package/dist/cli/handlers/run.d.ts.map +1 -1
  39. package/dist/cli/handlers/run.js +17 -6
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +41 -2
  42. package/dist/cli/parse-args.d.ts.map +1 -1
  43. package/dist/cli/parse-args.js +31 -2
  44. package/dist/cli/registry.d.ts +12 -2
  45. package/dist/cli/registry.d.ts.map +1 -1
  46. package/dist/components/auto-release.d.ts +35 -3
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +29 -2
  49. package/dist/components/cli-support.d.ts +2 -0
  50. package/dist/components/cli-support.d.ts.map +1 -1
  51. package/dist/components/cli-support.js +2 -0
  52. package/dist/components/deploy-units.d.ts.map +1 -1
  53. package/dist/components/deploy-units.js +5 -0
  54. package/dist/components/fan-out-run.d.ts +28 -0
  55. package/dist/components/fan-out-run.d.ts.map +1 -1
  56. package/dist/components/fan-out-run.js +27 -3
  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 +72 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +96 -5
  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.map +1 -1
  70. package/dist/components/pr-run.js +5 -3
  71. package/dist/components/promote.d.ts.map +1 -1
  72. package/dist/components/promote.js +39 -16
  73. package/dist/gate-decision.d.ts +15 -2
  74. package/dist/gate-decision.d.ts.map +1 -1
  75. package/dist/gate-decision.js +17 -2
  76. package/dist/gate-policy.d.ts +140 -0
  77. package/dist/gate-policy.d.ts.map +1 -0
  78. package/dist/gate-policy.js +242 -0
  79. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  80. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  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/publish-declaration.d.ts.map +1 -1
  86. package/dist/lifecycle/publish-declaration.js +4 -3
  87. package/dist/lifecycle/publish-npm.d.ts +2 -2
  88. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  89. package/dist/lifecycle/publish-npm.js +31 -8
  90. package/dist/lifecycle/query.d.ts +68 -0
  91. package/dist/lifecycle/query.d.ts.map +1 -0
  92. package/dist/lifecycle/query.js +246 -0
  93. package/dist/lifecycle/release-ledger.d.ts +31 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +19 -1
  96. package/dist/lifecycle/run-ledger.d.ts +13 -0
  97. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/run-ledger.js +16 -0
  99. package/dist/lifecycle/status.d.ts +5 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/status.js +10 -5
  102. package/dist/lifecycle/target-lock.d.ts +97 -0
  103. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  104. package/dist/lifecycle/target-lock.js +286 -0
  105. package/dist/live-endpoint.d.ts +18 -0
  106. package/dist/live-endpoint.d.ts.map +1 -1
  107. package/dist/live-endpoint.js +27 -0
  108. package/dist/op/activities/activity-contracts.d.ts +30 -0
  109. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  110. package/dist/op/activities/activity-contracts.js +26 -0
  111. package/dist/op/activities/index.d.ts +2 -0
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/index.js +4 -0
  114. package/dist/op/activities/review-agent.d.ts +137 -0
  115. package/dist/op/activities/review-agent.d.ts.map +1 -0
  116. package/dist/op/activities/review-agent.js +266 -0
  117. package/dist/op/builders.d.ts +9 -0
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/builders.js +11 -0
  120. package/dist/op/cron.d.ts +8 -0
  121. package/dist/op/cron.d.ts.map +1 -1
  122. package/dist/op/cron.js +51 -0
  123. package/dist/op/gate-approval.d.ts +20 -13
  124. package/dist/op/gate-approval.d.ts.map +1 -1
  125. package/dist/op/gate-approval.js +13 -18
  126. package/dist/op/gate-quorum.d.ts +131 -0
  127. package/dist/op/gate-quorum.d.ts.map +1 -0
  128. package/dist/op/gate-quorum.js +183 -0
  129. package/dist/op/gate-resume.d.ts +4 -2
  130. package/dist/op/gate-resume.d.ts.map +1 -1
  131. package/dist/op/gate-resume.js +5 -4
  132. package/dist/op/gate-review.js +1 -1
  133. package/dist/op/gate.d.ts +32 -4
  134. package/dist/op/gate.d.ts.map +1 -1
  135. package/dist/op/gate.js +40 -24
  136. package/dist/op/index.d.ts +6 -4
  137. package/dist/op/index.d.ts.map +1 -1
  138. package/dist/op/index.js +4 -3
  139. package/dist/op/local-executor.d.ts.map +1 -1
  140. package/dist/op/local-executor.js +9 -3
  141. package/dist/op/op-waves-run.d.ts +62 -1
  142. package/dist/op/op-waves-run.d.ts.map +1 -1
  143. package/dist/op/op-waves-run.js +190 -11
  144. package/dist/op/op-waves.d.ts +55 -3
  145. package/dist/op/op-waves.d.ts.map +1 -1
  146. package/dist/op/op-waves.js +63 -8
  147. package/dist/op/schedule-overdue.d.ts +57 -0
  148. package/dist/op/schedule-overdue.d.ts.map +1 -0
  149. package/dist/op/schedule-overdue.js +138 -0
  150. package/dist/override-gate.d.ts +2 -2
  151. package/dist/override-gate.d.ts.map +1 -1
  152. package/dist/override-gate.js +1 -1
  153. package/dist/pr-forge.d.ts +36 -0
  154. package/dist/pr-forge.d.ts.map +1 -1
  155. package/dist/pr-forge.js +82 -0
  156. package/dist/pr-loop.d.ts +90 -1
  157. package/dist/pr-loop.d.ts.map +1 -1
  158. package/dist/pr-loop.js +77 -3
  159. package/dist/pr-post.d.ts +72 -0
  160. package/dist/pr-post.d.ts.map +1 -0
  161. package/dist/pr-post.js +134 -0
  162. package/dist/wave-blocked.d.ts +79 -0
  163. package/dist/wave-blocked.d.ts.map +1 -0
  164. package/dist/wave-blocked.js +245 -0
  165. package/dist/wave-engine.d.ts +6 -0
  166. package/dist/wave-engine.d.ts.map +1 -1
  167. package/dist/wave-engine.js +2 -0
  168. package/dist/wave-runtime.mjs +239 -239
  169. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  170. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  171. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  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-locks.d.ts +12 -0
  179. package/dist/workspace/ci-locks.d.ts.map +1 -0
  180. package/dist/workspace/ci-locks.js +6 -0
  181. package/dist/workspace/ci-run.d.ts +24 -1
  182. package/dist/workspace/ci-run.d.ts.map +1 -1
  183. package/dist/workspace/ci-run.js +85 -6
  184. package/dist/workspace/declaration.d.ts +63 -0
  185. package/dist/workspace/declaration.d.ts.map +1 -1
  186. package/dist/workspace/declaration.js +72 -1
  187. package/dist/workspace/declaration.schema.json +147 -0
  188. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  189. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  190. package/dist/workspace/identity-gate-rule.js +1 -1
  191. package/dist/workspace/identity.d.ts +1 -1
  192. package/dist/workspace/identity.d.ts.map +1 -1
  193. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  194. package/dist/workspace/lineage-upgrade.js +8 -5
  195. package/dist/workspace/member-pipeline.d.ts +6 -0
  196. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  197. package/dist/workspace/member-pipeline.js +23 -0
  198. package/dist/workspace/pr-report.schema.json +73 -2
  199. package/dist/workspace/security.d.ts.map +1 -1
  200. package/dist/workspace/security.js +4 -0
  201. package/dist/workspace/status-gates.d.ts +3 -2
  202. package/dist/workspace/status-gates.d.ts.map +1 -1
  203. package/dist/workspace/status-gates.js +8 -7
  204. package/dist/workspace/status.d.ts +3 -1
  205. package/dist/workspace/status.d.ts.map +1 -1
  206. package/dist/workspace/status.js +7 -3
  207. package/dist/workspace/status.schema.json +1 -0
  208. package/dist/workspace/trust/seal.d.ts +2 -2
  209. package/dist/workspace/trust/seal.d.ts.map +1 -1
  210. package/dist/workspace/trust/seal.js +2 -2
  211. package/package.json +1 -1
  212. package/src/__fixtures__/oidc-emulator.ts +118 -0
  213. package/src/ci-handover.test.ts +209 -0
  214. package/src/ci-handover.ts +237 -0
  215. package/src/ci-identity.test.ts +201 -0
  216. package/src/ci-identity.ts +310 -0
  217. package/src/cli/component-endpoint.ts +43 -0
  218. package/src/cli/handlers/approve.ts +18 -5
  219. package/src/cli/handlers/build.ts +15 -1
  220. package/src/cli/handlers/fan-out.test.ts +10 -0
  221. package/src/cli/handlers/fan-out.ts +29 -4
  222. package/src/cli/handlers/gate-resume.ts +16 -2
  223. package/src/cli/handlers/lock.test.ts +98 -0
  224. package/src/cli/handlers/lock.ts +202 -0
  225. package/src/cli/handlers/operator.test.ts +3 -1
  226. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  227. package/src/cli/handlers/pr-env.ts +462 -0
  228. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  229. package/src/cli/handlers/pr.test.ts +184 -0
  230. package/src/cli/handlers/pr.ts +508 -64
  231. package/src/cli/handlers/promote.ts +25 -2
  232. package/src/cli/handlers/query.ts +78 -0
  233. package/src/cli/handlers/run.test.ts +2 -1
  234. package/src/cli/handlers/run.ts +16 -5
  235. package/src/cli/main.ts +41 -2
  236. package/src/cli/parse-args.ts +23 -2
  237. package/src/cli/registry.ts +12 -2
  238. package/src/components/auto-release-infra.test.ts +131 -0
  239. package/src/components/auto-release.test.ts +40 -1
  240. package/src/components/auto-release.ts +50 -4
  241. package/src/components/cli-support.ts +4 -0
  242. package/src/components/deploy-units.ts +5 -0
  243. package/src/components/fan-out-run.ts +39 -2
  244. package/src/components/fan-out-waves.test.ts +45 -1
  245. package/src/components/pr-environment.test.ts +249 -0
  246. package/src/components/pr-environment.ts +538 -0
  247. package/src/components/pr-hooks.test.ts +118 -0
  248. package/src/components/pr-hooks.ts +157 -0
  249. package/src/components/pr-pipeline.ts +130 -5
  250. package/src/components/pr-policy.test.ts +145 -0
  251. package/src/components/pr-policy.ts +211 -0
  252. package/src/components/pr-run.ts +5 -2
  253. package/src/components/promote.test.ts +39 -0
  254. package/src/components/promote.ts +38 -14
  255. package/src/gate-decision-bundle.test.ts +4 -3
  256. package/src/gate-decision.ts +39 -2
  257. package/src/gate-policy-bundle.test.ts +28 -0
  258. package/src/gate-policy.test.ts +162 -0
  259. package/src/gate-policy.ts +298 -0
  260. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  261. package/src/lifecycle/gate-checkpoint.ts +1 -1
  262. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  263. package/src/lifecycle/publish-declaration.ts +4 -3
  264. package/src/lifecycle/publish-npm.ts +33 -8
  265. package/src/lifecycle/publish-targets.test.ts +36 -3
  266. package/src/lifecycle/query.test.ts +91 -0
  267. package/src/lifecycle/query.ts +315 -0
  268. package/src/lifecycle/release-ledger.ts +42 -1
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/status.test.ts +19 -0
  271. package/src/lifecycle/status.ts +14 -6
  272. package/src/lifecycle/target-lock.test.ts +195 -0
  273. package/src/lifecycle/target-lock.ts +347 -0
  274. package/src/live-endpoint.ts +31 -0
  275. package/src/op/activities/activity-contracts.ts +31 -0
  276. package/src/op/activities/index.ts +6 -0
  277. package/src/op/activities/review-agent.test.ts +240 -0
  278. package/src/op/activities/review-agent.ts +355 -0
  279. package/src/op/builders.ts +13 -0
  280. package/src/op/cron.ts +44 -0
  281. package/src/op/gate-alias.test.ts +2 -2
  282. package/src/op/gate-approval.test.ts +30 -9
  283. package/src/op/gate-approval.ts +23 -26
  284. package/src/op/gate-quorum.test.ts +139 -0
  285. package/src/op/gate-quorum.ts +248 -0
  286. package/src/op/gate-resume.ts +6 -3
  287. package/src/op/gate-review.ts +1 -1
  288. package/src/op/gate.ts +64 -23
  289. package/src/op/index.ts +13 -1
  290. package/src/op/local-executor.ts +9 -2
  291. package/src/op/op-waves-hook-targets.test.ts +126 -0
  292. package/src/op/op-waves-policy-hold.test.ts +66 -0
  293. package/src/op/op-waves-quorum.test.ts +101 -0
  294. package/src/op/op-waves-run.ts +233 -11
  295. package/src/op/op-waves.ts +83 -10
  296. package/src/op/schedule-overdue.test.ts +220 -0
  297. package/src/op/schedule-overdue.ts +161 -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 +114 -0
  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 +146 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/wave-blocked-bundle.test.ts +25 -0
  309. package/src/wave-blocked.test.ts +146 -0
  310. package/src/wave-blocked.ts +271 -0
  311. package/src/wave-engine.ts +8 -0
  312. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  313. package/src/workspace/ci-branches.test.ts +62 -0
  314. package/src/workspace/ci-branches.ts +79 -0
  315. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  316. package/src/workspace/ci-cloud-identity.ts +23 -0
  317. package/src/workspace/ci-locks.ts +16 -0
  318. package/src/workspace/ci-run-index.test.ts +2 -1
  319. package/src/workspace/ci-run.test.ts +133 -8
  320. package/src/workspace/ci-run.ts +92 -7
  321. package/src/workspace/declaration.schema.json +147 -0
  322. package/src/workspace/declaration.test.ts +76 -0
  323. package/src/workspace/declaration.ts +134 -2
  324. package/src/workspace/identity-gate-rule.ts +1 -1
  325. package/src/workspace/identity.ts +1 -1
  326. package/src/workspace/lineage-upgrade.ts +11 -8
  327. package/src/workspace/member-pipeline.test.ts +29 -0
  328. package/src/workspace/member-pipeline.ts +30 -0
  329. package/src/workspace/pr-report.schema.json +73 -2
  330. package/src/workspace/security.ts +4 -0
  331. package/src/workspace/status-gates.ts +9 -6
  332. package/src/workspace/status.schema.json +1 -0
  333. package/src/workspace/status.ts +11 -4
  334. package/src/workspace/trust/gate-seal.test.ts +1 -1
  335. package/src/workspace/trust/seal.ts +2 -2
  336. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  337. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Scheduled watch and reconcile Ops that stopped running (#4289): the UTC
3
+ * cron count, the verdict, the forge clients' `scheduledRuns`, the check the
4
+ * plan job runs, and the warning line in the plan note.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, describe, expect, test } from "vitest";
11
+ import { cronFiresUtc } from "./cron";
12
+ import { checkOverdueOps, opPipelineFile, overdueVerdict, scheduledWatches } from "./schedule-overdue";
13
+ import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "../pr-forge";
14
+ import { renderPrNote, type PrReport } from "../pr-loop";
15
+ import type { OpConfig } from "./types";
16
+
17
+ function fakeFetch(route: (method: string, url: URL) => unknown): ForgeFetch {
18
+ return async (url, init) => {
19
+ const found = route(init.method, new URL(url));
20
+ if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
21
+ if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
22
+ return { ok: true, status: 200, text: async () => JSON.stringify(found) };
23
+ };
24
+ }
25
+
26
+ describe("cronFiresUtc", () => {
27
+ test("counts fires in UTC, after the start and up to the end", () => {
28
+ const fires = cronFiresUtc("0 6 * * *", new Date("2026-10-01T06:00:00Z"), new Date("2026-10-04T05:59:00Z"), 5);
29
+ expect(fires.map((d) => d.toISOString())).toEqual(["2026-10-02T06:00:00.000Z", "2026-10-03T06:00:00.000Z"]);
30
+ });
31
+
32
+ test("stops at max, reads weekday names as numbers and 7 as Sunday", () => {
33
+ expect(cronFiresUtc("*/15 * * * *", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-02T00:00:00Z"), 2)).toHaveLength(2);
34
+ const sundays = cronFiresUtc("0 0 * * 7", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-31T00:00:00Z"), 10);
35
+ expect(sundays.every((d) => d.getUTCDay() === 0)).toBe(true);
36
+ expect(sundays).toHaveLength(4);
37
+ });
38
+
39
+ test("an expression that is not 5 or 6 fields fires never", () => {
40
+ expect(cronFiresUtc("0 6 *", new Date("2026-01-01T00:00:00Z"), new Date("2026-12-31T00:00:00Z"), 2)).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe("scheduledWatches", () => {
45
+ const op = (name: string, labels?: Record<string, string>, cron?: string): OpConfig =>
46
+ ({ name, overview: "", phases: [], ...(labels ? { labels } : {}), ...(cron ? { schedule: { cron } } : {}) }) as OpConfig;
47
+
48
+ test("keeps scheduled Ops labelled Watch or Reconcile, sorted", () => {
49
+ const got = scheduledWatches([
50
+ op("z-watch", { Watch: "true" }, "0 * * * *"),
51
+ op("a-reconcile", { Reconcile: "true" }, "0 6 * * *"),
52
+ op("unscheduled", { Watch: "true" }),
53
+ op("deploy", undefined, "0 6 * * *"),
54
+ ]);
55
+ expect(got).toEqual([
56
+ { op: "a-reconcile", cron: "0 6 * * *" },
57
+ { op: "z-watch", cron: "0 * * * *" },
58
+ ]);
59
+ });
60
+ });
61
+
62
+ describe("overdueVerdict", () => {
63
+ const watch = { op: "drift", cron: "0 6 * * *" };
64
+ const now = new Date("2026-10-22T12:00:00Z");
65
+
66
+ test("one missed fire is on time; two is overdue", () => {
67
+ expect(overdueVerdict(watch, "github", { last: "2026-10-21T06:01:00Z" }, undefined, now)).toBeUndefined();
68
+ const late = overdueVerdict(watch, "github", { last: "2026-10-20T06:01:00Z" }, undefined, now);
69
+ expect(late?.since).toBe("2026-10-20T06:01:00Z");
70
+ expect(late?.message).toContain("Op `drift` is overdue");
71
+ expect(late?.message).toContain("its last run was on 2026-10-20");
72
+ });
73
+
74
+ test("a job that never ran counts from its pipeline file's commit", () => {
75
+ const v = overdueVerdict(watch, "forgejo", {}, "2026-10-01T00:00:00Z", now);
76
+ expect(v?.neverRan).toBe(true);
77
+ expect(v?.message).toContain("no run is on record since its pipeline was added on 2026-10-01");
78
+ });
79
+
80
+ test("nothing to count from is no verdict", () => {
81
+ expect(overdueVerdict(watch, "github", {}, undefined, now)).toBeUndefined();
82
+ });
83
+
84
+ test("names a disabled workflow and a missing GitLab schedule", () => {
85
+ expect(overdueVerdict(watch, "github", { last: "2026-09-01T00:00:00Z", disabled: "disabled_inactivity" }, undefined, now)?.message).toContain("disabled_inactivity");
86
+ expect(overdueVerdict(watch, "gitlab", { noSchedule: true }, "2026-09-01T00:00:00Z", now)?.message).toContain("No active pipeline schedule sets CHANT_SCHEDULED_OP to drift");
87
+ });
88
+ });
89
+
90
+ describe("scheduledRuns", () => {
91
+ test("github: the newest schedule or dispatch run, and a disabled workflow", async () => {
92
+ const fetch = fakeFetch((_m, u) => {
93
+ if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml") return { state: "disabled_inactivity" };
94
+ if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml/runs") {
95
+ return { workflow_runs: [{ created_at: u.searchParams.get("event") === "schedule" ? "2026-08-01T06:00:00Z" : "2026-08-03T10:00:00Z" }] };
96
+ }
97
+ return undefined;
98
+ });
99
+ const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch });
100
+ expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-08-03T10:00:00Z", disabled: "disabled_inactivity" });
101
+ });
102
+
103
+ test("forgejo: only this workflow's schedule and dispatch runs", async () => {
104
+ const fetch = fakeFetch((_m, u) =>
105
+ u.pathname === "/api/v1/repos/acme/infra/actions/runs"
106
+ ? {
107
+ workflow_runs: [
108
+ { event: "schedule", workflow_id: "other.yml", created: "2026-10-20T00:00:00Z" },
109
+ { event: "push", workflow_id: "drift.yml", created: "2026-10-19T00:00:00Z" },
110
+ { event: "schedule", workflow_id: "drift.yml", created: "2026-10-10T00:00:00Z" },
111
+ ],
112
+ }
113
+ : undefined,
114
+ );
115
+ const forge = forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch });
116
+ expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-10-10T00:00:00Z" });
117
+ });
118
+
119
+ test("gitlab: the schedules that select the Op, and none", async () => {
120
+ const fetch = fakeFetch((_m, u) => {
121
+ const p = u.pathname.replace("/api/v4/projects/42", "");
122
+ if (p === "/pipeline_schedules") return [{ id: 1 }, { id: 2 }];
123
+ if (p === "/pipeline_schedules/1") return { id: 1, variables: [{ key: "CHANT_SCHEDULED_OP", value: "drift" }], last_pipeline: { id: 900 } };
124
+ if (p === "/pipeline_schedules/2") return { id: 2, variables: [{ key: "CHANT_SCHEDULED_OP", value: "other" }], last_pipeline: { id: 901 } };
125
+ if (p === "/pipelines/900") return { id: 900, created_at: "2026-10-01T06:00:00Z" };
126
+ return undefined;
127
+ });
128
+ const forge = gitlabForge({ apiBase: "https://gitlab.com/api/v4", project: "42", token: "t", fetch });
129
+ expect(await forge.scheduledRuns!({ op: "drift", workflow: "ops.gitlab-ci.yml" })).toEqual({ last: "2026-10-01T06:00:00Z" });
130
+ expect(await forge.scheduledRuns!({ op: "nobody", workflow: "ops.gitlab-ci.yml" })).toEqual({ noSchedule: true });
131
+ });
132
+ });
133
+
134
+ describe("checkOverdueOps", () => {
135
+ let dir: string | undefined;
136
+ afterEach(() => {
137
+ if (dir) rmSync(dir, { recursive: true, force: true });
138
+ dir = undefined;
139
+ });
140
+
141
+ function repo(files: Record<string, string>, date: string): string {
142
+ dir = mkdtempSync(join(tmpdir(), "chant-overdue-"));
143
+ const git = (...args: string[]) =>
144
+ execFileSync("git", ["-C", dir!, ...args], { env: { ...process.env, GIT_COMMITTER_DATE: date, GIT_AUTHOR_DATE: date }, stdio: "pipe" });
145
+ git("init", "-q");
146
+ for (const [path, body] of Object.entries(files)) {
147
+ mkdirSync(join(dir, path, ".."), { recursive: true });
148
+ writeFileSync(join(dir, path), body);
149
+ }
150
+ git("add", ".");
151
+ git("-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "add");
152
+ return dir;
153
+ }
154
+
155
+ const forgeWith = (kind: PrForge["kind"], scheduledRuns: PrForge["scheduledRuns"]): PrForge => ({ kind, host: "example", scheduledRuns }) as PrForge;
156
+
157
+ test("warns for an Op whose job never ran since its workflow was added, and skips one with no pipeline file", async () => {
158
+ const root = repo({ ".github/workflows/drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
159
+ const asked: string[] = [];
160
+ const lines: string[] = [];
161
+ const found = await checkOverdueOps({
162
+ watches: [
163
+ { op: "drift", cron: "0 6 * * *" },
164
+ { op: "operator-only", cron: "0 6 * * *" },
165
+ ],
166
+ forge: forgeWith("github", async (q) => {
167
+ asked.push(q.workflow);
168
+ return {};
169
+ }),
170
+ repoRoot: root,
171
+ now: new Date("2026-10-22T00:00:00Z"),
172
+ log: (l) => lines.push(l),
173
+ });
174
+ expect(asked).toEqual(["drift.yml"]);
175
+ expect(found.map((o) => [o.op, o.neverRan, o.since.slice(0, 10)])).toEqual([["drift", true, "2026-10-01"]]);
176
+ expect(lines).toHaveLength(1);
177
+ });
178
+
179
+ test("a forge that will not answer is a log line and no verdict", async () => {
180
+ const root = repo({ ".github/workflows/web-drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
181
+ const lines: string[] = [];
182
+ const found = await checkOverdueOps({
183
+ watches: [{ op: "drift", cron: "0 6 * * *" }],
184
+ forge: forgeWith("github", async () => {
185
+ throw new Error("GET answered 403");
186
+ }),
187
+ repoRoot: root,
188
+ member: "web",
189
+ now: new Date("2026-10-22T00:00:00Z"),
190
+ log: (l) => lines.push(l),
191
+ });
192
+ expect(found).toEqual([]);
193
+ expect(lines[0]).toContain("did not say when Op drift last ran");
194
+ });
195
+
196
+ test("gitlab: the root file whose rules select the Op", () => {
197
+ const root = repo({ "ops.gitlab-ci.yml": 'rules:\n - if: $CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "drift"\n' }, "2026-10-01T00:00:00Z");
198
+ expect(opPipelineFile(root, "gitlab", "drift")).toBe("ops.gitlab-ci.yml");
199
+ expect(opPipelineFile(root, "gitlab", "other")).toBeUndefined();
200
+ });
201
+ });
202
+
203
+ describe("the plan note", () => {
204
+ test("prints each overdue Op as a warning", () => {
205
+ const report = {
206
+ stage: "plan",
207
+ env: "prod",
208
+ base: "b".repeat(40),
209
+ head: "a".repeat(40),
210
+ status: "nothing",
211
+ digest: "",
212
+ members: [],
213
+ selection: { changed: [], dependents: [], unclaimed: [], indeterminate: [], waves: [] },
214
+ changeSet: { members: [], entries: [], summary: { byMember: {} } },
215
+ approval: { status: "pending" },
216
+ overdue: [{ op: "drift", cron: "0 6 * * *", since: "2026-10-01T00:00:00Z", message: "Op `drift` is overdue: test." }],
217
+ } as unknown as PrReport;
218
+ expect(renderPrNote(report)).toContain("Warning: Op `drift` is overdue: test.");
219
+ });
220
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Scheduled watch and reconcile Ops that stopped running (#4289).
3
+ *
4
+ * A schedule can stop with no error anywhere. GitHub turns a scheduled
5
+ * workflow off after 60 days with no activity in the repository and drops
6
+ * scheduled runs under load. A GitLab schedule exists only once someone
7
+ * creates it. A watch Op cannot report its own absence, so the pull-request
8
+ * plan job does. For each discovered Op with a `schedule` and the `Watch` or
9
+ * `Reconcile` label, whose generated pipeline file is in the checkout, it
10
+ * asks the forge when the Op's job last ran. When the cron has fired at
11
+ * least twice since then, or since the pipeline file was added for a job
12
+ * that never ran, the job log and the plan note say the Op is overdue.
13
+ *
14
+ * One late or dropped run is not enough: two fires are. A forge that will
15
+ * not answer is a line in the log and no verdict.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import type { OpConfig } from "./types";
21
+ import { cronFiresUtc } from "./cron";
22
+ import type { ForgeKind, PrForge, ScheduledRuns } from "../pr-forge";
23
+
24
+ /** One scheduled watch or reconcile Op the plan job checks. */
25
+ export interface ScheduledWatch {
26
+ op: string;
27
+ cron: string;
28
+ }
29
+
30
+ /** An Op the plan note warns about. */
31
+ export interface OverdueOp {
32
+ op: string;
33
+ cron: string;
34
+ /** The time the count starts from: the job's last run, or the pipeline file's first commit (ISO 8601). */
35
+ since: string;
36
+ /** True when no run is on record and `since` is the pipeline file's first commit. */
37
+ neverRan?: boolean;
38
+ /** The line for the job log and the plan note. */
39
+ message: string;
40
+ }
41
+
42
+ /**
43
+ * The discovered Ops that are scheduled watch or reconcile Ops: a
44
+ * `schedule`, and the `Watch` label `WatchOp` sets (./composites/watch-op.ts)
45
+ * or the `Reconcile` label `ReconcileOp` sets (./composites/reconcile-op.ts).
46
+ * Sorted by name.
47
+ */
48
+ export function scheduledWatches(ops: Iterable<OpConfig>): ScheduledWatch[] {
49
+ const out: ScheduledWatch[] = [];
50
+ for (const op of ops) {
51
+ if (!op.schedule?.cron) continue;
52
+ if (op.labels?.Watch !== "true" && op.labels?.Reconcile !== "true") continue;
53
+ out.push({ op: op.name, cron: op.schedule.cron });
54
+ }
55
+ return out.sort((a, b) => a.op.localeCompare(b.op));
56
+ }
57
+
58
+ /**
59
+ * The file `chant run --generate <forge>` writes for an Op, relative to the
60
+ * repository root: `<dir>/<op>.yml` on GitHub and Forgejo, `<member>-<op>.yml`
61
+ * in a workspace member. GitLab puts every Op in one file, so the root's
62
+ * `*.gitlab-ci.yml` files are read for the one whose rules select the Op.
63
+ * Undefined when the checkout has no such file, so an Op that some other
64
+ * scheduler runs is never checked.
65
+ */
66
+ export function opPipelineFile(repoRoot: string, forge: ForgeKind, op: string, member?: string): string | undefined {
67
+ const base = member ? `${member}-${op}` : op;
68
+ if (forge === "github" || forge === "forgejo") {
69
+ const path = `.${forge}/workflows/${base}.yml`;
70
+ return existsSync(join(repoRoot, path)) ? path : undefined;
71
+ }
72
+ // The gitlab lexicon's cron rule: `$CHANT_SCHEDULED_OP == "<op>"`.
73
+ const selector = `$CHANT_SCHEDULED_OP == "${op}"`;
74
+ let names: string[];
75
+ try {
76
+ names = readdirSync(repoRoot).filter((n) => n.endsWith(".gitlab-ci.yml")).sort();
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ return names.find((n) => {
81
+ try {
82
+ return readFileSync(join(repoRoot, n), "utf8").includes(selector);
83
+ } catch {
84
+ return false;
85
+ }
86
+ });
87
+ }
88
+
89
+ /** When `path` was first committed (ISO 8601), if the checkout's history has it. */
90
+ export function fileAdded(repoRoot: string, path: string): string | undefined {
91
+ const r = spawnSync("git", ["-C", repoRoot, "log", "--format=%cI", "--diff-filter=A", "--", path], { encoding: "utf-8" });
92
+ if (r.status !== 0) return undefined;
93
+ return r.stdout.trim().split("\n").filter(Boolean).pop();
94
+ }
95
+
96
+ const HINT: Record<ForgeKind, string> = {
97
+ github: "GitHub turns a scheduled workflow off after 60 days with no activity in the repository. Turn the workflow on again under Actions, or run it by hand",
98
+ gitlab: "A GitLab project runs a scheduled Op only from a pipeline schedule. Check its schedule under CI/CD > Schedules",
99
+ forgejo: "Check that Actions is on for the repository and a runner is up, or run the workflow by hand",
100
+ };
101
+
102
+ /**
103
+ * Whether one Op is overdue: its cron has fired at least twice since its
104
+ * last run, or since `added` when no run is on record. Undefined when it is
105
+ * on time, or when nothing says when it should have run.
106
+ */
107
+ export function overdueVerdict(watch: ScheduledWatch, forge: ForgeKind, runs: ScheduledRuns, added: string | undefined, now: Date): OverdueOp | undefined {
108
+ const since = runs.last ?? added;
109
+ if (!since) return undefined;
110
+ if (cronFiresUtc(watch.cron, new Date(since), now, 2).length < 2) return undefined;
111
+ const day = since.slice(0, 10);
112
+ const what = runs.last ? `its last run was on ${day}` : `no run is on record since its pipeline was added on ${day}`;
113
+ const why = runs.disabled
114
+ ? `GitHub has turned the workflow off (${runs.disabled}). Turn it on again under Actions`
115
+ : forge === "gitlab" && runs.noSchedule
116
+ ? `No active pipeline schedule sets CHANT_SCHEDULED_OP to ${watch.op}. Add one with the cron \`${watch.cron}\` under CI/CD > Schedules`
117
+ : HINT[forge];
118
+ return {
119
+ op: watch.op,
120
+ cron: watch.cron,
121
+ since,
122
+ ...(runs.last ? {} : { neverRan: true }),
123
+ message: `Op \`${watch.op}\` is overdue: its schedule \`${watch.cron}\` has fired at least twice and ${what}. ${why}.`,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The plan job's check over every scheduled watch or reconcile Op that has a
129
+ * generated pipeline file. Each Op the forge will not answer for is a line
130
+ * in `log` and no verdict. A forge client with no `scheduledRuns` checks
131
+ * nothing.
132
+ */
133
+ export async function checkOverdueOps(input: {
134
+ watches: readonly ScheduledWatch[];
135
+ forge: PrForge;
136
+ repoRoot: string;
137
+ member?: string;
138
+ now: Date;
139
+ log: (line: string) => void;
140
+ }): Promise<OverdueOp[]> {
141
+ const { forge } = input;
142
+ if (!forge.scheduledRuns) return [];
143
+ const out: OverdueOp[] = [];
144
+ for (const watch of input.watches) {
145
+ const file = opPipelineFile(input.repoRoot, forge.kind, watch.op, input.member);
146
+ if (!file) continue;
147
+ let runs: ScheduledRuns;
148
+ try {
149
+ runs = await forge.scheduledRuns({ op: watch.op, workflow: file.split("/").pop()! });
150
+ } catch (err) {
151
+ input.log(`schedule check: ${forge.kind} did not say when Op ${watch.op} last ran, so it is not checked: ${(err instanceof Error ? err.message : String(err)).split("\n")[0]}`);
152
+ continue;
153
+ }
154
+ const found = overdueVerdict(watch, forge.kind, runs, runs.last ? undefined : fileAdded(input.repoRoot, file), input.now);
155
+ if (found) {
156
+ input.log(found.message);
157
+ out.push(found);
158
+ }
159
+ }
160
+ return out;
161
+ }
@@ -34,10 +34,10 @@ describe("the override-gate bundle", () => {
34
34
  "packages/core/src/effect-receipt.ts",
35
35
  "packages/core/src/intrinsic.ts",
36
36
  "packages/core/src/lifecycle/gate-ledger-select.ts",
37
+ "packages/core/src/lifecycle/gate-seal.ts",
37
38
  "packages/core/src/lifecycle/plan-digest.ts",
38
39
  "packages/core/src/op/gate-name.ts",
39
40
  "packages/core/src/override-gate.ts",
40
- "packages/core/src/workspace/trust/gate-seal.ts",
41
41
  "packages/core/src/workspace/trust/trust-files.ts",
42
42
  ]);
43
43
  // About 16 KB today.
@@ -38,14 +38,14 @@ import { spawnSync } from "node:child_process";
38
38
  import { OVERRIDE_GATE_OP } from "./op/gate-name";
39
39
  import { latestPendingGate } from "./lifecycle/gate-ledger-select";
40
40
  import { samePlanDigest } from "./lifecycle/plan-digest";
41
- import { gateSealRefusal, type GateSigners } from "./workspace/trust/gate-seal";
41
+ import { gateSealRefusal, type GateSigners } from "./lifecycle/gate-seal";
42
42
  import { DEFAULT_SIGNERS_PATH } from "./workspace/trust/trust-files";
43
43
  import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
44
44
  import type { GateApprovalSource } from "./op/op-waves";
45
45
  import type { GateAuditApply } from "./lifecycle/gate-audit";
46
46
 
47
47
  export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
48
- export type { GateSigners } from "./workspace/trust/gate-seal";
48
+ export type { GateSigners } from "./lifecycle/gate-seal";
49
49
 
50
50
  /** The op every override and denial is recorded under. `./lifecycle/gate-audit.ts` reads this op as overrides by default. */
51
51
  export const OVERRIDE_OP = OVERRIDE_GATE_OP;
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
34
34
  expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
35
35
  }
36
36
  expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
37
- // About 33 KB today, after the pull request authors (#4000).
38
- expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(36 * 1024);
37
+ // About 37 KB today, after the pull request authors (#4000) and pullRequestOpen for target locks (#4293).
38
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(40 * 1024);
39
39
  });
40
40
  });
@@ -127,6 +127,17 @@ describe("github", () => {
127
127
  expect(await githubForge({ ...opts, fetch }).pullRequestFor(SHA)).toBe(9);
128
128
  });
129
129
 
130
+ test("a pull request is open while its state is open and it has not merged; an unknown one is not (#4293)", async () => {
131
+ const { fetch } = fakeFetch({
132
+ "GET /repos/acme/infra/pulls/1": { number: 1, state: "open", merged: false },
133
+ "GET /repos/acme/infra/pulls/2": { number: 2, state: "closed", merged: true },
134
+ });
135
+ const forge = githubForge({ ...opts, fetch });
136
+ expect(await forge.pullRequestOpen!(1)).toBe(true);
137
+ expect(await forge.pullRequestOpen!(2)).toBe(false);
138
+ expect(await forge.pullRequestOpen!(3)).toBe(false);
139
+ });
140
+
130
141
  test("resolves to the note's web URL", async () => {
131
142
  const { fetch } = fakeFetch({
132
143
  "GET /repos/acme/infra/issues/12/comments": [],
@@ -237,6 +248,11 @@ describe("forgejo", () => {
237
248
  expect(await forgejoForge({ ...opts, fetch: none.fetch }).pullRequestFor(SHA)).toBeNull();
238
249
  });
239
250
 
251
+ test("reads whether a pull request is open from the same pulls endpoint (#4293)", async () => {
252
+ const { fetch } = fakeFetch({ "GET /api/v1/repos/acme/infra/pulls/4": { number: 4, state: "open", merged: false } });
253
+ expect(await forgejoForge({ ...opts, fetch }).pullRequestOpen!(4)).toBe(true);
254
+ });
255
+
240
256
  test("names reviewers on its host", () => {
241
257
  expect(forgejoForge({ ...opts, fetch: fakeFetch({}).fetch }).principalOf("Ana")).toBe("forgejo@codeberg.org:ana");
242
258
  });
@@ -295,6 +311,16 @@ describe("gitlab", () => {
295
311
  expect(await forge.pullRequestFor(SHA)).toBe(5);
296
312
  expect(forge.principalOf("ana")).toBe("gitlab@gitlab.example.com:ana");
297
313
  });
314
+
315
+ test("a merge request is open while its state is opened (#4293)", async () => {
316
+ const { fetch } = fakeFetch({
317
+ "GET /api/v4/projects/42/merge_requests/5": { iid: 5, state: "opened" },
318
+ "GET /api/v4/projects/42/merge_requests/6": { iid: 6, state: "merged" },
319
+ });
320
+ const forge = gitlabForge({ ...opts, fetch });
321
+ expect(await forge.pullRequestOpen!(5)).toBe(true);
322
+ expect(await forge.pullRequestOpen!(6)).toBe(false);
323
+ });
298
324
  });
299
325
 
300
326
  describe("naming reviewers", () => {
@@ -891,6 +917,13 @@ describe("pull requests over REST (#3798)", () => {
891
917
  expect(l.calls.map((c) => c.body)).toEqual([{ description: "d" }, { add_labels: "minor,breaking" }]);
892
918
  });
893
919
 
920
+ test("labels reads the names a pull request carries on each forge (#4292)", async () => {
921
+ const f = fakeFetch({ "GET /api/v1/repos/acme/infra/issues/10/labels": [{ name: "plan:prod" }, { name: "bug" }, { id: 3 }] });
922
+ expect(await forgejoForge({ ...fjOpts, fetch: f.fetch }).labels!(10)).toEqual(["plan:prod", "bug"]);
923
+ const l = fakeFetch({ "GET /api/v4/projects/group%2Finfra/merge_requests/7": { iid: 7, labels: ["plan:prod", 4] } });
924
+ expect(await gitlabForge({ ...gl, fetch: l.fetch }).labels!(7)).toEqual(["plan:prod"]);
925
+ });
926
+
894
927
  test("Forgejo's 404 on the pull list of a just-pushed empty repository is no pull request (#3845)", async () => {
895
928
  const empty = fakeFetch({ "GET /api/v1/repos/acme/infra": { default_branch: "main" } });
896
929
  expect(await forgejoForge({ ...fjOpts, fetch: empty.fetch }).findPullRequest("chant/x")).toBeUndefined();
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,38 @@ 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
+
416
+ /** Which scheduled Op job {@link PrForge.scheduledRuns} asks about. */
417
+ export interface ScheduledRunsQuery {
418
+ /** The Op's name, the value of GitLab's `CHANT_SCHEDULED_OP` selector. */
419
+ op: string;
420
+ /** GitHub and Forgejo: the Op's workflow file name, such as `drift-watch.yml`. */
421
+ workflow: string;
422
+ }
423
+
424
+ /** What a forge says about a scheduled Op job's runs (#4289). */
425
+ export interface ScheduledRuns {
426
+ /** When the job last ran from its schedule or by hand (ISO 8601), if it ever did. */
427
+ last?: string;
428
+ /** GitHub: the workflow is off, and why (`disabled_inactivity`, `disabled_manually`). */
429
+ disabled?: string;
430
+ /** GitLab: no active pipeline schedule selects the Op. */
431
+ noSchedule?: boolean;
432
+ }
433
+
434
+ /** The newest of some ISO 8601 times, ignoring what does not parse. */
435
+ function newestTime(times: readonly (string | undefined)[]): string | undefined {
436
+ 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
437
  }
395
438
 
396
439
  /** A GitHub or Forgejo client. */
@@ -691,6 +734,15 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
691
734
  }
692
735
  return [...logins].sort();
693
736
  },
737
+ async pullRequestOpen(pr) {
738
+ try {
739
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
740
+ return isObject(got) && got.state === "open" && got.merged !== true;
741
+ } catch (err) {
742
+ if (err instanceof ForgeApiError && err.status === 404) return false;
743
+ throw err;
744
+ }
745
+ },
694
746
  async pullRequestFor(sha) {
695
747
  if (kind === "forgejo") {
696
748
  try {
@@ -777,6 +829,27 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
777
829
  const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
778
830
  return commit ? str(commit.sha) ?? str(commit.id) : undefined;
779
831
  },
832
+ async scheduledRuns(q) {
833
+ const file = encodeURIComponent(q.workflow);
834
+ if (kind === "github") {
835
+ const workflow = await api.call("GET", `${repo}/actions/workflows/${file}`);
836
+ const runs = await Promise.all(["schedule", "workflow_dispatch"].map((event) => api.call("GET", `${repo}/actions/workflows/${file}/runs?event=${event}&per_page=1`)));
837
+ 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)) : [])));
838
+ const state = (isObject(workflow) ? str(workflow.state) : undefined) ?? "active";
839
+ return { ...(last ? { last } : {}), ...(state.startsWith("disabled") ? { disabled: state } : {}) };
840
+ }
841
+ // Forgejo lists a repository's runs newest first; the event and the workflow are read from each run, whatever the query honours.
842
+ const listed = await api.call("GET", `${repo}/actions/runs?event=schedule&limit=50`);
843
+ const runs = Array.isArray(listed) ? listed : isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : [];
844
+ const ours = runs.filter((r): r is Record<string, unknown> => {
845
+ if (!isObject(r)) return false;
846
+ const event = str(r.event) ?? str(r.trigger_event);
847
+ const workflow = str(r.workflow_id) ?? str(r.path);
848
+ return (event === "schedule" || event === "workflow_dispatch") && (workflow === undefined || workflow.endsWith(q.workflow));
849
+ });
850
+ const last = newestTime(ours.map((r) => str(r.created) ?? str(r.created_at) ?? str(r.started)));
851
+ return last ? { last } : {};
852
+ },
780
853
  async editPullRequest(pr, change) {
781
854
  return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
782
855
  },
@@ -784,6 +857,16 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
784
857
  if (labels.length === 0) return;
785
858
  await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
786
859
  },
860
+ async labels(pr) {
861
+ const listed = await api.list(`${repo}/issues/${pr}/labels`, pageParams, pageSize);
862
+ return listed.map((l) => (isObject(l) ? str(l.name) : undefined)).filter((n): n is string => n !== undefined);
863
+ },
864
+ async pullRequestState(pr) {
865
+ const p = await api.call("GET", `${repo}/pulls/${pr}`);
866
+ if (!isObject(p)) return "open";
867
+ if (p.merged === true || (typeof p.merged_at === "string" && p.merged_at !== "")) return "merged";
868
+ return p.state === "closed" ? "closed" : "open";
869
+ },
787
870
  async mergePullRequest(pr, m) {
788
871
  const method = m.method ?? "merge";
789
872
  let res: unknown;
@@ -962,6 +1045,15 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
962
1045
  }
963
1046
  return [...logins].sort();
964
1047
  },
1048
+ async pullRequestOpen(mr) {
1049
+ try {
1050
+ const got = await api.call("GET", `${notes}/${mr}`);
1051
+ return isObject(got) && got.state === "opened";
1052
+ } catch (err) {
1053
+ if (err instanceof ForgeApiError && err.status === 404) return false;
1054
+ throw err;
1055
+ }
1056
+ },
965
1057
  async pullRequestFor(sha) {
966
1058
  const mrs = await api.call("GET", `${project}/repository/commits/${sha}/merge_requests`);
967
1059
  if (!Array.isArray(mrs)) return null;
@@ -984,6 +1076,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
984
1076
  .filter((m): m is PullRequest => m !== undefined)
985
1077
  .sort((a, b) => a.number - b.number)[0];
986
1078
  },
1079
+ async scheduledRuns(q) {
1080
+ // The variable the gitlab lexicon's generated Op file selects a scheduled job by.
1081
+ const selects = (d: unknown): boolean =>
1082
+ isObject(d) && Array.isArray(d.variables) && d.variables.some((v) => isObject(v) && v.key === "CHANT_SCHEDULED_OP" && v.value === q.op);
1083
+ const schedules = await api.list(`${project}/pipeline_schedules?scope=active`, pageParams, 100);
1084
+ // A schedule's variables are on each schedule, not the list.
1085
+ const details = await Promise.all(schedules.map((s) => api.call("GET", `${project}/pipeline_schedules/${isObject(s) ? num(s.id) : ""}`)));
1086
+ const ours = details.filter(selects);
1087
+ 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);
1088
+ const pipelines = await Promise.all(ids.map((id) => api.call("GET", `${project}/pipelines/${id}`)));
1089
+ const last = newestTime(pipelines.map((p) => (isObject(p) ? str(p.created_at) : undefined)));
1090
+ return { ...(last ? { last } : {}), ...(ours.length === 0 ? { noSchedule: true } : {}) };
1091
+ },
987
1092
  async editPullRequest(mr, change) {
988
1093
  return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
989
1094
  },
@@ -991,6 +1096,15 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
991
1096
  if (labels.length === 0) return;
992
1097
  await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
993
1098
  },
1099
+ async labels(mr) {
1100
+ const m = await api.call("GET", `${notes}/${mr}`);
1101
+ return isObject(m) && Array.isArray(m.labels) ? m.labels.filter((l): l is string => typeof l === "string") : [];
1102
+ },
1103
+ async pullRequestState(mr) {
1104
+ const m = await api.call("GET", `${notes}/${mr}`);
1105
+ if (!isObject(m)) return "open";
1106
+ return m.state === "merged" ? "merged" : m.state === "closed" ? "closed" : "open";
1107
+ },
994
1108
  async mergePullRequest(mr, m) {
995
1109
  const method = m.method ?? "merge";
996
1110
  if (method === "rebase") throw new Error("GitLab has no one-call rebase merge; use method merge or squash");