openlanes 3.0.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 (466) hide show
  1. checksums.yaml +7 -0
  2. data/agents/hansolo-reviewer.md +59 -0
  3. data/agents/lando-broker.md +70 -0
  4. data/agents/leah-researcher.md +60 -0
  5. data/agents/luke-backend.md +73 -0
  6. data/agents/palpatine-planner.md +61 -0
  7. data/agents/r2d2-mechanic.md +57 -0
  8. data/agents/rey-frontend.md +68 -0
  9. data/agents/yoda-writer.md +67 -0
  10. data/bin/factory-e2e +157 -0
  11. data/bin/plan-number +164 -0
  12. data/bin/setup +26 -0
  13. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
  14. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
  15. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
  16. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
  17. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
  18. data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
  19. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
  20. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
  21. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
  22. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
  23. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
  24. data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
  25. data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
  26. data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
  27. data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
  28. data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
  29. data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
  30. data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
  31. data/evals/cases/lando-broker/unblock-fast.yml +44 -0
  32. data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
  33. data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
  34. data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
  35. data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
  36. data/evals/cases/leah-researcher/research-deep.yml +51 -0
  37. data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
  38. data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
  39. data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
  40. data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
  41. data/evals/cases/leah-researcher/research-fast.yml +55 -0
  42. data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
  43. data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
  44. data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
  45. data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
  46. data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
  47. data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
  48. data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
  49. data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
  50. data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
  51. data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
  52. data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
  53. data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
  54. data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
  55. data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
  56. data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
  57. data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
  58. data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
  59. data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
  60. data/evals/cases/luke-backend/build-medium.yml +68 -0
  61. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
  62. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
  63. data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
  64. data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
  65. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
  66. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
  67. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
  68. data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
  69. data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
  70. data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
  71. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
  72. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
  73. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
  74. data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
  75. data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
  76. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
  77. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
  78. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
  79. data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
  80. data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
  81. data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
  82. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
  83. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
  84. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
  85. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
  86. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
  87. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
  88. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
  89. data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
  90. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
  91. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
  92. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
  93. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
  94. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
  95. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
  96. data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
  97. data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
  98. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
  99. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
  100. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
  101. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
  102. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
  103. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
  104. data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
  105. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
  106. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
  107. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
  108. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
  109. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
  110. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
  111. data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
  112. data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
  113. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
  114. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
  115. data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
  116. data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
  117. data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
  118. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
  119. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
  120. data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
  121. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
  122. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
  123. data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
  124. data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
  125. data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
  126. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
  127. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
  128. data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
  129. data/evals/cases/rey-frontend/ui-medium.yml +55 -0
  130. data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
  131. data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
  132. data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
  133. data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
  134. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
  135. data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
  136. data/evals/cases/yoda-writer/spec-medium.yml +40 -0
  137. data/exe/depot +1 -0
  138. data/exe/lanes +1 -0
  139. data/exe/openlanes +32 -0
  140. data/lib/openlanes/adapters/base.rb +69 -0
  141. data/lib/openlanes/adapters/claude.rb +124 -0
  142. data/lib/openlanes/adapters/codex.rb +33 -0
  143. data/lib/openlanes/adapters/model.rb +37 -0
  144. data/lib/openlanes/adapters/pi.rb +22 -0
  145. data/lib/openlanes/adapters/stream.rb +196 -0
  146. data/lib/openlanes/adapters.rb +92 -0
  147. data/lib/openlanes/agents/agent.rb +103 -0
  148. data/lib/openlanes/agents/profile.rb +57 -0
  149. data/lib/openlanes/agents/registry.rb +151 -0
  150. data/lib/openlanes/agents/roster.rb +103 -0
  151. data/lib/openlanes/agents/routing.rb +88 -0
  152. data/lib/openlanes/agents.rb +20 -0
  153. data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
  154. data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
  155. data/lib/openlanes/cli/base.rb +263 -0
  156. data/lib/openlanes/cli/create/create.rb +354 -0
  157. data/lib/openlanes/cli/docs/docs.rb +40 -0
  158. data/lib/openlanes/cli/eval/eval.rb +110 -0
  159. data/lib/openlanes/cli/index/index.rb +40 -0
  160. data/lib/openlanes/cli/init/init.rb +145 -0
  161. data/lib/openlanes/cli/linear/linear.rb +36 -0
  162. data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
  163. data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
  164. data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
  165. data/lib/openlanes/cli/mail/mail.rb +31 -0
  166. data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
  167. data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
  168. data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
  169. data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
  170. data/lib/openlanes/cli/run/run.rb +544 -0
  171. data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
  172. data/lib/openlanes/cli/start/start.rb +110 -0
  173. data/lib/openlanes/cli/state/state.rb +26 -0
  174. data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
  175. data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
  176. data/lib/openlanes/cli/states/states.rb +15 -0
  177. data/lib/openlanes/cli/unblock/unblock.rb +235 -0
  178. data/lib/openlanes/cli/version/version.rb +13 -0
  179. data/lib/openlanes/cli/web/web.rb +100 -0
  180. data/lib/openlanes/cli/worktree/worktree.rb +85 -0
  181. data/lib/openlanes/cli.rb +162 -0
  182. data/lib/openlanes/engine/board.rb +104 -0
  183. data/lib/openlanes/engine/dispatcher.rb +1297 -0
  184. data/lib/openlanes/engine/progress_log.rb +125 -0
  185. data/lib/openlanes/engine/runner.rb +224 -0
  186. data/lib/openlanes/engine/state_file.rb +190 -0
  187. data/lib/openlanes/engine/tally.rb +177 -0
  188. data/lib/openlanes/engine.rb +19 -0
  189. data/lib/openlanes/evals/case.rb +191 -0
  190. data/lib/openlanes/evals/changes.rb +67 -0
  191. data/lib/openlanes/evals/check.rb +224 -0
  192. data/lib/openlanes/evals/evidence.rb +71 -0
  193. data/lib/openlanes/evals/live.rb +140 -0
  194. data/lib/openlanes/evals/recording.rb +38 -0
  195. data/lib/openlanes/evals/run.rb +34 -0
  196. data/lib/openlanes/evals/score.rb +24 -0
  197. data/lib/openlanes/evals/scorer.rb +42 -0
  198. data/lib/openlanes/evals/verdict.rb +18 -0
  199. data/lib/openlanes/evals.rb +56 -0
  200. data/lib/openlanes/execution/checks.rb +73 -0
  201. data/lib/openlanes/execution/child.rb +228 -0
  202. data/lib/openlanes/execution/clock.rb +121 -0
  203. data/lib/openlanes/execution/control.rb +194 -0
  204. data/lib/openlanes/execution/executor.rb +849 -0
  205. data/lib/openlanes/execution/prompt.rb +455 -0
  206. data/lib/openlanes/execution/transcript.rb +531 -0
  207. data/lib/openlanes/execution.rb +19 -0
  208. data/lib/openlanes/launcher.rb +69 -0
  209. data/lib/openlanes/lifecycle/adoption.rb +197 -0
  210. data/lib/openlanes/lifecycle/brief.rb +288 -0
  211. data/lib/openlanes/lifecycle/completion.rb +58 -0
  212. data/lib/openlanes/lifecycle/creator.rb +126 -0
  213. data/lib/openlanes/lifecycle/lanes.rb +74 -0
  214. data/lib/openlanes/lifecycle/resync.rb +428 -0
  215. data/lib/openlanes/lifecycle/unblocker.rb +236 -0
  216. data/lib/openlanes/lifecycle.rb +28 -0
  217. data/lib/openlanes/linear/api.rb +224 -0
  218. data/lib/openlanes/linear/attribution.rb +185 -0
  219. data/lib/openlanes/linear/fuzzy.rb +68 -0
  220. data/lib/openlanes/linear/import.rb +314 -0
  221. data/lib/openlanes/linear/issue.rb +187 -0
  222. data/lib/openlanes/linear/mapping.rb +116 -0
  223. data/lib/openlanes/linear/push.rb +197 -0
  224. data/lib/openlanes/linear/survey.rb +174 -0
  225. data/lib/openlanes/linear/unit.rb +274 -0
  226. data/lib/openlanes/linear.rb +41 -0
  227. data/lib/openlanes/live/claims.rb +71 -0
  228. data/lib/openlanes/live/command.rb +122 -0
  229. data/lib/openlanes/live/commands.rb +505 -0
  230. data/lib/openlanes/live/keys.rb +93 -0
  231. data/lib/openlanes/live/memory_store.rb +265 -0
  232. data/lib/openlanes/live/meter.rb +51 -0
  233. data/lib/openlanes/live/node.rb +88 -0
  234. data/lib/openlanes/live/publisher.rb +179 -0
  235. data/lib/openlanes/live/repo.rb +60 -0
  236. data/lib/openlanes/live/run_record.rb +133 -0
  237. data/lib/openlanes/live/run_state.rb +101 -0
  238. data/lib/openlanes/live/session.rb +107 -0
  239. data/lib/openlanes/live/snapshot.rb +322 -0
  240. data/lib/openlanes/live/stations.rb +67 -0
  241. data/lib/openlanes/live/store.rb +277 -0
  242. data/lib/openlanes/live.rb +109 -0
  243. data/lib/openlanes/plans/dev_work.rb +56 -0
  244. data/lib/openlanes/plans/feature.rb +131 -0
  245. data/lib/openlanes/plans/index.rb +208 -0
  246. data/lib/openlanes/plans/ledger.rb +255 -0
  247. data/lib/openlanes/plans/mailbox.rb +164 -0
  248. data/lib/openlanes/plans/ordinal.rb +92 -0
  249. data/lib/openlanes/plans/plan_state.rb +291 -0
  250. data/lib/openlanes/plans/pull_request.rb +29 -0
  251. data/lib/openlanes/plans/pull_requests.rb +229 -0
  252. data/lib/openlanes/plans/spec.rb +213 -0
  253. data/lib/openlanes/plans/state_machine.rb +433 -0
  254. data/lib/openlanes/plans/status.rb +365 -0
  255. data/lib/openlanes/plans/subject.rb +133 -0
  256. data/lib/openlanes/plans/tree.rb +79 -0
  257. data/lib/openlanes/plans.rb +39 -0
  258. data/lib/openlanes/presentation/diagram.rb +149 -0
  259. data/lib/openlanes/presentation/documentation.rb +438 -0
  260. data/lib/openlanes/presentation/reporter.rb +181 -0
  261. data/lib/openlanes/presentation/viewer.rb +62 -0
  262. data/lib/openlanes/presentation.rb +23 -0
  263. data/lib/openlanes/skills/installer.rb +68 -0
  264. data/lib/openlanes/skills/skill.rb +60 -0
  265. data/lib/openlanes/skills.rb +25 -0
  266. data/lib/openlanes/support/config.rb +217 -0
  267. data/lib/openlanes/support/frontmatter.rb +38 -0
  268. data/lib/openlanes/support/markdown.rb +56 -0
  269. data/lib/openlanes/support/project.rb +79 -0
  270. data/lib/openlanes/support/ui.rb +671 -0
  271. data/lib/openlanes/vcs/github.rb +171 -0
  272. data/lib/openlanes/vcs/publisher.rb +221 -0
  273. data/lib/openlanes/vcs/worktree.rb +248 -0
  274. data/lib/openlanes/vcs.rb +19 -0
  275. data/lib/openlanes/version.rb +10 -0
  276. data/lib/openlanes/web/app.rb +176 -0
  277. data/lib/openlanes/web/context.rb +66 -0
  278. data/lib/openlanes/web/demo.rb +169 -0
  279. data/lib/openlanes/web/demo_board.rb +286 -0
  280. data/lib/openlanes/web/events.rb +104 -0
  281. data/lib/openlanes/web/launcher.rb +143 -0
  282. data/lib/openlanes/web/server.rb +77 -0
  283. data/lib/openlanes/web/stores.rb +36 -0
  284. data/lib/openlanes/web.rb +92 -0
  285. data/lib/openlanes.rb +151 -0
  286. data/openlanes.gemspec +87 -0
  287. data/schemas/plan-state.schema.json +101 -0
  288. data/skills/openlanes/SKILL.md +48 -0
  289. data/skills/openlanes-documents/SKILL.md +61 -0
  290. data/skills/openlanes-documents/references/blocked-template.md +49 -0
  291. data/skills/openlanes-documents/references/closing-templates.md +59 -0
  292. data/skills/openlanes-documents/references/plan-template.md +61 -0
  293. data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
  294. data/skills/openlanes-documents/references/spec-template.md +45 -0
  295. data/skills/openlanes-run/SKILL.md +90 -0
  296. data/skills/openlanes-setup/SKILL.md +64 -0
  297. data/skills/openlanes-spec/SKILL.md +90 -0
  298. data/skills/openlanes-status/SKILL.md +32 -0
  299. data/skills/openlanes-sync/SKILL.md +22 -0
  300. data/web/dist/assets/apl-B4CMkyY2.js +2 -0
  301. data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
  302. data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
  303. data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
  304. data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  305. data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  306. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  307. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  308. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  309. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  310. data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
  311. data/web/dist/assets/clike-B9uivgTg.js +2 -0
  312. data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
  313. data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
  314. data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
  315. data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
  316. data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
  317. data/web/dist/assets/crystal-SjHAIU92.js +2 -0
  318. data/web/dist/assets/css-BnMrqG3P.js +2 -0
  319. data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
  320. data/web/dist/assets/d-pRatUO7H.js +2 -0
  321. data/web/dist/assets/diff-DbItnlRl.js +2 -0
  322. data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
  323. data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
  324. data/web/dist/assets/dylan-DwRh75JA.js +2 -0
  325. data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
  326. data/web/dist/assets/ecl-Cabwm37j.js +2 -0
  327. data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
  328. data/web/dist/assets/elm-vLlmbW-K.js +2 -0
  329. data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
  330. data/web/dist/assets/factor-kuTfRLto.js +2 -0
  331. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  332. data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
  333. data/web/dist/assets/forth-Ffai-XNe.js +2 -0
  334. data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
  335. data/web/dist/assets/gas-Bneqetm1.js +2 -0
  336. data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
  337. data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
  338. data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
  339. data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
  340. data/web/dist/assets/http-DBlCnlav.js +2 -0
  341. data/web/dist/assets/idl-BEugSyMb.js +2 -0
  342. data/web/dist/assets/index-2xPC-eHt.js +2 -0
  343. data/web/dist/assets/index-8dNhRmiO.js +8 -0
  344. data/web/dist/assets/index-B7-O9ujQ.js +2 -0
  345. data/web/dist/assets/index-BdR34cXC.js +2 -0
  346. data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
  347. data/web/dist/assets/index-Byyxbh_z.js +4 -0
  348. data/web/dist/assets/index-C0VmkrO-.js +2 -0
  349. data/web/dist/assets/index-CI74IkWP.js +198 -0
  350. data/web/dist/assets/index-CYxcbpRM.js +3 -0
  351. data/web/dist/assets/index-D0rmJN3h.js +2 -0
  352. data/web/dist/assets/index-DB4QwuYn.js +2 -0
  353. data/web/dist/assets/index-DDI7zoAS.js +2 -0
  354. data/web/dist/assets/index-DGLUuEXi.js +2 -0
  355. data/web/dist/assets/index-OR-odsl9.js +2 -0
  356. data/web/dist/assets/index-P3vSkiU-.js +2 -0
  357. data/web/dist/assets/index-a4EHR-u4.css +1 -0
  358. data/web/dist/assets/index-mRV4XhJZ.js +2 -0
  359. data/web/dist/assets/index-popk9wbW.js +2 -0
  360. data/web/dist/assets/index-uM88Njdj.js +2 -0
  361. data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
  362. data/web/dist/assets/julia-DuME0IfC.js +2 -0
  363. data/web/dist/assets/livescript-BwQOo05w.js +2 -0
  364. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  365. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  366. data/web/dist/assets/lua-VAEuO923.js +2 -0
  367. data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
  368. data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
  369. data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
  370. data/web/dist/assets/mllike-CXdrOF99.js +2 -0
  371. data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
  372. data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
  373. data/web/dist/assets/mumps-BT43cFF4.js +2 -0
  374. data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
  375. data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
  376. data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
  377. data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
  378. data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
  379. data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
  380. data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
  381. data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
  382. data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
  383. data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
  384. data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
  385. data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
  386. data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
  387. data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
  388. data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
  389. data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
  390. data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  391. data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
  392. data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  393. data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
  394. data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
  395. data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
  396. data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
  397. data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
  398. data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
  399. data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
  400. data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
  401. data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
  402. data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
  403. data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
  404. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
  405. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
  406. data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
  407. data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  408. data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
  409. data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
  410. data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
  411. data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
  412. data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
  413. data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
  414. data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
  415. data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
  416. data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
  417. data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
  418. data/web/dist/assets/oz-BzwKVEFT.js +2 -0
  419. data/web/dist/assets/pascal--L3eBynH.js +2 -0
  420. data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
  421. data/web/dist/assets/pig-CevX1Tat.js +2 -0
  422. data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
  423. data/web/dist/assets/properties-C78fOPTZ.js +2 -0
  424. data/web/dist/assets/protobuf-ChK-085T.js +2 -0
  425. data/web/dist/assets/pug-DeIclll2.js +2 -0
  426. data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
  427. data/web/dist/assets/python-BuPzkPfP.js +2 -0
  428. data/web/dist/assets/q-pXgVlZs6.js +2 -0
  429. data/web/dist/assets/r-B6wPVr8A.js +2 -0
  430. data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
  431. data/web/dist/assets/ruby-BK5aLJev.js +2 -0
  432. data/web/dist/assets/sas-B4kiWyti.js +2 -0
  433. data/web/dist/assets/scheme-C41bIUwD.js +2 -0
  434. data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
  435. data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
  436. data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
  437. data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
  438. data/web/dist/assets/solr-DehyRSwq.js +2 -0
  439. data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
  440. data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
  441. data/web/dist/assets/sql-D0XecflT.js +2 -0
  442. data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
  443. data/web/dist/assets/stylus-B533Al4x.js +2 -0
  444. data/web/dist/assets/swift-BzpIVaGY.js +2 -0
  445. data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
  446. data/web/dist/assets/textile-CnDTJFAw.js +2 -0
  447. data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
  448. data/web/dist/assets/tiki-DGYXhP31.js +2 -0
  449. data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
  450. data/web/dist/assets/troff-wAsdV37c.js +2 -0
  451. data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
  452. data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
  453. data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
  454. data/web/dist/assets/vb-CmGdzxic.js +2 -0
  455. data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
  456. data/web/dist/assets/velocity-D8B20fx6.js +2 -0
  457. data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
  458. data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
  459. data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
  460. data/web/dist/assets/xquery-DzFWVndE.js +2 -0
  461. data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
  462. data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
  463. data/web/dist/index.html +14 -0
  464. data/web/dist/vite.svg +1 -0
  465. data/web/src/api/fixtures/board.json +512 -0
  466. metadata +907 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty/command"
