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