openlanes 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -12,12 +12,23 @@ module OpenLanes
12
12
  # repository.
13
13
  class GitHub
14
14
  # Fields asked of `gh pr list`.
15
- FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
15
+ FIELDS = %w[number title url headRefName files state isDraft mergedAt labels].freeze
16
+
17
+ # A runner that runs every command in one directory. `gh` with no `-R`
18
+ # asks the checkout it runs in which repository it means, and the
19
+ # engine's working directory is the plans project, which `--root` can
20
+ # point away from: label sync then wrote to the wrong repository.
21
+ Rooted = Data.define(:command, :chdir) do
22
+ def run(*argv, **) = command.run(*argv, chdir:, **)
23
+ def run!(*argv, **) = command.run!(*argv, chdir:, **)
24
+ end
16
25
 
17
26
  # @param command [TTY::Command] runner, injectable for tests
18
27
  # @param limit [Integer] how many pull requests to fetch
19
- def initialize(command: TTY::Command.new(printer: :null), limit: 200)
20
- @command = command
28
+ # @param chdir [String, nil] the checkout `gh` runs in; nil means the
29
+ # current directory
30
+ def initialize(command: TTY::Command.new(printer: :null), limit: 200, chdir: nil)
31
+ @command = chdir ? Rooted.new(command:, chdir:) : command
21
32
  @limit = limit
22
33
  end
23
34
 
@@ -53,7 +64,8 @@ module OpenLanes
53
64
  branch: pr["headRefName"].to_s,
54
65
  files: Array(pr["files"]).map { |f| f["path"] }.compact,
55
66
  state: self.class.state_label(pr),
56
- open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
67
+ open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN",
68
+ labels: Array(pr["labels"]).map { |l| l["name"] }
57
69
  }
58
70
  end
59
71
  rescue TTY::Command::ExitError, JSON::ParserError => e
@@ -79,7 +91,10 @@ module OpenLanes
79
91
 
80
92
  # Fields asked of `gh pr view`, which unlike `pr list` can be told about
81
93
  # one pull request in another repository.
82
- VIEW_FIELDS = %w[number title url headRefName state isDraft mergedAt body].freeze
94
+ VIEW_FIELDS = %w[
95
+ number title url headRefName headRefOid baseRefName mergeCommit state isDraft mergedAt body labels
96
+ mergeStateStatus
97
+ ].freeze
83
98
 
84
99
  # A reference to one pull request: a bare number, a `#`-prefixed number,
85
100
  # or a full URL to a GitHub pull request or a GitLab merge request.
@@ -107,7 +122,8 @@ module OpenLanes
107
122
  # One pull request, by number or URL.
108
123
  #
109
124
  # @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
110
- # @return [Hash] `{number:, title:, url:, branch:, state:, body:}`
125
+ # @return [Hash] `{number:, title:, url:, branch:, head:, state:, open:, labels:, body:, merge_state:}`,
126
+ # the keys {#pulls} gives a row plus the body and GitHub's `mergeStateStatus`
111
127
  # @raise [OpenLanes::Error]
112
128
  def pull_request(ref)
113
129
  out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
@@ -115,12 +131,18 @@ module OpenLanes
115
131
 
116
132
  pr = JSON.parse(out)
117
133
  {
118
- number: pr["number"],
119
- title: pr["title"].to_s,
120
- url: pr["url"].to_s,
121
- branch: pr["headRefName"].to_s,
122
- state: self.class.state_label(pr),
123
- body: pr["body"].to_s
134
+ number: pr["number"],
135
+ title: pr["title"].to_s,
136
+ url: pr["url"].to_s,
137
+ branch: pr["headRefName"].to_s,
138
+ head: pr["headRefOid"].to_s,
139
+ state: self.class.state_label(pr),
140
+ open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN",
141
+ labels: Array(pr["labels"]).map { |l| l["name"] },
142
+ body: pr["body"].to_s,
143
+ merge_state: pr["mergeStateStatus"].to_s,
144
+ base: pr["baseRefName"].to_s,
145
+ merge_commit: pr.dig("mergeCommit", "oid").to_s
124
146
  }
125
147
  rescue TTY::Command::ExitError, JSON::ParserError => e
126
148
  raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
@@ -135,6 +157,30 @@ module OpenLanes
135
157
  refs.map { |ref| pull_request(ref) }
