@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-loop.ts CHANGED
@@ -48,6 +48,7 @@
48
48
  */
49
49
 
50
50
  import type { ChangeSetAction, ChangeSetDocument } from "./change-set";
51
+ import type { PrRequireOutcome } from "./pr-requires";
51
52
  import { GITHUB_COMMENT_LIMIT, groupChangeSet, renderPlanSummaryMarkdown } from "./plan-summary";
52
53
 
53
54
  /** The schema a pull-request report names in `$schema`. */
@@ -71,6 +72,18 @@ export const PR_REPORT_PLANNED_OUTPUTS_FLOOR = "0.124.32";
71
72
  /** The chant that writes the `record` stage and the `recorded` status (#4168 step 1). */
72
73
  export const PR_REPORT_RECORD_FLOOR = "0.124.33";
73
74
 
75
+ /** The first chant whose report can carry `hooks` (#4288). */
76
+ export const PR_REPORT_HOOKS_FLOOR = "0.124.41";
77
+
78
+ /** The first chant whose plan report can carry `overdue` (#4289). */
79
+ export const PR_REPORT_OVERDUE_FLOOR = "0.124.41";
80
+
81
+ /** The first chant whose report can carry `policy` and the `policy-denied` and `policy-error` refusals (#4294). */
82
+ export const PR_REPORT_POLICY_FLOOR = "0.124.41";
83
+
84
+ /** The first chant whose apply report can carry `beforeMerge` and the `requires-unmet` and `before-merge-off` refusals (#4301, ws-106). */
85
+ export const PR_REPORT_BEFORE_MERGE_FLOOR = "0.124.42";
86
+
74
87
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
75
88
  export const PR_APPLY_GATE = "pr-apply";
76
89
 
@@ -174,6 +187,8 @@ export interface PrMember {
174
187
  round?: number;
175
188
  /** Apply stage (#3999): why the first round's plan did not apply. */
176
189
  held?: string;
190
+ /** A blocked member's (#4291): the component whose failure held it, past any component blocked in between. */
191
+ blockedBy?: string;
177
192
  }
178
193
 
179
194
  /** A round of plans an apply made after an upstream applied (#3999), the report's `rounds`. */
@@ -258,7 +273,22 @@ export type PrRefusal =
258
273
  /** `--wave-gate`: an approval of the wave's gate stands for another digest, so its plan moved since. */
259
274
  | "wave-plan-changed"
260
275
  /** `--wave-gate` with `--wave <k>`: an earlier wave has work left, or no record says it applied. */
261
- | "earlier-wave";
276
+ | "earlier-wave"
277
+ /** Under `ci.locks` (#4293): another open pull request holds a lock on a target this apply reaches. */
278
+ | "locked"
279
+ /** `ci.policy` at base denied the plan (#4294), and no override of it counts. */
280
+ | "policy-denied"
281
+ /** `ci.policy` at base could not be run, or gave no answer chant can read (#4294). */
282
+ | "policy-error"
283
+ /** `pr-apply --before-merge` (#4301): the pull request is not approved, mergeable, undiverged or green; `beforeMerge` says which. */
284
+ | "requires-unmet"
285
+ /** `pr-apply --before-merge`: the declaration at base does not set `ci.workflow` to `apply-before-merge` for the member. */
286
+ | "before-merge-off";
287
+
288
+ /** An apply before merge (#4301, ws-106): the requires checks the forge answered, in order. */
289
+ export interface PrBeforeMerge {
290
+ requires: PrRequireOutcome[];
291
+ }
262
292
 
