@intentius/chant 0.124.31 → 0.124.32

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 (321) hide show
  1. package/bin/chant +7 -2
  2. package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-plugin.js +10 -7
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.js +18 -12
  7. package/dist/cli/commands/import.d.ts +10 -0
  8. package/dist/cli/commands/import.d.ts.map +1 -1
  9. package/dist/cli/commands/import.js +37 -0
  10. package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
  11. package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
  12. package/dist/cli/commands/onboard.d.ts.map +1 -1
  13. package/dist/cli/commands/onboard.js +8 -4
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +2 -1
  16. package/dist/cli/dependencies.d.ts +18 -0
  17. package/dist/cli/dependencies.d.ts.map +1 -0
  18. package/dist/cli/dependencies.js +120 -0
  19. package/dist/cli/handlers/build.d.ts.map +1 -1
  20. package/dist/cli/handlers/build.js +23 -4
  21. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  22. package/dist/cli/handlers/fan-out.js +12 -4
  23. package/dist/cli/handlers/graph.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.js +16 -6
  25. package/dist/cli/handlers/lint.d.ts.map +1 -1
  26. package/dist/cli/handlers/lint.js +6 -0
  27. package/dist/cli/handlers/operator.d.ts +2 -2
  28. package/dist/cli/handlers/operator.js +3 -3
  29. package/dist/cli/handlers/pr.d.ts +14 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +488 -32
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +3 -1
  34. package/dist/cli/handlers/run.d.ts +13 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +38 -15
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +44 -16
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +15 -0
  41. package/dist/cli/registry.d.ts +13 -4
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/registry.js +6 -2
  44. package/dist/codegen/generate-post-synth-barrel.js +2 -2
  45. package/dist/codegen/lexicon-regen.d.ts +4 -0
  46. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  47. package/dist/codegen/lexicon-regen.js +1 -1
  48. package/dist/codegen/pinned-upgrade.js +2 -2
  49. package/dist/components/capability.d.ts +44 -3
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/capability.js +21 -0
  52. package/dist/components/changed-files.d.ts +7 -1
  53. package/dist/components/changed-files.d.ts.map +1 -1
  54. package/dist/components/changed-files.js +9 -1
  55. package/dist/components/driver.d.ts +38 -0
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/driver.js +47 -6
  58. package/dist/components/fan-out-record.d.ts +6 -1
  59. package/dist/components/fan-out-record.d.ts.map +1 -1
  60. package/dist/components/fan-out-record.js +24 -0
  61. package/dist/components/fan-out-run.d.ts +20 -0
  62. package/dist/components/fan-out-run.d.ts.map +1 -1
  63. package/dist/components/fan-out-run.js +21 -1
  64. package/dist/components/pr-pipeline.d.ts +65 -2
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +107 -15
  67. package/dist/components/pr-run.d.ts +127 -29
  68. package/dist/components/pr-run.d.ts.map +1 -1
  69. package/dist/components/pr-run.js +290 -70
  70. package/dist/components/wave-plan.d.ts +5 -2
  71. package/dist/components/wave-plan.d.ts.map +1 -1
  72. package/dist/components/wave-plan.js +12 -1
  73. package/dist/discovery/fold-import.d.ts.map +1 -1
  74. package/dist/discovery/fold-import.js +16 -6
  75. package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
  76. package/dist/discovery/sandbox/bundle.js +6 -0
  77. package/dist/discovery/specifier-resolve.d.ts.map +1 -1
  78. package/dist/discovery/specifier-resolve.js +8 -2
  79. package/dist/fold/fold.d.ts.map +1 -1
  80. package/dist/fold/fold.js +68 -1
  81. package/dist/fold/subset.d.ts +37 -0
  82. package/dist/fold/subset.d.ts.map +1 -1
  83. package/dist/fold/subset.js +88 -0
  84. package/dist/lexicon-module.d.ts +10 -0
  85. package/dist/lexicon-module.d.ts.map +1 -1
  86. package/dist/lexicon-module.js +53 -8
  87. package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
  88. package/dist/lexicon-plugin-helpers.js +6 -4
  89. package/dist/lexicon.d.ts +2 -0
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/affected.d.ts.map +1 -1
  92. package/dist/lifecycle/affected.js +7 -1
  93. package/dist/lifecycle/release-ledger.d.ts +21 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +24 -0
  96. package/dist/lifecycle/work-dir.d.ts +45 -0
  97. package/dist/lifecycle/work-dir.d.ts.map +1 -0
  98. package/dist/lifecycle/work-dir.js +78 -0
  99. package/dist/lint/discover.d.ts +17 -9
  100. package/dist/lint/discover.d.ts.map +1 -1
  101. package/dist/lint/discover.js +62 -54
  102. package/dist/module-source.d.ts +57 -0
  103. package/dist/module-source.d.ts.map +1 -0
  104. package/dist/module-source.js +104 -0
  105. package/dist/op/activities/factory.js +1 -1
  106. package/dist/op/discover.d.ts.map +1 -1
  107. package/dist/op/discover.js +13 -3
  108. package/dist/op/gate.d.ts.map +1 -1
  109. package/dist/op/gate.js +3 -0
  110. package/dist/op/op-waves-run.d.ts.map +1 -1
  111. package/dist/op/op-waves-run.js +20 -5
  112. package/dist/op/operator.d.ts +2 -2
  113. package/dist/op/operator.js +2 -2
  114. package/dist/op/steward.d.ts +6 -4
  115. package/dist/op/steward.d.ts.map +1 -1
  116. package/dist/op/steward.js +5 -3
  117. package/dist/op/work-lease-run.d.ts +20 -7
  118. package/dist/op/work-lease-run.d.ts.map +1 -1
  119. package/dist/op/work-lease-run.js +56 -12
  120. package/dist/pr-forge.d.ts +10 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +39 -0
  123. package/dist/pr-loop.d.ts +141 -4
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +122 -6
  126. package/dist/pr-report-url.d.ts +2 -2
  127. package/dist/pr-report-url.d.ts.map +1 -1
  128. package/dist/pr-report-url.js +5 -5
  129. package/dist/ts-loader.d.ts +38 -4
  130. package/dist/ts-loader.d.ts.map +1 -1
  131. package/dist/ts-loader.js +190 -16
  132. package/dist/wave-runtime.mjs +208 -208
  133. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  134. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  135. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  136. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  137. package/dist/workspace/chant-migrations.js +2 -0
  138. package/dist/workspace/checks/boxes.d.ts +6 -6
  139. package/dist/workspace/checks/boxes.js +9 -9
  140. package/dist/workspace/ci-green-forge.d.ts +6 -4
  141. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  142. package/dist/workspace/ci-green.d.ts +28 -12
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +49 -21
  145. package/dist/workspace/ci-list.schema.json +12 -6
  146. package/dist/workspace/ci-report-url.d.ts +18 -0
  147. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  148. package/dist/workspace/ci-report-url.js +33 -0
  149. package/dist/workspace/ci-run-index.d.ts +12 -0
  150. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  151. package/dist/workspace/ci-run-index.js +6 -4
  152. package/dist/workspace/ci-run-index.schema.json +5 -3
  153. package/dist/workspace/ci-run.d.ts +75 -2
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +195 -53
  156. package/dist/workspace/ci-runner-key.d.ts +16 -3
  157. package/dist/workspace/ci-runner-key.d.ts.map +1 -1
  158. package/dist/workspace/ci-runner-key.js +30 -10
  159. package/dist/workspace/ci-serve.d.ts +54 -4
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +175 -26
  162. package/dist/workspace/declaration.d.ts +8 -0
  163. package/dist/workspace/declaration.d.ts.map +1 -1
  164. package/dist/workspace/declaration.js +11 -2
  165. package/dist/workspace/declaration.schema.json +7 -2
  166. package/dist/workspace/export.d.ts +1 -1
  167. package/dist/workspace/export.d.ts.map +1 -1
  168. package/dist/workspace/export.js +7 -3
  169. package/dist/workspace/ls.d.ts +6 -1
  170. package/dist/workspace/ls.d.ts.map +1 -1
  171. package/dist/workspace/ls.js +1 -0
  172. package/dist/workspace/ls.schema.json +7 -1
  173. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  174. package/dist/workspace/pin-integrity.js +3 -2
  175. package/dist/workspace/pr-report.schema.json +74 -7
  176. package/dist/workspace/reason-codes.d.ts +1 -1
  177. package/dist/workspace/reason-codes.d.ts.map +1 -1
  178. package/dist/workspace/reason-codes.js +2 -2
  179. package/dist/workspace/run-event.schema.json +3 -1
  180. package/dist/workspace/status.schema.json +3 -1
  181. package/dist/workspace/write-scope.d.ts +9 -0
  182. package/dist/workspace/write-scope.d.ts.map +1 -1
  183. package/dist/workspace/write-scope.js +11 -1
  184. package/package.json +1 -1
  185. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  186. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  187. package/src/cli/commands/audit.test.ts +2 -2
  188. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  189. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  190. package/src/cli/commands/check-lexicon.test.ts +1 -1
  191. package/src/cli/commands/check-lexicon.ts +16 -11
  192. package/src/cli/commands/import.ts +40 -0
  193. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  194. package/src/cli/commands/init-lexicon.test.ts +8 -5
  195. package/src/cli/commands/onboard.test.ts +45 -0
  196. package/src/cli/commands/onboard.ts +8 -4
  197. package/src/cli/commands/update.test.ts +6 -0
  198. package/src/cli/commands/update.ts +4 -1
  199. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  200. package/src/cli/dependencies.e2e.test.ts +101 -0
  201. package/src/cli/dependencies.test.ts +93 -0
  202. package/src/cli/dependencies.ts +122 -0
  203. package/src/cli/handlers/build.test.ts +69 -0
  204. package/src/cli/handlers/build.ts +20 -4
  205. package/src/cli/handlers/fan-out.test.ts +73 -0
  206. package/src/cli/handlers/fan-out.ts +11 -4
  207. package/src/cli/handlers/graph.test.ts +76 -0
  208. package/src/cli/handlers/graph.ts +19 -12
  209. package/src/cli/handlers/lint.test.ts +42 -0
  210. package/src/cli/handlers/lint.ts +6 -0
  211. package/src/cli/handlers/operator.ts +3 -3
  212. package/src/cli/handlers/pr-waves.test.ts +449 -0
  213. package/src/cli/handlers/pr.test.ts +134 -0
  214. package/src/cli/handlers/pr.ts +523 -31
  215. package/src/cli/handlers/promote.test.ts +26 -1
  216. package/src/cli/handlers/promote.ts +3 -2
  217. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  218. package/src/cli/handlers/run-signer.test.ts +79 -0
  219. package/src/cli/handlers/run.ts +34 -16
  220. package/src/cli/main.test.ts +18 -0
  221. package/src/cli/main.ts +43 -16
  222. package/src/cli/parse-args.ts +12 -0
  223. package/src/cli/registry.ts +19 -6
  224. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  225. package/src/codegen/lexicon-regen.ts +1 -1
  226. package/src/codegen/pinned-upgrade.ts +2 -2
  227. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  228. package/src/components/capability.ts +51 -3
  229. package/src/components/changed-files.test.ts +16 -1
  230. package/src/components/changed-files.ts +9 -1
  231. package/src/components/driver.test.ts +36 -1
  232. package/src/components/driver.ts +76 -4
  233. package/src/components/fan-out-record.ts +29 -1
  234. package/src/components/fan-out-run.test.ts +35 -0
  235. package/src/components/fan-out-run.ts +40 -1
  236. package/src/components/pr-pipeline.test.ts +100 -0
  237. package/src/components/pr-pipeline.ts +132 -17
  238. package/src/components/pr-run.test.ts +323 -18
  239. package/src/components/pr-run.ts +354 -75
  240. package/src/components/wave-plan.ts +16 -4
  241. package/src/discovery/fold-host-method.test.ts +87 -0
  242. package/src/discovery/fold-import.ts +15 -5
  243. package/src/discovery/fold-package-composite.test.ts +27 -0
  244. package/src/discovery/fold-specifiers.test.ts +8 -0
  245. package/src/discovery/sandbox/bundle.ts +6 -0
  246. package/src/discovery/specifier-resolve.ts +8 -2
  247. package/src/fold/fold.test.ts +72 -0
  248. package/src/fold/fold.ts +71 -0
  249. package/src/fold/subset.test.ts +31 -0
  250. package/src/fold/subset.ts +99 -0
  251. package/src/lexicon-dist.e2e.test.ts +254 -0
  252. package/src/lexicon-module.test.ts +47 -0
  253. package/src/lexicon-module.ts +49 -7
  254. package/src/lexicon-plugin-helpers.ts +6 -4
  255. package/src/lexicon.ts +2 -0
  256. package/src/lifecycle/affected.test.ts +7 -0
  257. package/src/lifecycle/affected.ts +6 -1
  258. package/src/lifecycle/release-ledger.ts +34 -0
  259. package/src/lifecycle/work-dir.test.ts +77 -0
  260. package/src/lifecycle/work-dir.ts +82 -0
  261. package/src/lint/discover.test.ts +46 -2
  262. package/src/lint/discover.ts +55 -49
  263. package/src/module-source.test.ts +85 -0
  264. package/src/module-source.ts +112 -0
  265. package/src/op/activities/factory.ts +1 -1
  266. package/src/op/discover-root.test.ts +31 -2
  267. package/src/op/discover.ts +14 -4
  268. package/src/op/gate.test.ts +30 -0
  269. package/src/op/gate.ts +3 -0
  270. package/src/op/op-waves-run.ts +20 -5
  271. package/src/op/op-waves.test.ts +49 -0
  272. package/src/op/operator.ts +2 -2
  273. package/src/op/steward.ts +6 -4
  274. package/src/op/work-lease-run.test.ts +88 -2
  275. package/src/op/work-lease-run.ts +52 -12
  276. package/src/pr-forge-bundle.test.ts +2 -2
  277. package/src/pr-forge.test.ts +42 -0
  278. package/src/pr-forge.ts +40 -0
  279. package/src/pr-loop-bundle.test.ts +2 -1
  280. package/src/pr-loop.test.ts +117 -1
  281. package/src/pr-loop.ts +252 -10
  282. package/src/pr-report-url.ts +5 -5
  283. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  284. package/src/ts-loader.test.ts +89 -1
  285. package/src/ts-loader.ts +180 -13
  286. package/src/workspace/changes.test.ts +1 -1
  287. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  288. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  289. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  290. package/src/workspace/chant-migrations.ts +2 -0
  291. package/src/workspace/checks/boxes.ts +9 -9
  292. package/src/workspace/ci-green-forge.ts +6 -4
  293. package/src/workspace/ci-green-runs.test.ts +53 -11
  294. package/src/workspace/ci-green.test.ts +1 -1
  295. package/src/workspace/ci-green.ts +50 -22
  296. package/src/workspace/ci-list.schema.json +12 -6
  297. package/src/workspace/ci-report-url.test.ts +220 -0
  298. package/src/workspace/ci-report-url.ts +33 -0
  299. package/src/workspace/ci-run-index.schema.json +5 -3
  300. package/src/workspace/ci-run-index.test.ts +42 -3
  301. package/src/workspace/ci-run-index.ts +18 -4
  302. package/src/workspace/ci-run.test.ts +185 -7
  303. package/src/workspace/ci-run.ts +224 -54
  304. package/src/workspace/ci-runner-key.test.ts +55 -17
  305. package/src/workspace/ci-runner-key.ts +36 -11
  306. package/src/workspace/ci-serve.test.ts +150 -6
  307. package/src/workspace/ci-serve.ts +169 -25
  308. package/src/workspace/declaration.schema.json +7 -2
  309. package/src/workspace/declaration.test.ts +23 -0
  310. package/src/workspace/declaration.ts +19 -3
  311. package/src/workspace/export.ts +6 -3
  312. package/src/workspace/ls.schema.json +7 -1
  313. package/src/workspace/ls.ts +7 -2
  314. package/src/workspace/pin-integrity.test.ts +5 -0
  315. package/src/workspace/pin-integrity.ts +3 -2
  316. package/src/workspace/pr-report.schema.json +74 -7
  317. package/src/workspace/reason-codes.ts +2 -2
  318. package/src/workspace/run-event.schema.json +3 -1
  319. package/src/workspace/status.schema.json +3 -1
  320. package/src/workspace/write-scope.test.ts +29 -1
  321. package/src/workspace/write-scope.ts +12 -1