136
158
  end
137
159
 
160
+ # The pull request a branch merged through, newest first, for
161
+ # `worktree prune` to ask whether a checkout's work landed.
162
+ #
163
+ # @param branch [String]
164
+ # @return [Hash, nil] `{number:, base:, merge_commit:}`; nil when none merged
165
+ def merged_pull_for(branch)
166
+ out = @command.run("gh",
167
+ "pr",
168
+ "list",
169
+ "--state",
170
+ "merged",
171
+ "--head",
172
+ branch,
173
+ "--limit",
174
+ "1",
175
+ "--json",
176
+ "number,baseRefName,mergeCommit").out
177
+ pr = JSON.parse(out.to_s.strip.empty? ? "[]" : out).first or return nil
178
+
179
+ { number: pr["number"], base: pr["baseRefName"].to_s, merge_commit: pr.dig("mergeCommit", "oid").to_s }
180
+ rescue TTY::Command::ExitError, JSON::ParserError => e
181
+ raise Error, "could not list the pull requests of #{branch}: #{e.message.lines.first.to_s.strip}"
182
+ end
183
+
138
184
  # `gh` speaks in enums; `pull-requests.md` speaks in the words the state
139
185
  # machine parses. Translate once, here, rather than at each call site.
140
186
  #
@@ -162,10 +208,314 @@ module OpenLanes
162
208
  raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
163
209
  end
164
210
 