263
293
  /**
264
294
  * One wave of an apply with `--wave-gate` (#4003): what it planned, where it
@@ -289,6 +319,54 @@ export interface PrWave {
289
319
  moved?: string[];
290
320
  }
291
321
 
322
+ /** A scheduled watch or reconcile Op that stopped running (#4289, `op/schedule-overdue.ts`). */
323
+ export interface PrOverdue {
324
+ op: string;
325
+ cron: string;
326
+ /** The job's last run, or the pipeline file's first commit when `neverRan` (ISO 8601). */
327
+ since: string;
328
+ neverRan?: boolean;
329
+ message: string;
330
+ }
331
+
332
+ /**
333
+ * What `ci.policy` at base said about the change set (#4294). Rule ids are
334
+ * `<package>.<rule>`.
335
+ */
336
+ export interface PrPolicyReport {
337
+ /** `denied` wins over `held`. `error`: the policy did not run or gave no readable answer. */
338
+ result: "passed" | "denied" | "held" | "error";
339
+ /** The result shape read: `conftest` or `opa`. */
340
+ engine: "conftest" | "opa";
341
+ /** The commit the settings and the policy were read at. */
342
+ base: string;
343
+ denials: Array<{ rule: string; message: string }>;
344
+ /** The ids of the rules that denied, sorted: what an override names. */
345
+ rules?: string[];
346
+ holds: Array<{ rule: string; message: string }>;
347
+ warnings: string[];
348
+ error?: string;
349
+ /** The override that lets the denied plan through. */
350
+ override?: { by: string; at: string; reason: string; digest: string };
351
+ /** While denied: the command that overrides the denial. */
352
+ overrideCommand?: string;
353
+ }
354
+
355
+ /**
356
+ * One stage hook a pull-request job ran (#4288), from `ci.hooks` in the
357
+ * workspace declaration at base. The shape of an Op waves hook outcome.
358
+ */
359
+ export interface PrHookOutcome {
360
+ /** `before-observe`, `before-plan`, `after-plan`, `before-apply` or `after-apply`. */
361
+ moment: string;
362
+ name: string;
363
+ /** The component, for a `perRun` hook. */
364
+ target?: string;
365
+ exit: number;
366
+ /** `held`: an `onFailure: approve` hook failed, which the pull request's gate answers. */
367
+ outcome: "passed" | "failed" | "held";
368
+ }
369
+
292
370
  /**
293
371
  * The document `pr-plan` and `pr-apply` write (`pr-report.schema.json`).
294
372
  * The terragucci plan report (#3349) reads it; fields are only ever added.
@@ -350,6 +428,14 @@ export interface PrReport {
350
428
  waveGate?: string;
351
429
  /** With `waveGate`: each wave reached so far, in order. */
352
430
  waves?: PrWave[];
431
+ /**
432
+ * Plan stage (#4289): the scheduled watch and reconcile Ops whose cron has
433
+ * fired at least twice since their job last ran, or since their pipeline
434
+ * file was added when no run is on record. Absent when none is overdue or
435
+ * the forge was not asked. Added within contract 1 by chant
436
+ * {@link PR_REPORT_OVERDUE_FLOOR}.
437
+ */
438
+ overdue?: PrOverdue[];
353
439
  /** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
354
440
  selection: {
355
441
  changed: string[];
@@ -361,6 +447,23 @@ export interface PrReport {
361
447
  };
362
448
  members: PrMember[];
363
449
  changeSet: ChangeSetDocument;
450
+ /**
451
+ * The stage hooks the job ran (#4288), in order. Absent when it ran none.
452
+ * Added within contract 1 by chant {@link PR_REPORT_HOOKS_FLOOR}.
453
+ */
454
+ hooks?: PrHookOutcome[];
455
+ /**
456
+ * What `ci.policy` at base said about the change set (#4294). Absent when
457
+ * the base sets no policy, or the stage reached no plan to judge. Added
458
+ * within contract 1 by chant {@link PR_REPORT_POLICY_FLOOR}.
459
+ */
460
+ policy?: PrPolicyReport;
461
+ /**
462
+ * `pr-apply --before-merge` (#4301): the requires checks. Absent on every
463
+ * other stage. Added within contract 1 by chant
464
+ * {@link PR_REPORT_BEFORE_MERGE_FLOOR}.
465
+ */
466
+ beforeMerge?: PrBeforeMerge;
364
467
  }
365
468
 
366
469
  /** The counts a member's entries add up to. */
