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