211
+ # Create a review label when the repository lacks it. Never `--force`:
212
+ # this runs before every label swap, and recolouring is {LabelSync}'s
213
+ # job, done once per run where the change is announced, not as a side
214
+ # effect of labelling one pull request.
215
+ #
216
+ # @param label [Plans::ReviewLabel]
217
+ # @param repo [String, nil] "owner/repo"; nil means the current checkout's
218
+ # @return [void]
219
+ # @raise [OpenLanes::Error]
220
+ def ensure_label(label, repo: nil)
221
+ @command.run("gh",
222
+ "label",
223
+ "create",
224
+ label.name,
225
+ "--color",
226
+ label.colour,
227
+ "--description",
228
+ label.meaning,
229
+ *repo_flag(repo))
230
+ rescue TTY::Command::ExitError, SystemCallError => e
231
+ return if e.message.match?(/already exists/i)
232
+
233
+ raise Error, "could not create label #{label.name}: #{first_line(e)}"
234
+ end
235
+
236
+ # Every label the repository has, and its colour.
237
+ #
238
+ # @param repo [String, nil]
239
+ # @return [Hash{String => String}] name to colour, lower-case hex without `#`
240
+ # @raise [OpenLanes::Error]
241
+ def label_colours(repo: nil)
242
+ out = @command.run("gh", "label", "list", "--limit", "1000", "--json", "name,color", *repo_flag(repo)).out
243
+ JSON.parse(out).to_h { [it["name"].to_s, it["color"].to_s.downcase] }
244
+ # A machine without `gh` (a CI image, say) is a reason to skip the sync,
245
+ # not to stop the run: {LabelSync}'s caller warns on Error.
246
+ rescue TTY::Command::ExitError, JSON::ParserError, SystemCallError => e
247
+ raise Error, "could not list labels: #{first_line(e)}"
248
+ end
249
+
250
+ # The open pull requests that carry one label, asked of GitHub rather
251
+ # than of `pull-requests.md`, which only knows the plans' own.
252
+ #
253
+ # Every one of them, through the issues API with `--paginate`: `gh pr
254
+ # list` stops at its `--limit`, and a migration that saw only the first
255
+ # thousand would mark itself done with the rest still carrying the label.
256
+ # The issues API lists pull requests among the issues; `pull_request` is
257
+ # what tells them apart.
258
+ #
259
+ # @param name [String] the label
260
+ # @param repo [String, nil]
261
+ # @return [Hash{Integer => Array<String>}] each one's number, and every
262
+ # label it carries
263
+ # @raise [OpenLanes::Error]
264
+ def open_pulls_labelled(name, repo: nil)
265
+ out = @command.run("gh",
266
+ "api",
267
+ "--paginate",
268
+ "-X",
269
+ "GET",
270
+ "repos/#{repo || "{owner}/{repo}"}/issues",
271
+ "-f",
272
+ "state=open",
273
+ "-f",
274
+ "labels=#{name}",
275
+ "-f",
276
+ "per_page=100",
277
+ "--jq",
278
+ ".[] | select(.pull_request) | {number, labels: [.labels[].name]}").out
279
+ out.each_line.reject { it.strip.empty? }.to_h do |line|
280
+ pull = JSON.parse(line)
281
+ [pull["number"], Array(pull["labels"])]
282
+ end
283
+ rescue TTY::Command::ExitError, JSON::ParserError, SystemCallError => e
284
+ raise Error, "could not list pull requests labelled #{name}: #{first_line(e)}"
285
+ end
286
+
287
+ # Give an existing label its colour and description back (R3). Only
288
+ # {LabelSync} calls this, and only for the labels openlanes owns.
289
+ #
290
+ # @param label [Plans::ReviewLabel]
291
+ # @param repo [String, nil]
292
+ # @return [void]
293
+ # @raise [OpenLanes::Error]
294
+ def recolour_label(label, repo: nil)
295
+ @command.run("gh",
296
+ "label",
297
+ "edit",
298
+ label.name,
299
+ "--color",
300
+ label.colour,
301
+ "--description",
302
+ label.meaning,
303
+ *repo_flag(repo))
304
+ rescue TTY::Command::ExitError, SystemCallError => e
305
+ raise Error, "could not recolour label #{label.name}: #{first_line(e)}"
306
+ end
307
+
308
+ # Put one label on a pull request and take others off, in one edit.
309
+ #
310
+ # @param number [Integer]
311
+ # @param repo [String, nil]
312
+ # @param add [Plans::ReviewLabel]
313
+ # @param remove [Array<String>] label names to take off
314
+ # @param create_missing [Boolean] create +add+ on the repository first;
315
+ # false when the caller already has, once, for many pull requests
316
+ # @return [Array<String>] the labels the pull request is left with
317
+ # @raise [OpenLanes::Error]
318
+ def swap_label(number:, add:, repo: nil, remove: [], create_missing: true)
319
+ ensure_label(add, repo:) if create_missing
320
+ removing = remove.map(&:to_s) - [add.name]
321
+ begin
322
+ edit_labels(number, add, removing, repo)
323
+ rescue Error
324
+ raise if removing.empty?
325
+
326
+ # Removing a label the pull request does not carry fails the whole
327
+ # edit (open question 6). Adding alone would leave any of `removing`
328
+ # it does carry beside the new one, two verdicts at once, so ask what
329
+ # is still there and take exactly that off. A second failure is real.
330
+ edit_labels(number, add, labels(number:, repo:) & removing, repo)
331
+ end
332
+ labels(number:, repo:)
333
+ end
334
+
335
+ # @param number [Integer]
336
+ # @param repo [String, nil]
337
+ # @return [Array<String>] label names on the pull request
338
+ def labels(number:, repo: nil)
339
+ out = @command.run("gh", "pr", "view", number.to_s, "--json", "labels", *repo_flag(repo)).out
340
+ Array(JSON.parse(out)["labels"]).map { |l| l["name"] }
341
+ rescue TTY::Command::ExitError, JSON::ParserError => e
342
+ raise Error, "could not read labels of ##{number}: #{first_line(e)}"
343
+ end
344
+
345
+ # Post one conversation comment. The engine, not chewie, says why a
346
+ # verification failed: only the engine has the SHA the screenshot link
347
+ # needs, because only the engine pushes.
348
+ #
349
+ # @param number [Integer]
350
+ # @param body [String]
351
+ # @param repo [String, nil]
352
+ # @return [void]
353
+ def comment(number:, body:, repo: nil)
354
+ @command.run("gh", "pr", "comment", number.to_s, "--body", body, *repo_flag(repo))
355
+ rescue TTY::Command::ExitError => e
356
+ raise Error, "could not comment on ##{number}: #{first_line(e)}"
357
+ end
358
+
359
+ # @param number [Integer]
360
+ # @param repo [String, nil]
361
+ # @return [Array<Hash>] `{name:, bucket:}`, bucket as `gh` reports it
362
+ def checks(number:, repo: nil)
363
+ # `gh pr checks` exits non-zero when a check failed or is pending, and
364
+ # the JSON it printed is still the answer, so its status is no verdict.
365
+ result = @command.run!("gh", "pr", "checks", number.to_s, "--json", "name,state,bucket", *repo_flag(repo))
366
+ out = result.out.to_s
367
+ if out.strip.empty?
368
+ return [] if result.success? || result.err.to_s.match?(/no checks reported/i)
369
+
370
+ raise Error, "could not read checks of ##{number}: #{result.err.to_s.lines.first.to_s.strip}"
371
+ end
372
+
373
+ JSON.parse(out).map { |c| { name: c["name"].to_s, bucket: c["bucket"].to_s } }
374
+ rescue JSON::ParserError => e
375
+ raise Error, "could not read checks of ##{number}: #{e.message.lines.first.to_s.strip}"
376
+ end
377
+
378
+ # CI on a branch's tip, `main` above all (R5): a pull request green on
379
+ # its own head still lands on whatever `main` is, so a red `main` holds
380
+ # every merge. Check runs and commit statuses both, mapped onto the
381
+ # buckets `gh pr checks` uses, so {CiGate.verdict} reads them alike.
382
+ # CircleCI reports statuses only: reading check runs alone saw no CI on
383
+ # `main` and held every merge, and missed a red status beside a green run.
384
+ #
385
+ # @param branch [String]
386
+ # @param repo [String, nil]
387
+ # @return [Array<Hash>] `{name:, bucket:}`
388
+ # @raise [OpenLanes::Error] when GitHub cannot be read
389
+ def branch_checks(branch:, repo: nil)
390
+ commit = "repos/#{repo || "{owner}/{repo}"}/commits/#{branch}"
391
+ runs = JSON.parse(@command.run("gh", "api", "#{commit}/check-runs").out)["check_runs"]
392
+ statuses = JSON.parse(@command.run("gh", "api", "#{commit}/status").out)["statuses"]
393
+ Array(runs).map { |c| { name: c["name"].to_s, bucket: bucket_of(c) } } +
394
+ Array(statuses).map { |s| { name: s["context"].to_s, bucket: status_bucket(s) } }
395
+ rescue TTY::Command::ExitError, JSON::ParserError => e
396
+ raise Error, "could not read checks of #{branch}: #{first_line(e)}"
397
+ end
398
+
399
+ # Merge one pull request, as the engine does on palpatine's `auto`
400
+ # (P4). A merge commit, which is how this repository's history reads;
401
+ # the branch is left alone, since the plan's worktree still has it
402
+ # checked out. Agents never reach this: `gh pr merge` is ungrantable.
403
+ #
404
+ # @param number [Integer]
405
+ # @param head [String] the head commit CI was read for; GitHub refuses
406
+ # the merge once the branch has moved past it
407
+ # @param repo [String, nil]
408
+ # @return [void]
409
+ # @raise [OpenLanes::Error] when GitHub refuses, a conflict among the reasons
410
+ def merge(number:, head:, repo: nil)
411
+ @command.run("gh", "pr", "merge", number.to_s, "--merge", "--match-head-commit", head, *repo_flag(repo))
412
+ rescue TTY::Command::ExitError => e
413
+ raise Error, "could not merge ##{number}: #{first_line(e)}"
414
+ end
415
+
416
+ # Rebase a pull request's branch onto its base on GitHub's side (P5),
417
+ # for a sibling built from a branch no worktree of this run holds.
418
+ #
419
+ # @param number [Integer]
420
+ # @param repo [String, nil]
421
+ # @return [void]
422
+ # @raise [OpenLanes::Error] when the rebase conflicts or GitHub refuses
423
+ def update_branch(number:, repo: nil)
424
+ @command.run("gh", "pr", "update-branch", number.to_s, "--rebase", *repo_flag(repo))
425
+ rescue TTY::Command::ExitError => e
426
+ raise Error, "could not rebase ##{number} onto its base: #{first_line(e)}"
427
+ end
428
+
429
+ # @param number [Integer]
430
+ # @param repo [String, nil]
431
+ # @return [Array<Hash>] `{author:, body:, url:}`, conversation comments then reviews
432
+ def review_comments(number:, repo: nil)
433
+ out = @command.run("gh", "pr", "view", number.to_s, "--json", "comments,reviews", *repo_flag(repo)).out
434
+ data = JSON.parse(out)
435
+ (Array(data["comments"]) + Array(data["reviews"])).filter_map do |c|
436
+ next if c["body"].to_s.strip.empty?
437
+
438
+ { author: c.dig("author", "login").to_s, body: c["body"].to_s, url: c["url"].to_s }
439
+ end
440
+ rescue TTY::Command::ExitError, JSON::ParserError => e
441
+ raise Error, "could not read comments of ##{number}: #{first_line(e)}"
442
+ end
443
+
444
+ # @param url [String] a pull request URL
445
+ # @return [String, nil] "owner/repo", or nil when the URL is not GitHub's
446
+ def self.repo_of(url) = url.to_s[%r{github\.com/([^/]+/[^/]+)/pull/}, 1]
447
+
448
+ # The number a pull request reference names: the `/pull/N` segment of a
449
+ # URL, or a bare or `#`-prefixed number. Never "the trailing digits",
450
+ # which read `…/pull/7#issuecomment-1234` as #1234 and relabel a
451
+ # different pull request.
452
+ #
453
+ # @param ref [String, Integer] "12", "#12" or a pull request URL
454
+ # @return [Integer]
455
+ # @raise [OpenLanes::Error] when `ref` names no pull request
456
+ def self.number_of(ref)
457
+ text = ref.to_s.strip
458
+ found = text[%r{\Ahttps?://\S+?/(?:pull|merge_requests)/(\d+)(?:[/?#]\S*)?\z}, 1] || text[/\A#?(\d+)\z/, 1]
459
+ raise Error, "not a pull request number or URL: #{ref}" unless found
460
+
461
+ found.to_i
462
+ end
463
+
165
464
  # @return [Boolean] whether `gh` is installed and authenticated
166
465
  def available?
167
466
  @command.run!("gh", "auth", "status").success?
168
467
  end
468
+
469
+ private
470
+
471
+ def repo_flag(repo) = repo ? ["-R", repo] : []
472
+
473
+ # A check run as a `gh pr checks` bucket. An unlisted conclusion is a
474
+ # failure, as {CiGate} treats an unknown bucket.
475
+ #
476
+ # @param run [Hash] one entry of `check_runs`
477
+ # @return [String]
478
+ def bucket_of(run)
479
+ return "pending" unless run["status"].to_s == "completed"
480
+
481
+ case run["conclusion"].to_s
482
+ when "success", "neutral" then "pass"
483
+ when "skipped" then "skipping"
484
+ else "fail"
485
+ end
486
+ end
487
+
488
+ # A commit status as a `gh pr checks` bucket. `error`, and any state
489
+ # GitHub adds later, is a failure.
490
+ #
491
+ # @param status [Hash] one entry of `statuses`
492
+ # @return [String]
493
+ def status_bucket(status)
494
+ case status["state"].to_s
495
+ when "success" then "pass"
496
+ when "pending" then "pending"
497
+ else "fail"
498
+ end
499
+ end
500
+
501
+ # The first line `gh` wrote to stderr, which is the one naming the problem.
502
+ def first_line(error)
503
+ stderr = error.message[/stderr: (.*)/, 1].to_s.strip
504
+ stderr.empty? ? error.message.lines.first.to_s.strip : stderr
505
+ end
506
+
507
+ def edit_labels(number, add, removing, repo)
508
+ @command.run("gh",
509
+ "pr",
510
+ "edit",
511
+ number.to_s,
512
+ "--add-label",
513
+ add.name,
514
+ *removing.flat_map { |r| ["--remove-label", r] },
515
+ *repo_flag(repo))
516
+ rescue TTY::Command::ExitError => e
517
+ raise Error, "could not label ##{number}: #{first_line(e)}"
518
+ end
169
519
  end
170
520
  end
171
521
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Vcs
5
+ # Moves the open pull requests that still carry M3's review labels onto
6
+ # R3's, once per repository, before anything reads a label in R3's sense.
7
+ #
8
+ # Under M3 `ready-to-merge` was a review verdict, "passed with nothing
9
+ # open"; under R3 it is chewie-verifier's, and the merge gate reads it as
10
+ # "verified". Left on a pull request no verifier ever saw, it would be
11
+ # read as a verification that never happened. So it goes back to
12
+ # `review-approved`, and a pull request chewie did verify earns it again
13
+ # from the coverage gate: an ambiguous label is re-checked, never trusted.
14
+ # M3's two other names map straight across ({Plans::ReviewLabel::RENAMED}).
15
+ #
16
+ # The steps run in order, and a pull request that carries `ok-to-merge`
17
+ # is left out of the first two: the third puts `ready-to-merge` on it and
18
+ # leaves `ok-to-merge` where it is, so a run interrupted after the third
19
+ # step cannot read the `ready-to-merge` it just put there as M3's and take
20
+ # it off again. `ok-to-merge` comes off once the migration is marked done.
21
+ #
22
+ # Done is a label on the repository, {Plans::ReviewLabel::MIGRATED},
23
+ # created after the last step and never touched by {LabelSync}. Not the
24
+ # colour of `ready-to-merge`: an earlier release recoloured that without
25
+ # moving a pull request, which made the old meaning look migrated on
26
+ # every repository it had run on. Nothing is kept on this machine, so a
27
+ # second machine starting on the same repository agrees.
28
+ class LabelMigration
29
+ # Every open pull request carrying +from+ moves to +to+.
30
+ Step = Data.define(:from, :to) do
31
+ # @return [String]
32
+ def to_s = "#{from} → #{to}"
33
+ end
34
+
35
+ # M3's chewie verification, kept beside `ready-to-merge` until the end.
36
+ VERIFIED = "ok-to-merge"
37
+
38
+ STEPS = [
39
+ Step.new(from: Plans::ReviewLabel::READY_TO_MERGE.name, to: Plans::ReviewLabel::APPROVED),
40
+ *Plans::ReviewLabel::RENAMED.map { |from, to| Step.new(from:, to:) }
41
+ ].freeze
42
+
43
+ # What the migration did, or under a dry run would do.
44
+ #
45
+ # @!attribute [r] pending
46
+ # @return [Boolean] whether the repository was not yet migrated
47
+ # @!attribute [r] moves
48
+ # @return [Hash{Step => Array<Integer>}] each step's pull request numbers
49
+ Outcome = Data.define(:pending, :moves) do
50
+ alias_method :pending?, :pending
51
+
52
+ # @return [Array<Integer>] every pull request relabelled, in step order
53
+ def relabelled = moves.values.flatten
54
+
55
+ # @return [String, nil] one line for the startup notice; nil when nothing moved
56
+ def to_s
57
+ return if relabelled.empty?
58
+
59
+ "relabel #{moves.map { |step, numbers| "#{step} on #{numbered(numbers)}" }.join("; ")}"
60
+ end
61
+
62
+ private
63
+
64
+ # @param numbers [Array<Integer>]
65
+ # @return [String] "#7, #9"
66
+ def numbered(numbers) = numbers.map { "##{it}" }.join(", ")
67
+ end
68
+
69
+ # @param github [GitHub]
70
+ def initialize(github: GitHub.new)
71
+ @github = github
72
+ end
73
+
74
+ # @param repo [String, nil] "owner/repo"; nil means the current checkout's
75
+ # @param dry_run [Boolean] read the pull requests, relabel none, mark nothing
76
+ # @return [Outcome]
77
+ # @raise [OpenLanes::Error] when `gh` cannot list or relabel; the
78
+ # repository stays unmarked, so the next start tries again
79
+ def call(repo: nil, dry_run: false)
80
+ return Outcome.new(pending: false, moves: {}) if done?(repo)
81
+
82
+ moves = STEPS.to_h { [it, move(it, repo:, dry_run:)] }.reject { |_, numbers| numbers.empty? }
83
+ finish(repo) unless dry_run
84
+ Outcome.new(pending: true, moves:)
85
+ end
86
+
87
+ private
88
+
89
+ # @return [Boolean]
90
+ def done?(repo) = @github.label_colours(repo:).key?(Plans::ReviewLabel::MIGRATED.name)
91
+
92
+ # @param step [Step]
93
+ # @return [Array<Integer>] the pull requests moved, or under a dry run to be
94
+ def move(step, repo:, dry_run:)
95
+ numbers = @github.open_pulls_labelled(step.from, repo:).reject { |_, labels| held?(step, labels) }.keys
96
+ return numbers if dry_run || numbers.empty?
97
+
98
+ @github.ensure_label(step.to, repo:)
99
+ remove = Plans::ReviewLabel.displaced_by(step.to) - [VERIFIED]
100
+ numbers.each { @github.swap_label(number: it, add: step.to, remove:, repo:, create_missing: false) }
101
+ numbers
102
+ end
103
+
104
+ # Whether a pull request waits for the `ok-to-merge` step instead.
105
+ #
106
+ # @param step [Step]
107
+ # @param labels [Array<String>]
108
+ # @return [Boolean]
109
+ def held?(step, labels) = step.from != VERIFIED && labels.include?(VERIFIED)
110
+
111
+ # Mark the repository done, then take `ok-to-merge` off. The mark first:
112
+ # once `ok-to-merge` is gone a `ready-to-merge` beside it is no longer
113
+ # told apart from M3's. Taking it off is tidying, so it may fail: the
114
+ # next verdict on that pull request takes it off anyway.
115
+ #
116
+ # @return [void]
117
+ def finish(repo)
118
+ @github.ensure_label(Plans::ReviewLabel::MIGRATED, repo:)
119
+ tidy(repo)
120
+ end
121
+
122
+ # @return [void]
123
+ def tidy(repo)
124
+ @github.open_pulls_labelled(VERIFIED, repo:).each_key do |number|
125
+ @github.swap_label(number:, add: Plans::ReviewLabel::READY_TO_MERGE, remove: [VERIFIED], repo:, create_missing: false)
126
+ end
127
+ rescue Error => e
128
+ UI.log("#{VERIFIED} left on open pull requests: #{e.message}", level: :warn)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Vcs
5
+ # Makes the repository's review labels match {Plans::ReviewLabel::ALL}
6
+ # (R3): creates the missing, recolours the mismatched, leaves the rest.
7
+ #
8
+ # Run once when the engine starts on a project. Labelling a pull request
9
+ # still creates a missing label on first use, but never recolours one, so
10
+ # without this a repository labelled under M3 would keep `review-rejected`
11
+ # in M3's red forever and a person reading the board would see two reds
12
+ # meaning one thing. One `gh label list`, and a write only per label that
13
+ # is wrong, so a second run asks once and changes nothing.
14
+ class LabelSync
15
+ # What was done to one label, or under a dry run what would have been.
16
+ #
17
+ # @!attribute [r] action
18
+ # @return [Symbol] :create, :recolour or :keep
19
+ # @!attribute [r] was
20
+ # @return [String, nil] the colour the repository had, for :recolour
21
+ Change = Data.define(:label, :action, :was) do
22
+ # @return [Boolean] whether this label needs a write
23
+ def write? = action != :keep
24
+
25
+ # @return [String] one line for the startup notice
26
+ def to_s
27
+ case action
28
+ when :create then "create #{label.name} (##{label.colour})"
29
+ when :recolour then "recolour #{label.name} ##{was} → ##{label.colour}"
30
+ else "keep #{label.name}"
31
+ end
32
+ end
33
+ end
34
+
35
+ # @param github [GitHub]
36
+ # @param labels [Array<Plans::ReviewLabel>]
37
+ def initialize(github: GitHub.new, labels: Plans::ReviewLabel::ALL)
38
+ @github = github
39
+ @labels = labels
40
+ end
41
+
42
+ # @param repo [String, nil] "owner/repo"; nil means the current checkout's
43
+ # @param dry_run [Boolean] read the repository's labels, write nothing
44
+ # @return [Array<Change>] one per label, in R3 order
45
+ # @raise [OpenLanes::Error] when `gh` cannot list or write a label
46
+ def call(repo: nil, dry_run: false)
47
+ have = @github.label_colours(repo:)
48
+ changes = @labels.map { change_for(it, have) }
49
+ return changes if dry_run
50
+
51
+ changes.each do |change|
52
+ case change.action
53
+ when :create then @github.ensure_label(change.label, repo:)
54
+ when :recolour then @github.recolour_label(change.label, repo:)
55
+ end
56
+ end
57
+ changes
58
+ end
59
+
60
+ private
61
+
62
+ # GitHub answers colours in lower case and R3 writes some in upper, so
63
+ # they are compared without case; otherwise every run would "fix" them.
64
+ #
65
+ # @return [Change]
66
+ def change_for(label, have)
67
+ colour = have[label.name]
68
+ return Change.new(label:, action: :create, was: nil) if colour.nil?
69
+ return Change.new(label:, action: :keep, was: colour) if colour.casecmp?(label.colour)
70
+
71
+ Change.new(label:, action: :recolour, was: colour)
72
+ end
73
+ end
74
+ end
75
+ end