@@ -421,11 +524,14 @@ function headline(report: PrReport): string {
421
524
  case "recorded":
422
525
  return `Recorded the plan of ${members} on merge. Nothing applied: a release that carries this commit deploys it.`;
423
526
  case "plan-failed":
527
+ if (report.hooks?.some((h) => h.outcome === "failed")) return "A hook failed, so nothing can apply.";
424
528
  return `Planning failed for ${report.members.filter((m) => m.status === "plan-failed").length} of ${members}, so nothing can apply.`;
425
529
  case "applied":
426
530
  return `Applied ${members}.`;
427
- case "failed":
428
- return `The apply failed: ${report.members.filter((m) => m.status === "failed").length} failed, ${report.members.filter((m) => m.status === "applied").length} applied.`;
531
+ case "failed": {
532
+ const blocked = report.members.filter((m) => m.status === "blocked").length;
533
+ return `The apply failed: ${report.members.filter((m) => m.status === "failed").length} failed, ${blocked > 0 ? `${blocked} blocked, ` : ""}${report.members.filter((m) => m.status === "applied").length} applied.`;
534
+ }
429
535
  case "wave-applied": {
430
536
  const applied = (report.waves ?? []).filter((w) => w.status === "applied").map((w) => w.wave);
431
537
  const total = report.selection.waves.length;
@@ -440,6 +546,43 @@ function headline(report: PrReport): string {
440
546
  }
441
547
  }
442
548
 
549
+ /**
550
+ * The blocked set (#4291): each component a failure held, grouped by the
551
+ * component that failed, and that the rest went on. Undefined when nothing
552
+ * is blocked.
553
+ */
554
+ function blockedLine(members: readonly PrMember[]): string | undefined {
555
+ const byRoot = new Map<string, Set<string>>();
556
+ for (const m of members) {
557
+ if (m.status !== "blocked") continue;
558
+ const by = m.blockedBy ?? "a dependency";
559
+ byRoot.set(by, (byRoot.get(by) ?? new Set()).add(m.component));
560
+ }
561
+ if (byRoot.size === 0) return undefined;
562
+ const groups = [...byRoot]
563
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
564
+ .map(([by, names]) => `${[...names].sort().map(code).join(", ")} (blocked by ${by === "a dependency" ? by : code(by)})`);
565
+ return `Blocked, so not applied: ${groups.join("; ")}. A failure blocks only the members that depend on it, so the rest went on.`;
566
+ }
567
+
568
+ /** The policy's lines in the note (#4294): what denied or held the plan, and how a denial is overridden. */
569
+ function policyLines(report: PrReport): string[] {
570
+ const p = report.policy;
571
+ if (!p) return [];
572
+ const at = `read at ${code(short(p.base))}`;
573
+ const each = (list: Array<{ rule: string; message: string }>): string[] => list.map((f) => `- ${code(f.rule)}: ${f.message}`);
574
+ const out: string[] = [""];
575
+ if (p.result === "error") out.push(`Policy (${p.engine}, ${at}) could not be checked, so nothing applies: ${p.error ?? "no answer"}.`);
576
+ else if (p.result === "passed") out.push(`Policy (${p.engine}, ${at}) passed.`);
577
+ if (p.denials.length > 0) {
578
+ out.push(p.override ? `Policy (${p.engine}, ${at}) denied this plan; ${code(p.override.by)} overrode it: ${p.override.reason}` : `Policy (${p.engine}, ${at}) denied this plan:`, ...each(p.denials));
579
+ if (!p.override && p.overrideCommand) out.push("", `A person the overriders file at base lists may override it once pr-apply has recorded the denial: ${code(p.overrideCommand)}`);
580
+ }
581
+ if (p.holds.length > 0) out.push(`Policy (${p.engine}, ${at}) holds this plan for a person's approval of its digest:`, ...each(p.holds));
582
+ if (p.warnings.length > 0) out.push(`Policy warnings: ${p.warnings.join("; ")}`);
583
+ return out;
584
+ }
585
+
443
586
  /** Who a wave applied or waits under, as a table cell. */
444
587
  function waveApproval(w: PrWave): string {
445
588
  const whose = w.approval === "pull-request" ? "the pull request's" : "its own";
@@ -513,6 +656,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
513
656
  `${headline(report)} Head ${code(short(report.head))}, measured from ${code(short(report.base))}.`,
514
657
  ];
515
658
  if (report.message) out.push("", `> ${report.message.split("\n").join("\n> ")}`);
659
+ for (const o of report.overdue ?? []) out.push("", `Warning: ${o.message}`);
516
660
  if (report.resumed && report.resumed.applied.length > 0) {
517
661
  out.push("", `Resumed under the same approval: ${report.resumed.applied.map(code).join(", ")} applied in an earlier attempt and did not run again.`);
518
662
  }
@@ -522,6 +666,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
522
666
  const holes = memberHoleReasons(report, m.member);
523
667
  const status =
524
668
  STATUS_WORDS[m.status] +
669
+ (m.status === "blocked" && m.blockedBy ? ` by ${code(m.blockedBy)}` : "") +
525
670
  (m.inputsMoved?.length ? `, inputs from ${m.inputsMoved.join(", ")} moved` : "") +
526
671
  (holes.length > 0 ? `; ${holes.length > 2 ? `${holes[0]}; and ${holes.length - 1} more, listed under Holes` : holes.join("; ")}` : "");
527
672
  // A member whose plan has holes and no entries says nothing about what
@@ -534,7 +679,17 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
534
679
  out.push("", "A member whose inputs moved applied the plan it was approved with. The next run plans it against the new values.");
535
680
  }