5
+
6
+ module OpenLanes
7
+ module Vcs
8
+ # The `gh` CLI, wrapped thinly.
9
+ #
10
+ # It is a seam rather than a convenience: every example in the suite injects
11
+ # a double here, so nothing in the tests reaches the network or a real
12
+ # repository.
13
+ class GitHub
14
+ # Fields asked of `gh pr list`.
15
+ FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
16
+
17
+ # @param command [TTY::Command] runner, injectable for tests
18
+ # @param limit [Integer] how many pull requests to fetch
19
+ def initialize(command: TTY::Command.new(printer: :null), limit: 200)
20
+ @command = command
21
+ @limit = limit
22
+ end
23
+
24
+ # Every pull request, normalised into plain hashes.
25
+ #
26
+ # @param state [String] "open", "closed", "merged" or "all"
27
+ # @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
28
+ def pulls(state: "all")
29
+ out = UI.spinning("Fetching pull requests from GitHub") {
30
+ @command.run("gh",
31
+ "pr",
32
+ "list",
33
+ "--state",
34
+ state,
35
+ "--limit",
36
+ @limit.to_s,
37
+ "--json",
38
+ FIELDS.join(",")).out
39
+ }
40
+
41
+ # `gh` can exit 0 having printed NOTHING — most often when it cannot reach
42
+ # the credential store, as in a non-interactive shell that has no keyring
43
+ # access, or when GH_TOKEN is set to something invalid and shadows a
44
+ # working login. Left alone this parses as a JSON error and reports as
45
+ # "bad output", sending you to look at the wrong thing entirely.
46
+ raise Error, no_output_message if out.to_s.strip.empty?
47
+
48
+ JSON.parse(out).map do |pr|
49
+ {
50
+ number: pr["number"],
51
+ title: pr["title"].to_s,
52
+ url: pr["url"],
53
+ branch: pr["headRefName"].to_s,
54
+ files: Array(pr["files"]).map { |f| f["path"] }.compact,
55
+ state: self.class.state_label(pr),
56
+ open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
57
+ }
58
+ end
59
+ rescue TTY::Command::ExitError, JSON::ParserError => e
60
+ raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
61
+ end
62
+
63
+ # @return [String] the diagnosis for a silent `gh`
64
+ def no_output_message
65
+ <<~MESSAGE.strip
66
+ `gh` produced no output and did not report an error.
67
+
68
+ That is almost always authentication rather than an empty repository:
69
+
70
+ - Check `gh auth status`. An invalid GH_TOKEN in the environment
71
+ shadows a working keyring login and fails without saying so.
72
+
73
+ - A non-interactive shell may have no access to the system keyring
74
+ even when an interactive one does.
75
+
76
+ Verify with: gh pr list --state all --limit 1
77
+ MESSAGE
78
+ end
79
+
80
+ # Fields asked of `gh pr view`, which unlike `pr list` can be told about
81
+ # one pull request in another repository.
82
+ VIEW_FIELDS = %w[number title url headRefName state isDraft mergedAt body].freeze
83
+
84
+ # A reference to one pull request: a bare number, a `#`-prefixed number,
85
+ # or a full URL to a GitHub pull request or a GitLab merge request.
86
+ REF = %r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}
87
+
88
+ # Split and validate a `--prs` value before any of it reaches the network,
89
+ # so a typo fails in a hundredth of a second with the offending token named
90
+ # rather than after four round trips with a `gh` diagnostic.
91
+ #
92
+ # @param text [String] e.g. "12,15,18" or "https://…/pull/12, #15"
93
+ # @return [Array<String>] references, in the order given, de-duplicated
94
+ # @raise [OpenLanes::Error] on anything that is not a reference
95
+ def self.parse_refs(text)
96
+ refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
97
+ raise Error, "no pull requests given" if refs.empty?
98
+
99
+ bad = refs.reject { |r| r.match?(REF) }
100
+ unless bad.empty?
101
+ raise Error, "not a pull request number or URL: #{bad.join(", ")}"
102
+ end
103
+
104
+ refs.uniq
105
+ end
106
+
107
+ # One pull request, by number or URL.
108
+ #
109
+ # @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
110
+ # @return [Hash] `{number:, title:, url:, branch:, state:, body:}`
111
+ # @raise [OpenLanes::Error]
112
+ def pull_request(ref)
113
+ out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
114
+ raise Error, no_output_message if out.to_s.strip.empty?
115
+
116
+ pr = JSON.parse(out)
117
+ {
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
124
+ }
125
+ rescue TTY::Command::ExitError, JSON::ParserError => e
126
+ raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
127
+ end
128
+
129
+ # Several pull requests, in the order asked for.
130
+ #
131
+ # @param refs [Array<String>]
132
+ # @return [Array<Hash>]
133
+ def pull_requests(refs)
134
+ UI.stepping(refs, "Fetching pull requests") { |ref| ref }
135
+ refs.map { |ref| pull_request(ref) }
136
+ end
137
+
138
+ # `gh` speaks in enums; `pull-requests.md` speaks in the words the state
139
+ # machine parses. Translate once, here, rather than at each call site.
140
+ #
141
+ # @param pr [Hash] a decoded `gh pr view` payload
142
+ # @return [String] one of the labels in {PullRequests::STATES}
143
+ def self.state_label(pr)
144
+ return "Merged 🟣" if pr["mergedAt"]
145
+ return "WIP 🟡" if pr["isDraft"]
146
+
147
+ case pr["state"].to_s.upcase
148
+ when "OPEN" then "Open 🟡"
149
+ when "CLOSED" then "Closed 🔴"
150
+ else "Unknown"
151
+ end
152
+ end
153
+
154
+ # Change a pull request's title.
155
+ #
156
+ # @param number [Integer]
157
+ # @param title [String]
158
+ # @return [void]
159
+ def retitle(number:, title:)
160
+ @command.run("gh", "pr", "edit", number.to_s, "--title", title)
161
+ rescue TTY::Command::ExitError => e
162
+ raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
163
+ end
164
+
165
+ # @return [Boolean] whether `gh` is installed and authenticated
166
+ def available?
167
+ @command.run!("gh", "auth", "status").success?
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "tty/command"
5
+
6
+ module OpenLanes
7
+ module Vcs
8
+ # Turns a finished worktree into a pushed branch and a pull request.
9
+ #
10
+ # This is the one place the harness reaches off the machine, so it does
11
+ # nothing under `--dry-run`, is skipped with `--no-git-push`, and refuses anything
12
+ # it is not certain about. Everything it does is reported before it does it.
13
+ #
14
+ # Titles follow the convention the whole system joins on:
15
+ #
16
+ # [002.00](A) Tenancy Households — a plan's first pull request
17
+ # [002.00](B) Tenancy Households — its second
18
+ #
19
+ # The slug is title-cased from the folder name, so the title reads as a
20
+ # sentence rather than as a path fragment.
21
+ class Publisher
22
+ # A published, or publishable, pull request.
23
+ #
24
+ # @!attribute [r] ordinal
25
+ # @return [OpenLanes::Plans::Ordinal]
26
+ # @!attribute [r] branch
27
+ # @return [String]
28
+ # @!attribute [r] title
29
+ # @return [String]
30
+ # @!attribute [r] url
31
+ # @return [String, nil] nil on a dry run or a refusal
32
+ # @!attribute [r] refusal
33
+ # @return [String, nil]
34
+ # @!attribute [r] updated
35
+ # @return [Boolean] whether the push went to a pull request the plan
36
+ # already had open, rather than opening a new one
37
+ Publication = Data.define(:ordinal, :branch, :title, :url, :refusal, :updated) do
38
+ def initialize(updated: false, **rest) = super
39
+
40
+ # @return [Boolean]
41
+ def published? = !url.nil?
42
+ end
43
+
44
+ # A..Z, which is more parts than any feature should be split into.
45
+ LETTERS = ("A".."Z").to_a.freeze
46
+
47
+ # @param root [String] repository the worktree belongs to
48
+ # @param command [TTY::Command]
49
+ # @param base [String] the branch pull requests target
50
+ # @param dry_run [Boolean]
51
+ # @param github [OpenLanes::Vcs::GitHub] asked which branch an open pull
52
+ # request is built from
53
+ def initialize(root:, command: TTY::Command.new(printer: :null), base: "main", dry_run: true, github: GitHub.new)
54
+ @root = root
55
+ @command = command
56
+ @github = github
57
+ @base = base
58
+ @dry_run = dry_run
59
+ end
60
+
61
+ # Push one plan's branch and open its pull request, or, when the plan
62
+ # already has one open, push to it.
63
+ #
64
+ # A re-build after 🔴 Changes Requested is the second case. Its branch
65
+ # already carries the open pull request the reviewer asked changes of,
66
+ # and `gh pr create` on that branch fails ("a pull request for branch
67
+ # ... already exists") after the push has gone through, so the fixes
68
+ # reached the pull request and the run reported a refusal. One branch per
69
+ # plan means at most one open pull request per plan, so the open row in
70
+ # `pull-requests.md` is the one to push to.
71
+ #
72
+ # Only if that pull request is built from this checkout's branch, though.
73
+ # `pull-requests.md` does not record the head branch, and an adopted or
74
+ # hand-opened pull request can come from any branch at all; pushing the
75
+ # worktree branch then reaches no pull request while reporting one as
76
+ # updated. So the head is asked of GitHub first, and anything other than
77
+ # the checkout's own branch, or no answer, is a refusal.
78
+ #
79
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
80
+ # @param subject [OpenLanes::Plans::Subject]
81
+ # @param letter [String, nil] force a part letter; nil auto-assigns
82
+ # @return [OpenLanes::Vcs::Publisher::Publication]
83
+ def publish(checkout:, subject:, letter: nil)
84
+ open = subject.pull_requests.select(&:open?).last
85
+ title = open&.title || title_for(subject, letter)
86
+
87
+ unless checkout.dirty?
88
+ return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
89
+ end
90
+
91
+ return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
92
+
93
+ mismatch = open && head_mismatch(open, checkout)
94
+ return refuse(subject, checkout, title, mismatch) if mismatch
95
+
96
+ commit(checkout, title)
97
+ push(checkout)
98
+ url = open ? open.url : create_pull_request(checkout, subject, title)
99
+
100
+ Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil, updated: !open.nil?)
101
+ rescue TTY::Command::ExitError => e
102
+ refuse(subject, checkout, title, git_complaint(e))
103
+ end
104
+
105
+ # git and gh put the useful sentence in stderr, several lines below the
106
+ # command they echo. Reporting only the first line reports "it failed".
107
+ #
108
+ # @param error [TTY::Command::ExitError]
109
+ # @return [String]
110
+ def git_complaint(error)
111
+ lines = error.message.lines.map(&:strip).reject(&:empty?)
112
+ stderr = lines.find { |l| l.start_with?("stderr:") }
113
+ complaint = stderr&.delete_prefix("stderr:")&.strip
114
+
115
+ complaint.to_s.empty? || complaint == "Nothing written" ? lines.first.to_s : complaint
116
+ end
117
+
118
+ # The title a plan's next pull request should carry.
119
+ #
120
+ # @param subject [OpenLanes::Plans::Subject]
121
+ # @param letter [String, nil]
122
+ # @return [String]
123
+ def title_for(subject, letter = nil)
124
+ "[#{subject.feature.ordinal}](#{letter || auto_letter(subject)}) #{subject.feature.title}"
125
+ end
126
+
127
+ # Which part this is. Always a letter, starting at A: a plan that turns out
128
+ # to need only one pull request still reads consistently with every other,
129
+ # and nothing has to be renamed when a second arrives.
130
+ #
131
+ # @param subject [OpenLanes::Plans::Subject]
132
+ # @return [String]
133
+ def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
134
+
135
+ private
136
+
137
+ # Why the open pull request cannot receive this checkout's push, if it
138
+ # cannot.
139
+ #
140
+ # @param open [OpenLanes::Plans::PullRequest]
141
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
142
+ # @return [String, nil] nil when the pull request is built from the checkout's branch
143
+ def head_mismatch(open, checkout)
144
+ head = @github.pull_request(open.number || open.url).fetch(:branch)
145
+ return if head == checkout.branch
146
+
147
+ "publish refused — ##{open.number} is built from #{head.empty? ? "an unknown branch" : head}, " \
148
+ "not #{checkout.branch}, so a push would not reach it"
149
+ rescue Error => e
150
+ "publish refused — could not tell which branch ##{open.number} is built from: #{e.message}"
151
+ end
152
+
153
+ # @return [Hash]
154
+ def base_fields(subject, checkout, title)
155
+ { ordinal: subject.feature.ordinal, branch: checkout.branch, title: }
156
+ end
157
+
158
+ # @return [OpenLanes::Vcs::Publisher::Publication]
159
+ def refuse(subject, checkout, title, why)
160
+ Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: why)
161
+ end
162
+
163
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
164
+ # @param title [String]
165
+ # @return [void]
166
+ def commit(checkout, title)
167
+ run(checkout, "git", "add", "-A")
168
+ # The subject line drops the bracketed prefix: it belongs in the pull
169
+ # request title, where things join on it, not in every commit message.
170
+ run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
171
+ end
172
+
173
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
174
+ # @return [void]
175
+ def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
176
+
177
+ # @return [String, nil] the pull request URL
178
+ def create_pull_request(checkout, subject, title)
179
+ Tempfile.create(["pr", ".md"]) do |file|
180
+ file.write(description(subject, title))
181
+ file.flush
182
+ result = run(checkout, "gh", "pr", "create", "-a", "@me", "-B", @base, "-t", title, "-F", file.path)
183
+ return result.out.to_s[%r{https://\S+}]
184
+ end
185
+ end
186
+
187
+ # The body `/create-pr` would have written: a title line, a summary lifted
188
+ # from the specification, and a link back to the plan folder so a reviewer
189
+ # can find the rest.
190
+ #
191
+ # @return [String]
192
+ def description(subject, title)
193
+ <<~MARKDOWN
194
+ # #{title}
195
+
196
+ ## Summary
197
+
198
+ #{summary_text(subject)}
199
+
200
+ ## Plan
201
+
202
+ Implements `#{subject.feature.dirname}`. The specification and the plan
203
+ for this work live in that folder.
204
+ MARKDOWN
205
+ end
206
+
207
+ # The specification's own Goal section, verbatim. A pull request body that
208
+ # paraphrases the spec is a second copy that drifts; quoting it is not.
209
+ #
210
+ # @param subject [OpenLanes::Plans::Subject]
211
+ # @return [String]
212
+ def summary_text(subject)
213
+ text = subject.goal.join("\n\n")
214
+ text.empty? ? "See `#{subject.feature.dirname}/spec.md`." : text
215
+ end
216
+
217
+ # @return [TTY::Command::Result]
218
+ def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+
6
+ module OpenLanes
7
+ module Vcs
8
+ # A git worktree per plan, so agents working on different plans cannot
9
+ # collide at all.
10
+ #
11
+ # A lock coordinates a shared tree; a worktree removes the sharing. That
12
+ # matters more than it sounds for an agent loop: two agents editing one
13
+ # checkout produce no git conflict — same branch, same files — so the last
14
+ # writer simply wins and the loser's work vanishes with nothing anywhere to
15
+ # say it happened.
16
+ #
17
+ # The branch carries the plan number (`<user>/NNN.MM-slug`), which is not
18
+ # decoration: it is the first thing `resync prs` reads when deciding which
19
+ # plan a pull request implements. Naming branches this way means the number
20
+ # carries itself from worktree creation through to a merged pull request with
21
+ # nobody having to remember it.
22
+ class Worktree
23
+ # `bin/setup-worktree`, which copies in the ignored-but-required files a new
24
+ # checkout does not get. Shelling out rather than reimplementing the rules
25
+ # here keeps one answer to "what does a worktree need", and keeps that answer
26
+ # usable from a shell on a machine that has never run `bundle install`.
27
+ SEEDER = File.expand_path("../../../bin/setup-worktree", __dir__)
28
+
29
+ # One plan's isolated checkout.
30
+ #
31
+ # @!attribute [r] ordinal
32
+ # @return [OpenLanes::Plans::Ordinal]
33
+ # @!attribute [r] branch
34
+ # @return [String]
35
+ # @!attribute [r] path
36
+ # @return [String] absolute
37
+ # @!attribute [r] created
38
+ # @return [Boolean] false when an existing worktree was reused
39
+ Checkout = Data.define(:ordinal, :branch, :path, :created) do
40
+ # @return [Boolean] whether the agent left anything behind
41
+ def dirty? = !`git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
42
+
43
+ # @return [String] the plans directory inside this checkout
44
+ def plans_dir = File.join(path, OpenLanes::PLANS_DIR)
45
+
46
+ # @return [Boolean] whether this round made it, rather than reusing one
47
+ def created? = created
48
+ end
49
+
50
+ # @param root [String] the main repository
51
+ # @param dir [String, nil] where worktrees go; defaults to a sibling
52
+ # @param user [String] branch namespace
53
+ def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
54
+ @root = File.expand_path(root)
55
+ @user = user
56
+ @dir = File.expand_path(dir || default_dir)
57
+ end
58
+
59
+ # @return [String] the main repository
60
+ attr_reader :root
61
+
62
+ # @return [String] where worktrees are kept
63
+ attr_reader :dir
64
+
65
+ # @return [Boolean] whether root is a git repository at all
66
+ def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
67
+
68
+ # Get, or create, the isolated checkout for one plan.
69
+ #
70
+ # The checkout is seeded before it is handed back, on both paths. `git
71
+ # worktree add` brings every TRACKED file and nothing else, so an agent
72
+ # given a bare one cannot run the suite it was told to keep green: a Rails
73
+ # project dies on `MissingKeyError` because `config/credentials/*.key` is
74
+ # gitignored. The agent then reports a broken checkout as a broken plan,
75
+ # which is the worst failure this loop has, because the diagnosis points
76
+ # at the code rather than at the tree.
77
+ #
78
+ # A reused worktree is seeded too. {#seed} only ever adds a missing file,
79
+ # so running it again costs one fast subprocess and repairs the worktrees
80
+ # that were created before any of this existed.
81
+ #
82
+ # @param feature [OpenLanes::Plans::Feature]
83
+ # @return [OpenLanes::Vcs::Worktree::Checkout]
84
+ # @raise [OpenLanes::Error] when git refuses
85
+ def checkout_for(feature)
86
+ branch = branch_for(feature)
87
+ path = path_for(feature)
88
+
89
+ if File.directory?(path)
90
+ seed(path)
91
+ return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false)
92
+ end
93
+
94
+ # git keeps a registration for a worktree whose directory was deleted by
95
+ # hand, marks it `prunable`, and then refuses to create a new one at that
96
+ # path. Anybody who has ever `rm -rf`d a worktree meets this, and the
97
+ # error says only "already exists" about a directory that does not.
98
+ forget_stale
99
+
100
+ FileUtils.mkdir_p(dir)
101
+ add(branch, path)
102
+ seed(path)
103
+ Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
104
+ end
105
+
106
+ # Copy in the files git ignores and a new checkout therefore lacks: `.env`
107
+ # and friends, and credential keys.
108
+ #
109
+ # Never fatal. A repository with no ignored files is perfectly normal, and
110
+ # a plan is not worth abandoning over a seeding step, so this notes it and
111
+ # carries on.
112
+ #
113
+ # The note goes to the progress log, never the terminal: this runs while
114
+ # agents' spinner lines are live on STDERR, and a stray line lands in the
115
+ # middle of them. The seeder's own output is silenced for the same reason. A suite that later dies on a missing key is explained
116
+ # by the log line.
117
+ #
118
+ # @param path [String] the worktree to seed
119
+ # @return [Boolean] whether the seeder ran and succeeded
120
+ def seed(path)
121
+ unless File.executable?(SEEDER)
122
+ UI.log("worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys")
123
+ return false
124
+ end
125
+
126
+ return true if system(SEEDER, "--quiet", path, out: File::NULL, err: File::NULL)
127
+
128
+ UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
129
+ false
130
+ end
131
+
132
+ # @param feature [OpenLanes::Plans::Feature]
133
+ # @return [String] e.g. "kig/002.00-tenancy-households"
134
+ def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}"
135
+
136
+ # Every worktree this class manages, as git sees them.
137
+ #
138
+ # @return [Array<String>] absolute paths
139
+ def list
140
+ # Compare canonical paths. git reports resolved ones, and on macOS the
141
+ # temp and home trees run through symlinks (/var -> /private/var), so a
142
+ # raw string compare matches nothing and prune silently does nothing.
143
+ mine = canonical(dir)
144
+
145
+ `git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
146
+ .lines(chomp: true)
147
+ .filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") }
148
+ .select { |p| canonical(p).start_with?(mine) }
149
+ end
150
+
151
+ # @param path [String]
152
+ # @return [String] the symlink-resolved path, or the input when it is gone
153
+ def canonical(path)
154
+ File.realpath(path)
155
+ rescue Errno::ENOENT
156
+ path
157
+ end
158
+
159
+ # Remove worktrees the agents left untouched.
160
+ #
161
+ # An agent that ran and changed nothing has produced a checkout that is
162
+ # pure cost: it looks like work in progress and is not. Dirty ones are
163
+ # always kept — that is the output.
164
+ #
165
+ # @return [Array<String>] paths removed
166
+ def prune
167
+ list.select { |path| clean?(path) }.each { |path| remove(path) }
168
+ end
169
+
170
+ # Where one plan's checkout lives, whether or not it has been made.
171
+ #
172
+ # @param feature [OpenLanes::Plans::Feature]
173
+ # @return [String]
174
+ def path_for(feature) = File.join(dir, "#{feature.ordinal}-#{feature.slug}")
175
+
176
+ # Throw away what has not been committed in one plan's checkout: every
177
+ # change to a tracked file, and every untracked file that is not
178
+ # ignored, so the seeded `.env` and keys stay. This is the reset behind
179
+ # a person's discard (W9). Agents never commit, so everything a round
180
+ # did is uncommitted, and the branch's own last commit is the base it
181
+ # started from; going further back would drop work already published
182
+ # behind an open pull request.
183
+ #
184
+ # It only ever runs inside a worktree this class manages. The main
185
+ # checkout, or a directory git does not list as one of these
186
+ # worktrees, is refused, because `reset --hard` there would take a
187
+ # person's own work with it.
188
+ #
189
+ # @param feature [OpenLanes::Plans::Feature]
190
+ # @return [Boolean] false when the plan has no checkout, so nothing to reset
191
+ # @raise [OpenLanes::Error] when the path is not one of these worktrees,
192
+ # or git refuses
193
+ def reset(feature)
194
+ path = path_for(feature)
195
+ return false unless File.directory?(path)
196
+
197
+ mine = list.map { canonical(it) }
198
+ unless mine.include?(canonical(path)) && canonical(path) != canonical(root)
199
+ raise Error, "#{path} is not a worktree openlanes made, so it is not reset"
200
+ end
201
+
202
+ [%w[reset --hard --quiet], %w[clean -fd --quiet]].each do |args|
203
+ next if system("git", "-C", path, *args, out: File::NULL, err: File::NULL)
204
+
205
+ raise Error, "git #{args.first} failed in #{path}"
206
+ end
207
+ true
208
+ end
209
+
210
+ # @param path [String]
211
+ # @return [void]
212
+ def remove(path)
213
+ system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
214
+ end
215
+
216
+ private
217
+
218
+ # Sibling of the repository, not inside it: a worktree under the repo shows
219
+ # up in its own `git status` and in every glob anybody writes.
220
+ #
221
+ # @return [String]
222
+ def default_dir = "#{@root}.worktrees"
223
+
224
+ # Drop registrations whose directory is gone.
225
+ #
226
+ # @return [void]
227
+ def forget_stale
228
+ system("git", "-C", root, "worktree", "prune", out: File::NULL, err: File::NULL)
229
+ end
230
+
231
+ # @param path [String]
232
+ # @return [Boolean]
233
+ def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
234
+
235
+ # @param branch [String]
236
+ # @param path [String]
237
+ # @return [void]
238
+ def add(branch, path)
239
+ exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
240
+ args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
241
+
242
+ return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
243
+
244
+ raise Error, "could not create a worktree for #{branch} at #{path}"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # Git and GitHub: a checkout per plan, pushing a branch, and reading
5
+ # pull requests. Everything that leaves the machine leaves through here.
6
+ module Vcs
7
+ module_function
8
+
9
+ # @return [OpenLanes::Vcs::GitHub]
10
+ def github = GitHub.new
11
+
12
+ # @param root [String] repository root
13
+ # @return [OpenLanes::Vcs::Worktree]
14
+ def worktree(root:, **) = Worktree.new(root:, **)
15
+
16
+ # @return [OpenLanes::Vcs::Publisher]
17
+ def publisher(**) = Publisher.new(**)
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Alone in its own file, requiring nothing, so the gemspec can read the version
4
+ # without loading the library — and therefore without the library's own
5
+ # dependencies having to be installed before the gemspec can be evaluated.
6
+ #
7
+ # © 2026 Konstantin Gredeskoul
8
+ module OpenLanes
9
+ VERSION = "3.0.0"
10
+ end