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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Lifecycle
5
+ # Draining a plan's `blocked.md`, and saying what happened.
6
+ #
7
+ # `unblock` is the one command a human runs *because* they know something the
8
+ # tool cannot observe: an answer has arrived. So it owes them an account of
9
+ # what it did with it. Four things can happen to a plan named on the command
10
+ # line, and only one of them is the interesting one:
11
+ #
12
+ # * the number names no folder,
13
+ # * the folder is not blocked and there is nothing to drain,
14
+ # * the folder is blocked, and some, none or all of its questions fold in,
15
+ # * `blocked.md` is there but numbers nothing `## B<n>`, so no program can
16
+ # read it at all.
17
+ #
18
+ # Each is reported differently, because each needs a different thing done
19
+ # next. This object works out which one happened; {CLI::Unblock} prints it.
20
+ class Unblocker
21
+ # One open question, as `blocked.md` writes it.
22
+ #
23
+ # @!attribute [r] number
24
+ # @return [Integer] the `n` in `## B<n>`
25
+ # @!attribute [r] heading
26
+ # @return [String] the heading text, hashes stripped
27
+ # @!attribute [r] answered
28
+ # @return [Boolean] whether an `## A<n>` of the same number is waiting
29
+ Question = Data.define(:number, :heading, :answered) do
30
+ # @return [String]
31
+ def to_s = answered ? "#{heading} ← answer waiting" : heading
32
+ end
33
+
34
+ # One token from the command line, once the tree has had a look at it.
35
+ #
36
+ # @!attribute [r] token
37
+ # @return [String] what the user typed
38
+ # @!attribute [r] subject
39
+ # @return [OpenLanes::Plans::Subject, nil]
40
+ # @!attribute [r] problem
41
+ # @return [Symbol, nil] :missing, :not_blocked, or nil when it is drainable
42
+ Target = Data.define(:token, :subject, :problem) do
43
+ # @return [Boolean]
44
+ def drainable? = problem.nil?
45
+ end
46
+
47
+ # What became of one plan.
48
+ #
49
+ # @!attribute [r] subject
50
+ # @return [OpenLanes::Plans::Subject] as the folder stands *now*
51
+ # @!attribute [r] ok
52
+ # @return [Boolean] whether the agent ran without failing
53
+ # @!attribute [r] note
54
+ # @return [String] one line about how it went
55
+ # @!attribute [r] before
56
+ # @return [Array<Question>] open before the agent ran
57
+ # @!attribute [r] after
58
+ # @return [Array<Question>] open now
59
+ Outcome = Data.define(:subject, :ok, :note, :before, :after) do
60
+ # @return [OpenLanes::Plans::Ordinal]
61
+ def ordinal = subject.feature.ordinal
62
+
63
+ # @return [Array<Integer>] the questions this run actually disposed of
64
+ def folded = before.map(&:number) - after.map(&:number)
65
+
66
+ # @return [Boolean] `blocked.md` is gone, so the plan is out of the block
67
+ def cleared? = !subject.file?("blocked.md")
68
+
69
+ # @return [Boolean] a `blocked.md` no program can read
70
+ def unreadable? = subject.unreadable_block?
71
+
72
+ # @return [Array<Question>] still open, with an answer sitting unfolded
73
+ def waiting = after.select(&:answered)
74
+ end
75
+
76
+ # The questions a folder still names, in the order the file names them.
77
+ #
78
+ # @param subject [OpenLanes::Plans::Subject]
79
+ # @return [Array<Question>]
80
+ def self.questions(subject)
81
+ answered = subject.block_answers
82
+
83
+ subject.read("blocked.md").to_s.lines.grep(Plans::OPEN_BLOCK).map do |line|
84
+ number = line[Plans::OPEN_BLOCK, 1].to_i
85
+ Question.new(number:, heading: line.strip.sub(/\A\#+[ \t]*/, ""), answered: answered.include?(number))
86
+ end
87
+ end
88
+
89
+ # @param tree [OpenLanes::Plans::Tree]
90
+ # @param agent [OpenLanes::Agents::Agent]
91
+ # @param executor [#call] receives (agent, subject, root:) and yields each
92
+ # phrase describing what the agent is doing
93
+ # @param root [String] the repository the agent works in
94
+ # @param commit [Boolean] false plans the invocation and changes nothing
95
+ def initialize(tree:, agent:, executor:, root: nil, commit: false)
96
+ @tree = tree
97
+ @agent = agent
98
+ @executor = executor
99
+ @root = root || File.dirname(tree.dir)
100
+ @commit = commit
101
+ end
102
+
103
+ # @return [OpenLanes::Plans::Tree]
104
+ attr_reader :tree
105
+
106
+ # @return [OpenLanes::Agents::Agent]
107
+ attr_reader :agent
108
+
109
+ # @return [String]
110
+ attr_reader :root
111
+
112
+ # @return [Boolean]
113
+ def applying? = @commit
114
+
115
+ # Work out what each number on the command line refers to.
116
+ #
117
+ # Nothing is rejected here and nothing exits: a token that names no folder
118
+ # is as much a thing the user needs told about as a folder that drained, and
119
+ # stopping at the first bad one hides the rest of the report.
120
+ #
121
+ # The gate is `blocked.md` itself, not the folder's emoji. ⭕️ is *derived*
122
+ # from the file, so gating on the emoji made the tool circular: a
123
+ # `blocked.md` whose questions are not numbered `## B<n>` never earns the
124
+ # emoji, so `unblock` refused it, so the file could never drain and the
125
+ # folder could never leave 🟡.
126
+ #
127
+ # @param tokens [Array<String>] `NNN`, `NNN.MM`, or several comma separated
128
+ # @return [Array<Target>]
129
+ def resolve(tokens)
130
+ tokens.flat_map { |token| token.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |token|
131
+ subject = tree.find(token)
132
+ next Target.new(token:, subject: nil, problem: :missing) if subject.nil?
133
+ next Target.new(token:, subject:, problem: :not_blocked) unless subject.file?("blocked.md")
134
+
135
+ Target.new(token:, subject:, problem: nil)
136
+ end
137
+ end
138
+
139
+ # Hand each folder to the agent, one at a time, and read the file back.
140
+ #
141
+ # What the agent claims it did is not evidence. The questions are counted
142
+ # off disk before and after, so "folded B3" means the heading is gone from
143
+ # `blocked.md`, not that an agent said so.
144
+ #
145
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
146
+ # @return [Array<Outcome>]
147
+ def call(subjects)
148
+ return [] if subjects.empty?
149
+
150
+ before = subjects.map { |subject| [subject.feature.ordinal.to_s, self.class.questions(subject)] }.to_h
151
+ results = UI.concurrently(subjects,
152
+ headline(subjects),
153
+ jobs: 1,
154
+ label: method(:label),
155
+ fields: method(:log_fields)) do |subject, progress|
156
+ # After ctrl-c the plans not yet started stay as they are, blocked.md
157
+ # untouched, so the next run picks them up from the beginning.
158
+ next [false, "not started: interrupted by ctrl-c"] if Execution::Control.interrupted?
159
+
160
+ invoke(subject, progress)
161
+ end
162
+
163
+ settle
164
+ subjects.zip(results).map { |subject, result| outcome(subject, result, before) }
165
+ end
166
+
167
+ # One folder, with no spinner and no rename: what the run loop does when
168
+ # a person has answered every question a blocked plan asked (plan 004).
169
+ # The loop owns the screen, so nothing here draws on it, and the loop
170
+ # owns renames, so the folder's name is left to its next `resync dirs`,
171
+ # which sees `blocked.md` gone. Everything else is {#call}'s: the same
172
+ # agent, the same invocation, the same count of questions off disk.
173
+ #
174
+ # @param subject [OpenLanes::Plans::Subject]
175
+ # @yieldparam progress [Object] whatever the executor reports
176
+ # @return [Outcome]
177
+ def drain(subject, &progress)
178
+ before = { subject.feature.ordinal.to_s => self.class.questions(subject) }
179
+ result = applying? ? invoke(subject, progress || proc {}) : [false, "not attempted: a dry run"]
180
+ tree.reload
181
+ outcome(subject, result, before)
182
+ end
183
+
184
+ private
185
+
186
+ # @param subject [OpenLanes::Plans::Subject]
187
+ # @param progress [OpenLanes::UI::Line]
188
+ # @return [Array(Boolean, String)]
189
+ def invoke(subject, progress)
190
+ @executor.call(agent, subject, root:, &progress)
191
+ rescue StandardError => e
192
+ [false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
193
+ end
194
+
195
+ # The same pass the run loop makes after every round, for the same reason:
196
+ # a drained folder is only *named* differently once something reads the
197
+ # file the agent just deleted.
198
+ #
199
+ # @return [void]
200
+ def settle
201
+ Resync::Dirs.new(tree:).call(commit: true) if applying?
202
+ tree.reload
203
+ end
204
+
205
+ # @param subject [OpenLanes::Plans::Subject] as it was before the run
206
+ # @param result [Array, Exception]
207
+ # @param before [Hash{String => Array<Question>}]
208
+ # @return [Outcome]
209
+ def outcome(subject, result, before)
210
+ ok, note = result.is_a?(Exception) ? [false, result.message.lines.first.to_s.strip] : result
211
+ current = tree.find(subject.feature.ordinal) || subject
212
+
213
+ Outcome.new(subject: current,
214
+ ok: !!ok,
215
+ note: note.to_s,
216
+ before: before.fetch(subject.feature.ordinal.to_s, []),
217
+ after: self.class.questions(current))
218
+ end
219
+
220
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
221
+ # @return [String]
222
+ def headline(subjects)
223
+ "#{applying? ? "handing" : "would hand"} #{subjects.size} plan#{"s" unless subjects.size == 1} " \
224
+ "to #{agent.name} in #{root}"
225
+ end
226
+
227
+ # @param subject [OpenLanes::Plans::Subject]
228
+ # @return [String]
229
+ def label(subject) = "#{subject.feature.ordinal} → #{agent.name}"
230
+
231
+ # @param subject [OpenLanes::Plans::Subject]
232
+ # @return [Hash] the columns this plan's log lines carry
233
+ def log_fields(subject) = { plan: subject.feature.ordinal.to_s, status: subject.status.to_s, agent: agent.name }
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # Operations that change which plans exist or what they are called:
5
+ # minting a folder, drafting its brief, adopting an orphan pull request,
6
+ # reconciling names with contents, and draining answered questions.
7
+ module Lifecycle
8
+ module_function
9
+
10
+ # @param dir [String] the `.plans` directory
11
+ # @param options [Hash] passed to {Creator#create}
12
+ # @return [Dry::Monads::Result] Success(path) or Failure(message)
13
+ def create(dir:, **) = Creator.new(dir:).create(**)
14
+
15
+ # @param tree [OpenLanes::Plans::Tree]
16
+ # @return [OpenLanes::Lifecycle::Resync::Dirs]
17
+ def resync_dirs(tree:, **) = Resync::Dirs.new(tree:, **)
18
+
19
+ # @return [OpenLanes::Lifecycle::Resync::Prs]
20
+ def resync_prs(**) = Resync::Prs.new(**)
21
+
22
+ # @return [OpenLanes::Lifecycle::Brief]
23
+ def brief(**) = Brief.new(**)
24
+
25
+ # @return [OpenLanes::Lifecycle::Unblocker]
26
+ def unblocker(**) = Unblocker.new(**)
27
+ end
28
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module OpenLanes
8
+ module Linear
9
+ # Linear's GraphQL API, wrapped as thinly as {OpenLanes::Vcs::GitHub} wraps
10
+ # `gh`, and for the same reason: it is a seam. Every example in the suite
11
+ # injects a transport here, so nothing in the tests reaches Linear.
12
+ #
13
+ # Only the dozen operations an import needs are here. This is not a client
14
+ # library, and it should not grow into one — anything Linear can do that
15
+ # `.plans` cannot express does not belong in a tool whose whole premise is
16
+ # that the folder is the source of truth.
17
+ class API
18
+ ENDPOINT = "https://api.linear.app/graphql"
19
+
20
+ # The environment variable holding a personal API key.
21
+ TOKEN_VARIABLE = "LINEAR_API_KEY"
22
+
23
+ # @return [String, nil] the token, from the environment
24
+ def self.token_from_env = ENV[TOKEN_VARIABLE].to_s.strip.then { |t| t.empty? ? nil : t }
25
+
26
+ # @param token [String] a Linear personal API key
27
+ # @param transport [#call, nil] `(query, variables) -> Hash`, for tests
28
+ # @param endpoint [String]
29
+ def initialize(token: nil, transport: nil, endpoint: ENDPOINT)
30
+ @token = token
31
+ @transport = transport
32
+ @endpoint = endpoint
33
+ return if transport || (token && !token.empty?)
34
+
35
+ raise Error,
36
+ "no Linear token. Set #{TOKEN_VARIABLE}, or use the MCP transport:\n " \
37
+ "openlanes linear import <TEAM> -p <PROJECT> --format json"
38
+ end
39
+
40
+ # The team, its workflow states and its labels, in one round trip.
41
+ #
42
+ # @param key [String] the team key, e.g. "TAX"
43
+ # @return [Hash] `{id:, name:, key:, states: [...], labels: [...]}`
44
+ # @raise [OpenLanes::Error] when no team wears that key
45
+ def team(key)
46
+ node = query(TEAM, key: key.to_s.upcase).dig("teams", "nodes")&.first
47
+ raise Error, "no Linear team has the key #{key.to_s.upcase}" unless node
48
+
49
+ { id: node["id"], name: node["name"], key: node["key"],
50
+ states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a }
51
+ end
52
+
53
+ # @param team_id [String]
54
+ # @return [Array<Hash>] `{id:, name:, url:}`
55
+ def projects(team_id) = query(PROJECTS, teamId: team_id).dig("team", "projects", "nodes").to_a
56
+
57
+ # @param input [Hash] `{name:, teamIds:, content:, icon:}`
58
+ # @return [Hash] `{id:, name:, url:}`
59
+ def create_project(input) = unwrap(query(PROJECT_CREATE, input:), "projectCreate", "project")
60
+
61
+ # @param id [String]
62
+ # @param input [Hash]
63
+ # @return [Hash]
64
+ def update_project(id, input) = unwrap(query(PROJECT_UPDATE, id:, input:), "projectUpdate", "project")
65
+
66
+ # @param input [Hash] `{teamId:, projectId:, title:, description:, stateId:, labelIds:}`
67
+ # @return [Hash] `{id:, identifier:, url:}`
68
+ def create_issue(input) = unwrap(query(ISSUE_CREATE, input:), "issueCreate", "issue")
69
+
70
+ # @param id [String] a UUID or an identifier such as "TAX-41"
71
+ # @param input [Hash]
72
+ # @return [Hash]
73
+ def update_issue(id, input) = unwrap(query(ISSUE_UPDATE, id:, input:), "issueUpdate", "issue")
74
+
75
+ # @param name [String]
76
+ # @param team_id [String]
77
+ # @return [Hash] `{id:, name:}`
78
+ def create_label(name, team_id)
79
+ unwrap(query(LABEL_CREATE, input: { name:, teamId: team_id }), "issueLabelCreate", "issueLabel")
80
+ end
81
+
82
+ # @param issue_id [String]
83
+ # @param url [String]
84
+ # @param title [String]
85
+ # @return [void]
86
+ def link(issue_id:, url:, title:)
87
+ query(ATTACHMENT_CREATE, input: { issueId: issue_id, url:, title: })
88
+ end
89
+
90
+ # @param query [String] a GraphQL document
91
+ # @param variables [Hash]
92
+ # @return [Hash] the `data` object
93
+ # @raise [OpenLanes::Error] on a transport or GraphQL error
94
+ def query(query, **variables)
95
+ body = transport.call(query, variables)
96
+ errors = body["errors"]
97
+ raise Error, "Linear rejected the request: #{describe(errors)}" if errors&.any?
98
+
99
+ body.fetch("data") { raise Error, "Linear returned no data" }
100
+ end
101
+
102
+ private
103
+
104
+ # @return [String, nil]
105
+ attr_reader :token
106
+
107
+ # @return [String]
108
+ attr_reader :endpoint
109
+
110
+ # @return [#call]
111
+ def transport = @transport ||= method(:post)
112
+
113
+ # Linear reports a failed mutation as `success: false` rather than as an
114
+ # error, so the envelope has to be checked as well as the errors array.
115
+ #
116
+ # @param data [Hash]
117
+ # @param mutation [String]
118
+ # @param field [String]
119
+ # @return [Hash]
120
+ def unwrap(data, mutation, field)
121
+ payload = data[mutation] or raise Error, "Linear returned no #{mutation} payload"
122
+ raise Error, "Linear declined the #{mutation}" unless payload["success"]
123
+
124
+ payload.fetch(field)
125
+ end
126
+
127
+ # @param errors [Array<Hash>]
128
+ # @return [String]
129
+ def describe(errors)
130
+ Array(errors).map { |e| e["message"] || e.to_s }.join("; ")
131
+ end
132
+
133
+ # @param document [String]
134
+ # @param variables [Hash]
135
+ # @return [Hash] the parsed response body
136
+ def post(document, variables)
137
+ uri = URI(endpoint)
138
+ request = Net::HTTP::Post.new(uri)
139
+ request["Content-Type"] = "application/json"
140
+ request["Authorization"] = token
141
+ request.body = JSON.generate(query: document, variables:)
142
+
143
+ response = Net::HTTP.start(uri.hostname,
144
+ uri.port,
145
+ use_ssl: uri.scheme == "https",
146
+ open_timeout: 10,
147
+ read_timeout: 30) { |http| http.request(request) }
148
+
149
+ parse(response)
150
+ rescue JSON::ParserError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
151
+ raise Error, "could not reach Linear at #{endpoint}: #{e.message}"
152
+ end
153
+
154
+ # @param response [Net::HTTPResponse]
155
+ # @return [Hash]
156
+ def parse(response)
157
+ return JSON.parse(response.body.to_s) if response.is_a?(Net::HTTPSuccess)
158
+
159
+ hint = if ["400", "401"].include?(response.code)
160
+ "\n\nCheck #{TOKEN_VARIABLE}. A personal API key is sent verbatim, without a `Bearer` prefix."
161
+ end
162
+ raise Error, "Linear returned HTTP #{response.code}#{hint}"
163
+ end
164
+
165
+ TEAM = <<~GRAPHQL
166
+ query Team($key: String!) {
167
+ teams(filter: { key: { eq: $key } }, first: 1) {
168
+ nodes {
169
+ id name key
170
+ states(first: 100) { nodes { id name type position } }
171
+ labels(first: 250) { nodes { id name } }
172
+ }
173
+ }
174
+ }
175
+ GRAPHQL
176
+
177
+ PROJECTS = <<~GRAPHQL
178
+ query Projects($teamId: String!) {
179
+ team(id: $teamId) {
180
+ projects(first: 250) {
181
+ nodes { id name url description content status { name type } }
182
+ }
183
+ }
184
+ }
185
+ GRAPHQL
186
+
187
+ PROJECT_CREATE = <<~GRAPHQL
188
+ mutation CreateProject($input: ProjectCreateInput!) {
189
+ projectCreate(input: $input) { success project { id name url } }
190
+ }
191
+ GRAPHQL
192
+
193
+ PROJECT_UPDATE = <<~GRAPHQL
194
+ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
195
+ projectUpdate(id: $id, input: $input) { success project { id name url } }
196
+ }
197
+ GRAPHQL
198
+
199
+ ISSUE_CREATE = <<~GRAPHQL
200
+ mutation CreateIssue($input: IssueCreateInput!) {
201
+ issueCreate(input: $input) { success issue { id identifier url } }
202
+ }
203
+ GRAPHQL
204
+
205
+ ISSUE_UPDATE = <<~GRAPHQL
206
+ mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
207
+ issueUpdate(id: $id, input: $input) { success issue { id identifier url } }
208
+ }
209
+ GRAPHQL
210
+
211
+ LABEL_CREATE = <<~GRAPHQL
212
+ mutation CreateLabel($input: IssueLabelCreateInput!) {
213
+ issueLabelCreate(input: $input) { success issueLabel { id name } }
214
+ }
215
+ GRAPHQL
216
+
217
+ ATTACHMENT_CREATE = <<~GRAPHQL
218
+ mutation CreateAttachment($input: AttachmentCreateInput!) {
219
+ attachmentCreate(input: $input) { success attachment { id } }
220
+ }
221
+ GRAPHQL
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Linear
5
+ # Which plan a pull request belongs to, when its title does not say.
6
+ #
7
+ # `resync prs` puts an `[NNN.MM]` on every title it can resolve, and most
8
+ # of them resolve. What is left is the tail: work that shipped before the
9
+ # convention existed, or from a branch named after nothing in particular.
10
+ # Those pull requests are real work with no home, and dropping them means
11
+ # the issues that stand for a plan are missing pieces of it.
12
+ #
13
+ # The rule is word overlap between the pull request's title and the
14
+ # folder's name. It works here, where it did not work for matching plans
15
+ # to projects, because the candidates are different: a folder slug is
16
+ # `ledger-carryforward-vintages`, three specific words about one thing,
17
+ # and there are twenty of them to choose between. A project is called
18
+ # "US Tax Law: Self Contained Ruby Gem" and there are four.
19
+ #
20
+ # It is still a guess, and it says so. A tie is refused rather than broken,
21
+ # because two folders matching equally well is evidence that neither is
22
+ # right rather than a reason to pick the first.
23
+ class Attribution
24
+ # One pull request, placed.
25
+ #
26
+ # @!attribute [r] pull
27
+ # @return [OpenLanes::Plans::PullRequest]
28
+ # @!attribute [r] subject
29
+ # @return [OpenLanes::Plans::Subject, nil] nil when nothing matched
30
+ # @!attribute [r] score
31
+ # @return [Integer] words the title and the folder name shared
32
+ # @!attribute [r] rivals
33
+ # @return [Array<String>] folders that matched equally well
34
+ Placed = Data.define(:pull, :subject, :score, :rivals, :widened) do
35
+ # @return [Boolean]
36
+ def placed? = !subject.nil?
37
+
38
+ # @return [String]
39
+ def percent = "#{(score * 100).round}%"
40
+
41
+ # @return [String] where the matching words came from
42
+ def source = widened ? "title and description" : "title"
43
+
44
+ # @return [String]
45
+ def why
46
+ return "matched no folder at all" if subject.nil? && score.zero?
47
+ return "#{percent} of a folder name is not enough" if subject.nil? && rivals.empty?
48
+ # `rivals` already holds every tied folder — there is no winner to
49
+ # add back in, and `+ 1` here once reported a two-way tie as three.
50
+ return "#{rivals.size} folders matched equally well (#{percent})" if subject.nil?
51
+
52
+ "#{source}: covers #{percent} of the folder name"
53
+ end
54
+ end
55
+
56
+ # Words too common in this domain to carry a match on their own. Every
57
+ # plan in a tax engine says "tax"; a pull request that says it too has
58
+ # told you nothing.
59
+ # How much of a folder's name a title must cover to claim it.
60
+ FLOOR = 0.5
61
+
62
+ # And how much a description must cover, which is more.
63
+ #
64
+ # A title is written to say what the change is. A description is written
65
+ # to get the change reviewed, and it opens with whatever this repository
66
+ # puts at the top of every one of them — a stacking note, a diff
67
+ # summary, a checklist. Read at the same bar as a title it produced two
68
+ # placements here and both were wrong, one of them on the word
69
+ # "documents" inside "diff includes nineteen documents".
70
+ WIDER_FLOOR = 0.75
71
+
72
+ # Words, not just a fraction. Half of a two-word folder name is one
73
+ # word, which is the case that was already established as too thin —
74
+ # "core" alone claiming `deterministic-core-and-as-of`.
75
+ MINIMUM_WORDS = 3
76
+
77
+ NOISE = %w[the and for with from into that this add adds added fix fixes
78
+ update updates use uses spec plan pull request pr tax app web api].freeze
79
+
80
+ # @param tree [OpenLanes::Plans::Tree]
81
+ def initialize(tree:)
82
+ @tree = tree
83
+ end
84
+
85
+ # How much of a pull request's description to read when its title was
86
+ # not enough. The opening sentence or two of a description says what the
87
+ # change is for; further down it turns into checklists, test plans and
88
+ # generated tables, which are the same words on every pull request in
89
+ # the repository and match everything equally.
90
+ OPENING_WORDS = 20
91
+
92
+ # @param pulls [Array<OpenLanes::Plans::PullRequest>] the ones with no number
93
+ # @param bodies [Hash{String => String}] descriptions, by pull request number
94
+ # @return [Array<OpenLanes::Linear::Attribution::Placed>]
95
+ def call(pulls, bodies: {})
96
+ pulls.map do |pull|
97
+ found = place(pull, words(pull.title))
98
+ next found if found.placed?
99
+
100
+ # Second pass. A title is a headline and sometimes says nothing
101
+ # useful — "Add the Drake reference-return worklist" names a vendor
102
+ # rather than the work. The description usually opens by saying what
103
+ # the change is actually about.
104
+ opening = opening_words(bodies[pull.number.to_s])
105
+ next found if opening.empty?
106
+
107
+ wider = place(pull, words(pull.title) | opening, floor: WIDER_FLOOR)
108
+ wider.placed? ? wider.with(widened: true) : found
109
+ end
110
+ end
111
+
112
+ # @param body [String, nil]
113
+ # @return [Array<String>]
114
+ def opening_words(body)
115
+ text = body.to_s
116
+ .gsub(/<!--.*?-->/m, "")
117
+ .gsub(/```.*?```/m, "")
118
+ .gsub(/^\s*[-*|#>]+/, " ")
119
+ words(text.split(/\s+/).first(OPENING_WORDS * 3).join(" ")).first(OPENING_WORDS)
120
+ end
121
+
122
+ private
123
+
124
+ # @return [OpenLanes::Plans::Tree]
125
+ attr_reader :tree
126
+
127
+ # @param pull [OpenLanes::Plans::PullRequest]
128
+ # @param wanted [Array<String>] the words to match on
129
+ # @param floor [Float] how much of a folder name is enough
130
+ # @return [OpenLanes::Linear::Attribution::Placed]
131
+ def place(pull, wanted, floor: FLOOR)
132
+ return Placed.new(pull:, subject: nil, score: 0.0, rivals: [], widened: false) if wanted.empty?
133
+
134
+ ranked = tree.subjects.filter_map { |s|
135
+ overlap = shared(folder_words(s), wanted)
136
+ [s, overlap] if overlap.positive?
137
+ }.sort_by { |_, overlap| -overlap }
138
+
139
+ best, score = ranked.first
140
+
141
+ # One word in common is not evidence. "Add year-keyed tax rules" shares
142
+ # "rules" with `rules-retirement-and-engine-convergence` and has
143
+ # nothing to do with it.
144
+ enough = best && score >= floor && (score * folder_words(best).size).round >= MINIMUM_WORDS
145
+ return Placed.new(pull:, subject: nil, score: score.to_f, rivals: [], widened: false) unless enough
146
+
147
+ tied = ranked.select { |_, overlap| overlap == score }
148
+ return Placed.new(pull:, subject: best, score:, rivals: [], widened: false) if tied.one?
149
+
150
+ Placed.new(pull:, subject: nil, score:, rivals: tied.map { |s, _| s.feature.dirname }, widened: false)
151
+ end
152
+
153
+ # @param subject [OpenLanes::Plans::Subject]
154
+ # @return [Array<String>]
155
+ def folder_words(subject)
156
+ (@folder_words ||= {})[subject.feature.path] ||=
157
+ words(subject.feature.slug.tr("-", " ")) | words(subject.feature.title)
158
+ end
159
+
160
+ # @param text [String]
161
+ # @return [Array<String>]
162
+ def words(text)
163
+ text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").split
164
+ .reject { |w| w.length < 4 || NOISE.include?(w) }.uniq
165
+ end
166
+
167
+ # How much of a folder's name a pull request title actually said.
168
+ #
169
+ # A pull request said `signed_off` where its folder says `sign-off`, and
170
+ # `vintages` where the folder says `vintage`. Compared literally those
171
+ # are strangers, and the first cost a placement a human makes instantly
172
+ # — one a later, numbered pull request went on to prove correct.
173
+ #
174
+ # Matching on a shared prefix rather than by stripping suffixes, because
175
+ # stripping is destructive and gets it wrong in both directions:
176
+ # `corpus` is not a plural, and turning it into `corpu` reads as a typo
177
+ # in every diagnostic that prints it.
178
+ #
179
+ # @param folder [Array<String>]
180
+ # @param title [Array<String>]
181
+ # @return [Float] 0.0 to 1.0
182
+ def shared(folder, title) = Fuzzy.coverage(folder, title)
183
+ end
184
+ end
185
+ end