536
681
  }
682
+ const blocked = blockedLine(report.members);
683
+ if (blocked) out.push("", blocked);
537
684
  out.push(...waveLines(report, options.approver ?? "<you>"));
685
+ // #4288: the hooks that did not pass, by name; a passed hook is a count.
686
+ const hooks = report.hooks ?? [];
687
+ if (hooks.length > 0) {
688
+ const passed = hooks.filter((h) => h.outcome === "passed").length;
689
+ const other = hooks.filter((h) => h.outcome !== "passed").map((h) => `${code(h.name)} ${h.moment}${h.target ? ` for ${code(h.target)}` : ""} ${h.outcome === "held" ? "held" : "failed"} (exit ${h.exit})`);
690
+ out.push("", `Hooks: ${passed} passed${other.length > 0 ? `; ${other.join("; ")}` : ""}.`);
691
+ }
692
+ out.push(...policyLines(report));
538
693
  if (report.selection.unclaimed.length > 0) {
539
694
  out.push("", `Changed, and deployed by no component: ${report.selection.unclaimed.map(code).join(", ")}.`);
540
695
  }
@@ -635,7 +790,16 @@ export function prStatusDescription(report: PrReport): string {
635
790
  text = "refused: an earlier wave has not applied";
636
791
  break;
637
792
  }
638
- text = `refused: ${report.refusal === "plan-changed" ? "the plan changed after review" : report.refusal === "not-a-reviewer" ? "the approver has not approved the pull request" : report.refusal === "author-approved" ? "the approver wrote the change" : report.refusal === "no-pull-request" ? "no pull request merged this commit" : "the plan is not approved"}`;
793
+ if (report.refusal === "requires-unmet" || report.refusal === "before-merge-off") {
794
+ const unmet = (report.beforeMerge?.requires ?? []).filter((r) => !r.passed).map((r) => r.name);
795
+ text = report.refusal === "before-merge-off" ? "refused: this workspace does not apply before merge" : `refused: ${unmet.join(", ") || "a require"} unmet for an apply before merge`;
796
+ break;
797
+ }
798
+ if (report.refusal === "policy-denied" || report.refusal === "policy-error") {
799
+ text = report.refusal === "policy-denied" ? `refused: the policy denied the plan (${(report.policy?.rules ?? []).join(", ")})` : "refused: the policy could not be checked";
800
+ break;
801
+ }
802
+ text = `refused: ${report.refusal === "plan-changed" ? "the plan changed after review" : report.refusal === "not-a-reviewer" ? "the approver has not approved the pull request" : report.refusal === "author-approved" ? "the approver wrote the change" : report.refusal === "no-pull-request" ? "no pull request merged this commit" : report.refusal === "locked" ? "another pull request holds a target lock" : "the plan is not approved"}`;
639
803
  break;
640
804
  }
641
805
  }