@@ -68,6 +68,7 @@ import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger"
68
68
  import type { GatePointAsker } from "../point-decision";
69
69
  import type { WaitingPoint } from "./steward-points";
70
70
  import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
71
+ import { isLifecycleLocalNote } from "../lifecycle/local-note";
71
72
  import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
72
73
  import type { TrustPolicy } from "../workspace/trust/policy";
73
74
  import type { GateSigners } from "../workspace/trust/gate-seal";
@@ -1721,11 +1722,22 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1721
1722
  }
1722
1723
  }
1723
1724
 
1725
+ /**
1726
+ * Throws unless a spend's record reached the remote the next run reads
1727
+ * (#3985). Without that, the next run reads the approval or override as
1728
+ * unspent. A ledger with no remote is the whole ledger, so a line recorded
1729
+ * there is spent.
1730
+ */
1731
+ function requireSpendPushed(result: { pushed: boolean; pushWarning?: string }): void {
1732
+ if (result.pushed || isLifecycleLocalNote(result.pushWarning)) return;
1733
+ throw new Error(`the record did not reach the remote the next run reads: ${result.pushWarning ?? "the push did not land"}`);
1734
+ }
1735
+
1724
1736
  /**
1725
1737
  * Record each override the wave is about to apply under (#3821), in
1726
1738
  * `_gates/policy-override/applied.jsonl`, which spends it: written before
1727
- * anything applies, once. False when the lines could not be written: the
1728
- * wave then fails and applies nothing.
1739
+ * anything applies, once. False when the lines could not be written or did
1740
+ * not reach the remote (#3985): the wave then fails and applies nothing.
1729
1741
  */
