openlanes 3.0.0 → 3.1.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 +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Setup
|
|
7
|
+
# This machine, as openlanes needs it: `brew bundle` over the gem's
|
|
8
|
+
# `Brewfile`, then the checks brew cannot settle. Every process and the
|
|
9
|
+
# Redis ping go through injected seams, so the suite never runs brew.
|
|
10
|
+
class Machine
|
|
11
|
+
HOMEBREW_INSTALL =
|
|
12
|
+
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
|
13
|
+
CLAUDE_INSTALL = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
14
|
+
|
|
15
|
+
# A check runs `brew bundle check`, which would otherwise update
|
|
16
|
+
# Homebrew first: a question about the machine must not take a minute
|
|
17
|
+
# and change it.
|
|
18
|
+
QUIET_BREW = { "HOMEBREW_NO_AUTO_UPDATE" => "1", "HOMEBREW_NO_ENV_HINTS" => "1" }.freeze
|
|
19
|
+
|
|
20
|
+
# What one run did: what was missing before it, whether it ran
|
|
21
|
+
# `brew bundle`, and the checks taken afterwards.
|
|
22
|
+
Outcome = Data.define(:missing, :installed, :checks) do
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def ok? = checks.all?(&:ok?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param brewfile [String]
|
|
28
|
+
# @param redis_url [String, nil] nil: W16's order, from the project when there is one
|
|
29
|
+
# @param capture [#call] argv, env to [output, success]
|
|
30
|
+
# @param install [#call] argv to success, its output left on the terminal
|
|
31
|
+
# @param which [#call] a command name to whether it is on `PATH`
|
|
32
|
+
# @param ping [#call] a Redis URL to whether it answered
|
|
33
|
+
def initialize(brewfile: BREWFILE, redis_url: nil, capture: method(:capture),
|
|
34
|
+
install: method(:install), which: method(:on_path?), ping: method(:ping?))
|
|
35
|
+
@brewfile = brewfile
|
|
36
|
+
@redis_url = redis_url
|
|
37
|
+
@capture = capture
|
|
38
|
+
@install = install
|
|
39
|
+
@which = which
|
|
40
|
+
@ping = ping
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Installs what the Brewfile lists and is missing, unless +dry_run+,
|
|
44
|
+
# then checks the machine.
|
|
45
|
+
#
|
|
46
|
+
# @param dry_run [Boolean]
|
|
47
|
+
# @return [Outcome]
|
|
48
|
+
def call(dry_run: false)
|
|
49
|
+
missing = brew? ? self.missing : []
|
|
50
|
+
installed = !dry_run && brew? && !bundled? && @install.call(bundle_command)
|
|
51
|
+
Outcome.new(missing:, installed: installed == true, checks:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every check, read-only, in the order the report prints them.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Check>]
|
|
57
|
+
def checks = [brew_check, brewfile_check, claude_check, gh_check, redis_check]
|
|
58
|
+
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def ready? = checks.all?(&:ok?)
|
|
61
|
+
|
|
62
|
+
# What `brew bundle check` says the Brewfile lacks.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<String>] formula, cask and tap names
|
|
65
|
+
def missing = needs(bundle_check.first)
|
|
66
|
+
|
|
67
|
+
# @return [Array<String>] the command that installs the Brewfile
|
|
68
|
+
def bundle_command = ["brew", "bundle", "--no-upgrade", "--file", @brewfile]
|
|
69
|
+
|
|
70
|
+
# @return [String] the Redis the run would use
|
|
71
|
+
def redis_url = @redis_url || Live.redis_url(config: config)
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def brew? = @which.call("brew")
|
|
77
|
+
|
|
78
|
+
# `brew bundle check --verbose` names each lack as "Formula jq needs to be installed".
|
|
79
|
+
#
|
|
80
|
+
# @param output [String]
|
|
81
|
+
# @return [Array<String>]
|
|
82
|
+
def needs(output) = output.scan(/(?:Formula|Cask|Tap|Package) (\S+) needs/).flatten
|
|
83
|
+
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def bundled? = bundle_check.last
|
|
86
|
+
|
|
87
|
+
# @return [Array(String, Boolean)]
|
|
88
|
+
def bundle_check
|
|
89
|
+
@capture.call(["brew", "bundle", "check", "--verbose", "--no-upgrade", "--file", @brewfile], QUIET_BREW)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Check]
|
|
93
|
+
def brew_check
|
|
94
|
+
return Check.new(name: "brew", ok: true, detail: "Homebrew is installed", fix: nil) if brew?
|
|
95
|
+
|
|
96
|
+
Check.new(name: "brew", ok: false, detail: "Homebrew is not on PATH", fix: HOMEBREW_INSTALL)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Check]
|
|
100
|
+
def brewfile_check
|
|
101
|
+
unless brew?
|
|
102
|
+
return Check.new(name: "Brewfile", ok: false, detail: "needs Homebrew", fix: "install Homebrew, then: openlanes setup")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
output, ok = bundle_check
|
|
106
|
+
return Check.new(name: "Brewfile", ok: true, detail: "every tool in #{@brewfile} is installed", fix: nil) if ok
|
|
107
|
+
|
|
108
|
+
names = needs(output)
|
|
109
|
+
Check.new(name: "Brewfile",
|
|
110
|
+
ok: false,
|
|
111
|
+
detail: "missing: #{names.empty? ? "see brew bundle check" : names.join(", ")}",
|
|
112
|
+
fix: bundle_command.join(" "))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Check]
|
|
116
|
+
def claude_check
|
|
117
|
+
return Check.new(name: "claude", ok: true, detail: "the claude CLI is on PATH", fix: nil) if @which.call("claude")
|
|
118
|
+
|
|
119
|
+
Check.new(name: "claude", ok: false, detail: "the claude CLI is not on PATH", fix: CLAUDE_INSTALL)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Check]
|
|
123
|
+
def gh_check
|
|
124
|
+
unless @which.call("gh")
|
|
125
|
+
return Check.new(name: "gh", ok: false, detail: "gh is not on PATH", fix: "brew install gh && gh auth login")
|
|
126
|
+
end
|
|
127
|
+
return Check.new(name: "gh", ok: true, detail: "gh auth status passes", fix: nil) if @capture.call(%w[gh auth status], {}).last
|
|
128
|
+
|
|
129
|
+
Check.new(name: "gh", ok: false, detail: "gh is not logged in", fix: "gh auth login")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Check]
|
|
133
|
+
def redis_check
|
|
134
|
+
url = redis_url
|
|
135
|
+
shown = Live.mask(url)
|
|
136
|
+
return Check.new(name: "redis", ok: true, detail: "#{shown} answers PING", fix: nil) if @ping.call(url)
|
|
137
|
+
|
|
138
|
+
local = url.match?(%r{\Aredis://(?:[^@/]*@)?(?:localhost|127\.0\.0\.1)(?::\d+)?(?:/|\z)})
|
|
139
|
+
fix = local ? "brew services start redis" : "make #{shown} reachable, or set redis_url in .openlanes.yml"
|
|
140
|
+
Check.new(name: "redis", ok: false, detail: "#{shown} does not answer", fix:)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The project's settings when there is a project, else none: setup runs
|
|
144
|
+
# from anywhere, and a broken `.openlanes.yml` is a question for the
|
|
145
|
+
# commands that read it, not a reason the machine is not ready.
|
|
146
|
+
#
|
|
147
|
+
# @return [OpenLanes::Config]
|
|
148
|
+
def config
|
|
149
|
+
Config.current
|
|
150
|
+
rescue OpenLanes::Error
|
|
151
|
+
Config.new
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [Array(String, Boolean)]
|
|
155
|
+
def capture(argv, env)
|
|
156
|
+
output, status = Open3.capture2e(env, *argv)
|
|
157
|
+
[output, status.success?]
|
|
158
|
+
rescue SystemCallError
|
|
159
|
+
["", false]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# brew's progress belongs on STDERR: STDOUT carries the report.
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def install(argv) = system(*argv, out: $stderr, err: $stderr) == true
|
|
166
|
+
|
|
167
|
+
# @return [Boolean]
|
|
168
|
+
def on_path?(name) = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, name)) }
|
|
169
|
+
|
|
170
|
+
# @return [Boolean]
|
|
171
|
+
def ping?(url)
|
|
172
|
+
Live.connect(url)
|
|
173
|
+
true
|
|
174
|
+
rescue Live::Unavailable, RedisClient::Error, ArgumentError
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# Gets a machine ready to run the factory (plan 035): the tools in the
|
|
5
|
+
# gem's `Brewfile`, then what brew cannot install. `openlanes setup` and
|
|
6
|
+
# `openlanes init` both come through here, so the two ask the same
|
|
7
|
+
# questions of the machine and never disagree about whether it is ready.
|
|
8
|
+
module Setup
|
|
9
|
+
# The gem's own root, which an installed gem and a checkout both have.
|
|
10
|
+
GEM_ROOT = File.expand_path("../..", __dir__)
|
|
11
|
+
|
|
12
|
+
# The one Brewfile, shipped in the gem: the runtime tools and a
|
|
13
|
+
# contributor's, which `bin/setup` bundles from the same file.
|
|
14
|
+
BREWFILE = File.join(GEM_ROOT, "Brewfile")
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @return [OpenLanes::Setup::Machine]
|
|
19
|
+
def machine(**) = Machine.new(**)
|
|
20
|
+
|
|
21
|
+
# One line per check, ✓ or ✗, and under each ✗ the command that fixes it.
|
|
22
|
+
#
|
|
23
|
+
# @param checks [Array<OpenLanes::Setup::Check>]
|
|
24
|
+
# @return [String]
|
|
25
|
+
def render(checks)
|
|
26
|
+
width = checks.map { it.name.length }.max.to_i
|
|
27
|
+
checks.map do |check|
|
|
28
|
+
mark = check.ok? ? UI.paint("✓", :green) : UI.paint("✗", :red)
|
|
29
|
+
line = "#{mark} #{check.name.ljust(width)} #{check.detail}".rstrip
|
|
30
|
+
check.ok? || check.fix.nil? ? "#{line}\n" : "#{line}\n fix: #{check.fix}\n"
|
|
31
|
+
end.join
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# A time source, so a spec can move time instead of waiting for it.
|
|
5
|
+
#
|
|
6
|
+
# Not to be confused with {OpenLanes::Execution::Clock}, the timeout timer
|
|
7
|
+
# with a thread of its own; inside `Execution` a bare `Clock` means that
|
|
8
|
+
# one, so code there writes `OpenLanes::Clock` in full.
|
|
9
|
+
module Clock
|
|
10
|
+
# The real clock.
|
|
11
|
+
module System
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @return [Time]
|
|
15
|
+
def now = Time.now
|
|
16
|
+
|
|
17
|
+
# @return [Float] seconds on a clock no wall-clock change can move
|
|
18
|
+
def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
19
|
+
|
|
20
|
+
# @param seconds [Numeric]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def sleep(seconds) = Kernel.sleep(seconds)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A clock that only moves when told to. {#sleep} advances rather than
|
|
26
|
+
# blocks, so a loop that waits on it runs at once and still sees time pass.
|
|
27
|
+
# Shared between threads in the stress spec, hence the mutex.
|
|
28
|
+
class Fake
|
|
29
|
+
# @param now [Time]
|
|
30
|
+
def initialize(now: Time.utc(2026, 1, 1))
|
|
31
|
+
@now = now
|
|
32
|
+
@monotonic = 0.0
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Time]
|
|
37
|
+
def now = @mutex.synchronize { @now }
|
|
38
|
+
|
|
39
|
+
# @return [Float]
|
|
40
|
+
def monotonic = @mutex.synchronize { @monotonic }
|
|
41
|
+
|
|
42
|
+
# @param seconds [Numeric]
|
|
43
|
+
# @return [self]
|
|
44
|
+
def advance(seconds)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@now += seconds
|
|
47
|
+
@monotonic += seconds
|
|
48
|
+
end
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param seconds [Numeric]
|
|
53
|
+
# @return [void]
|
|
54
|
+
def sleep(seconds) = advance(seconds)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
3
4
|
require "yaml"
|
|
4
5
|
|
|
5
6
|
module OpenLanes
|
|
@@ -10,9 +11,37 @@ module OpenLanes
|
|
|
10
11
|
# frontend: false # a spec's `frontend:` when spec.md omits it
|
|
11
12
|
# jobs: 4 # how many plans `openlanes run` works on at once
|
|
12
13
|
# redis_url: redis://10.0.4.2:6379/0 # the Redis the live run shares (W16)
|
|
14
|
+
# redis_outage_hours: 1 # how long agents stay paused for a lost Redis
|
|
15
|
+
# # before the run shuts down (W32); any positive number
|
|
16
|
+
# redis_backoff: # how a lost Redis is asked again (plan 022)
|
|
17
|
+
# initial: 1 # seconds; the Fibonacci gaps are its multiples
|
|
18
|
+
# cap: 30 # seconds, the longest gap between two asks
|
|
19
|
+
# jitter: true # full jitter: each gap drawn from [0, gap)
|
|
20
|
+
# desired_codebase_quality: high # or mvp (R4): what a review that is ok but
|
|
21
|
+
# # not perfect does, and M5's column of defaults
|
|
22
|
+
# merge:
|
|
23
|
+
# by: human # who merges a reviewed PR (M4); `openlanes start` watches it
|
|
24
|
+
# max_rejections: 2 # rejections, hansolo's and chewie's together, before
|
|
25
|
+
# # the plan stops going back to its builders (M6)
|
|
13
26
|
# commands: # per-command option defaults
|
|
14
27
|
# run:
|
|
15
28
|
# timeout: 1800
|
|
29
|
+
# questions:
|
|
30
|
+
# model: anthropic/claude-haiku-5.5 # rewrites prose questions into choices (D9)
|
|
31
|
+
# review:
|
|
32
|
+
# max_hmm: 5 # most 🤔 a review may carry and be approved (R3);
|
|
33
|
+
# # $OPENLANES_REVIEW_MAX_HMM beats it
|
|
34
|
+
# app: # how to run the app a plan builds (M12)
|
|
35
|
+
# kind: web
|
|
36
|
+
# processes:
|
|
37
|
+
# - name: web
|
|
38
|
+
# start: npx vite --port $PORT_WEB --strictPort
|
|
39
|
+
# ready: /
|
|
40
|
+
# preview: true
|
|
41
|
+
# coverage: # what the coverage gate measures (M15)
|
|
42
|
+
# command: bundle exec rspec
|
|
43
|
+
# report_command: jq '.total.lines.percent' coverage/coverage.json
|
|
44
|
+
# minimum: 95 # or report: + format: instead of report_command:
|
|
16
45
|
#
|
|
17
46
|
# Precedence, loudest first: a flag typed on the command line, the plan's
|
|
18
47
|
# spec.md frontmatter, this file, the built-in default. The file supplies
|
|
@@ -40,14 +69,111 @@ module OpenLanes
|
|
|
40
69
|
# @!attribute [r] redis_url
|
|
41
70
|
# @return [String, nil] where the live run keeps its board, its commands
|
|
42
71
|
# and its plan claims; `--redis-url` beats it, and it beats `$REDIS_URL`
|
|
72
|
+
# @!attribute [r] redis_outage_hours
|
|
73
|
+
# @return [Numeric, nil] `redis_outage_hours` as the file says it; read
|
|
74
|
+
# {#outage_limit}, which applies the default
|
|
75
|
+
# @!attribute [r] redis_backoff
|
|
76
|
+
# @return [Hash{Symbol => Object}] `redis_backoff:` as typed, keyed
|
|
77
|
+
# :initial, :cap, :jitter; {Live::Outage} applies the defaults
|
|
43
78
|
# @!attribute [r] commands
|
|
44
79
|
# @return [Hash{String => Hash{Symbol => Object}}] by command name, as
|
|
45
80
|
# typed: "run", "resync dirs"
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
# @!attribute [r] merge_by
|
|
82
|
+
# @return [Symbol, nil] `merge.by`, one of {MERGERS}; nil reads as {#merger}'s default
|
|
83
|
+
# @!attribute [r] merge_max_rejections
|
|
84
|
+
# @return [Integer, nil] `merge.max_rejections` as the file says it; read
|
|
85
|
+
# {#max_rejections}, which applies the default
|
|
86
|
+
# @!attribute [r] app
|
|
87
|
+
# @return [OpenLanes::Apps::Definition, nil] the processes `openlanes app
|
|
88
|
+
# start` runs; nil when the project declares none, and then nothing
|
|
89
|
+
# about apps happens anywhere
|
|
90
|
+
# @!attribute [r] aging_since
|
|
91
|
+
# @return [Date, nil] `plans.aging_since`: no blocked plan ages from
|
|
92
|
+
# before it, so weeks the project sat paused do not count; nil is no floor
|
|
93
|
+
# @!attribute [r] questions_model
|
|
94
|
+
# @return [String, nil] `questions.model`: the OpenRouter model that
|
|
95
|
+
# rewrites a prose question into lettered choices; nil takes
|
|
96
|
+
# {Live::ChoiceConverter::DEFAULT_MODEL}
|
|
97
|
+
# @!attribute [r] review_max_hmm
|
|
98
|
+
# @return [Integer, nil] `review.max_hmm` as the file says it; read
|
|
99
|
+
# {#max_hmm}, which applies the environment and the default
|
|
100
|
+
# @!attribute [r] desired_codebase_quality
|
|
101
|
+
# @return [Symbol, nil] one of {QUALITIES}, as the file says it; nil
|
|
102
|
+
# while the project is still to be asked (R4)
|
|
103
|
+
# @!attribute [r] coverage
|
|
104
|
+
# @return [OpenLanes::Coverage::Config, nil] the `coverage:` section; nil
|
|
105
|
+
# when the project declares none, and then the gate sends the pull
|
|
106
|
+
# request back to its builder to write one
|
|
107
|
+
Config = Data.define(:path,
|
|
108
|
+
:project,
|
|
109
|
+
:lane,
|
|
110
|
+
:frontend,
|
|
111
|
+
:jobs,
|
|
112
|
+
:redis_url,
|
|
113
|
+
:redis_outage_hours,
|
|
114
|
+
:redis_backoff,
|
|
115
|
+
:commands,
|
|
116
|
+
:merge_by,
|
|
117
|
+
:app,
|
|
118
|
+
:aging_since,
|
|
119
|
+
:questions_model,
|
|
120
|
+
:review_max_hmm,
|
|
121
|
+
:merge_max_rejections,
|
|
122
|
+
:desired_codebase_quality,
|
|
123
|
+
:coverage) do
|
|
124
|
+
def initialize(path: nil, project: nil, lane: nil, frontend: nil, jobs: nil, redis_url: nil,
|
|
125
|
+
redis_outage_hours: nil, redis_backoff: {}, commands: {}, merge_by: nil, app: nil, aging_since: nil, questions_model: nil, review_max_hmm: nil, merge_max_rejections: nil,
|
|
126
|
+
desired_codebase_quality: nil, coverage: nil)
|
|
48
127
|
super
|
|
49
128
|
end
|
|
50
129
|
|
|
130
|
+
# How many times a plan's pull request may be rejected, by hansolo and
|
|
131
|
+
# chewie counted together, before it stops going back to its builders
|
|
132
|
+
# (M6). `merge.max_rejections` when the file says it, otherwise M5's
|
|
133
|
+
# column for `desired_codebase_quality:` (R4); two, `high`'s, when neither is set.
|
|
134
|
+
#
|
|
135
|
+
# @return [Integer]
|
|
136
|
+
def max_rejections
|
|
137
|
+
merge_max_rejections || Config::MAX_REJECTIONS_BY_QUALITY.fetch(desired_codebase_quality, Config::DEFAULT_MAX_REJECTIONS)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] whether the file has no `desired_codebase_quality:`,
|
|
141
|
+
# and so the person is still to be asked (R4)
|
|
142
|
+
def quality_unset? = desired_codebase_quality.nil?
|
|
143
|
+
|
|
144
|
+
# Seconds a run keeps its agents paused for a Redis that stopped
|
|
145
|
+
# answering before it gives up and shuts down (W32): an hour, unless
|
|
146
|
+
# `redis_outage_hours` says otherwise.
|
|
147
|
+
#
|
|
148
|
+
# @return [Float]
|
|
149
|
+
def outage_limit = (redis_outage_hours || Config::DEFAULT_OUTAGE_HOURS) * 3600.0
|
|
150
|
+
|
|
151
|
+
# The most 🤔 a review may carry and still be `review-approved` (R3).
|
|
152
|
+
# Loudest first: `$OPENLANES_REVIEW_MAX_HMM`, then `review.max_hmm`, then
|
|
153
|
+
# {Plans::Verdict::DEFAULT_MAX_HMM}. The variable is there so one run can
|
|
154
|
+
# be stricter or looser without editing a file the whole team shares.
|
|
155
|
+
#
|
|
156
|
+
# @param env [Hash] the environment, injectable for tests
|
|
157
|
+
# @return [Integer]
|
|
158
|
+
# @raise [OpenLanes::Error] when the variable is not a whole number
|
|
159
|
+
def max_hmm(env: ENV)
|
|
160
|
+
raw = env[Config::MAX_HMM_ENV].to_s.strip
|
|
161
|
+
return review_max_hmm || Plans::Verdict::DEFAULT_MAX_HMM if raw.empty?
|
|
162
|
+
return Integer(raw, 10) if raw.match?(/\A\d+\z/)
|
|
163
|
+
|
|
164
|
+
raise Error, "#{Config::MAX_HMM_ENV}=#{raw.inspect} is not a whole number"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Who merges a pull request once its review passed (M4). A person, unless
|
|
168
|
+
# the file says otherwise, since merging is the one thing no agent may do.
|
|
169
|
+
#
|
|
170
|
+
# @return [Symbol] `:human` or `:auto`
|
|
171
|
+
def merger = merge_by || Config::MERGERS.first
|
|
172
|
+
|
|
173
|
+
# @return [Boolean] whether the engine waits on a person to merge, and so
|
|
174
|
+
# watches the pull requests it opened
|
|
175
|
+
def human_merges? = merger == :human
|
|
176
|
+
|
|
51
177
|
# One command's option defaults, the top-level spelling folded in.
|
|
52
178
|
#
|
|
53
179
|
# @param command [String, Symbol] e.g. :run or "resync dirs"
|
|
@@ -70,10 +196,25 @@ module OpenLanes
|
|
|
70
196
|
# @return [String]
|
|
71
197
|
def to_yaml
|
|
72
198
|
settings = { "project" => project, "lane" => lane&.to_s, "frontend" => frontend, "jobs" => jobs,
|
|
73
|
-
"redis_url" => redis_url
|
|
199
|
+
"redis_url" => redis_url, "redis_outage_hours" => redis_outage_hours,
|
|
200
|
+
"redis_backoff" => (redis_backoff.transform_keys(&:to_s) unless redis_backoff.empty?),
|
|
201
|
+
"desired_codebase_quality" => desired_codebase_quality&.to_s, "merge" => merge_settings }
|
|
74
202
|
settings["commands"] = commands.transform_values { it.transform_keys(&:to_s) } unless commands.empty?
|
|
203
|
+
settings["app"] = app.to_h if app
|
|
204
|
+
settings["plans"] = { "aging_since" => aging_since.iso8601 } if aging_since
|
|
205
|
+
settings["questions"] = { "model" => questions_model } if questions_model
|
|
206
|
+
settings["review"] = { "max_hmm" => review_max_hmm } if review_max_hmm
|
|
207
|
+
settings["coverage"] = coverage.to_h if coverage
|
|
75
208
|
YAML.dump(settings.compact).delete_prefix("---\n")
|
|
76
209
|
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# @return [Hash, nil] `merge:` as the file would hold it, nil when nothing is set
|
|
214
|
+
def merge_settings
|
|
215
|
+
merge = { "by" => merge_by&.to_s, "max_rejections" => merge_max_rejections }.compact
|
|
216
|
+
merge.empty? ? nil : merge
|
|
217
|
+
end
|
|
77
218
|
end
|
|
78
219
|
|
|
79
220
|
class Config
|
|
@@ -81,7 +222,42 @@ module OpenLanes
|
|
|
81
222
|
FILE = Project::FILE
|
|
82
223
|
|
|
83
224
|
# Every key the top level may hold.
|
|
84
|
-
KEYS = %w[project lane frontend jobs redis_url commands
|
|
225
|
+
KEYS = %w[project lane frontend jobs redis_url redis_outage_hours redis_backoff desired_codebase_quality merge commands app plans
|
|
226
|
+
questions review coverage].freeze
|
|
227
|
+
|
|
228
|
+
# What `desired_codebase_quality:` may say (R4), the default first. M5's
|
|
229
|
+
# `top_notch` and `medium` were dropped.
|
|
230
|
+
QUALITIES = %i[high mvp].freeze
|
|
231
|
+
|
|
232
|
+
# Who may merge, the default first.
|
|
233
|
+
MERGERS = %i[human auto].freeze
|
|
234
|
+
|
|
235
|
+
# Every key `merge:` may hold.
|
|
236
|
+
MERGE_KEYS = %w[by max_rejections].freeze
|
|
237
|
+
|
|
238
|
+
# {#max_rejections} when the file names none (M6).
|
|
239
|
+
DEFAULT_MAX_REJECTIONS = 2
|
|
240
|
+
|
|
241
|
+
# M5's `max_rejections` row, for the two columns R4 kept.
|
|
242
|
+
MAX_REJECTIONS_BY_QUALITY = { high: 2, mvp: 1 }.freeze
|
|
243
|
+
|
|
244
|
+
# {#outage_limit} when the file names none (W32).
|
|
245
|
+
DEFAULT_OUTAGE_HOURS = 1
|
|
246
|
+
|
|
247
|
+
# Every key `redis_backoff:` may hold.
|
|
248
|
+
BACKOFF_KEYS = %w[initial cap jitter].freeze
|
|
249
|
+
|
|
250
|
+
# Every key `plans:` may hold. Checked key by key, like the top level.
|
|
251
|
+
PLAN_KEYS = %w[aging_since].freeze
|
|
252
|
+
|
|
253
|
+
# Every key `questions:` may hold.
|
|
254
|
+
QUESTION_KEYS = %w[model].freeze
|
|
255
|
+
|
|
256
|
+
# Every key `review:` may hold.
|
|
257
|
+
REVIEW_KEYS = %w[max_hmm].freeze
|
|
258
|
+
|
|
259
|
+
# The variable that beats `review.max_hmm`.
|
|
260
|
+
MAX_HMM_ENV = "OPENLANES_REVIEW_MAX_HMM"
|
|
85
261
|
|
|
86
262
|
# Schemes a `redis_url:` may use.
|
|
87
263
|
REDIS_SCHEMES = %w[redis rediss unix].freeze
|
|
@@ -118,7 +294,7 @@ module OpenLanes
|
|
|
118
294
|
# @param path [String] for every error message
|
|
119
295
|
# @return [OpenLanes::Config]
|
|
120
296
|
def parse(text, path:)
|
|
121
|
-
data = YAML.safe_load(text) || {}
|
|
297
|
+
data = YAML.safe_load(text, permitted_classes: [Date]) || {}
|
|
122
298
|
fail_with(path, "expected a mapping of settings at the top") unless data.is_a?(Hash)
|
|
123
299
|
|
|
124
300
|
settings = data.transform_keys(&:to_s)
|
|
@@ -135,7 +311,16 @@ module OpenLanes
|
|
|
135
311
|
frontend: frontend(settings, path),
|
|
136
312
|
jobs: jobs(settings, path),
|
|
137
313
|
redis_url: redis_url(settings, path),
|
|
138
|
-
|
|
314
|
+
redis_outage_hours: redis_outage_hours(settings, path),
|
|
315
|
+
redis_backoff: redis_backoff(settings["redis_backoff"], path),
|
|
316
|
+
desired_codebase_quality: quality(settings, path),
|
|
317
|
+
**merge(settings["merge"], path),
|
|
318
|
+
commands: commands(settings["commands"], path),
|
|
319
|
+
app: settings.key?("app") ? Apps::Definition.parse(settings["app"], path:) : nil,
|
|
320
|
+
**plans(settings["plans"], path),
|
|
321
|
+
**questions(settings["questions"], path),
|
|
322
|
+
**review(settings["review"], path),
|
|
323
|
+
coverage: settings.key?("coverage") ? Coverage::Config.parse(settings["coverage"], path:) : nil)
|
|
139
324
|
conflicts!(config, path)
|
|
140
325
|
config
|
|
141
326
|
rescue Psych::Exception => e
|
|
@@ -191,6 +376,74 @@ module OpenLanes
|
|
|
191
376
|
fail_with(path, "redis_url: #{value.inspect} is not a #{REDIS_SCHEMES.map { "#{it}://" }.join(", ")} URL")
|
|
192
377
|
end
|
|
193
378
|
|
|
379
|
+
# Any positive number: half an hour is `0.5`.
|
|
380
|
+
#
|
|
381
|
+
# @return [Numeric, nil]
|
|
382
|
+
def redis_outage_hours(settings, path)
|
|
383
|
+
value = settings["redis_outage_hours"]
|
|
384
|
+
return value if value.nil? || (value.is_a?(Numeric) && value.positive?)
|
|
385
|
+
|
|
386
|
+
fail_with(path, "redis_outage_hours: #{value.inspect} is not a number of hours above zero")
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# @return [Hash{Symbol => Object}] `redis_backoff:`, checked key by key
|
|
390
|
+
def redis_backoff(value, path)
|
|
391
|
+
return {} if value.nil?
|
|
392
|
+
|
|
393
|
+
fail_with(path, "redis_backoff: must be a mapping of setting to value") unless value.is_a?(Hash)
|
|
394
|
+
|
|
395
|
+
value.transform_keys(&:to_s).to_h do |key, setting|
|
|
396
|
+
fail_with(path, "redis_backoff.#{key}: unknown key; known: #{BACKOFF_KEYS.join(", ")}") unless BACKOFF_KEYS.include?(key)
|
|
397
|
+
if key == "jitter"
|
|
398
|
+
fail_with(path, "redis_backoff.jitter: #{setting.inspect} is not true or false") unless [true, false].include?(setting)
|
|
399
|
+
elsif !(setting.is_a?(Numeric) && setting.positive?)
|
|
400
|
+
fail_with(path, "redis_backoff.#{key}: #{setting.inspect} is not a number of seconds above zero")
|
|
401
|
+
end
|
|
402
|
+
[key.to_sym, setting]
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# @return [Symbol, nil]
|
|
407
|
+
def quality(settings, path)
|
|
408
|
+
return nil unless settings.key?("desired_codebase_quality")
|
|
409
|
+
|
|
410
|
+
value = settings["desired_codebase_quality"].to_s.strip.downcase.to_sym
|
|
411
|
+
return value if QUALITIES.include?(value)
|
|
412
|
+
|
|
413
|
+
fail_with(path, "desired_codebase_quality: #{settings["desired_codebase_quality"].inspect} is not one of #{QUALITIES.join(", ")}")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# @return [Hash{Symbol => Object}] the `merge:` settings, as keywords for {Config.new}
|
|
417
|
+
def merge(value, path)
|
|
418
|
+
return {} if value.nil?
|
|
419
|
+
|
|
420
|
+
fail_with(path, "merge: must be a mapping, e.g. `merge: { by: human }`") unless value.is_a?(Hash)
|
|
421
|
+
|
|
422
|
+
settings = value.transform_keys(&:to_s)
|
|
423
|
+
unknown = settings.keys - MERGE_KEYS
|
|
424
|
+
fail_with(path, "merge: unknown key #{unknown.join(", ")}; known: #{MERGE_KEYS.join(", ")}") unless unknown.empty?
|
|
425
|
+
|
|
426
|
+
{ merge_by: merge_by(settings, path), merge_max_rejections: max_rejections(settings, path) }.compact
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @return [Symbol, nil]
|
|
430
|
+
def merge_by(settings, path)
|
|
431
|
+
return nil unless settings.key?("by")
|
|
432
|
+
|
|
433
|
+
by = settings["by"].to_s.strip.downcase.to_sym
|
|
434
|
+
return by if MERGERS.include?(by)
|
|
435
|
+
|
|
436
|
+
fail_with(path, "merge.by: #{settings["by"].inspect} is not one of #{MERGERS.join(", ")}")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# @return [Integer, nil]
|
|
440
|
+
def max_rejections(settings, path)
|
|
441
|
+
max = settings["max_rejections"]
|
|
442
|
+
return max if max.nil? || (max.is_a?(Integer) && max.positive?)
|
|
443
|
+
|
|
444
|
+
fail_with(path, "merge.max_rejections: #{max.inspect} is not a whole number above zero")
|
|
445
|
+
end
|
|
446
|
+
|
|
194
447
|
# @return [Hash{String => Hash{Symbol => Object}}]
|
|
195
448
|
def commands(value, path)
|
|
196
449
|
return {} if value.nil?
|
|
@@ -204,6 +457,65 @@ module OpenLanes
|
|
|
204
457
|
end
|
|
205
458
|
end
|
|
206
459
|
|
|
460
|
+
# @return [Hash{Symbol => Object}] the `plans:` settings, as keywords for {Config.new}
|
|
461
|
+
def plans(value, path)
|
|
462
|
+
return {} if value.nil?
|
|
463
|
+
|
|
464
|
+
fail_with(path, "plans: must be a mapping of setting to value") unless value.is_a?(Hash)
|
|
465
|
+
|
|
466
|
+
settings = value.transform_keys(&:to_s)
|
|
467
|
+
(settings.keys - PLAN_KEYS).each do |key|
|
|
468
|
+
fail_with(path, "plans.#{key}: unknown key; known: #{PLAN_KEYS.join(", ")}")
|
|
469
|
+
end
|
|
470
|
+
{ aging_since: aging_since(settings["aging_since"], path) }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# @return [Hash{Symbol => Object}] the `questions:` settings, as keywords for {Config.new}
|
|
474
|
+
def questions(value, path)
|
|
475
|
+
return {} if value.nil?
|
|
476
|
+
|
|
477
|
+
fail_with(path, "questions: must be a mapping of setting to value") unless value.is_a?(Hash)
|
|
478
|
+
|
|
479
|
+
settings = value.transform_keys(&:to_s)
|
|
480
|
+
(settings.keys - QUESTION_KEYS).each do |key|
|
|
481
|
+
fail_with(path, "questions.#{key}: unknown key; known: #{QUESTION_KEYS.join(", ")}")
|
|
482
|
+
end
|
|
483
|
+
model = settings["model"]
|
|
484
|
+
return {} if model.nil?
|
|
485
|
+
return { questions_model: model.strip } if model.is_a?(String) && !model.strip.empty?
|
|
486
|
+
|
|
487
|
+
fail_with(path, "questions.model: #{model.inspect} is not a model id, e.g. #{Live::ChoiceConverter::DEFAULT_MODEL}")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# @return [Hash{Symbol => Object}] the `review:` settings, as keywords for {Config.new}
|
|
491
|
+
def review(value, path)
|
|
492
|
+
return {} if value.nil?
|
|
493
|
+
|
|
494
|
+
fail_with(path, "review: must be a mapping of setting to value") unless value.is_a?(Hash)
|
|
495
|
+
|
|
496
|
+
settings = value.transform_keys(&:to_s)
|
|
497
|
+
(settings.keys - REVIEW_KEYS).each do |key|
|
|
498
|
+
fail_with(path, "review.#{key}: unknown key; known: #{REVIEW_KEYS.join(", ")}")
|
|
499
|
+
end
|
|
500
|
+
max = settings["max_hmm"]
|
|
501
|
+
return {} if max.nil?
|
|
502
|
+
return { review_max_hmm: max } if max.is_a?(Integer) && !max.negative?
|
|
503
|
+
|
|
504
|
+
fail_with(path, "review.max_hmm: #{max.inspect} is not a whole number")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# YAML hands an unquoted `2026-10-01` over as a Date already, and a
|
|
508
|
+
# quoted one as a String; both are the same day to whoever typed it.
|
|
509
|
+
#
|
|
510
|
+
# @return [Date, nil]
|
|
511
|
+
def aging_since(value, path)
|
|
512
|
+
return value if value.nil? || value.instance_of?(Date)
|
|
513
|
+
|
|
514
|
+
Date.iso8601(value.to_s)
|
|
515
|
+
rescue Date::Error
|
|
516
|
+
fail_with(path, "plans.aging_since: #{value.inspect} is not a date (YYYY-MM-DD)")
|
|
517
|
+
end
|
|
518
|
+
|
|
207
519
|
# @return [void]
|
|
208
520
|
def conflicts!(config, path)
|
|
209
521
|
SHORTHANDS.each do |key, owner|
|