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,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Lifecycle
|
|
7
|
+
# `openlanes create` without the command: read the seed, fetch the pull
|
|
8
|
+
# requests, mint the folder, and either reconstruct its spec from the
|
|
9
|
+
# pull requests or scaffold and draft a new one.
|
|
10
|
+
#
|
|
11
|
+
# The spinner line and the warning boxes for a writer that fell through
|
|
12
|
+
# draw on STDERR as the work happens. The folder, the success text and
|
|
13
|
+
# the `spec.md` worth opening come back for the command to print.
|
|
14
|
+
class CreateWorkflow
|
|
15
|
+
include UI
|
|
16
|
+
include Dry::Monads[:result]
|
|
17
|
+
include Dry::Monads::Do.for(:call)
|
|
18
|
+
|
|
19
|
+
# What a create made.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute [r] path
|
|
22
|
+
# @return [String] the folder, where it ended up
|
|
23
|
+
# @!attribute [r] message
|
|
24
|
+
# @return [String] the success text: what was made and the next step
|
|
25
|
+
# @!attribute [r] spec
|
|
26
|
+
# @return [String, nil] the scaffolded `spec.md`, for a person to open;
|
|
27
|
+
# nil when nothing was scaffolded
|
|
28
|
+
Created = Data.define(:path, :message, :spec)
|
|
29
|
+
|
|
30
|
+
# @param dir [String] the `.plans` directory
|
|
31
|
+
# @param root [String, nil] repository root (default: the .plans parent)
|
|
32
|
+
# @param after [String, nil] mint a retroactive plan in the gap after this one
|
|
33
|
+
# @param status [String, nil] open in this state instead of the default
|
|
34
|
+
# @param prs [String, nil] pull request numbers or URLs, comma separated
|
|
35
|
+
# @param from [String, nil] a seed markdown file, already resolved
|
|
36
|
+
# @param spec [Boolean] with +prs+, write spec.md from them
|
|
37
|
+
# @param draft [Boolean] without +prs+, attempt the four headings
|
|
38
|
+
# @param lane [String, nil] written into spec.md's frontmatter
|
|
39
|
+
# @param depth [String, nil] written into spec.md's frontmatter
|
|
40
|
+
def initialize(dir:, root: nil, after: nil, status: nil, prs: nil, from: nil, spec: true, draft: true,
|
|
41
|
+
lane: nil, depth: nil)
|
|
42
|
+
@dir = dir
|
|
43
|
+
@root = root
|
|
44
|
+
@after = after
|
|
45
|
+
@status = status
|
|
46
|
+
@prs = prs
|
|
47
|
+
@from = from
|
|
48
|
+
@spec = spec
|
|
49
|
+
@draft = draft
|
|
50
|
+
@settings = { "lane" => lane, "depth" => depth }.compact
|
|
51
|
+
end
|
|
52
|
+
# rubocop:enable Metrics/ParameterLists
|
|
53
|
+
|
|
54
|
+
# @param words [Array<String>] the topic; empty when +from+ names it
|
|
55
|
+
# @return [Dry::Monads::Result] Success({Created}), or Failure({Refusal})
|
|
56
|
+
# with 64 (usage), 65 (bad data) or 66 (no seed file)
|
|
57
|
+
def call(words)
|
|
58
|
+
FileUtils.mkdir_p(@dir)
|
|
59
|
+
words, seed = yield resolve_seed(words)
|
|
60
|
+
prs = yield fetch_prs
|
|
61
|
+
path = yield mint(words, prs)
|
|
62
|
+
|
|
63
|
+
Success(created(path, prs, seed))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# @param message [String]
|
|
69
|
+
# @param status [Integer]
|
|
70
|
+
# @return [Dry::Monads::Result::Failure]
|
|
71
|
+
def refusal(message, status) = Failure(Refusal.new(message:, status:))
|
|
72
|
+
|
|
73
|
+
# `--from FILE` trades the words argument for a markdown file: the
|
|
74
|
+
# frontmatter's `title:` names the folder, and the body travels on to
|
|
75
|
+
# {Brief} as the author's own opening statement. Words and a file are
|
|
76
|
+
# two answers to one question — the folder's name — so holding both is
|
|
77
|
+
# refused rather than silently ranked.
|
|
78
|
+
#
|
|
79
|
+
# @param words [Array<String>]
|
|
80
|
+
# @return [Dry::Monads::Result] Success([slug words, seed body or nil])
|
|
81
|
+
def resolve_seed(words)
|
|
82
|
+
return Success([words, nil]) unless @from
|
|
83
|
+
|
|
84
|
+
unless words.empty?
|
|
85
|
+
return refusal("--from names the topic by its frontmatter title;\ndrop the words, or drop --from", 64)
|
|
86
|
+
end
|
|
87
|
+
if @prs
|
|
88
|
+
return refusal("--prs reconstructs spec.md from the pull requests, so a seed file\n" \
|
|
89
|
+
"would be ignored; use one or the other",
|
|
90
|
+
64)
|
|
91
|
+
end
|
|
92
|
+
return refusal("Could not read the seed file:\n#{@from} does not exist", 66) unless File.file?(@from)
|
|
93
|
+
|
|
94
|
+
parse_seed(File.read(@from, encoding: "UTF-8"))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Frontmatter that will not parse used to be reported as a missing
|
|
98
|
+
# title, which sends the author looking for a `title:` line that is
|
|
99
|
+
# sitting right there. Say which of the two actually went wrong.
|
|
100
|
+
#
|
|
101
|
+
# @param content [String]
|
|
102
|
+
# @return [Dry::Monads::Result] Success([title words, body])
|
|
103
|
+
def parse_seed(content)
|
|
104
|
+
meta, body = Frontmatter.split(content)
|
|
105
|
+
title = meta["title"].to_s
|
|
106
|
+
if title.strip.empty?
|
|
107
|
+
return refusal("The seed file needs a frontmatter title, e.g.\n---\ntitle: Tax Rule DSL\n---", 65)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Success([title.split, body.strip])
|
|
111
|
+
rescue Psych::Exception => e
|
|
112
|
+
refusal("The seed file's frontmatter is not valid YAML:\n#{e.message}", 65)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A retroactive plan documents work that landed *somewhere* in the
|
|
116
|
+
# sequence, and only its author knows where. Guessing would put the
|
|
117
|
+
# number — the one thing that never changes — in the wrong place.
|
|
118
|
+
#
|
|
119
|
+
# @return [Dry::Monads::Result] Success(Array<Hash>) or Success(nil) without +prs+
|
|
120
|
+
def fetch_prs
|
|
121
|
+
return Success(nil) unless @prs
|
|
122
|
+
|
|
123
|
+
unless @after
|
|
124
|
+
return refusal("--prs documents work that already shipped;\n" \
|
|
125
|
+
"name the plan it landed after with --after, e.g. --after 018",
|
|
126
|
+
64)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Success(Vcs.github.pull_requests(Vcs::GitHub.parse_refs(@prs)))
|
|
130
|
+
rescue OpenLanes::Error => e
|
|
131
|
+
refusal("Could not read the pull requests:\n#{e.message}", 65)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param words [Array<String>]
|
|
135
|
+
# @param prs [Array<Hash>, nil]
|
|
136
|
+
# @return [Dry::Monads::Result] Success(path)
|
|
137
|
+
def mint(words, prs)
|
|
138
|
+
Lifecycle.create(dir: @dir, words:, after: @after, status: @status, prs:)
|
|
139
|
+
.alt_map { |message| Refusal.new(message: "Could not create the plan:\n#{message}", status: 65) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A plan with recorded pull requests already has its facts — hand it to
|
|
143
|
+
# the writer that reconstructs a specification from a diff. One without
|
|
144
|
+
# them does not exist yet, and reconstructing is not the job; {#brief} is.
|
|
145
|
+
#
|
|
146
|
+
# @param path [String]
|
|
147
|
+
# @param prs [Array<Hash>, nil]
|
|
148
|
+
# @param seed [String, nil]
|
|
149
|
+
# @return [Created]
|
|
150
|
+
def created(path, prs, seed)
|
|
151
|
+
from_prs = prs && !prs.empty?
|
|
152
|
+
spec = nil
|
|
153
|
+
if from_prs
|
|
154
|
+
path = synthesize(path) if @spec
|
|
155
|
+
else
|
|
156
|
+
spec = brief(path, seed)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
feature = Plans::Feature.parse(path)
|
|
160
|
+
Created.new(path:,
|
|
161
|
+
spec:,
|
|
162
|
+
message: "Created #{File.basename(path)}\n\n" \
|
|
163
|
+
"#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
|
|
164
|
+
"#{next_step(path, feature, from_prs:)}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Hand the folder to the writer that already knows how to write a
|
|
168
|
+
# retroactive specification, then let `resync dirs` decide what the
|
|
169
|
+
# folder has become — ⏪ is only true while there is no `spec.md`.
|
|
170
|
+
#
|
|
171
|
+
# @param path [String]
|
|
172
|
+
# @return [String] the folder's path, which the resync may have renamed
|
|
173
|
+
def synthesize(path)
|
|
174
|
+
agent = OpenLanes::Agents.registry.find(OpenLanes::RETROACTIVE_WRITER) or return path
|
|
175
|
+
|
|
176
|
+
feature = Plans::Feature.parse(path)
|
|
177
|
+
ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
|
|
178
|
+
agent: agent.name,
|
|
179
|
+
plan: feature.ordinal.to_s) do |line|
|
|
180
|
+
Execution.executor(root: root_of(path)).call(agent, Plans.subject(feature), &line)
|
|
181
|
+
end
|
|
182
|
+
unless ok
|
|
183
|
+
error("The folder was created, but spec.md was not written:\n#{note}\n\n" \
|
|
184
|
+
"The pull requests are recorded. Run `openlanes run` to retry.")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
settle(path)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A folder with no pull requests to reconstruct from is a feature that
|
|
191
|
+
# does not exist yet. {Brief} writes the four headings a human still has
|
|
192
|
+
# to answer and makes a best-effort pass at them from what the project
|
|
193
|
+
# already has on disk. The folder's state never moves: ⚪ New only ever
|
|
194
|
+
# claimed that a specification exists, not that it is complete.
|
|
195
|
+
#
|
|
196
|
+
# @param path [String]
|
|
197
|
+
# @param seed [String, nil] the seed body from the `--from` file
|
|
198
|
+
# @return [String, nil] the scaffolded spec.md; nil for a retroactive folder
|
|
199
|
+
def brief(path, seed)
|
|
200
|
+
feature = Plans::Feature.parse(path)
|
|
201
|
+
return if feature.status.key == :retroactive
|
|
202
|
+
|
|
203
|
+
brief = Lifecycle.brief(path:, title: feature.title, root: root_of(path), seed:)
|
|
204
|
+
brief.write_scaffold!
|
|
205
|
+
draft(brief, feature, seed) if @draft
|
|
206
|
+
# After the draft, so a drafting agent that rewrites the file cannot
|
|
207
|
+
# drop `--lane` and `--depth`.
|
|
208
|
+
brief.stamp!(@settings)
|
|
209
|
+
brief.spec_path
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @param brief [OpenLanes::Lifecycle::Brief]
|
|
213
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
214
|
+
# @param seed [String, nil]
|
|
215
|
+
# @return [void]
|
|
216
|
+
def draft(brief, feature, seed)
|
|
217
|
+
# Painted the way Runner::Task#label paints a roster agent, so the
|
|
218
|
+
# half-agent reads as one of them on the terminal.
|
|
219
|
+
label = "#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
|
|
220
|
+
ok, note = on_agent_row(label, agent: Brief::AGENT_NAME, plan: feature.ordinal.to_s) { brief.attempt! }
|
|
221
|
+
return if ok
|
|
222
|
+
|
|
223
|
+
error("spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
|
|
224
|
+
"The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher.")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @param path [String] a plan folder
|
|
228
|
+
# @return [String] the repository root the agents work in
|
|
229
|
+
def root_of(path) = @root || File.dirname(path, 2)
|
|
230
|
+
|
|
231
|
+
# One agent writing spec.md, drawn as a spinner line.
|
|
232
|
+
#
|
|
233
|
+
# @param label [String] the spinner line's text
|
|
234
|
+
# @param agent [String]
|
|
235
|
+
# @param plan [String]
|
|
236
|
+
# @yieldparam line [OpenLanes::UI::Line]
|
|
237
|
+
# @return [Object] the block's value
|
|
238
|
+
def on_agent_row(label, agent:, plan:, &block)
|
|
239
|
+
Execution.on_interrupt do
|
|
240
|
+
UI.concurrently([plan],
|
|
241
|
+
label,
|
|
242
|
+
jobs: 1,
|
|
243
|
+
label: ->(_) { label },
|
|
244
|
+
fields: ->(_) { { plan:, agent: } }) { |_, line| block.call(line) }.first
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @param path [String]
|
|
249
|
+
# @return [String] where the folder ended up
|
|
250
|
+
def settle(path)
|
|
251
|
+
change = Lifecycle.resync_dirs(tree: Plans.tree(File.dirname(path)))
|
|
252
|
+
.call(commit: true)
|
|
253
|
+
.find { |c| c.source == path }
|
|
254
|
+
change ? change.target : path
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @param path [String]
|
|
258
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
259
|
+
# @param from_prs [Boolean]
|
|
260
|
+
# @return [String]
|
|
261
|
+
def next_step(path, feature, from_prs:)
|
|
262
|
+
spec = File.join(File.basename(path), "spec.md")
|
|
263
|
+
return "Next: write #{spec}" unless feature.status.key == :new && File.file?(File.join(path, "spec.md"))
|
|
264
|
+
if from_prs
|
|
265
|
+
return "Next: read #{spec} — it was written from the pull requests, so check it against what actually shipped"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
"Next: fill in the four headings in #{spec}, then hand off to leah-researcher"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -33,7 +33,7 @@ module OpenLanes
|
|
|
33
33
|
# @param after [String, nil] anchor for a retroactive plan, e.g. "002"
|
|
34
34
|
# @param status [String, Symbol, nil] override the default state
|
|
35
35
|
# @param prs [Array<Hash>, nil] pull requests to record, already fetched;
|
|
36
|
-
# their presence is what makes the folder
|
|
36
|
+
# their presence is what makes the folder ⏪ Retroactive rather than ⚪
|
|
37
37
|
# @return [Dry::Monads::Result] Success(absolute path) or Failure(message)
|
|
38
38
|
def create(words:, after: nil, status: nil, prs: nil)
|
|
39
39
|
resolved = resolve_status(status, after) or
|
|
@@ -42,24 +42,28 @@ module OpenLanes
|
|
|
42
42
|
slug = self.class.slugify(words)
|
|
43
43
|
return Failure("the topic produced an empty slug") if slug.empty?
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
# Read the taken numbers and make the folder under one lock, fresh
|
|
46
|
+
# each time: a number read before the lock was taken may be gone.
|
|
47
|
+
allocator.hold do
|
|
48
|
+
@existing = nil
|
|
49
|
+
ordinal = after ? retroactive_ordinal(after) : Plans::Ordinal.next_major(existing)
|
|
50
|
+
return ordinal if ordinal.is_a?(Dry::Monads::Result)
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
|
|
53
|
+
end
|
|
49
54
|
rescue OpenLanes::Error => e
|
|
50
55
|
Failure(e.message)
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
# Archived, deferred and abandoned plans keep their numbers (N3), so a
|
|
59
|
+
# number that lives only in `archive/` is still taken; so is one that
|
|
60
|
+
# lives only on another branch, or two checkouts mint it twice.
|
|
61
|
+
#
|
|
53
62
|
# @return [Array<OpenLanes::Plans::Ordinal>] every number already taken
|
|
54
|
-
def existing
|
|
55
|
-
@existing ||= begin
|
|
56
|
-
return [] unless File.directory?(dir)
|
|
63
|
+
def existing = @existing ||= allocator.taken
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.filter_map { |c| Plans::Ordinal.from_dirname(c) }
|
|
61
|
-
end
|
|
62
|
-
end
|
|
65
|
+
# @return [OpenLanes::Lifecycle::Allocator] where numbers may come from
|
|
66
|
+
def allocator = @allocator ||= Allocator.new(dir:)
|
|
63
67
|
|
|
64
68
|
# How many words of an idea name its plan.
|
|
65
69
|
TOPIC_WORDS = 8
|
|
@@ -117,7 +121,7 @@ module OpenLanes
|
|
|
117
121
|
Plans::Ordinal.next_minor(existing, major: anchor.major)
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
# Write the pull requests the folder was created from, so
|
|
124
|
+
# Write the pull requests the folder was created from, so ⏪ Retroactive
|
|
121
125
|
# is justified the moment the folder exists — that state means "the work is
|
|
122
126
|
# live and undocumented", and it is the recorded pull requests that make
|
|
123
127
|
# the first half of that true.
|
|
@@ -8,7 +8,7 @@ module OpenLanes
|
|
|
8
8
|
# trusts, so research and rewriting are skipped: the harness writes the
|
|
9
9
|
# blank `plan.md` yoda-writer would have left and moves the folder to 📋.
|
|
10
10
|
# A plan in the `quick` lane is a short mechanical task: the harness
|
|
11
|
-
# writes a one-unit `plan.md` and moves the folder to
|
|
11
|
+
# writes a one-unit `plan.md` and moves the folder to ⭐, where
|
|
12
12
|
# r2d2-mechanic is the only builder that serves the lane.
|
|
13
13
|
#
|
|
14
14
|
# Both moves are ordinary transitions through the state machine, so the
|
|
@@ -14,23 +14,35 @@ module OpenLanes
|
|
|
14
14
|
#
|
|
15
15
|
# - **The emoji is wrong** — the contents justify a different state. A
|
|
16
16
|
# folder whose status already holds is left alone, which is what stops
|
|
17
|
-
#
|
|
17
|
+
# ⭕ Blocked and 🛑 Product Blocked — deliberately identical invariants,
|
|
18
18
|
# distinguished only by the name — from collapsing into one.
|
|
19
|
-
# - **The spelling is not canonical** — a folder written `018
|
|
20
|
-
# before the `NNN.MM` padding rule or the
|
|
21
|
-
# `18.1
|
|
19
|
+
# - **The spelling is not canonical** — a folder written `018-⚪--foo`
|
|
20
|
+
# before the `NNN.MM` padding rule or the `━━❯` separator, or
|
|
21
|
+
# `18.1-⚪--foo` by hand. Unpadded numbers read fine and sort wrong —
|
|
22
22
|
# `018.09` < `018.1` < `018.10` puts a single mixed-width folder in the
|
|
23
|
-
# middle of the range. Both are renamed, to `018.00
|
|
24
|
-
# `018.01
|
|
23
|
+
# middle of the range. Both are renamed, to `018.00━━⚪━━❯foo` and
|
|
24
|
+
# `018.01━━⚪━━❯foo`.
|
|
25
25
|
#
|
|
26
|
-
# The
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
26
|
+
# - **The folder is in the wrong place** — a plan in an inactive state
|
|
27
|
+
# (✅ 😎 💤 ❌) belongs in the subfolder {Plans::Tree::INACTIVE_DIRS}
|
|
28
|
+
# names, under a name dated by {Vcs::RenameDate}; a plan in any other
|
|
29
|
+
# state belongs at the top level, undated. Moving a plan back out is
|
|
30
|
+
# how one is revived: the state machine renames it in place, and the
|
|
31
|
+
# next resync brings it home (decision N3).
|
|
32
|
+
#
|
|
33
|
+
# The repairs are applied as **up to three renames per folder**: first
|
|
34
|
+
# the spelling, under the status the folder already claims — which is
|
|
35
|
+
# how an older folder migrates to the `NNN.MM━━<emoji>━━❯<slug>` form —
|
|
36
|
+
# then the status its contents justify, then the move. All of them aim
|
|
37
|
+
# at {Feature.plan_dirname}, so they cannot disagree about the final
|
|
38
|
+
# name; splitting them keeps each auditable on its own, instead of one
|
|
39
|
+
# report line where a spelling migration also appears to have opinions
|
|
40
|
+
# about the state of the work.
|
|
41
|
+
#
|
|
42
|
+
# A folder an agent holds a lock on ({Plans::Lock}) keeps its name, the
|
|
43
|
+
# same as one passed in +except+. A lock left by a process that is gone
|
|
44
|
+
# from this machine is removed by a committing run; one from another
|
|
45
|
+
# machine is reported and never touched, since its pid means nothing here.
|
|
34
46
|
class Dirs
|
|
35
47
|
# A proposed rename.
|
|
36
48
|
#
|
|
@@ -49,7 +61,46 @@ module OpenLanes
|
|
|
49
61
|
Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
|
|
50
62
|
# @return [String] a single auditable line — `⇒` rather than `→`,
|
|
51
63
|
# which the folder names themselves now contain
|
|
52
|
-
def to_s = "#{dirname} ⇒ #{
|
|
64
|
+
def to_s = "#{dirname} ⇒ #{target_name} (#{reason})"
|
|
65
|
+
|
|
66
|
+
# @return [String] the new name, with its subfolder when it is in one
|
|
67
|
+
def target_name
|
|
68
|
+
parent = File.basename(File.dirname(target))
|
|
69
|
+
Plans::Tree::SUBFOLDERS.include?(parent) ? "#{parent}/#{File.basename(target)}" : File.basename(target)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A spec.md whose `how-to-verify:` key moves into its body (M10).
|
|
74
|
+
#
|
|
75
|
+
# @!attribute [r] dirname
|
|
76
|
+
# @return [String] the plan folder, as it stands
|
|
77
|
+
# @!attribute [r] path
|
|
78
|
+
# @return [String] the spec, absolute
|
|
79
|
+
Rewrite = Data.define(:dirname, :path) do
|
|
80
|
+
# @return [String]
|
|
81
|
+
def reason = "how-to-verify: moves into #{Plans::Spec::HOW_TO_VERIFY_HEADING}"
|
|
82
|
+
|
|
83
|
+
# @return [String]
|
|
84
|
+
def to_s = "#{dirname}/#{File.basename(path)} (#{reason})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A lock file found on the way.
|
|
88
|
+
#
|
|
89
|
+
# @!attribute [r] dirname
|
|
90
|
+
# @return [String] the plan folder it is in
|
|
91
|
+
# @!attribute [r] path
|
|
92
|
+
# @return [String] the plan folder, absolute
|
|
93
|
+
# @!attribute [r] lock
|
|
94
|
+
# @return [OpenLanes::Plans::Lock]
|
|
95
|
+
HeldLock = Data.define(:dirname, :path, :lock) do
|
|
96
|
+
# @return [Boolean] written on this machine by a process that is gone
|
|
97
|
+
def stale? = lock.stale?
|
|
98
|
+
|
|
99
|
+
# @return [Boolean] written on another machine, so never cleared here
|
|
100
|
+
def foreign? = !lock.local?
|
|
101
|
+
|
|
102
|
+
# @return [String]
|
|
103
|
+
def to_s = "#{dirname}: locked by pid #{lock.pid} on #{lock.host} since #{lock.started_at.utc.iso8601}"
|
|
53
104
|
end
|
|
54
105
|
|
|
55
106
|
# The state a folder's contents justify, or the one it claims when
|
|
@@ -78,17 +129,44 @@ module OpenLanes
|
|
|
78
129
|
#
|
|
79
130
|
# @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Change>]
|
|
80
131
|
def plan
|
|
81
|
-
|
|
82
|
-
|
|
132
|
+
held = held_paths
|
|
133
|
+
tree.everything.subjects.flat_map { |subject| left_alone?(subject, held) ? [] : changes_for(subject) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Specs that still carry `how-to-verify:` in their frontmatter.
|
|
137
|
+
#
|
|
138
|
+
# @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Rewrite>]
|
|
139
|
+
def rewrites
|
|
140
|
+
held = held_paths
|
|
141
|
+
tree.everything.subjects.filter_map do |subject|
|
|
142
|
+
next if left_alone?(subject, held)
|
|
143
|
+
|
|
144
|
+
path = File.join(subject.feature.path, Plans::Spec::FILENAME)
|
|
145
|
+
next unless File.file?(path)
|
|
146
|
+
|
|
147
|
+
text = File.read(path, encoding: "UTF-8")
|
|
148
|
+
Rewrite.new(dirname: subject.feature.dirname, path:) unless Plans::Spec.migrate_how_to_verify(text).equal?(text)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Every lock file in the tree, inactive subfolders included.
|
|
153
|
+
#
|
|
154
|
+
# @return [Array<OpenLanes::Lifecycle::Resync::Dirs::HeldLock>]
|
|
155
|
+
def locks
|
|
156
|
+
tree.everything.subjects.filter_map do |subject|
|
|
157
|
+
lock = Plans::Lock.read(subject.feature.path) or next
|
|
158
|
+
HeldLock.new(dirname: subject.feature.dirname, path: subject.feature.path, lock:)
|
|
83
159
|
end
|
|
84
160
|
end
|
|
85
161
|
|
|
86
|
-
# @param commit [Boolean] actually rename
|
|
162
|
+
# @param commit [Boolean] actually rename, and clear stale local locks
|
|
87
163
|
# @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Change>] what was proposed
|
|
88
164
|
def call(commit: false)
|
|
89
165
|
changes = plan
|
|
90
166
|
return changes unless commit
|
|
91
167
|
|
|
168
|
+
locks.select(&:stale?).each { |held| Plans::Lock.remove(held.path) }
|
|
169
|
+
rewrites.each { |rewrite| rewrite_spec(rewrite.path) }
|
|
92
170
|
UI.stepping(changes, "Renaming") { |change| rename(change) }
|
|
93
171
|
tree.reload
|
|
94
172
|
changes
|
|
@@ -96,6 +174,22 @@ module OpenLanes
|
|
|
96
174
|
|
|
97
175
|
private
|
|
98
176
|
|
|
177
|
+
# @return [Array<String>] plan folders a live or foreign lock holds
|
|
178
|
+
def held_paths = locks.reject(&:stale?).map(&:path)
|
|
179
|
+
|
|
180
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
181
|
+
# @param held [Array<String>]
|
|
182
|
+
# @return [Boolean] whether an agent may be inside it right now
|
|
183
|
+
def left_alone?(subject, held)
|
|
184
|
+
@except.include?(subject.feature.ordinal.to_s) || held.include?(subject.feature.path)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param path [String] a spec.md
|
|
188
|
+
# @return [void]
|
|
189
|
+
def rewrite_spec(path)
|
|
190
|
+
File.write(path, Plans::Spec.migrate_how_to_verify(File.read(path, encoding: "UTF-8")))
|
|
191
|
+
end
|
|
192
|
+
|
|
99
193
|
# A folder moves when the name it has is not the name it should have.
|
|
100
194
|
# {target} says what that name is; this says how to get there, as up
|
|
101
195
|
# to two renames applied in order. The status rename starts from where
|
|
@@ -113,40 +207,71 @@ module OpenLanes
|
|
|
113
207
|
|
|
114
208
|
spelled = feature.dirname_as(subject.status)
|
|
115
209
|
if spelled != feature.dirname
|
|
116
|
-
steps << step(
|
|
117
|
-
from: subject.status,
|
|
210
|
+
steps << step(from: subject.status,
|
|
118
211
|
to: subject.status,
|
|
119
212
|
source: feature.path,
|
|
213
|
+
target: beside(feature.path, spelled),
|
|
120
214
|
reason: spelling_reason(feature))
|
|
121
215
|
end
|
|
122
216
|
|
|
123
217
|
if fit.key != subject.status.key
|
|
124
218
|
source = steps.empty? ? feature.path : steps.last.target
|
|
125
|
-
steps << step(
|
|
126
|
-
from: subject.status,
|
|
219
|
+
steps << step(from: subject.status,
|
|
127
220
|
to: fit,
|
|
128
221
|
source:,
|
|
222
|
+
target: beside(source, feature.dirname_as(fit)),
|
|
129
223
|
reason: subject.violation || "contents now justify #{fit.label}")
|
|
130
224
|
end
|
|
131
225
|
|
|
226
|
+
source = steps.empty? ? feature.path : steps.last.target
|
|
227
|
+
home = home_for(feature, fit)
|
|
228
|
+
steps << step(from: fit, to: fit, source:, target: home, reason: placement_reason(feature, fit)) if home != source
|
|
229
|
+
|
|
132
230
|
steps
|
|
133
231
|
end
|
|
134
232
|
|
|
135
|
-
# @param feature [OpenLanes::Plans::Feature]
|
|
136
233
|
# @param from [OpenLanes::Plans::Status]
|
|
137
234
|
# @param to [OpenLanes::Plans::Status]
|
|
138
235
|
# @param source [String] absolute path the rename starts from
|
|
236
|
+
# @param target [String] absolute path it ends at
|
|
139
237
|
# @param reason [String]
|
|
140
238
|
# @return [OpenLanes::Lifecycle::Resync::Dirs::Change]
|
|
141
|
-
def step(
|
|
142
|
-
Change.new(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
239
|
+
def step(from:, to:, source:, target:, reason:)
|
|
240
|
+
Change.new(dirname: File.basename(source), from: from.key, to: to.key, source:, target:, reason:)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @param path [String]
|
|
244
|
+
# @param name [String]
|
|
245
|
+
# @return [String] +name+ in the same directory as +path+
|
|
246
|
+
def beside(path, name) = File.join(File.dirname(path), name)
|
|
247
|
+
|
|
248
|
+
# Where a plan in this state lives, and under which name: an inactive
|
|
249
|
+
# plan in its subfolder, dated, keeping a date it already carries
|
|
250
|
+
# there; any other plan at the top level, undated.
|
|
251
|
+
#
|
|
252
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
253
|
+
# @param status [OpenLanes::Plans::Status]
|
|
254
|
+
# @return [String] absolute
|
|
255
|
+
def home_for(feature, status)
|
|
256
|
+
subfolder = Plans::Tree.subfolder_for(status)
|
|
257
|
+
return File.join(tree.dir, feature.dirname_as(status, archived_on: nil)) unless subfolder
|
|
258
|
+
|
|
259
|
+
parent = File.join(tree.dir, subfolder)
|
|
260
|
+
date = (feature.archived_on if File.dirname(feature.path) == parent) || Vcs::RenameDate.for(feature.path)
|
|
261
|
+
File.join(parent, feature.dirname_as(status, archived_on: date))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
265
|
+
# @param status [OpenLanes::Plans::Status]
|
|
266
|
+
# @return [String]
|
|
267
|
+
def placement_reason(feature, status)
|
|
268
|
+
subfolder = Plans::Tree.subfolder_for(status)
|
|
269
|
+
here = File.dirname(feature.path)
|
|
270
|
+
return "#{status.label} lives in #{subfolder}/" if subfolder && here != File.join(tree.dir, subfolder)
|
|
271
|
+
return "a plan moved aside carries the date it was moved" if subfolder
|
|
272
|
+
return "#{status.label} is not inactive; back to #{File.basename(tree.dir)}/" if here != tree.dir
|
|
273
|
+
|
|
274
|
+
"an open plan's name carries no date"
|
|
150
275
|
end
|
|
151
276
|
|
|
152
277
|
# @param feature [OpenLanes::Plans::Feature]
|
|
@@ -154,19 +279,26 @@ module OpenLanes
|
|
|
154
279
|
def spelling_reason(feature)
|
|
155
280
|
return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
|
|
156
281
|
|
|
157
|
-
"name is not in canonical NNN.MM
|
|
282
|
+
"name is not in canonical NNN.MM━━<emoji>━━❯<slug> form"
|
|
158
283
|
end
|
|
159
284
|
|
|
160
285
|
# A rename that finds its target occupied has not happened, and saying
|
|
161
286
|
# nothing about it would leave the folder misnamed with a report
|
|
162
287
|
# claiming otherwise. Two folders can want the same canonical name —
|
|
163
|
-
# `018
|
|
288
|
+
# `018-⚪--foo` and `018.00━━⚪━━❯foo` are the same plan written twice.
|
|
164
289
|
#
|
|
165
290
|
# @param change [OpenLanes::Lifecycle::Resync::Dirs::Change]
|
|
166
291
|
# @return [void]
|
|
167
292
|
# @raise [OpenLanes::Error] when the target name is already taken
|
|
168
293
|
def rename(change)
|
|
169
|
-
|
|
294
|
+
if OpenLanes.move_directory(change.source, change.target)
|
|
295
|
+
# Counted like any other move, so a session left in the state
|
|
296
|
+
# the plan has just been taken out of is not resumed when it
|
|
297
|
+
# comes back. A folder with no state.json has no session to lose.
|
|
298
|
+
state = Plans::PlanState.for(change.target)
|
|
299
|
+
state.state!(change.to) if change.from != change.to && state.exist?
|
|
300
|
+
return
|
|
301
|
+
end
|
|
170
302
|
|
|
171
303
|
raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
|
|
172
304
|
end
|
|
@@ -346,7 +478,7 @@ module OpenLanes
|
|
|
346
478
|
match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
|
|
347
479
|
ordinal = Plans::Ordinal.parse(match[1])
|
|
348
480
|
|
|
349
|
-
unless tree.include?(ordinal)
|
|
481
|
+
unless tree.everything.include?(ordinal)
|
|
350
482
|
return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
|
|
351
483
|
end
|
|
352
484
|
|
|
@@ -369,13 +501,9 @@ module OpenLanes
|
|
|
369
501
|
# @param path [String] a path from the diff
|
|
370
502
|
# @return [OpenLanes::Plans::Ordinal, nil]
|
|
371
503
|
def ordinal_for_path(path)
|
|
372
|
-
|
|
373
|
-
index = parts.index(OpenLanes::PLANS_DIR) or return nil
|
|
374
|
-
folder = parts[index + 1] or return nil
|
|
375
|
-
return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
|
|
376
|
-
|
|
504
|
+
folder = Plans::Tree.plan_folder(path) or return nil
|
|
377
505
|
ordinal = Plans::Ordinal.from_dirname(folder)
|
|
378
|
-
ordinal if ordinal && tree.include?(ordinal)
|
|
506
|
+
ordinal if ordinal && tree.everything.include?(ordinal)
|
|
379
507
|
end
|
|
380
508
|
|
|
381
509
|
# @param pull [Hash]
|