1730
1742
  async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
1731
1743
  const used = decision.members.filter((m) => m.override);
@@ -1738,7 +1750,7 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
1738
1750
  if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
1739
1751
  for (const m of used) {
1740
1752
  const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
1741
- await port.appendApplied(record as unknown as AppliedGateRecord);
1753
+ requireSpendPushed(await port.appendApplied(record as unknown as AppliedGateRecord));
1742
1754
  }
1743
1755
  } catch (err) {
1744
1756
  failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
@@ -2153,9 +2165,11 @@ async function applyStage(
2153
2165
  * Record that the wave applies under its approval (#3784), which spends it:
2154
2166
  * one line in `_gates/<name>/applied.jsonl`, before anything applies, as
2155
2167
  * terragucci's wave gate writes it. Only an approved wave under the spend
2156
- * rule records, and only once. False when the line could not be written: the
2168
+ * rule records, and only once. False when the line could not be written, or
2169
+ * was written but did not reach the remote the next run reads (#3985): the
2157
2170
  * wave then fails and applies nothing, since an apply the record misses
2158
- * would leave its approval standing.
2171
+ * would leave its approval standing. A ledger with no remote is the whole
2172
+ * ledger, so a line recorded there is spent.
2159
2173
  */
2160
2174
  async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
2161
2175
  if (!decision.spend || decision.status !== "approved" || decision.spentAt || !decision.approvedAt) return true;
@@ -2177,6 +2191,7 @@ async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decisi
2177
2191
  try {
2178
2192
  if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied approval");
2179
2193
  const pushed = await port.appendApplied(record);
2194
+ requireSpendPushed(pushed);
2180
2195
  if (!pushed.pushed && pushed.pushWarning) log(`wave ${k} (${wave.name}): ${pushed.pushWarning}`);
2181
2196
  } catch (err) {
2182
2197
  failed(ctx, `wave ${k} (${wave.name}) could not record that it applies under the approval by ${record.approvedBy}, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
@@ -17,6 +17,8 @@ import {
17
17
  } from "./op-waves-run";
18
18
  import { memoryGateLedgerPort } from "./gate";
19
19
  import { waveSetDigest } from "../gated-waves";
20
+ import type { AppliedGateRecord } from "../lifecycle/gate-applied";
21
+ import { LIFECYCLE_LOCAL_NOTE } from "../lifecycle/local-note";
20
22
 
21
23
  const spec: OpWavesSpec = {
22
24
  name: "migrations",
@@ -475,6 +477,39 @@ describe("spent approvals (#3784)", () => {
475
477
  h.done();
476
478
  });
477
479
 
480
+ test("a spend whose record does not reach the remote applies nothing, and the approval stays unspent (#3985)", async () => {
481
+ const h = harness(spending);
482
+ const memory = memoryGateLedgerPort({ resolutions: [approval] });
483
+ const gates = {
484
+ ...memory,
485
+ async appendApplied(record: AppliedGateRecord) {
486
+ await memory.appendApplied!(record);
487
+ return { pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' };
488
+ },
489
+ };
490
+ const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
491
+ expect(result).toMatchObject({ exitCode: 1, applied: [] });
492
+ expect(result.decision?.spentAt).toBeUndefined();
493
+ expect(h.applies()).toBe(0);
494
+ h.done();
495
+ });
496
+
497
+ test("with no remote the local ledger is the whole ledger, so a spend recorded there applies (#3985)", async () => {
498
+ const h = harness(spending);
499
+ const memory = memoryGateLedgerPort({ resolutions: [approval] });
500
+ const gates = {
501
+ ...memory,
502
+ async appendApplied(record: AppliedGateRecord) {
503
+ await memory.appendApplied!(record);
504
+ return { pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE };
505
+ },
506
+ };
507
+ const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
508
+ expect(result).toMatchObject({ exitCode: 0, decision: { status: "approved", spentAt: "2026-10-11T00:00:00Z" } });
509
+ expect(memory.applied).toHaveLength(1);
510
+ h.done();
511
+ });
512
+
478
513
  test("a wave that cannot record the approval applies nothing", async () => {
479
514
  const h = harness(spending);
480
515
  const gates = { ...memoryGateLedgerPort({ resolutions: [approval] }), appendApplied: undefined };
@@ -878,6 +913,20 @@ describe("gates and holds a product's wave needs (#3821)", () => {
878
913
  other.done();
879
914
  });
880
915
 
916
+ test("an override spend whose record does not reach the remote applies nothing (#3985)", async () => {
917
+ const { overrideDigest } = await import("../override-gate");
918
+ const od = overrideDigest("vpc", digest("1"), ["no-public-s3"]);
919
+ const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2"), empty: true } }, { extra: { overriders: ["github:carol"] }, atBase: { ...tg, waves: [{ ...tg.waves[0]!, gate: "never" }] } });
920
+ const memory = memoryGateLedgerPort({
921
+ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "the bucket is the public docs site" }],
922
+ });
923
+ const gates = { ...memory, appendApplied: async () => ({ pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' }) };
924
+ const result = await run(h, gates);
925
+ expect(result).toMatchObject({ exitCode: 1, applied: [] });
926
+ expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
927
+ h.done();
928
+ });
929
+
881
930
  test("a plans file's overridersSource names the list in the refusal and the decision record (#3848)", async () => {
882
931
  const mk = (extra: Record<string, unknown>) => harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra });
883
932
  const logs: string[] = [];
@@ -399,8 +399,8 @@ const STEWARD_TURN_POLL_MS = 150;
399
399
  *
400
400
  * Contention never queues past `waitMs` (0 by default): consistent with a
401
401
  * round's own leases, which skip and report rather than wait (see this
402
- * module's doc), and with the fountain form, whose busy teammate is likewise
403
- * refused rather than retried. A caller that wants to ride out a race with a
402
+ * module's doc), and with the fountain form, whose busy steward conversation
403
+ * is likewise refused rather than retried. A caller that wants to ride out a race with a
404
404
  * turn about to end passes `waitMs`, and this polls every
405
405
  * {@link STEWARD_TURN_POLL_MS} until it acquires or the wait runs out.
406
406
  */
package/src/op/steward.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  * A steward runs a fixed list of Ops, each on its own `schedule` when it has
6
6
  * one, one Op at a time. Where it runs is its form:
7
7
  *
8
- * - `fountain`: the fountain lexicon's `Steward` composite, an `acp` Agent on
9
- * a persistent sandbox bound to a Teammate, with a Fountain `Schedule` per
10
- * scheduled Op. A turn is one `chant run <op>` on the teammate's thread.
8
+ * - `fountain`: the fountain lexicon's `Steward` composite, an `acp` Agent
9
+ * whose runs `chant run <op> --on fountain` sends to one long-lived
10
+ * conversation, resumed by channel_id. A turn is one `chant run <op>` in
11
+ * that conversation. Managoat has no scheduler, so a scheduled Op's cron
12
+ * fires from a clock outside it (a CI schedule, a CronJob).
11
13
  * - `local`: `chant operator --steward <name>` in the box itself, for a box
12
14
  * with no Fountain behind it (a docker compose box, a Sprites box). The same
13
15
  * Ops on the same crons, each run under its operator lease, and the steward
@@ -129,7 +131,7 @@ export interface StewardBeside {
129
131
  }
130
132
 
131
133
  export interface StewardDeclarationConfig {
132
- /** The steward's name. On Fountain, the Agent's and the Teammate's. */
134
+ /** The steward's name. On Fountain, the Agent's, and the key of its conversation. */
133
135
  name: string;
134
136
  /** The Ops it runs. A scheduled one runs on its cron; any other runs when asked. */
135
137
  ops: StewardOpInput[];
@@ -342,9 +342,10 @@ describe("an Op that changes the checkout", () => {
342
342
  expect(result.workLease).toMatchObject({ item: "W-7", branch: "chant/work/W-7", released: true, outcome: "done" });
343
343
  expect(result.workLease?.holder).toBe(stewardWorkHolder("box", "dispatch", "h1"));
344
344
 
345
- // The worktree sat under the git directory and is gone; the branch keeps the build.
346
- expect(worktree.startsWith(join(dir, ".git"))).toBe(true);
345
+ // The worktree sat at .chant/work/<item> in the checkout and is gone, with the directories it left empty; the branch keeps the build.
346
+ expect(worktree).toBe(join(dir, ".chant", "work", "W-7"));
347
347
  expect(existsSync(worktree)).toBe(false);
348
+ expect(existsSync(join(dir, ".chant"))).toBe(false);
348
349
  expect(git(["show", "chant/work/W-7:app/feature.ts"], dir).stdout).toBe("export const feature = true;\n");
349
350
 
350
351
  // The coding agent's checkout: its edit, its lock, HEAD and the index, as they were.
@@ -403,3 +404,88 @@ describe("an Op that changes the checkout", () => {
403
404
  });
404
405
  });
405
406
  });
407
+
408
+ describe("where the worktree is (#2893)", () => {
409
+ const record = (seen: { worktree?: string; status?: string }) =>
410
+ new Map<string, ActivityFn>([
411
+ [
412
+ "build",
413
+ async (args) => {
414
+ seen.worktree = String(args.worktree);
415
+ writeFileSync(join(seen.worktree, "app", "feature.ts"), "export const feature = true;\n");
416
+ seen.status = git(["status", "--porcelain", "--untracked-files=all"], dir()).stdout;
417
+ return {};
418
+ },
419
+ ],
420
+ ]);
421
+ let current = "";
422
+ const dir = () => current;
423
+ const op = (item: string) => dispatchOp({ workLease: { item }, changesCheckout: true }, [step("build", { worktree: workLeaseOutput("worktree") })]);
424
+
425
+ test("it is .chant/work/<item> in the checkout, info/exclude gets /.chant/work/ once, and git status stays clean while it is there", async () => {
426
+ await withTestDir(async (d) => {
427
+ current = d;
428
+ await initRepo(d);
429
+ const seen: { worktree?: string; status?: string } = {};
430
+ const first = await run(op("W-20"), record(seen), d);
431
+ expect(first.workLease).toMatchObject({ released: true, outcome: "done" });
432
+ expect(seen.worktree).toBe(join(d, ".chant", "work", "W-20"));
433
+ expect(seen.status).toBe("");
434
+ const exclude = () => readFileSync(join(d, ".git", "info", "exclude"), "utf-8").split("\n").filter((l) => l === "/.chant/work/");
435
+ expect(exclude()).toHaveLength(1);
436
+ await run(op("W-21"), record(seen), d);
437
+ expect(exclude()).toHaveLength(1);
438
+ expect(git(["status", "--porcelain", "--untracked-files=all"], d).stdout).toBe("");
439
+ expect(git(["worktree", "list", "--porcelain"], d).stdout).not.toContain(".chant/work");
440
+ });
441
+ });
442
+
443
+ test("info/exclude is left alone when the project already ignores .chant/work", async () => {
444
+ await withTestDir(async (d) => {
445
+ current = d;
446
+ await initRepo(d);
447
+ writeFileSync(join(d, ".gitignore"), ".chant/\n");
448
+ git(["add", ".gitignore"], d);
449
+ git(["commit", "-q", "-m", "ignore"], d);
450
+ const before = existsSync(join(d, ".git", "info", "exclude")) ? readFileSync(join(d, ".git", "info", "exclude"), "utf-8") : "";
451
+ const seen: { worktree?: string; status?: string } = {};
452
+ await run(op("W-22"), record(seen), d);
453
+ expect(seen.status).toBe("");
454
+ const after = existsSync(join(d, ".git", "info", "exclude")) ? readFileSync(join(d, ".git", "info", "exclude"), "utf-8") : "";
455
+ expect(after).toBe(before);
456
+ });
457
+ });
458
+
459
+ test("a worktree a crashed run left under .git/chant-work with the branch checked out is removed, and the run goes on from the branch", async () => {
460
+ await withTestDir(async (d) => {
461
+ current = d;
462
+ await initRepo(d);
463
+ const old = join(d, ".git", "chant-work", "W-23");
464
+ expect(git(["worktree", "add", "-q", "-b", "chant/work/W-23", old, "HEAD"], d).status).toBe(0);
465
+ writeFileSync(join(old, "app", "earlier.ts"), "export const earlier = 1;\n");
466
+ git(["add", "-A"], old);
467
+ git(["commit", "-q", "-m", "an earlier run"], old);
468
+ const seen: { worktree?: string; status?: string } = {};
469
+ const result = await run(op("W-23"), record(seen), d);
470
+ expect(result.workLease).toMatchObject({ released: true, outcome: "done" });
471
+ expect(seen.worktree).toBe(join(d, ".chant", "work", "W-23"));
472
+ expect(existsSync(old)).toBe(false);
473
+ expect(git(["worktree", "list", "--porcelain"], d).stdout).not.toContain("chant-work");
474
+ expect(git(["show", "chant/work/W-23:app/earlier.ts"], d).stdout).toBe("export const earlier = 1;\n");
475
+ });
476
+ });
477
+
478
+ test("a worktree a crashed run left at .chant/work with the branch checked out is replaced", async () => {
479
+ await withTestDir(async (d) => {
480
+ current = d;
481
+ await initRepo(d);
482
+ const left = join(d, ".chant", "work", "W-24");
483
+ expect(git(["worktree", "add", "-q", "-b", "chant/work/W-24", left, "HEAD"], d).status).toBe(0);
484
+ writeFileSync(join(left, "app", "stray.ts"), "stray\n");
485
+ const seen: { worktree?: string; status?: string } = {};
486
+ const result = await run(op("W-24"), record(seen), d);
487
+ expect(result.workLease).toMatchObject({ released: true, outcome: "done" });
488
+ expect(existsSync(left)).toBe(false);
489
+ });
490
+ });
491
+ });
@@ -39,10 +39,11 @@
39
39
  *
40
40
  * An Op with `changesCheckout` (applying a build, `chant workspace upgrade`)
41
41
  * must declare `workLease`, and its leased steps get a git worktree of their
42
- * own: `chant/work/<item>` checked out under the repository's git directory,
43
- * from the checkout's HEAD, or from the branch as an earlier run left it. The
44
- * worktree is removed when the run ends and the branch keeps whatever the
45
- * steps committed, for the runner to deliver. The checkout a coding agent is
42
+ * own: `chant/work/<item>` checked out at `.chant/work/<item>` in the
43
+ * checkout (#2893), which `<git-common-dir>/info/exclude` keeps out of
44
+ * `git status`, from the checkout's HEAD or from the branch as an earlier run
45
+ * left it. The worktree is removed when the run ends and the branch keeps
46
+ * whatever the steps committed, for the runner to deliver. The checkout a coding agent is
46
47
  * editing, its index and its uncommitted files, are never touched.
47
48
  *
48
49
  * A run that ends `not_done` keeps its attempt aside (#3147): whatever the
@@ -54,7 +55,7 @@
54
55
  */
55
56
 
56
57
  import { existsSync, rmSync } from "node:fs";
57
- import { isAbsolute, join, resolve } from "node:path";
58
+ import { isAbsolute, resolve, sep } from "node:path";
58
59
  import { getRuntime } from "../runtime-adapter";
59
60
  import {
60
61
  claimWorkLease,
@@ -69,6 +70,7 @@ import {
69
70
  type WorkLease,
70
71
  } from "../lifecycle/work-lease";
71
72
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
73
+ import { excludeWorkDir, legacyWorkWorktreePath, LEGACY_WORK_DIR, removeEmptyWorkDirs, WORK_DIR, workWorktreePath } from "../lifecycle/work-dir";
72
74
  import { currentHolderId } from "../lifecycle/lease";
73
75
  import { parseDuration } from "./duration";
74
76
  import { isStepOutputRef, stepOutput, type StepOutputRef } from "./step-output-ref";
@@ -271,21 +273,36 @@ export class RunWorkLease {
271
273
  }
272
274
 
273
275
  /**
274
- * A worktree for `id` on `chant/work/[<member>/]<id>`, under the git
275
- * directory so the checkout's own `git status` never lists it. Starts from
276
- * the branch when an earlier run left it, from HEAD otherwise.
276
+ * A worktree for `id` on `chant/work/[<member>/]<id>`, at
277
+ * `<checkout>/.chant/work/[<member>/]<id>` (#2893): inside the checkout, so
278
+ * a coding agent may write there and `node_modules` resolves, and in
279
+ * `<git-common-dir>/info/exclude`, so the checkout's own `git status` never
280
+ * lists it. Starts from the branch when an earlier run left it, from HEAD
281
+ * otherwise. A worktree an earlier run left on the branch, here or where
282
+ * older versions made it under the git directory, is removed first.
277
283
  */
278
284
  private async makeWorktree(id: string, members: readonly string[]): Promise<{ worktree: string; branch: string }> {
279
285
  const cwd = this.opts.cwd;
280
286
  const common = await git(["rev-parse", "--git-common-dir"], cwd);
281
287
  if (!common.ok) throw new Error(`Op "${this.config.name}" changes the checkout, but ${cwd} is not a git checkout: ${common.stderr}`);
282
288
  const commonDir = isAbsolute(common.stdout) ? common.stdout : resolve(cwd, common.stdout);
289
+ // The top as the caller spells it (git's --show-toplevel resolves symbolic links).
290
+ const cdup = await git(["rev-parse", "--show-cdup"], cwd);
291
+ if (!cdup.ok) throw new Error(`Op "${this.config.name}" changes the checkout, but ${cwd} has no working tree: ${cdup.stderr}`);
292
+ const top = resolve(cwd, cdup.stdout);
283
293
  const scope = members.join("/");
284
294
  const branch = `${WORK_BRANCH_PREFIX}${scope ? `${scope}/` : ""}${id}`;
285
- const worktree = join(commonDir, "chant-work", ...members, id);
286
- if (existsSync(worktree)) {
287
- await git(["worktree", "remove", "--force", worktree], cwd);
288
- rmSync(worktree, { recursive: true, force: true });
295
+ const worktree = workWorktreePath(top, members, id);
296
+ const ignored = (await git(["check-ignore", "-q", `${WORK_DIR}/${id}`], top)).ok;
297
+ excludeWorkDir(commonDir, ignored);
298
+ for (const stale of await this.staleWorktrees(branch, commonDir, members, id)) {
299
+ await git(["worktree", "remove", "--force", stale], cwd);
300
+ rmSync(stale, { recursive: true, force: true });
301
+ }
302
+ for (const dir of [worktree, legacyWorkWorktreePath(commonDir, members, id)]) {
303
+ if (!existsSync(dir)) continue;
304
+ await git(["worktree", "remove", "--force", dir], cwd);
305
+ rmSync(dir, { recursive: true, force: true });
289
306
  }
290
307
  await git(["worktree", "prune"], cwd);
291
308
  const exists = (await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd)).ok;
@@ -298,6 +315,28 @@ export class RunWorkLease {
298
315
  return { worktree, branch };
299
316
  }
300
317
 
318
+ /**
319
+ * The registered worktrees that have `branch` checked out at a path chant
320
+ * makes work worktrees at: `.chant/work/[<member>/]<id>` in any checkout,
321
+ * or `<git-common-dir>/chant-work/[<member>/]<id>` where versions before
322
+ * #2893 made them. A run that crashed leaves one behind, and `git worktree
323
+ * add` then refuses the branch as already checked out.
324
+ */
325
+ private async staleWorktrees(branch: string, commonDir: string, members: readonly string[], id: string): Promise<string[]> {
326
+ const list = await git(["worktree", "list", "--porcelain"], this.opts.cwd);
327
+ if (!list.ok) return [];
328
+ const tail = [...members, id].join("/");
329
+ const legacy = legacyWorkWorktreePath(commonDir, members, id).split(sep).join("/");
330
+ const out: string[] = [];
331
+ for (const block of list.stdout.split(/\n\s*\n/)) {
332
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
333
+ if (!path || !block.split("\n").includes(`branch refs/heads/${branch}`)) continue;
334
+ const posixPath = path.split(sep).join("/");
335
+ if (posixPath.endsWith(`/${WORK_DIR}/${tail}`) || posixPath === legacy || posixPath.endsWith(`/${LEGACY_WORK_DIR}/${tail}`)) out.push(path);
336
+ }
337
+ return out;
338
+ }
339
+
301
340
  private lose(why: string): void {
302
341
  if (this.lost !== undefined) return;
303
342
  this.lost = why;
@@ -408,6 +447,7 @@ export class RunWorkLease {
408
447
  if (lease.worktree) {
409
448
  await git(["worktree", "remove", "--force", lease.worktree], this.opts.cwd).catch(() => undefined);
410
449
  rmSync(lease.worktree, { recursive: true, force: true });
450
+ removeEmptyWorkDirs(lease.worktree);
411
451
  await git(["worktree", "prune"], this.opts.cwd).catch(() => undefined);
412
452
  }
413
453
  if (this.lost !== undefined) return base;
@@ -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 27 KB today.
38
- expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(32 * 1024);
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);
39
39
  });
40
40
  });
@@ -85,6 +85,21 @@ describe("github", () => {
85
85
  expect(calls[0].body).toEqual({ state: "success", context: "chant/plan", description: "2 members", target_url: "https://ci/run/1" });
86
86
  });
87
87
 
88
+ test("the authors of a pull request are its opener and every commit's author and committer, every page read (#4000)", async () => {
89
+ const page1 = Array.from({ length: 100 }, () => ({ author: { login: "carol" }, committer: { login: "carol" } }));
90
+ const { fetch, calls } = fakeFetch({
91
+ "GET /repos/acme/infra/pulls/12": { number: 12, user: { login: "carol" } },
92
+ "GET /repos/acme/infra/pulls/12/commits?page=1": page1,
93
+ "GET /repos/acme/infra/pulls/12/commits?page=2": [{ author: null, committer: { login: "dave" } }, { author: { login: "erin" }, committer: { login: "web-flow" } }],
94
+ });
95
+ expect(await githubForge({ ...opts, fetch }).authors(12)).toEqual(["carol", "dave", "erin", "web-flow"]);
96
+ expect(calls.map((c) => c.url)).toEqual([
97
+ "https://api.github.com/repos/acme/infra/pulls/12",
98
+ "https://api.github.com/repos/acme/infra/pulls/12/commits?per_page=100&page=1",
99
+ "https://api.github.com/repos/acme/infra/pulls/12/commits?per_page=100&page=2",
100
+ ]);
101
+ });
102
+
88
103
  test("an approver is someone whose newest deciding review approves", async () => {
89
104
  const { fetch } = fakeFetch({
90
105
  "GET /repos/acme/infra/pulls/12/reviews": [
@@ -195,6 +210,15 @@ describe("forgejo", () => {
195
210
  expect(calls[0].headers.Authorization).toBe("token t0k");
196
211
  });
197
212
 
213
+ test("the authors of a pull request come from the same endpoints, paged by limit (#4000)", async () => {
214
+ const { fetch, calls } = fakeFetch({
215
+ "GET /api/v1/repos/acme/infra/pulls/4": { number: 4, user: { login: "ana" } },
216
+ "GET /api/v1/repos/acme/infra/pulls/4/commits": [{ author: { login: "ana" }, committer: { login: "ben" } }],
217
+ });
218
+ expect(await forgejoForge({ ...opts, fetch }).authors(4)).toEqual(["ana", "ben"]);
219
+ expect(calls.map((c) => c.url)).toContain("https://codeberg.org/api/v1/repos/acme/infra/pulls/4/commits?limit=50&page=1");
220
+ });
221
+
198
222
  test("a dismissed approval no longer counts, and a comment review decides nothing", async () => {
199
223
  const { fetch } = fakeFetch({
200
224
  "GET /api/v1/repos/acme/infra/pulls/4/reviews": [
@@ -240,6 +264,24 @@ describe("gitlab", () => {
240
264
  expect(calls[0].body).toEqual({ state: "failed", name: "chant/apply", description: "refused" });
241
265
  });
242
266
 
267
+ test("the authors of a merge request: its author, and each commit email's account by the users search (#4000)", async () => {
268
+ const { fetch, calls } = fakeFetch({
269
+ "GET /api/v4/projects/42/merge_requests/5": { iid: 5, author: { username: "ana" } },
270
+ "GET /api/v4/projects/42/merge_requests/5/commits": [
271
+ { author_email: "ana@example.com", committer_email: "Ben@Example.com" },
272
+ { author_email: "ghost@example.com", committer_email: "ana@example.com" },
273
+ ],
274
+ "GET /api/v4/users": (c: Call) =>
275
+ ({ "ana@example.com": [{ username: "ana" }], "ben@example.com": [{ username: "ben" }] })[new URL(c.url).searchParams.get("search")!] ?? [],
276
+ });
277
+ expect(await gitlabForge({ ...opts, fetch }).authors(5)).toEqual(["ana", "ben"]);
278
+ expect(calls.filter((c) => new URL(c.url).pathname === "/api/v4/users").map((c) => new URL(c.url).searchParams.get("search"))).toEqual([
279
+ "ana@example.com",
280
+ "ben@example.com",
281
+ "ghost@example.com",
282
+ ]);
283
+ });
284
+
243
285
  test("approvers come from the approvals endpoint, merged requests from the commit", async () => {
244
286
  const { fetch } = fakeFetch({
245
287
  "GET /api/v4/projects/42/merge_requests/5/approvals": { approved_by: [{ user: { username: "ana" } }, { user: { username: "ben" } }] },
package/src/pr-forge.ts CHANGED
@@ -349,6 +349,16 @@ export interface PrForge {
349
349
  upsertIssue(marker: string, title: string, body: string): Promise<string | undefined>;
350
350
  /** Logins whose standing review approves `pr`. */
351
351
  approvers(pr: number): Promise<string[]>;
352
+ /**
353
+ * Logins of the people who wrote `pr` (#4000): its author, and the author
354
+ * and committer of each of its commits. GitHub and Forgejo name a commit's
355
+ * people by the account its email belongs to; a commit whose email is on no
356
+ * account adds nobody. GitLab names them by email only, so each email is
357
+ * looked up among the users (`GET /users?search=`), which matches an
358
+ * account's public email; an email no account shows publicly adds nobody.
359
+ * Sorted, without repeats.
360
+ */
361
+ authors(pr: number): Promise<string[]>;
352
362
  /** The merged pull request that produced `sha`, or null. */
353
363
  pullRequestFor(sha: string): Promise<number | null>;
354
364
  /**
@@ -667,6 +677,20 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
667
677
  }
668
678
  return [...standing].filter(([, state]) => state === "APPROVED").map(([login]) => login).sort();
669
679
  },
680
+ async authors(pr) {
681
+ const logins = new Set<string>();
682
+ const add = (v: unknown) => {
683
+ if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
684
+ };
685
+ const opened = await api.call("GET", `${repo}/pulls/${pr}`);
686
+ if (isObject(opened)) add(opened.user);
687
+ for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
688
+ if (!isObject(c)) continue;
689
+ add(c.author);
690
+ add(c.committer);
691
+ }
692
+ return [...logins].sort();
693
+ },
670
694
  async pullRequestFor(sha) {
671
695
  if (kind === "forgejo") {
672
696
  try {
@@ -922,6 +946,22 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
922
946
  .filter((l): l is string => l !== undefined);
923
947
  return [...new Set(logins)].sort();
924
948
  },
949
+ async authors(mr) {
950
+ const logins = new Set<string>();
951
+ const merge = await api.call("GET", `${notes}/${mr}`);
952
+ const author = isObject(merge) ? authorOf(merge) : undefined;
953
+ if (author) logins.add(author);
954
+ const emails = new Set<string>();
955
+ for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
956
+ if (!isObject(c)) continue;
957
+ for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
958
+ }
959
+ for (const email of [...emails].sort()) {
960
+ const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
961
+ for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
962
+ }
963
+ return [...logins].sort();
964
+ },
925
965
  async pullRequestFor(sha) {
926
966
  const mrs = await api.call("GET", `${project}/repository/commits/${sha}/merge_requests`);
927
967
  if (!Array.isArray(mrs)) return null;
@@ -41,6 +41,7 @@ describe("the pull-request loop bundle", () => {
41
41
  "packages/core/src/pr-forge.ts",
42
42
  "packages/core/src/pr-loop.ts",
43
43
  ]);
44
- expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(64 * 1024);
44
+ // About 65 KB today, after the pull request authors (#4000).
45
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(72 * 1024);
45
46
  });
46
47
  });