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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fuzzystringmatch"
4
+
5
+ module OpenLanes
6
+ module Linear
7
+ # How alike two short strings are, on a scale rather than a yes or no.
8
+ #
9
+ # Counting shared words gives whole numbers, and whole numbers tie. Three
10
+ # folders scoring 2 against the same pull request is not three good
11
+ # answers — it is no answer — but a tie has to be *detected* to be
12
+ # refused, and with a coarse enough score everything ties.
13
+ #
14
+ # Jaro-Winkler grades instead, and it is the right measure for these
15
+ # inputs: it is built for short strings, and its prefix bonus is exactly
16
+ # the shape of the errors here — a word and its inflection agree from the
17
+ # front and diverge at the end.
18
+ module Fuzzy
19
+ module_function
20
+
21
+ # Two words count as the same word above this.
22
+ #
23
+ # Calibrated rather than chosen. The prefix bonus that makes
24
+ # `rounding`/`round` (0.938) work also lifts `plaid`/`plain` (0.920) and
25
+ # `state`/`statement` (0.926), which are different words that would
26
+ # wrongly file a pull request. 0.93 is the gap between those two groups.
27
+ # It costs `corpus`/`corpora` (0.848), which is rarer than either.
28
+ AKIN = 0.93
29
+
30
+ # Deliberately the pure-Ruby implementation. The native one needs a C
31
+ # extension that does not build everywhere — it fails on this machine
32
+ # over a jemalloc header — and asking for it prints a compile warning to
33
+ # STDERR on every single load, which would land in the middle of every
34
+ # command's output. An import compares a few hundred short strings; that
35
+ # is nowhere near enough work to be worth a build step.
36
+ #
37
+ # @return [FuzzyStringMatch::JaroWinklerPure]
38
+ def matcher = @matcher ||= FuzzyStringMatch::JaroWinkler.create(:pure)
39
+
40
+ # @param one [String]
41
+ # @param other [String]
42
+ # @return [Float] 0.0 to 1.0
43
+ def similarity(one, other) = matcher.getDistance(one.to_s.downcase, other.to_s.downcase)
44
+
45
+ # @param one [String]
46
+ # @param other [String]
47
+ # @return [Boolean] whether these are the same word, inflections aside
48
+ def akin?(one, other) = similarity(one, other) >= AKIN
49
+
50
+ # What fraction of `wanted` has a counterpart in `found`.
51
+ #
52
+ # Asymmetric on purpose. A folder is named for what it is about, in two
53
+ # or three words; a pull request title is a sentence. Asking how much of
54
+ # the sentence the folder covers would punish every long title, so the
55
+ # question is the other way round: how much of this folder's name did
56
+ # the title actually say?
57
+ #
58
+ # @param wanted [Array<String>] the folder's words
59
+ # @param found [Array<String>] the title's words
60
+ # @return [Float] 0.0 to 1.0
61
+ def coverage(wanted, found)
62
+ return 0.0 if wanted.empty?
63
+
64
+ wanted.count { |word| found.any? { |other| akin?(word, other) } }.fdiv(wanted.size)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Linear
7
+ # One thing that would happen in Linear.
8
+ #
9
+ # `args` is deliberately shaped as the Linear MCP server's `save_issue`
10
+ # arguments, because that server addresses everything by
11
+ # the names a human already knows — a team by its key, a project by its
12
+ # name, a workflow state by its name, an issue by its identifier. Emitting
13
+ # exactly that shape means the JSON this produces can drive either
14
+ # transport unchanged: {Push} turns it into GraphQL, and the skill hands
15
+ # it to the MCP tools as-is. One contract, so the two cannot drift.
16
+ #
17
+ # @!attribute [r] kind
18
+ # @return [Symbol] `:issue` for a plan, `:subissue` for a unit of one
19
+ # @!attribute [r] op
20
+ # @return [Symbol] `:create`, `:update` or `:skip`
21
+ # @!attribute [r] ordinal
22
+ # @return [String] the plan this belongs to, e.g. "003.00"
23
+ # @!attribute [r] unit
24
+ # @return [String, nil] the {Unit#key}, nil for a plan's own issue
25
+ # @!attribute [r] identifier
26
+ # @return [String, nil] what Linear already calls it, when it exists
27
+ # @!attribute [r] title
28
+ # @return [String] for the human reading the dry run
29
+ # @!attribute [r] digest
30
+ # @return [String] fingerprint of `args`, recorded after a push
31
+ # @!attribute [r] args
32
+ # @return [Hash] the MCP argument shape
33
+ # @!attribute [r] reason
34
+ # @return [String] why this operation and not another
35
+ Action = Data.define(:kind, :op, :ordinal, :unit, :identifier, :title, :digest, :args, :reason) do
36
+ # @return [Boolean] whether this would change anything
37
+ def pending? = op != :skip
38
+
39
+ # @return [Boolean] whether this issue hangs off another
40
+ def child? = kind == :subissue
41
+
42
+ # @return [Hash] for `--format json`
43
+ def to_h_json
44
+ { kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args: }.compact
45
+ end
46
+ end
47
+
48
+ # What a plan folder would become in Linear, and what has already become
49
+ # of it. Reads the filesystem; touches nothing else.
50
+ #
51
+ # One folder is one issue, and the units inside its `plan.md` are that
52
+ # issue's children. Projects are never created: a team's project list is
53
+ # something a human curated, and every plan is filed under one the caller
54
+ # named on the command line.
55
+ #
56
+ # Keeping the whole decision offline is what makes `--dry-run` honest. The
57
+ # dry run is not an approximation of what a push would do — it is the same
58
+ # object the push consumes, so what gets printed and what gets sent cannot
59
+ # disagree. Everything that needs the network — the project, the repository
60
+ # pull request list — is handed in.
61
+ class Import
62
+ # @param tree [OpenLanes::Plans::Tree]
63
+ # @param team [String] the team key, e.g. "TAX"
64
+ # @param project [Hash] the Linear project, `{"id", "name", "url"}`
65
+ # @param adopted [Hash{String => Array<OpenLanes::Plans::PullRequest>}]
66
+ # pull requests {Attribution} placed, by folder name
67
+ # @param since [String, nil] skip plans numbered below this
68
+ # @param statuses [Array<Symbol>, nil] only these states
69
+ # @param update_unchanged [Boolean] update everything, matching digest or not
70
+ def initialize(tree:, team:, project:, adopted: {}, since: nil, statuses: nil, update_unchanged: false)
71
+ @tree = tree
72
+ @team = team.to_s.strip.upcase
73
+ @project = project
74
+ @adopted = adopted
75
+ @since = since && Plans::Ordinal.parse(since)
76
+ @statuses = statuses
77
+ @update_unchanged = update_unchanged
78
+ end
79
+
80
+ # @return [String] the team key
81
+ attr_reader :team
82
+
83
+ # @return [Hash] the project everything is filed under
84
+ attr_reader :project
85
+
86
+ # @return [String]
87
+ def project_name = project["name"].to_s
88
+
89
+ # @return [Array<OpenLanes::Linear::Action>] each plan's issue,
90
+ # followed by that issue's children
91
+ def actions = @actions ||= subjects.flat_map { |s| actions_for(s) }
92
+
93
+ # @return [Array<OpenLanes::Linear::Action>]
94
+ def pending = actions.select(&:pending?)
95
+
96
+ # @return [String] the whole import, for a pipe
97
+ def to_json(*_args)
98
+ JSON.pretty_generate(team:, project: project_name, actions: actions.map(&:to_h_json))
99
+ end
100
+
101
+ # Plans in a state nobody has decided how to file, and why not.
102
+ #
103
+ # @return [Hash{OpenLanes::Plans::Status => Array<String>}] state => ordinals
104
+ def unplaced
105
+ @unplaced ||= (chosen - subjects).group_by(&:status)
106
+ .transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
107
+ end
108
+
109
+ private
110
+
111
+ # @return [OpenLanes::Plans::Tree]
112
+ attr_reader :tree
113
+
114
+ # @return [Hash]
115
+ attr_reader :adopted
116
+
117
+ # @return [OpenLanes::Plans::Ordinal, nil]
118
+ attr_reader :since
119
+
120
+ # @return [Array<Symbol>, nil]
121
+ attr_reader :statuses
122
+
123
+ # @return [Boolean]
124
+ attr_reader :update_unchanged
125
+
126
+ # @return [Array<OpenLanes::Plans::Subject>]
127
+ def subjects = @subjects ||= chosen.select { |s| OpenLanes::Linear.placement(s.status) }
128
+
129
+ # @return [Array<OpenLanes::Plans::Subject>] before the placement question
130
+ def chosen
131
+ @chosen ||= tree.subjects.select { |s|
132
+ (since.nil? || s.feature.ordinal >= since) &&
133
+ (statuses.nil? || statuses.include?(s.status.key))
134
+ }
135
+ end
136
+
137
+ # The units a plan's issue will have children for: the ones its
138
+ # `plan.md` declares, plus any pull request {Attribution} placed here
139
+ # that none of them already claims.
140
+ #
141
+ # @param subject [OpenLanes::Plans::Subject]
142
+ # @return [Array<OpenLanes::Linear::Unit>]
143
+ def units_for(subject)
144
+ (@units ||= {})[subject.feature.path] ||= begin
145
+ declared = Units.new(subject:).all
146
+ claimed = declared.flat_map(&:pull_requests).map(&:number)
147
+ extra = adopted.fetch(subject.feature.dirname, []).reject { |pr| claimed.include?(pr.number) }
148
+ declared + extra.map { |pr| adopted_unit(pr) }
149
+ end
150
+ end
151
+
152
+ # @param pull [OpenLanes::Plans::PullRequest]
153
+ # @return [OpenLanes::Linear::Unit]
154
+ def adopted_unit(pull)
155
+ Unit.new(key: "##{pull.number}",
156
+ title: Units.clean_title(pull.title),
157
+ body: "",
158
+ pull_requests: [pull])
159
+ end
160
+
161
+ # @param subject [OpenLanes::Plans::Subject]
162
+ # @return [OpenLanes::Linear::Issues]
163
+ def record_for(subject) = (@records ||= {})[subject.feature.path] ||= Issues.new(dir: subject.feature.path)
164
+
165
+ # @param subject [OpenLanes::Plans::Subject]
166
+ # @return [Array<OpenLanes::Linear::Action>]
167
+ def actions_for(subject)
168
+ [plan_action(subject)] + units_for(subject).map { |unit| unit_action(subject, unit) }
169
+ end
170
+
171
+ # The issue that stands for the whole plan folder.
172
+ #
173
+ # @param subject [OpenLanes::Plans::Subject]
174
+ # @return [OpenLanes::Linear::Action]
175
+ def plan_action(subject)
176
+ placement = OpenLanes::Linear.placement(subject.status)
177
+ recorded = record_for(subject).by_unit[Issues::PARENT]
178
+ title = plan_title(subject)
179
+
180
+ args = { team:, project: project_name, title:, description: plan_description(subject),
181
+ state: placement.name, labels: placement.labels }
182
+ digest = Issues.digest(args)
183
+
184
+ op, reason = decide(recorded&.identifier, recorded&.digest, digest)
185
+ args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
186
+
187
+ Action.new(kind: :issue,
188
+ op:,
189
+ ordinal: subject.feature.ordinal.to_s,
190
+ unit: Issues::PARENT,
191
+ identifier: recorded&.identifier,
192
+ title:,
193
+ digest:,
194
+ args:,
195
+ reason:)
196
+ end
197
+
198
+ # @param subject [OpenLanes::Plans::Subject]
199
+ # @param unit [OpenLanes::Linear::Unit]
200
+ # @return [OpenLanes::Linear::Action]
201
+ def unit_action(subject, unit)
202
+ placement = OpenLanes::Linear.placement_for(subject.status, unit.pull_requests)
203
+ recorded = record_for(subject).by_unit[unit.key]
204
+ parent = record_for(subject).by_unit[Issues::PARENT]
205
+
206
+ args = { team:, project: project_name, title: unit.title,
207
+ description: unit_description(subject, unit),
208
+ state: placement.name, labels: placement.labels, links: links_for(unit) }
209
+ args = args.merge(parentId: parent.identifier) if parent
210
+ digest = Issues.digest(args.except(:parentId))
211
+
212
+ op, reason = decide(recorded&.identifier, recorded&.digest, digest)
213
+ args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
214
+
215
+ Action.new(kind: :subissue,
216
+ op:,
217
+ ordinal: subject.feature.ordinal.to_s,
218
+ unit: unit.key,
219
+ identifier: recorded&.identifier,
220
+ title: unit.title,
221
+ digest:,
222
+ args:,
223
+ reason:)
224
+ end
225
+
226
+ # The three-way decision, in one place so a plan and a unit cannot
227
+ # answer it differently.
228
+ #
229
+ # @param existing [String, nil] what Linear already calls it
230
+ # @param was [String, nil] the digest recorded at the last push
231
+ # @param now [String] the digest of what we would push
232
+ # @return [Array(Symbol, String)] the operation and its reason
233
+ def decide(existing, was, now)
234
+ return [:create, "not recorded in #{Issues::FILENAME}"] if existing.nil?
235
+ return [:update, "--update-unchanged"] if update_unchanged
236
+ return [:skip, "unchanged since the last import"] if was == now
237
+
238
+ [:update, "the plan has changed since #{existing} was pushed"]
239
+ end
240
+
241
+ # @param subject [OpenLanes::Plans::Subject]
242
+ # @return [String]
243
+ def plan_title(subject) = "[#{subject.feature.ordinal}] #{heading(subject)}"
244
+
245
+ # The specification's own H1 when it has one, because an agent writing
246
+ # `spec.md` gives it a real sentence — "Tenancy: users, households,
247
+ # memberships" — while the folder slug can only carry kebab-case.
248
+ #
249
+ # @param subject [OpenLanes::Plans::Subject]
250
+ # @return [String]
251
+ def heading(subject)
252
+ line = subject.read("spec.md").to_s[/^[ \t]{0,3}#[ \t]+(.+)$/, 1]
253
+ cleaned = line.to_s.sub(/\A(?:spec(?:ification)?\s*)?\d+(?:\.\d+)?\s*[—–:.-]\s*/i, "").strip
254
+ cleaned.empty? ? subject.feature.title : cleaned
255
+ end
256
+
257
+ # @param subject [OpenLanes::Plans::Subject]
258
+ # @return [String]
259
+ def plan_description(subject)
260
+ [
261
+ subject.goal.join("\n\n"),
262
+ "**State**: #{subject.status} — #{subject.status.note}",
263
+ "**Folder**: `#{OpenLanes::PLANS_DIR}/#{subject.feature.dirname}`",
264
+ provenance
265
+ ].compact.reject(&:empty?).join("\n\n")
266
+ end
267
+
268
+ # @param subject [OpenLanes::Plans::Subject]
269
+ # @param unit [OpenLanes::Linear::Unit]
270
+ # @return [String]
271
+ def unit_description(subject, unit)
272
+ [
273
+ truncate(unit.body),
274
+ "**Plan**: `#{OpenLanes::PLANS_DIR}/#{subject.feature.dirname}` · unit `#{unit.key}`",
275
+ provenance
276
+ ].compact.reject(&:empty?).join("\n\n")
277
+ end
278
+
279
+ # A pull request is attached to its issue, not listed in its body.
280
+ #
281
+ # Linear has a first-class relationship for this and renders it as what
282
+ # it is — an artifact implementing the work, carrying its own state. A
283
+ # markdown bullet is a claim about a relationship; an attachment is the
284
+ # relationship. Links are keyed on the URL, so re-sending one updates
285
+ # rather than duplicates.
286
+ #
287
+ # @param unit [OpenLanes::Linear::Unit]
288
+ # @return [Array<Hash>]
289
+ def links_for(unit)
290
+ unit.pull_requests.select(&:url).map { |pr| { url: pr.url, title: pr.label } }
291
+ end
292
+
293
+ # A plan section can run to several hundred lines of design notes and
294
+ # DDL. Linear is where the work is tracked, not where it is specified,
295
+ # and the folder is one click away.
296
+ #
297
+ # @param body [String]
298
+ # @param limit [Integer]
299
+ # @return [String]
300
+ def truncate(body, limit = 6_000)
301
+ text = body.to_s.strip
302
+ return text if text.length <= limit
303
+
304
+ "#{text[0, limit].rpartition("\n").first.rstrip}\n\n_…truncated; the plan folder has the rest._"
305
+ end
306
+
307
+ # @return [String]
308
+ def provenance
309
+ "_Imported by `openlanes linear import`. The plan folder is the source of truth; " \
310
+ "edits made here do not travel back._"
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module OpenLanes
6
+ module Linear
7
+ # One row of a plan's `linear.md`: an issue this tool has already pushed.
8
+ #
9
+ # @!attribute [r] unit
10
+ # @return [String] the {Unit#key} it was made from
11
+ # @!attribute [r] identifier
12
+ # @return [String] "TAX-41" — Linear resolves this as an id
13
+ # @!attribute [r] url
14
+ # @return [String, nil]
15
+ # @!attribute [r] title
16
+ # @return [String] as pushed
17
+ # @!attribute [r] state
18
+ # @return [String] as pushed
19
+ # @!attribute [r] digest
20
+ # @return [String] fingerprint of the payload as pushed
21
+ Issue = Data.define(:unit, :identifier, :url, :title, :state, :digest)
22
+
23
+ # A plan's record of what it has put into Linear.
24
+ #
25
+ # Without it, a second `linear import` cannot tell an issue it created
26
+ # last week from one it has never created, and the only two outcomes are
27
+ # duplicating everything or searching Linear by title on every run. The
28
+ # file is the memory, and it is a committed artifact rather than a dotfile
29
+ # so the record travels with the plan and shows up in review.
30
+ #
31
+ # The `Synced` column is what makes an update cheap to decide: it is a
32
+ # digest of the payload last pushed, so a run can tell — with no network
33
+ # at all — whether anything about this issue has changed since. A digest
34
+ # that no longer matches is the whole trigger for an update.
35
+ class Issues
36
+ # The file this reads and writes.
37
+ FILENAME = "linear.md"
38
+
39
+ # The unit key given to the issue that stands for the whole folder. Its
40
+ # children carry their own unit keys; it carries this, so one table can
41
+ # hold a plan and its parts without a second shape to parse.
42
+ PARENT = "PLAN"
43
+
44
+ # Fingerprint of a payload, short enough to read in a table.
45
+ #
46
+ # @param payload [Object] anything with a stable #inspect ordering
47
+ # @return [String] eight hex characters
48
+ def self.digest(payload) = ::Digest::SHA256.hexdigest(JSON.generate(payload))[0, 8]
49
+
50
+ # @param team [String] the team key, e.g. "TAX"
51
+ # @param project [Hash, nil] `{name:, url:}`
52
+ # @param issues [Array<OpenLanes::Linear::Issue>]
53
+ # @return [String]
54
+ def self.render(team:, project:, issues:)
55
+ rows = order(issues).map { |i|
56
+ "| #{i.unit} | #{link(i.identifier, i.url)} | #{escape(i.title)} | #{i.state} | #{i.digest} |"
57
+ }
58
+
59
+ <<~MARKDOWN
60
+ # Linear
61
+
62
+ #{heading(team, project)}
63
+
64
+ | Unit | Issue | Title | State | Synced |
65
+ | :--- | :---- | :---- | ----: | :----- |
66
+ #{rows.join("\n")}
67
+
68
+ <!-- Written by `openlanes linear import`. Do not hand-edit the
69
+ Synced column: it fingerprints what was last pushed, and a row whose
70
+ fingerprint no longer matches the plan is what triggers an update. -->
71
+ MARKDOWN
72
+ end
73
+
74
+ # The folder's own issue first, then its children. A table that opens
75
+ # with a child reads as a list of unrelated issues.
76
+ #
77
+ # @param issues [Array<OpenLanes::Linear::Issue>]
78
+ # @return [Array<OpenLanes::Linear::Issue>]
79
+ def self.order(issues)
80
+ parent, children = issues.partition { |i| i.unit == PARENT }
81
+ parent + children
82
+ end
83
+
84
+ # @param team [String]
85
+ # @param project [Hash, nil]
86
+ # @return [String]
87
+ def self.heading(team, project)
88
+ return "Team **#{team}**." unless project
89
+
90
+ "Team **#{team}** · project #{link(project[:name], project[:url])}"
91
+ end
92
+
93
+ # @param text [String]
94
+ # @param url [String, nil]
95
+ # @return [String]
96
+ def self.link(text, url) = url ? "[#{escape(text)}](#{url})" : escape(text)
97
+
98
+ # @param text [String]
99
+ # @return [String]
100
+ def self.escape(text) = text.to_s.gsub("|", "\\|").gsub(/\s+/, " ").strip
101
+
102
+ # @param dir [String] absolute path to the plan folder
103
+ def initialize(dir:)
104
+ @dir = dir
105
+ end
106
+
107
+ # @return [Array<OpenLanes::Linear::Issue>] possibly empty
108
+ def all = @all ||= parse
109
+
110
+ # @return [Hash{String => OpenLanes::Linear::Issue}] keyed by unit
111
+ def by_unit = @by_unit ||= all.to_h { |issue| [issue.unit, issue] }
112
+
113
+ # The project this plan was last filed under, if any.
114
+ #
115
+ # @return [Hash, nil] `{name:, url:}`
116
+ def project = (@project ||= [parse_project])[0]
117
+
118
+ # The issue standing for the folder itself.
119
+ #
120
+ # @return [OpenLanes::Linear::Issue, nil]
121
+ def parent = by_unit[PARENT]
122
+
123
+ # @return [String] absolute path to `linear.md`
124
+ def path = File.join(dir, FILENAME)
125
+
126
+ # @return [Boolean]
127
+ def exist? = File.file?(path)
128
+
129
+ # @param team [String]
130
+ # @param project [Hash, nil]
131
+ # @param issues [Array<OpenLanes::Linear::Issue>]
132
+ # @return [String] the path written
133
+ def write(team:, project:, issues:)
134
+ File.write(path, self.class.render(team:, project:, issues:))
135
+ @all = @by_unit = @project = nil
136
+ path
137
+ end
138
+
139
+ private
140
+
141
+ # @return [String]
142
+ attr_reader :dir
143
+
144
+ # @return [String]
145
+ def text = @text ||= exist? ? File.read(path, encoding: "UTF-8") : ""
146
+
147
+ # @return [Array<OpenLanes::Linear::Issue>]
148
+ def parse
149
+ table = Markdown.tables(text).find { |t|
150
+ t[:header].any? { |h| h.match?(/\Aissue\z/i) } && t[:header].any? { |h| h.match?(/\Aunit\z/i) }
151
+ }
152
+ return [] unless table
153
+
154
+ index = table[:header].each_with_index.to_h { |h, i| [h.downcase.strip, i] }
155
+ table[:rows].filter_map { |cells| row_to_issue(cells, index) }
156
+ end
157
+
158
+ # @param cells [Array<String>]
159
+ # @param index [Hash{String => Integer}]
160
+ # @return [OpenLanes::Linear::Issue, nil]
161
+ def row_to_issue(cells, index)
162
+ cell = ->(name) { cells[index[name]].to_s }
163
+ link = cell["issue"].match(/\[([^\]]+)\]\((\S+?)\)/)
164
+ identifier = (link ? link[1] : cell["issue"]).gsub(/[*_`]/, "").strip
165
+ return nil if identifier.empty?
166
+
167
+ Issue.new(unit: cell["unit"].strip,
168
+ identifier:,
169
+ url: link && link[2],
170
+ title: cell["title"].gsub(/\\([|\\])/, '\1').strip,
171
+ state: cell["state"].strip,
172
+ digest: cell["synced"].gsub(/[`\s]/, ""))
173
+ end
174
+
175
+ # @return [Hash, nil]
176
+ def parse_project
177
+ line = text.lines.find { |l| l.match?(/project\s+\[/i) }
178
+ return nil unless line
179
+
180
+ link = line.match(/project\s+\[([^\]]+)\]\((\S+?)\)/i)
181
+ return nil unless link
182
+
183
+ { name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2] }
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Linear
5
+ # What a folder's state means to Linear.
6
+ #
7
+ # Linear gives every workflow state a *type* — one of five, fixed across
8
+ # every workspace — and a *name*, which each team chooses for itself. A
9
+ # team may call its started state "In Progress", "Doing" or "🚧 WIP", so
10
+ # matching on the name alone works right up until it meets somebody else's
11
+ # workspace. Each row here therefore carries both: the type, which is the
12
+ # contract, and the name we would prefer if the team happens to have one.
13
+ # {Push} resolves the name first and falls back to the type.
14
+ #
15
+ # Several states have no Linear equivalent at all — ⭕️ Technical Block is
16
+ # a reason, not a position in a workflow — so they map to the nearest
17
+ # position and carry a label saying which reason.
18
+ #
19
+ # Two states are left out entirely; see {UNPLACED}.
20
+ #
21
+ # @!attribute [r] type
22
+ # @return [String] Linear's canonical type: backlog, unstarted, started,
23
+ # completed or canceled
24
+ # @!attribute [r] name
25
+ # @return [String] the state name to prefer when the team has one
26
+ # @!attribute [r] labels
27
+ # @return [Array<String>] labels that carry what the type cannot
28
+ Placement = Data.define(:type, :name, :labels)
29
+
30
+ # Linear's five workflow state types, in lifecycle order.
31
+ TYPES = %w[backlog unstarted started completed canceled].freeze
32
+
33
+ # Every plan state this tool is willing to place, keyed by
34
+ # {OpenLanes::Plans::Status#key}.
35
+ #
36
+ # Between this and {UNPLACED} every entry in {OpenLanes::Plans::STATUSES} is
37
+ # named exactly once, and a spec asserts it. That is the guard against the
38
+ # failure this file is most prone to: a sixteenth state gets added,
39
+ # nothing here changes, and its plans quietly import as Backlog with no
40
+ # indication anything was missed.
41
+ PLACEMENTS = {
42
+ new: Placement.new(type: "backlog", name: "Backlog", labels: []),
43
+ researched: Placement.new(type: "backlog", name: "Backlog", labels: %w[researched]),
44
+ ready_for_planning: Placement.new(type: "backlog", name: "Backlog", labels: %w[spec-ready]),
45
+ planned: Placement.new(type: "unstarted", name: "Todo", labels: []),
46
+ building: Placement.new(type: "started", name: "In Progress", labels: []),
47
+ # Both halves of building are one column on a board. A reader there
48
+ # wants to know work is under way; which half is under way is this
49
+ # tool's business, and the label carries it for anyone who does care.
50
+ building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
51
+ ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
52
+ in_review: Placement.new(type: "started", name: "In Review", labels: []),
53
+ rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
54
+ approved: Placement.new(type: "completed", name: "Done", labels: []),
55
+ deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
56
+ blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
57
+ product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
58
+ deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
59
+ retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
60
+ discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
61
+ }.freeze
62
+
63
+ # States deliberately left out, and why.
64
+ #
65
+ # Both are real positions in this tool's lifecycle and neither is
66
+ # obviously any position on a Linear board. Where they belong is a
67
+ # statement about how a particular team works, and this tool does not know
68
+ # that. Guessing would be worse than not knowing: an issue filed in the
69
+ # wrong column reads exactly like an issue filed in the right one, and
70
+ # nobody goes looking for a mistake that renders correctly.
71
+ #
72
+ # A plan in one of these states is reported and skipped. To import them,
73
+ # decide where they belong and move the entry into {PLACEMENTS}.
74
+ UNPLACED = {
75
+ shit: "the plan survives and its pull requests do not; whether that is work still to do " \
76
+ "or work abandoned depends on what the team does next",
77
+ rolled_back: "it shipped and was pulled; whether that reopens this work or opens new work " \
78
+ "depends on what broke"
79
+ }.freeze
80
+
81
+ # Where one unit of work belongs, which is not always where its plan does.
82
+ #
83
+ # A plan in 🟡 Building has some units merged and some not started. Giving
84
+ # every one of its issues the plan's own state says they are all in
85
+ # progress, which is false about most of them and useless on a board. A
86
+ # unit's pull requests are the better evidence, so they are used when
87
+ # there are any.
88
+ #
89
+ # @param status [OpenLanes::Plans::Status] the plan's state
90
+ # @param pulls [Array<OpenLanes::Plans::PullRequest>] the unit's
91
+ # @return [OpenLanes::Linear::Placement, nil]
92
+ def self.placement_for(status, pulls)
93
+ return placement(status) if pulls.empty?
94
+ return PLACEMENTS[:building].with(name: "In Review") if pulls.any?(&:open?)
95
+ return PLACEMENTS[:approved] if pulls.all?(&:merged?)
96
+
97
+ placement(status)
98
+ end
99
+
100
+ # Where a plan in this state belongs on a Linear board.
101
+ #
102
+ # @param status [OpenLanes::Plans::Status]
103
+ # @return [OpenLanes::Linear::Placement, nil] nil when nobody has decided
104
+ def self.placement(status) = PLACEMENTS[status.key]
105
+
106
+ # Why a state is not imported, for the report that says so.
107
+ #
108
+ # @param status [OpenLanes::Plans::Status]
109
+ # @return [String] the reason, or the louder one for a state nobody has considered at all
110
+ def self.reason_unplaced(status)
111
+ UNPLACED.fetch(status.key) do
112
+ "no Linear placement has ever been decided for it — add one to Linear::PLACEMENTS"
113
+ end
114
+ end
115
+ end
116
+ end