@@ -646,6 +810,8 @@ export function prStatusDescription(report: PrReport): string {
646
810
  export function prStatusState(report: PrReport): "success" | "failure" | "pending" {
647
811
  // #4168 decision 1: a merge whose plan differs from the approved one is recorded, and its status fails.
648
812
  if (report.status === "recorded") return report.approval.status === "changed" ? "failure" : "success";
813
+ // #4294: a plan the policy denied, with no override, or one it could not check, fails at the plan stage as it would at the apply.
814
+ if (report.status === "planned" && report.policy && (report.policy.result === "error" || (report.policy.result === "denied" && !report.policy.override))) return "failure";
649
815
  if (report.status === "planned" || report.status === "applied" || report.status === "wave-applied" || report.status === "nothing") return "success";
650
816
  if (report.status === "refused" && (report.refusal === "not-approved" || report.refusal === "wave-not-approved")) return "pending";
651
817
  if (report.status === "waiting") return "pending";
@@ -0,0 +1,141 @@
1
+ /**
2
+ * #4295: `chant components pr-post` posts the plan's note and `chant/plan`
3
+ * from a report the pull request's code wrote, so it reads it as data: the
4
+ * pull request, head, environment and member are the caller's, a report that
5
+ * names others is refused, and the note carries no hidden marker but its own.
6
+ */
7
+
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { postPlanReport, readPlanReport } from "./pr-post";
13
+ import { prNoteMarker, type PrReport } from "./pr-loop";
14
+ import type { ForgeStatus, PrForge } from "./pr-forge";
15
+
16
+ const FIXTURE = join(import.meta.dirname, "__fixtures__", "pr-loop", "pr-plan.golden.json");
17
+ const REPORT = JSON.parse(readFileSync(FIXTURE, "utf-8")) as PrReport;
18
+ const HEAD = REPORT.head;
19
+
20
+ const dirs: string[] = [];
21
+ afterAll(() => {
22
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
23
+ });
24
+
25
+ function reportDir(report: unknown): string {
26
+ const dir = mkdtempSync(join(tmpdir(), "chant-pr-post-"));
27
+ dirs.push(dir);
28
+ mkdirSync(dir, { recursive: true });
29
+ if (report !== undefined) writeFileSync(join(dir, "pr-plan.json"), typeof report === "string" ? report : JSON.stringify(report));
30
+ return dir;
31
+ }
32
+
33
+ function fakeForge(refuse = false): { forge: PrForge; statuses: Array<{ sha: string; status: ForgeStatus }>; notes: Array<{ pr: number; marker: string; body: string }> } {
34
+ const statuses: Array<{ sha: string; status: ForgeStatus }> = [];
35
+ const notes: Array<{ pr: number; marker: string; body: string }> = [];
36
+ const forge = {
37
+ kind: "github",
38
+ host: "github.com",
39
+ runUrl: "https://github.com/acme/estate/actions/runs/1",
40
+ async setStatus(sha: string, status: ForgeStatus) {
41
+ if (refuse) throw new Error("403");
42
+ statuses.push({ sha, status });
43
+ },
44
+ async upsertNote(pr: number, marker: string, body: string) {
45
+ if (refuse) throw new Error("403");
46
+ notes.push({ pr, marker, body });
47
+ return "1";
48
+ },
49
+ } as unknown as PrForge;
50
+ return { forge, statuses, notes };
51
+ }
52
+
53
+ const at = { pr: REPORT.pr!, head: HEAD, env: REPORT.env };
54
+
55
+ describe("pr-post reads the plan report as data (#4295)", () => {
56
+ test("a report for this pull request, head and environment posts its status and its note", async () => {
57
+ const { forge, statuses, notes } = fakeForge();
58
+ const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at, result: "success" });
59
+ expect(r).toMatchObject({ state: "success", noted: true, warnings: [] });
60
+ expect(statuses).toEqual([{ sha: HEAD, status: expect.objectContaining({ context: "chant/plan", state: "success", url: "https://github.com/acme/estate/actions/runs/1" }) }]);
61
+ expect(notes).toHaveLength(1);
62
+ expect(notes[0]!.marker).toBe(prNoteMarker("prod"));
63
+ expect(notes[0]!.body.startsWith(prNoteMarker("prod") + "\n")).toBe(true);
64
+ });
65
+
66
+ test("a marker the change wrote into its report is defused, so the note claims no other environment's", async () => {
67
+ const forged = { ...REPORT, message: `planned ${prNoteMarker("staging")} <!-- chant-pr:prod -->` };
68
+ const { forge, notes } = fakeForge();
69
+ await postPlanReport({ forge, dir: reportDir(forged), ...at });
70
+ const body = notes[0]!.body;
71
+ expect(body.split("<!--")).toHaveLength(2);
72
+ expect(body).toContain("&lt;!-- chant-pr:staging -->");
73
+ });
74
+
75
+ test.each([
76
+ ["another pull request", { pr: 13 }, /pull request 13, not 12/],
77
+ ["another head", { head: "3".repeat(40) }, /for head/],
78
+ ["another environment", { env: "staging" }, /environment "staging"/],
79
+ ["a member", { member: "net" }, /member "net"/],
80
+ ["the apply stage", { stage: "apply" }, /not a plan report/],
81
+ ])("a report for %s is refused: a failed status and no note", async (_what, change, why) => {
82
+ const { forge, statuses, notes } = fakeForge();
83
+ const r = await postPlanReport({ forge, dir: reportDir({ ...REPORT, ...change }), ...at });
84
+ expect(r.refused).toMatch(why);
85
+ expect(r.state).toBe("failure");
86
+ expect(statuses[0]!.sha).toBe(HEAD);
87
+ expect(statuses[0]!.status.state).toBe("failure");
88
+ expect(notes).toEqual([]);
89
+ });
90
+
91
+ test("no report, or one that does not read, fails the status", async () => {
92
+ for (const report of [undefined, "{not json", "[]", JSON.stringify({ ...REPORT, members: "x" })]) {
93
+ const { forge, statuses, notes } = fakeForge();
94
+ const r = await postPlanReport({ forge, dir: reportDir(report), ...at });
95
+ expect(r.state).toBe("failure");
96
+ expect(statuses[0]!.status.state).toBe("failure");
97
+ expect(notes).toEqual([]);
98
+ }
99
+ expect(readPlanReport({ dir: reportDir(undefined), ...at })).toEqual({ refused: "the plan job wrote no report" });
100
+ });
101
+
102
+ test("a plan job that failed fails the status, whatever its report says", async () => {
103
+ const { forge, statuses } = fakeForge();
104
+ const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at, result: "failure" });
105
+ expect(r.state).toBe("failure");
106
+ expect(statuses[0]!.status.description).toMatch(/^the plan job failed; /);
107
+ expect(statuses[0]!.status.description.length).toBeLessThanOrEqual(140);
108
+ });
109
+
110
+ test("a member's report posts under the member's context and marker, and none when the change does not reach it", async () => {
111
+ const { forge, statuses, notes } = fakeForge();
112
+ await postPlanReport({ forge, dir: reportDir({ ...REPORT, member: "net" }), ...at, member: "net" });
113
+ expect(statuses[0]!.status.context).toBe("chant/plan/net");
114
+ expect(notes[0]!.marker).toBe(prNoteMarker("prod", "net"));
115
+ const quiet = fakeForge();
116
+ await postPlanReport({ forge: quiet.forge, dir: reportDir({ ...REPORT, member: "net", status: "nothing" }), ...at, member: "net" });
117
+ expect(quiet.statuses).toHaveLength(1);
118
+ expect(quiet.notes).toEqual([]);
119
+ });
120
+
121
+ test("a forge that refuses is a warning", async () => {
122
+ const { forge } = fakeForge(true);
123
+ const r = await postPlanReport({ forge, dir: reportDir(REPORT), ...at });
124
+ expect(r.warnings).toEqual([expect.stringMatching(/could not set the chant\/plan status: 403/), expect.stringMatching(/could not post the note on #12: 403/)]);
125
+ expect(r.noted).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("pr-post asks for overdue scheduled Ops itself (#4289, #4295)", () => {
130
+ test("the Ops it finds go in the note, and an overdue list the change wrote is dropped", async () => {
131
+ const forged = { ...REPORT, overdue: [{ op: "forged", cron: "* * * * *", message: "forged warning" }] };
132
+ const found = { op: "drift-watch", cron: "0 * * * *", message: "Op drift-watch has not run since its cron fired twice" };
133
+ const { forge, notes } = fakeForge();
134
+ await postPlanReport({ forge, dir: reportDir(forged), ...at, overdue: async () => [found as never] });
135
+ expect(notes[0]!.body).toContain("drift-watch has not run");
136
+ expect(notes[0]!.body).not.toContain("forged warning");
137
+ const quiet = fakeForge();
138
+ await postPlanReport({ forge: quiet.forge, dir: reportDir(forged), ...at, overdue: async () => [] });
139
+ expect(quiet.notes[0]!.body).not.toContain("forged warning");
140
+ });
141
+ });
package/src/pr-post.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Post a pull request's plan note and its `chant/plan` status from the plan
3
+ * report (#4295), in a job or a process that runs none of the change's code.
4
+ *
5
+ * The plan job runs the pull request's code and holds no forge token
6
+ * (./ci-handover.ts), so `pr-plan --no-status` only writes its report. Then
7
+ * `chant components pr-post` in the `plan-note` job, or `chant ci run plan`
8
+ * once its steps settle, reads the report and posts.
9
+ *
10
+ * The report is the change's: its code could write anything into it. So it
11
+ * is read as data. The pull request, the head, the environment and the
12
+ * member come from the caller, never the report, and a report that names
13
+ * others is refused. The note is rendered again here from the report, and
14
+ * every hidden HTML comment after its first line is defused, so a forged
15
+ * marker cannot claim another environment's note. A plan job that failed
16
+ * fails the status whatever its report says.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { prNoteMarker, prStatusContext, prStatusDescription, prStatusState, renderPrNote, type PrOverdue, type PrReport } from "./pr-loop";
22
+ import { forgePrincipalOf, type PrForge } from "./pr-forge";
23
+ import { GITHUB_COMMENT_LIMIT, GITLAB_NOTE_LIMIT } from "./plan-summary";
24
+
25
+ /** The plan report's file under the report directory. */
26
+ export const PR_PLAN_REPORT_FILE = "pr-plan.json";
27
+
28
+ /** What {@link postPlanReport} posts for. */
29
+ export interface PlanPostInput {
30
+ forge: PrForge;
31
+ /** The report directory the plan job wrote, `.chant/pr`. */
32
+ dir: string;
33
+ /** The pull request, from the forge's event. */
34
+ pr: number;
35
+ /** The commit the plan job checked out, from the forge's event. */
36
+ head: string;
37
+ env: string;
38
+ member?: string;
39
+ /** The plan job's own result (`needs.plan.result`). `failure` fails the status. */
40
+ result?: string;
41
+ /**
42
+ * The scheduled Ops that stopped running (#4289), asked here since the plan
43
+ * job held no token to ask with. It replaces any `overdue` in the report.
44
+ */
45
+ overdue?: () => Promise<PrOverdue[]>;
46
+ }
47
+
48
+ /** What {@link postPlanReport} did. */
49
+ export interface PlanPostResult {
50
+ state: "success" | "failure" | "pending";
51
+ description: string;
52
+ /** Whether a note was posted. A refused or missing report posts none. */
53
+ noted: boolean;
54
+ /** Why the report was not used, when it was not. */
55
+ refused?: string;
56
+ warnings: string[];
57
+ }
58
+
59
+ const SHA = /^[0-9a-f]{40}$/;
60
+
61
+ /**
62
+ * Read the plan report under `dir` and check it is the one the caller ran:
63
+ * a plan report for the same pull request, head, environment and member.
64
+ * Returns the report, or why it is not used.
65
+ */
66
+ export function readPlanReport(input: Pick<PlanPostInput, "dir" | "pr" | "head" | "env" | "member">): { report: PrReport } | { refused: string } {
67
+ const file = join(input.dir, PR_PLAN_REPORT_FILE);
68
+ if (!existsSync(file)) return { refused: "the plan job wrote no report" };
69
+ let doc: unknown;
70
+ try {
71
+ doc = JSON.parse(readFileSync(file, "utf-8"));
72
+ } catch (err) {
73
+ return { refused: `the plan report does not read: ${err instanceof Error ? err.message : String(err)}` };
74
+ }
75
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return { refused: "the plan report is not an object" };
76
+ const r = doc as Record<string, unknown>;
77
+ if (r.stage !== "plan") return { refused: `the report is not a plan report (stage ${JSON.stringify(r.stage)})` };
78
+ if (r.pr !== input.pr) return { refused: `the report is for pull request ${JSON.stringify(r.pr)}, not ${input.pr}` };
79
+ if (typeof r.head !== "string" || !SHA.test(r.head) || r.head !== input.head) return { refused: `the report is for head ${JSON.stringify(r.head)}, not ${input.head}` };
80
+ if (r.env !== input.env) return { refused: `the report is for environment ${JSON.stringify(r.env)}, not ${input.env}` };
81
+ if ((r.member ?? undefined) !== input.member) return { refused: `the report is for member ${JSON.stringify(r.member ?? null)}, not ${JSON.stringify(input.member ?? null)}` };
82
+ if (!Array.isArray(r.members) || !r.changeSet || typeof r.changeSet !== "object" || !r.selection || typeof r.selection !== "object" || !r.approval || typeof r.approval !== "object") {
83
+ return { refused: "the plan report is missing its members, change set, selection or approval" };
84
+ }
85
+ return { report: doc as PrReport };
86
+ }
87
+
88
+ /**
89
+ * The note for `report`, rendered here, with every `<!--` after the marker
90
+ * line written as `&lt;!--`, so the note carries no hidden marker but its own.
91
+ */
92
+ export function planNoteFor(report: PrReport, forge: Pick<PrForge, "kind" | "host">, marker: string): string {
93
+ const note = renderPrNote(report, {
94
+ limit: forge.kind === "gitlab" ? GITLAB_NOTE_LIMIT : GITHUB_COMMENT_LIMIT,
95
+ approver: forgePrincipalOf(forge.kind, forge.host, "<your-login>"),
96
+ });
97
+ const at = note.indexOf("\n");
98
+ const rest = (at < 0 ? "" : note.slice(at + 1)).split("<!--").join("&lt;!--");
99
+ return `${marker}\n${rest}`;
100
+ }
101
+
102
+ const cut = (text: string, max = 140): string => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
103
+
104
+ /**
105
+ * Post the status and the note for the plan report under `input.dir`. A
106
+ * forge that refuses a call is a warning. A report that is missing or not
107
+ * the caller's sets a failed status and posts no note.
108
+ */
109
+ export async function postPlanReport(input: PlanPostInput): Promise<PlanPostResult> {
110
+ const warnings: string[] = [];
111
+ const context = prStatusContext("plan", input.member);
112
+ const read = readPlanReport(input);
113
+ const failedJob = input.result === "failure" || input.result === "cancelled";
114
+ let state: PlanPostResult["state"];
115
+ let description: string;
116
+ let note: string | undefined;
117
+ let refused: string | undefined;
118
+ if ("refused" in read) {
119
+ refused = read.refused;
120
+ state = "failure";
121
+ description = cut(read.refused);
122
+ } else {
123
+ let report = read.report;
124
+ if (input.overdue) {
125
+ const { overdue: _forged, ...rest } = report;
126
+ const found = await input.overdue().catch(() => [] as PrOverdue[]);
127
+ report = (found.length > 0 ? { ...rest, overdue: found } : rest) as PrReport;
128
+ }
129
+ try {
130
+ state = prStatusState(report);
131
+ description = prStatusDescription(report);
132
+ // A member the change does not reach has nothing to say on the pull request (as pr-plan's own publish).
133
+ if (!(input.member && report.status === "nothing" && !report.overdue?.length)) note = planNoteFor(report, input.forge, prNoteMarker(input.env, input.member));
134
+ } catch (err) {
135
+ refused = `the plan report does not render: ${err instanceof Error ? err.message : String(err)}`;
136
+ state = "failure";
137
+ description = cut(refused);
138
+ note = undefined;
139
+ }
140
+ if (failedJob && state !== "failure") {
141
+ state = "failure";
142
+ description = cut(`the plan job failed; ${description}`);
143
+ }
144
+ }
145
+ try {
146
+ await input.forge.setStatus(input.head, { context, state, description: cut(description), ...(input.forge.runUrl ? { url: input.forge.runUrl } : {}) });
147
+ } catch (err) {
148
+ warnings.push(`could not set the ${context} status: ${err instanceof Error ? err.message : String(err)}`);
149
+ }
150
+ if (note !== undefined) {
151
+ try {
152
+ await input.forge.upsertNote(input.pr, prNoteMarker(input.env, input.member), note);
153
+ } catch (err) {
154
+ warnings.push(`could not post the note on #${input.pr}: ${err instanceof Error ? err.message : String(err)}`);
155
+ note = undefined;
156
+ }
157
+ }
158
+ return { state, description: cut(description), noted: note !== undefined, ...(refused ? { refused } : {}), warnings };
159
+ }