openlanes 3.0.1 → 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 +4 -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 +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- 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 +7 -0
- 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 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- 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 +84 -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 +1175 -117
- 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 +6 -6
- 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 +12 -120
- 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 +223 -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 +17 -13
- 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 +60 -11
- 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 +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- 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 +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- 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 +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -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 +64 -5
- 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 +39 -24
- 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 +226 -8
- 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 +167 -6
- 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 +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- 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 +29 -10
- data/lib/openlanes/web.rb +6 -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/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- 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/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Vcs
|
|
9
|
+
# The date a plan reached the state that sends it into an inactive
|
|
10
|
+
# subfolder, for the `[YYYY-MM-DD]` its archived name carries (N3).
|
|
11
|
+
#
|
|
12
|
+
# A state change is a folder rename, so the date of the folder's latest
|
|
13
|
+
# rename is the date it got there. Git knows it, through a file inside the
|
|
14
|
+
# folder: `--follow --diff-filter=R` returns the rename itself, where
|
|
15
|
+
# `git log -1 -- <folder>` returns whichever edit came last, and a spec
|
|
16
|
+
# touched up a month after the merge would date the archive by the edit.
|
|
17
|
+
#
|
|
18
|
+
# Git does not always know. The folder may never have been committed, the
|
|
19
|
+
# clone may be shallow, or the tree may not be a repository at all; then
|
|
20
|
+
# `state.json`'s `updated_at`, which the harness writes on every state
|
|
21
|
+
# change, and failing that, today.
|
|
22
|
+
module RenameDate
|
|
23
|
+
# Files tried first; a folder holding none of them (a ❌ plan may hold
|
|
24
|
+
# only `discarded.md`) is followed through whichever file it does hold.
|
|
25
|
+
PROBES = %w[spec.md plan.md pull-requests.md].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# @param dir [String] a plan folder
|
|
29
|
+
# @param today [Date] the last resort
|
|
30
|
+
# @return [Date]
|
|
31
|
+
def for(dir, today: Date.today) = from_git(dir) || from_state(dir) || today
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @param dir [String]
|
|
36
|
+
# @return [Date, nil]
|
|
37
|
+
def from_git(dir)
|
|
38
|
+
probe = probe_in(dir) or return nil
|
|
39
|
+
|
|
40
|
+
out, status = Open3.capture2("git",
|
|
41
|
+
"-C",
|
|
42
|
+
dir,
|
|
43
|
+
"log",
|
|
44
|
+
"--follow",
|
|
45
|
+
"--diff-filter=R",
|
|
46
|
+
"--format=%cI",
|
|
47
|
+
"-1",
|
|
48
|
+
"--",
|
|
49
|
+
probe,
|
|
50
|
+
err: File::NULL)
|
|
51
|
+
status.success? ? parse(out.strip) : nil
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param dir [String]
|
|
57
|
+
# @return [String, nil] a file name inside +dir+
|
|
58
|
+
def probe_in(dir)
|
|
59
|
+
PROBES.find { File.file?(File.join(dir, it)) } ||
|
|
60
|
+
Dir.children(dir).sort.find { File.file?(File.join(dir, it)) && it != Plans::Lock::FILENAME }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param dir [String]
|
|
64
|
+
# @return [Date, nil]
|
|
65
|
+
def from_state(dir)
|
|
66
|
+
parse(JSON.parse(File.read(File.join(dir, Plans::PlanState::FILENAME)))["updated_at"])
|
|
67
|
+
rescue SystemCallError, JSON::ParserError, TypeError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param text [String, nil] an ISO-8601 timestamp
|
|
72
|
+
# @return [Date, nil]
|
|
73
|
+
def parse(text)
|
|
74
|
+
return nil if text.to_s.empty?
|
|
75
|
+
|
|
76
|
+
Date.iso8601(text.to_s[0, 10])
|
|
77
|
+
rescue Date::Error
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Vcs
|
|
5
|
+
# Moves a pull request from one review label to the next (M3, R3).
|
|
6
|
+
#
|
|
7
|
+
# The only way a review label is set: `gh pr edit` stays forbidden to
|
|
8
|
+
# agents (L3), so the CI rule below cannot be skipped by labelling by hand
|
|
9
|
+
# from inside a round.
|
|
10
|
+
class Review
|
|
11
|
+
# What happened, or under a dry run what would have.
|
|
12
|
+
Result = Data.define(:pr, :from, :to, :ci, :refused, :reason, :dry_run) do
|
|
13
|
+
alias_method :refused?, :refused
|
|
14
|
+
alias_method :dry_run?, :dry_run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param github [GitHub]
|
|
18
|
+
def initialize(github: GitHub.new)
|
|
19
|
+
@github = github
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param pr [String, Integer] "12", "#12" or a pull request URL
|
|
23
|
+
# @param label [String] a {Plans::ReviewLabel} name
|
|
24
|
+
# @param dry_run [Boolean] compute the result, write nothing
|
|
25
|
+
# @return [Result]
|
|
26
|
+
# @raise [OpenLanes::Error] on an unknown label or a reference naming no
|
|
27
|
+
# pull request, before any `gh` call
|
|
28
|
+
def call(pr:, label:, dry_run: false) # rubocop:disable Naming/MethodParameterName -- `pr` is the contract with CLI::Review
|
|
29
|
+
to = Plans::ReviewLabel.fetch(label)
|
|
30
|
+
number = GitHub.number_of(pr)
|
|
31
|
+
repo = GitHub.repo_of(pr)
|
|
32
|
+
|
|
33
|
+
from = @github.labels(number:, repo:) & Plans::ReviewLabel.known
|
|
34
|
+
ci = CiGate.verdict(@github.checks(number:, repo:))
|
|
35
|
+
reason = refusal(to, ci)
|
|
36
|
+
result = Result.new(pr: number,
|
|
37
|
+
from:,
|
|
38
|
+
to: to.name,
|
|
39
|
+
ci:,
|
|
40
|
+
refused: !reason.nil?,
|
|
41
|
+
reason: reason || (ci == :none ? NO_CHECKS : nil),
|
|
42
|
+
dry_run:)
|
|
43
|
+
return result if result.refused? || dry_run
|
|
44
|
+
|
|
45
|
+
@github.swap_label(number:, repo:, add: to, remove: from & Plans::ReviewLabel.displaced_by(to))
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Zero checks, or only skipped ones, counts as green (open question 2),
|
|
50
|
+
# said out loud.
|
|
51
|
+
NO_CHECKS = "no CI checks ran; treated as green"
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] why `to` may not be set now
|
|
56
|
+
def refusal(label, verdict)
|
|
57
|
+
return unless label.passing?
|
|
58
|
+
|
|
59
|
+
case verdict
|
|
60
|
+
when :red then "CI is red"
|
|
61
|
+
when :pending then "CI pending"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -56,6 +56,18 @@ module OpenLanes
|
|
|
56
56
|
|
|
57
57
|
# @return [String] where {.seeder} looks, for a person
|
|
58
58
|
def seeder_search = "$#{SEEDER_ENV}, #{SEEDER_NAME} on PATH, #{SEEDER_HOMES.join(", ")}"
|
|
59
|
+
|
|
60
|
+
# The main checkout of the repository +path+ is in, even when +path+
|
|
61
|
+
# is itself a worktree. Worktrees live beside the main checkout, so a
|
|
62
|
+
# command run from inside one and rooted there looks for
|
|
63
|
+
# `<worktree>.worktrees` and finds nothing.
|
|
64
|
+
#
|
|
65
|
+
# @param path [String] any checkout of the repository
|
|
66
|
+
# @return [String] the main checkout; +path+ when git cannot say
|
|
67
|
+
def main_root(path)
|
|
68
|
+
common = `git -C #{path.shellescape} rev-parse --path-format=absolute --git-common-dir 2>/dev/null`.strip
|
|
69
|
+
common.empty? ? path : File.dirname(common)
|
|
70
|
+
end
|
|
59
71
|
end
|
|
60
72
|
|
|
61
73
|
# One plan's isolated checkout.
|
|
@@ -137,6 +149,18 @@ module OpenLanes
|
|
|
137
149
|
Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
|
|
138
150
|
end
|
|
139
151
|
|
|
152
|
+
# Bring a plan's checkout up to date with the default branch before an
|
|
153
|
+
# agent runs in it, and say what happened in the progress log.
|
|
154
|
+
#
|
|
155
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
156
|
+
# @param hold [String, nil] see {Refresh#call}
|
|
157
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
158
|
+
def refresh(checkout, hold: nil)
|
|
159
|
+
outcome = Refresh.new(path: checkout.path).call(hold:)
|
|
160
|
+
UI.log(outcome.summary, plan: checkout.ordinal.to_s) unless outcome.kind == :current
|
|
161
|
+
outcome
|
|
162
|
+
end
|
|
163
|
+
|
|
140
164
|
# Copy in the files git ignores and a new checkout therefore lacks: `.env`
|
|
141
165
|
# and friends, and credential keys.
|
|
142
166
|
#
|
|
@@ -154,13 +178,14 @@ module OpenLanes
|
|
|
154
178
|
def seed(path)
|
|
155
179
|
unless @seeder && File.executable?(@seeder)
|
|
156
180
|
UI.log("worktree: no #{SEEDER_NAME} found (searched #{self.class.seeder_search}), " \
|
|
157
|
-
"so #{path} has no .env or credential keys"
|
|
181
|
+
"so #{path} has no .env or credential keys",
|
|
182
|
+
level: :warn)
|
|
158
183
|
return false
|
|
159
184
|
end
|
|
160
185
|
|
|
161
186
|
return true if system(@seeder, "--quiet", path, out: File::NULL, err: File::NULL)
|
|
162
187
|
|
|
163
|
-
UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
|
|
188
|
+
UI.log("worktree: could not seed #{path}; its suite may fail on a missing key", level: :warn)
|
|
164
189
|
false
|
|
165
190
|
end
|
|
166
191
|
|
|
@@ -199,7 +224,82 @@ module OpenLanes
|
|
|
199
224
|
#
|
|
200
225
|
# @return [Array<String>] paths removed
|
|
201
226
|
def prune
|
|
202
|
-
list.select { |path| clean?(path) }.
|
|
227
|
+
list.select { |path| clean?(path) }.select { |path| remove(path) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @param path [String] a checkout
|
|
231
|
+
# @return [String] the branch it has checked out; empty when detached
|
|
232
|
+
def branch_of(path) = `git -C #{path.shellescape} branch --show-current 2>/dev/null`.strip
|
|
233
|
+
|
|
234
|
+
# Every checkout of one plan: the one at {#path_for}, and any other
|
|
235
|
+
# whose folder starts with the same ordinal. A folder-name parse that
|
|
236
|
+
# once leaked the status into the slug (`018.00-018.00━━🟡━━❯x`) gave a
|
|
237
|
+
# plan three checkouts, and only the ordinal finds all of them.
|
|
238
|
+
#
|
|
239
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
240
|
+
# @return [Array<String>] absolute paths, as git lists them
|
|
241
|
+
def checkouts_of(feature)
|
|
242
|
+
prefix = "#{feature.ordinal}-"
|
|
243
|
+
list.select { File.basename(it).start_with?(prefix) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Remove a checkout whose work is on the default branch, and its local
|
|
247
|
+
# branch with it.
|
|
248
|
+
#
|
|
249
|
+
# A merged pull request ends a plan's worktree; left behind, they piled
|
|
250
|
+
# up to forty-eight in one repository, each looking like work in
|
|
251
|
+
# progress. Removal cannot be undone for anything that never left the
|
|
252
|
+
# machine, so every condition below has to hold first, and a refusal
|
|
253
|
+
# says which one failed and leaves the checkout exactly as it was.
|
|
254
|
+
#
|
|
255
|
+
# "On the default branch" is a tree comparison, never `git log
|
|
256
|
+
# main..branch`: a squash merge rewrites every hash, so that log lists
|
|
257
|
+
# every commit of a branch that landed whole. A stacked pull request
|
|
258
|
+
# merged into its parent's branch reports MERGED too, which is why the
|
|
259
|
+
# base is checked.
|
|
260
|
+
#
|
|
261
|
+
# @param path [String] a checkout from {#list}
|
|
262
|
+
# @param merge_commit [String] the commit the pull request merged as
|
|
263
|
+
# @param base [String] the branch the pull request merged into
|
|
264
|
+
# @param default_branch [String]
|
|
265
|
+
# @param held [#call, nil] path -> who holds a lock in it, or nil
|
|
266
|
+
# @param dry_run [Boolean] check everything, remove nothing
|
|
267
|
+
# @return [Retirement]
|
|
268
|
+
def retire(path, merge_commit:, base:, default_branch: Refresh.default_branch(root), held: nil, dry_run: false)
|
|
269
|
+
branch = branch_of(path)
|
|
270
|
+
sha = `git -C #{path.shellescape} rev-parse HEAD 2>/dev/null`.strip
|
|
271
|
+
refusal = retire_refusal(path, sha, merge_commit, base, default_branch, held)
|
|
272
|
+
return Retirement.new(path:, branch:, sha:, removed: false, reason: refusal) if refusal
|
|
273
|
+
return Retirement.new(path:, branch:, sha:, removed: false, reason: "would be removed: landed in #{merge_commit[0, 8]}") if dry_run
|
|
274
|
+
return Retirement.new(path:, branch:, sha:, removed: false, reason: "git refused to remove it") unless remove(path)
|
|
275
|
+
|
|
276
|
+
# -D, not -d: a squash-merged branch looks unmerged to -d. The tree
|
|
277
|
+
# comparison above is the check that makes this safe.
|
|
278
|
+
system("git", "-C", root, "branch", "-D", branch, out: File::NULL, err: File::NULL) unless branch.empty?
|
|
279
|
+
Retirement.new(path:, branch:, sha:, removed: true, reason: "landed in #{merge_commit[0, 8]}")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# What {#retire} did with one checkout.
|
|
283
|
+
#
|
|
284
|
+
# @!attribute [r] path
|
|
285
|
+
# @return [String]
|
|
286
|
+
# @!attribute [r] branch
|
|
287
|
+
# @return [String] empty for a detached checkout
|
|
288
|
+
# @!attribute [r] sha
|
|
289
|
+
# @return [String] the branch's last commit, so it can be restored
|
|
290
|
+
# @!attribute [r] removed
|
|
291
|
+
# @return [Boolean]
|
|
292
|
+
# @!attribute [r] reason
|
|
293
|
+
# @return [String] why it was removed, or why not
|
|
294
|
+
Retirement = Data.define(:path, :branch, :sha, :removed, :reason) do
|
|
295
|
+
# @return [Boolean]
|
|
296
|
+
def removed? = removed
|
|
297
|
+
|
|
298
|
+
# @return [String] one line for the progress log
|
|
299
|
+
def to_s
|
|
300
|
+
name = File.basename(path)
|
|
301
|
+
removed? ? "worktree #{name} removed (#{branch} was #{sha[0, 8]}): #{reason}" : "worktree #{name} kept: #{reason}"
|
|
302
|
+
end
|
|
203
303
|
end
|
|
204
304
|
|
|
205
305
|
# Where one plan's checkout lives, whether or not it has been made.
|
|
@@ -242,10 +342,14 @@ module OpenLanes
|
|
|
242
342
|
true
|
|
243
343
|
end
|
|
244
344
|
|
|
345
|
+
# Never `--force`: git refuses a checkout with uncommitted or untracked
|
|
346
|
+
# work, and that refusal is the last guard against deleting it.
|
|
347
|
+
# Ignored files, the seeded `.env` among them, do not count.
|
|
348
|
+
#
|
|
245
349
|
# @param path [String]
|
|
246
|
-
# @return [
|
|
350
|
+
# @return [Boolean] whether git removed it
|
|
247
351
|
def remove(path)
|
|
248
|
-
system("git", "-C", root, "worktree", "remove",
|
|
352
|
+
system("git", "-C", root, "worktree", "remove", path, out: File::NULL, err: File::NULL)
|
|
249
353
|
end
|
|
250
354
|
|
|
251
355
|
private
|
|
@@ -267,12 +371,69 @@ module OpenLanes
|
|
|
267
371
|
# @return [Boolean]
|
|
268
372
|
def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
269
373
|
|
|
374
|
+
# The first reason {#retire} must not remove a checkout, or nil.
|
|
375
|
+
#
|
|
376
|
+
# @return [String, nil]
|
|
377
|
+
def retire_refusal(path, sha, merge_commit, base, default_branch, held)
|
|
378
|
+
return "not a worktree openlanes manages" unless managed?(path)
|
|
379
|
+
return "its pull request merged into #{base}, not #{default_branch}" unless base == default_branch
|
|
380
|
+
return "it has uncommitted changes" unless clean?(path)
|
|
381
|
+
|
|
382
|
+
holder = held&.call(path)
|
|
383
|
+
return "#{holder} holds a lock in it" if holder
|
|
384
|
+
return "merge commit #{merge_commit[0, 8]} is not in this repository, even after a fetch" unless commit?(merge_commit)
|
|
385
|
+
|
|
386
|
+
"its branch has work that is not in #{merge_commit[0, 8]}" unless landed?(sha, merge_commit)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# @param path [String]
|
|
390
|
+
# @return [Boolean] one of {#list}, and not the main checkout
|
|
391
|
+
def managed?(path)
|
|
392
|
+
here = canonical(path)
|
|
393
|
+
here != canonical(root) && list.any? { canonical(it) == here }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Present locally, fetching once if not: the engine merged it a moment
|
|
397
|
+
# ago on GitHub, and nothing has fetched since.
|
|
398
|
+
#
|
|
399
|
+
# @param sha [String]
|
|
400
|
+
# @return [Boolean]
|
|
401
|
+
def commit?(sha)
|
|
402
|
+
known = -> { system("git", "-C", root, "cat-file", "-e", "#{sha}^{commit}", out: File::NULL, err: File::NULL) }
|
|
403
|
+
known.call || (system("git", "-C", root, "fetch", "--quiet", "origin", out: File::NULL, err: File::NULL) && known.call)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The branch tip's tree is the merge commit's, or every file the branch
|
|
407
|
+
# changed reads the same in the merge commit. The second covers a
|
|
408
|
+
# default branch that moved on before the merge.
|
|
409
|
+
#
|
|
410
|
+
# @param sha [String] the branch tip
|
|
411
|
+
# @param merge_commit [String]
|
|
412
|
+
# @return [Boolean]
|
|
413
|
+
def landed?(sha, merge_commit)
|
|
414
|
+
tree = ->(ref) { `git -C #{root.shellescape} rev-parse #{"#{ref}^{tree}".shellescape} 2>/dev/null`.strip }
|
|
415
|
+
return true if tree.call(sha) == tree.call(merge_commit)
|
|
416
|
+
|
|
417
|
+
fork = `git -C #{root.shellescape} merge-base #{sha.shellescape} #{merge_commit.shellescape} 2>/dev/null`.strip
|
|
418
|
+
return false if fork.empty?
|
|
419
|
+
|
|
420
|
+
files = `git -C #{root.shellescape} diff --name-only #{fork.shellescape} #{sha.shellescape} 2>/dev/null`.lines(chomp: true)
|
|
421
|
+
files.empty? || system("git", "-C", root, "diff", "--quiet", sha, merge_commit, "--", *files, out: File::NULL, err: File::NULL)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A new branch starts from the freshly fetched default branch on
|
|
425
|
+
# `origin`, not from whatever the main checkout has checked out: that
|
|
426
|
+
# is often a branch of its own, or a `main` nobody has pulled in weeks,
|
|
427
|
+
# and the plan's agent then builds against code that is not there.
|
|
428
|
+
# Without such a remote branch it falls back to the main checkout's
|
|
429
|
+
# HEAD, as it always did.
|
|
430
|
+
#
|
|
270
431
|
# @param branch [String]
|
|
271
432
|
# @param path [String]
|
|
272
433
|
# @return [void]
|
|
273
434
|
def add(branch, path)
|
|
274
435
|
exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
275
|
-
args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
|
|
436
|
+
args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, *Refresh.fetch(root)]
|
|
276
437
|
|
|
277
438
|
return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
|
|
278
439
|
|
data/lib/openlanes/vcs.rb
CHANGED
|
@@ -15,5 +15,23 @@ module OpenLanes
|
|
|
15
15
|
|
|
16
16
|
# @return [OpenLanes::Vcs::Publisher]
|
|
17
17
|
def publisher(**) = Publisher.new(**)
|
|
18
|
+
|
|
19
|
+
# @param github [OpenLanes::Vcs::GitHub]
|
|
20
|
+
# @return [OpenLanes::Vcs::Review]
|
|
21
|
+
def review(github: GitHub.new) = Review.new(github:)
|
|
22
|
+
|
|
23
|
+
# @param root [String, nil] the checkout whose repository is synced; nil
|
|
24
|
+
# means the current directory's
|
|
25
|
+
# @param github [OpenLanes::Vcs::GitHub]
|
|
26
|
+
# @return [OpenLanes::Vcs::LabelSync]
|
|
27
|
+
def label_sync(root: nil, github: GitHub.new(chdir: root)) = LabelSync.new(github:)
|
|
28
|
+
|
|
29
|
+
# Run before {label_sync}, once per repository: the repository's
|
|
30
|
+
# {Plans::ReviewLabel::MIGRATED} label marks it done.
|
|
31
|
+
#
|
|
32
|
+
# @param root [String, nil] as for {label_sync}
|
|
33
|
+
# @param github [OpenLanes::Vcs::GitHub]
|
|
34
|
+
# @return [OpenLanes::Vcs::LabelMigration]
|
|
35
|
+
def label_migration(root: nil, github: GitHub.new(chdir: root)) = LabelMigration.new(github:)
|
|
18
36
|
end
|
|
19
37
|
end
|
data/lib/openlanes/version.rb
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "rack/auth/basic"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Web
|
|
9
|
+
# Basic auth for every request from off this machine. The page can stop
|
|
10
|
+
# agents, answer their questions and message them, so a server listening
|
|
11
|
+
# past loopback is refused unless it is given credentials
|
|
12
|
+
# (`--admin-credentials`), and this asks for them.
|
|
13
|
+
#
|
|
14
|
+
# A request from loopback passes untouched: it already reached the
|
|
15
|
+
# machine, the browser `openlanes start` opens is on it, and so is the
|
|
16
|
+
# launcher's `/healthz` probe.
|
|
17
|
+
#
|
|
18
|
+
# use AdminAuth, credentials: AdminAuth::Credentials.parse("admin:s3cret")
|
|
19
|
+
class AdminAuth
|
|
20
|
+
# Where `openlanes run` hands its server the credentials. argv is
|
|
21
|
+
# world-readable through `ps`; a child's environment is not.
|
|
22
|
+
ENV_NAME = "OPENLANES_ADMIN_CREDENTIALS"
|
|
23
|
+
|
|
24
|
+
# What the browser's login prompt names.
|
|
25
|
+
REALM = "openlanes control room"
|
|
26
|
+
|
|
27
|
+
# Raised for credentials that are not `USER:PASSWORD`.
|
|
28
|
+
class Invalid < OpenLanes::Error; end
|
|
29
|
+
|
|
30
|
+
# One user and one password. The password may itself hold a colon;
|
|
31
|
+
# the user may not, since Basic auth splits at the first one.
|
|
32
|
+
Credentials = Data.define(:user, :password) do
|
|
33
|
+
# @param user [String]
|
|
34
|
+
# @param password [String]
|
|
35
|
+
# @return [Boolean] whether both match, compared in constant time
|
|
36
|
+
def match?(user, password)
|
|
37
|
+
# Both compared, always, so the time taken says nothing about
|
|
38
|
+
# which of the two was wrong.
|
|
39
|
+
[Rack::Utils.secure_compare(self.user, user.to_s),
|
|
40
|
+
Rack::Utils.secure_compare(self.password, password.to_s)].all?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Never the password, so a credential that reaches a log or an
|
|
44
|
+
# exception message leaks nothing.
|
|
45
|
+
#
|
|
46
|
+
# @return [String]
|
|
47
|
+
def inspect = "#<#{self.class.name} user=#{user.inspect}>"
|
|
48
|
+
alias_method :to_s, :inspect
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# @param text [String] `USER:PASSWORD`
|
|
53
|
+
# @return [Credentials]
|
|
54
|
+
# @raise [Invalid] when either half is missing
|
|
55
|
+
def parse(text)
|
|
56
|
+
user, password = text.to_s.split(":", 2)
|
|
57
|
+
unless user.to_s.strip.empty? || password.to_s.empty?
|
|
58
|
+
return Credentials.new(user: user.strip, password:)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise Invalid, "takes USER:PASSWORD, both non-empty"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param address [String, nil] `REMOTE_ADDR`
|
|
65
|
+
# @return [Boolean] whether it is this machine, IPv4-mapped IPv6 included
|
|
66
|
+
def loopback?(address)
|
|
67
|
+
ip = IPAddr.new(address.to_s)
|
|
68
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
69
|
+
ip.loopback?
|
|
70
|
+
rescue IPAddr::Error
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param app [#call]
|
|
76
|
+
# @param credentials [Credentials]
|
|
77
|
+
def initialize(app, credentials:)
|
|
78
|
+
@app = app
|
|
79
|
+
@credentials = credentials
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param env [Hash] the Rack environment
|
|
83
|
+
# @return [Array] the Rack response
|
|
84
|
+
def call(env)
|
|
85
|
+
return @app.call(env) if self.class.loopback?(env["REMOTE_ADDR"])
|
|
86
|
+
|
|
87
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
|
88
|
+
return challenge unless auth.provided? && auth.basic? && @credentials.match?(*auth.credentials)
|
|
89
|
+
|
|
90
|
+
env["REMOTE_USER"] = auth.username
|
|
91
|
+
@app.call(env)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# @return [Array] a 401 the browser answers with its login prompt
|
|
97
|
+
def challenge
|
|
98
|
+
[401,
|
|
99
|
+
{ "content-type" => "text/plain", "content-length" => "0", "www-authenticate" => %(Basic realm="#{REALM}") },
|
|
100
|
+
[]]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|