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,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "aasm"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Plans
|
|
7
|
+
# The state machine for one pull request (decision P2).
|
|
8
|
+
#
|
|
9
|
+
# A plan's state is the emoji in its folder name; a pull request's is the
|
|
10
|
+
# {ReviewLabel} it carries on GitHub. A plan may have several pull
|
|
11
|
+
# requests, each somewhere else in review, so the two machines are kept
|
|
12
|
+
# apart: {StateMachine} moves folders, this one says which label may
|
|
13
|
+
# follow which. Like {StateMachine}, it keeps no state of its own. It is
|
|
14
|
+
# read off the labels and the row's GitHub state, and the caller makes
|
|
15
|
+
# the change on GitHub through {Vcs::Review} or {Vcs::GitHub}.
|
|
16
|
+
#
|
|
17
|
+
# `merged` and `closed` are not labels. They are GitHub's own states,
|
|
18
|
+
# and they outrank any label still on the pull request.
|
|
19
|
+
class PullRequestMachine
|
|
20
|
+
include AASM
|
|
21
|
+
|
|
22
|
+
# Raised when a move is refused, naming both ends.
|
|
23
|
+
class Refused < Error; end
|
|
24
|
+
|
|
25
|
+
# Label name => state key: `ready-to-merge` is `:ready_to_merge`.
|
|
26
|
+
#
|
|
27
|
+
# @param name [String]
|
|
28
|
+
# @return [Symbol]
|
|
29
|
+
def self.key_for(name) = name.to_s.tr("-", "_").to_sym
|
|
30
|
+
|
|
31
|
+
# The state with no label on it yet: opened before the publisher
|
|
32
|
+
# labelled it, or by a person.
|
|
33
|
+
UNLABELLED = :unlabelled
|
|
34
|
+
|
|
35
|
+
aasm do
|
|
36
|
+
state UNLABELLED, initial: true
|
|
37
|
+
ReviewLabel::ALL.each { |label| state PullRequestMachine.key_for(label.name) }
|
|
38
|
+
state :merged
|
|
39
|
+
state :closed
|
|
40
|
+
|
|
41
|
+
event :submit do
|
|
42
|
+
transitions from: %i[unlabelled review_rejected review_vulnerability], to: :ready_for_review
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
event :start_review do
|
|
46
|
+
transitions from: %i[unlabelled ready_for_review], to: :review_in_progress
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The reviewers' verdicts, and chewie's rejection after them. A
|
|
50
|
+
# rejection also reaches back from `ready-to-merge` and
|
|
51
|
+
# `merge-manually`: a pull request that no longer rebases onto
|
|
52
|
+
# `main` after its sibling merged goes back to the builders (P5), and
|
|
53
|
+
# a person may reject one they were asked to merge.
|
|
54
|
+
event :reject do
|
|
55
|
+
transitions from: %i[review_in_progress review_ok_not_perfect review_approved ready_to_merge merge_manually],
|
|
56
|
+
to: :review_rejected
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
event :flag_vulnerability do
|
|
60
|
+
transitions from: %i[review_in_progress review_rejected], to: :review_vulnerability
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
event :pass_with_doubts do
|
|
64
|
+
transitions from: %i[review_in_progress], to: :review_ok_not_perfect
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
event :approve do
|
|
68
|
+
transitions from: %i[review_in_progress], to: :review_approved
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# chewie verified it, CI is green and the coverage gate holds.
|
|
72
|
+
event :ready do
|
|
73
|
+
transitions from: %i[review_ok_not_perfect review_approved], to: :ready_to_merge
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# palpatine's two verdicts, as the engine acted on them. Only a
|
|
77
|
+
# `ready-to-merge` pull request is ruled on.
|
|
78
|
+
event :merge_automatically do
|
|
79
|
+
transitions from: %i[ready_to_merge], to: :auto_merged
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
event :leave_for_a_person do
|
|
83
|
+
transitions from: %i[ready_to_merge], to: :merge_manually
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# GitHub's own states. A person may merge or close at any point; an
|
|
87
|
+
# `auto-merged` pull request is merged already.
|
|
88
|
+
event :merged do
|
|
89
|
+
transitions from: ReviewLabel::ALL.map { PullRequestMachine.key_for(it.name) } + [UNLABELLED], to: :merged
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
event :close do
|
|
93
|
+
transitions from: (ReviewLabel::ALL.map { PullRequestMachine.key_for(it.name) } - %i[auto_merged]) + [UNLABELLED],
|
|
94
|
+
to: :closed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Where a pull request stands, from its row and its labels. GitHub's
|
|
99
|
+
# merged or closed wins over any label; among labels, the one furthest
|
|
100
|
+
# along {ReviewLabel::ALL} wins, since a stale verdict left beside a
|
|
101
|
+
# newer one is the older of the two.
|
|
102
|
+
#
|
|
103
|
+
# @param pull [PullRequest] its row in `pull-requests.md`
|
|
104
|
+
# @param labels [Array<String>] its labels on GitHub now
|
|
105
|
+
# @return [Symbol]
|
|
106
|
+
def self.state_of(pull, labels)
|
|
107
|
+
return :merged if pull.merged?
|
|
108
|
+
return :closed unless pull.open?
|
|
109
|
+
|
|
110
|
+
label = ReviewLabel::ALL.rfind { labels.include?(it.name) }
|
|
111
|
+
label ? key_for(label.name) : UNLABELLED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param pull [PullRequest]
|
|
115
|
+
# @param labels [Array<String>]
|
|
116
|
+
def initialize(pull, labels)
|
|
117
|
+
@pull = pull
|
|
118
|
+
@key = self.class.state_of(pull, labels)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [PullRequest]
|
|
122
|
+
attr_reader :pull
|
|
123
|
+
|
|
124
|
+
# @return [Symbol] the state right now
|
|
125
|
+
attr_reader :key
|
|
126
|
+
|
|
127
|
+
# AASM keeps state in a column; this one is read off GitHub, and a move
|
|
128
|
+
# is only ever asked about, never stored here.
|
|
129
|
+
#
|
|
130
|
+
# @return [Symbol]
|
|
131
|
+
def aasm_read_state(_name = :default) = @key
|
|
132
|
+
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
|
|
135
|
+
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def aasm_write_state_without_persistence(new_state, _name = :default)
|
|
138
|
+
@key = new_state
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Boolean] whether palpatine's verdict has been acted on (P2)
|
|
143
|
+
def complete? = %i[auto_merged merge_manually].include?(key)
|
|
144
|
+
|
|
145
|
+
# @param event [Symbol]
|
|
146
|
+
# @return [Boolean] whether +event+ may fire from here
|
|
147
|
+
def may?(event) = aasm.may_fire_event?(event)
|
|
148
|
+
|
|
149
|
+
# Take one step, so the caller learns the move is legal before it
|
|
150
|
+
# changes anything on GitHub.
|
|
151
|
+
#
|
|
152
|
+
# @param event [Symbol]
|
|
153
|
+
# @return [Symbol] the state now occupied
|
|
154
|
+
# @raise [Refused] when the move is not in the topology
|
|
155
|
+
def fire!(event)
|
|
156
|
+
raise Refused, "PR ##{pull.number.to_s.delete("#")} is #{key}; #{event} does not apply" unless may?(event)
|
|
157
|
+
|
|
158
|
+
aasm.fire!(event)
|
|
159
|
+
key
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# A label that says where a pull request stands in review (decision R3,
|
|
6
|
+
# which replaced M3's set).
|
|
7
|
+
#
|
|
8
|
+
# A pull request carries at most one of these at a time; a new verdict
|
|
9
|
+
# replaces the previous one. The one exception is {VULNERABILITY}, which
|
|
10
|
+
# sits beside {REJECTED}. This file is the only place under `lib/` that
|
|
11
|
+
# spells a label's name, so the CLI help, the docs table, the `gh` calls
|
|
12
|
+
# and the label sync cannot drift apart.
|
|
13
|
+
ReviewLabel = Data.define(:name, :colour, :meaning, :passing) do
|
|
14
|
+
alias_method :passing?, :passing
|
|
15
|
+
|
|
16
|
+
# @return [String] the name, so a label interpolates as itself
|
|
17
|
+
def to_s = name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ReviewLabel
|
|
21
|
+
ALL = [
|
|
22
|
+
new(name: "ready-for-review",
|
|
23
|
+
colour: "c2e0c6",
|
|
24
|
+
meaning: "luke and rey are done; the PR waits for a free review slot",
|
|
25
|
+
passing: false),
|
|
26
|
+
new(name: "review-in-progress", colour: "FFBE20", meaning: "the reviewers have started", passing: false),
|
|
27
|
+
new(name: "review-rejected", colour: "F90000", meaning: "at least one ⛔️ or 💣", passing: false),
|
|
28
|
+
new(name: "review-vulnerability",
|
|
29
|
+
colour: "FF0050",
|
|
30
|
+
meaning: "at least one 💣; sits beside review-rejected",
|
|
31
|
+
passing: false),
|
|
32
|
+
new(name: "review-ok-not-perfect", colour: "E8E890", meaning: "no ⛔️ or 💣, but more 🤔 than review.max_hmm (5 by default)", passing: true),
|
|
33
|
+
new(name: "review-approved", colour: "109a10", meaning: "no ⛔️ or 💣, few 🤔", passing: true),
|
|
34
|
+
new(name: "ready-to-merge",
|
|
35
|
+
colour: "5319e7",
|
|
36
|
+
meaning: "chewie-verifier verified it and its E2E tests pass",
|
|
37
|
+
passing: true),
|
|
38
|
+
# palpatine's two verdicts, as the engine acted on them (P2).
|
|
39
|
+
new(name: "auto-merged",
|
|
40
|
+
colour: "953384",
|
|
41
|
+
meaning: "palpatine-risk-assessor judged it safe and the engine merged it",
|
|
42
|
+
passing: true),
|
|
43
|
+
new(name: "merge-manually",
|
|
44
|
+
colour: "0e8a16",
|
|
45
|
+
meaning: "palpatine-risk-assessor, or merge.by: human, left it for a person to merge",
|
|
46
|
+
passing: true)
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# @param name [String]
|
|
50
|
+
# @return [ReviewLabel]
|
|
51
|
+
def self.named(name) = ALL.find { |l| l.name == name }
|
|
52
|
+
|
|
53
|
+
# The label the publisher puts on a pull request it has just opened.
|
|
54
|
+
INITIAL = named("ready-for-review")
|
|
55
|
+
|
|
56
|
+
# The verdict accepted even while CI is red.
|
|
57
|
+
REJECTED = named("review-rejected")
|
|
58
|
+
|
|
59
|
+
# The one label that sits beside another: always next to {REJECTED},
|
|
60
|
+
# since a 💣 is also a rejection. Accepted while CI is red for the same
|
|
61
|
+
# reason {REJECTED} is.
|
|
62
|
+
VULNERABILITY = named("review-vulnerability")
|
|
63
|
+
|
|
64
|
+
# The verdict for a review with no ⛔️ or 💣 but too many 🤔.
|
|
65
|
+
OK_NOT_PERFECT = named("review-ok-not-perfect")
|
|
66
|
+
|
|
67
|
+
# The verdict for everything else.
|
|
68
|
+
APPROVED = named("review-approved")
|
|
69
|
+
|
|
70
|
+
# chewie-verifier's label, and the one the coverage gate withholds.
|
|
71
|
+
READY_TO_MERGE = named("ready-to-merge")
|
|
72
|
+
|
|
73
|
+
# The engine merged it on palpatine's `auto`.
|
|
74
|
+
AUTO_MERGED = named("auto-merged")
|
|
75
|
+
|
|
76
|
+
# palpatine said `manual`, or `merge.by: human` overrode its `auto`.
|
|
77
|
+
MERGE_MANUALLY = named("merge-manually")
|
|
78
|
+
|
|
79
|
+
# Either verdict: the pull request is complete (P2).
|
|
80
|
+
COMPLETE = [AUTO_MERGED, MERGE_MANUALLY].freeze
|
|
81
|
+
|
|
82
|
+
# Not a verdict, and so not in {ALL}: {Vcs::LabelSync} never creates or
|
|
83
|
+
# recolours it. Its presence on a repository is the one sign that
|
|
84
|
+
# {Vcs::LabelMigration} has finished there. The colour of
|
|
85
|
+
# `ready-to-merge` could not be that sign: an earlier release recoloured
|
|
86
|
+
# it without moving a single pull request.
|
|
87
|
+
MIGRATED = new(name: "openlanes-r3-labels",
|
|
88
|
+
colour: "ededed",
|
|
89
|
+
meaning: "openlanes moved this repository's open PRs from M3's review labels to R3's",
|
|
90
|
+
passing: false)
|
|
91
|
+
|
|
92
|
+
# M3's names for verdicts R3 kept under another name: `review-passed`
|
|
93
|
+
# is R3's {OK_NOT_PERFECT}, and chewie's `ok-to-merge` is R3's
|
|
94
|
+
# {READY_TO_MERGE}.
|
|
95
|
+
RENAMED = { "review-passed" => OK_NOT_PERFECT, "ok-to-merge" => READY_TO_MERGE }.freeze
|
|
96
|
+
|
|
97
|
+
# M3's verdicts that R3 dropped. Nothing sets them any more, but pull
|
|
98
|
+
# requests labelled before R3 still carry them, so every new verdict
|
|
99
|
+
# takes them off: otherwise #62's `review-passed` would sit beside its
|
|
100
|
+
# next verdict, two at once. LabelSync never deletes them from the
|
|
101
|
+
# repository; that stays a person's call.
|
|
102
|
+
RETIRED = RENAMED.keys.freeze
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
# @return [Array<String>] every label name, in R3 order
|
|
106
|
+
def names = ALL.map(&:name)
|
|
107
|
+
|
|
108
|
+
# @return [Array<String>] every name a verdict replaces: R3's and {RETIRED}
|
|
109
|
+
def known = names + RETIRED
|
|
110
|
+
|
|
111
|
+
# @return [Array<ReviewLabel>] the verdicts refused while CI is red
|
|
112
|
+
def passing = ALL.select(&:passing?)
|
|
113
|
+
|
|
114
|
+
# The labels a new one takes off the pull request. Everything else in
|
|
115
|
+
# the set, {RETIRED} included, except that setting {VULNERABILITY} leaves {REJECTED} where
|
|
116
|
+
# it is, and setting {REJECTED} leaves {VULNERABILITY}: the two are
|
|
117
|
+
# set as a pair, one call each, in either order.
|
|
118
|
+
#
|
|
119
|
+
# @param label [ReviewLabel]
|
|
120
|
+
# @return [Array<String>]
|
|
121
|
+
def displaced_by(label)
|
|
122
|
+
kept = [label, *(PAIR if PAIR.include?(label))].map(&:name)
|
|
123
|
+
known - kept
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param name [String, ReviewLabel]
|
|
127
|
+
# @return [ReviewLabel]
|
|
128
|
+
# @raise [OpenLanes::Error] naming the valid labels
|
|
129
|
+
def fetch(name)
|
|
130
|
+
named(name.to_s) ||
|
|
131
|
+
raise(Error, "unknown review label #{name.to_s.inspect}; one of: #{names.join(", ")}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The two labels that may sit together.
|
|
136
|
+
PAIR = [REJECTED, VULNERABILITY].freeze
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/openlanes/plans/spec.rb
CHANGED
|
@@ -20,6 +20,7 @@ module OpenLanes
|
|
|
20
20
|
# - `openlanes create` opens an editor and writes no draft
|
|
21
21
|
# how-to-verify:
|
|
22
22
|
# - bundle exec rspec spec/openlanes/cli/create_spec.rb
|
|
23
|
+
# depends_on: ["034.00"] # plans that must be ✅ before this one starts
|
|
23
24
|
# ---
|
|
24
25
|
#
|
|
25
26
|
# The body is the specification; this is how it should be worked. A value
|
|
@@ -49,6 +50,13 @@ module OpenLanes
|
|
|
49
50
|
# @!attribute [r] how_to_verify
|
|
50
51
|
# @return [Array<String>] the author's `how-to-verify`: the commands or
|
|
51
52
|
# checks that show it is.
|
|
53
|
+
# @!attribute [r] depends_on
|
|
54
|
+
# @return [Array<String>, nil] canonical `NNN.MM` ordinals of the plans
|
|
55
|
+
# this one waits for. nil when the key is absent ("not checked yet"),
|
|
56
|
+
# which is not the same as `[]` ("checked, none").
|
|
57
|
+
# @!attribute [r] how_to_test
|
|
58
|
+
# @return [Array<String>] the items under {HOW_TO_TELL} in the body, or
|
|
59
|
+
# {#how_to_verify} when the spec has no such section (until plan 007).
|
|
52
60
|
# @!attribute [r] problems
|
|
53
61
|
# @return [Array<String>] what was ignored, and why
|
|
54
62
|
Spec = Data.define(:lane,
|
|
@@ -59,9 +67,11 @@ module OpenLanes
|
|
|
59
67
|
:requirements,
|
|
60
68
|
:completed_when,
|
|
61
69
|
:how_to_verify,
|
|
70
|
+
:depends_on,
|
|
71
|
+
:how_to_test,
|
|
62
72
|
:problems) do
|
|
63
73
|
def initialize(lane: :full, frontend: nil, depth: nil, phases: {}, suggestions: [], requirements: [],
|
|
64
|
-
completed_when: [], how_to_verify: [], problems: [])
|
|
74
|
+
completed_when: [], how_to_verify: [], how_to_test: [], depends_on: nil, problems: [])
|
|
65
75
|
super
|
|
66
76
|
end
|
|
67
77
|
|
|
@@ -106,8 +116,32 @@ module OpenLanes
|
|
|
106
116
|
COMPLETED_WHEN_KEY = "task-completed-when"
|
|
107
117
|
HOW_TO_VERIFY_KEY = "how-to-verify"
|
|
108
118
|
|
|
119
|
+
# The plans this one waits for. Quoted canonical ordinals only: YAML
|
|
120
|
+
# reads a bare `[034]` as octal 28, a silent wrong answer.
|
|
121
|
+
DEPENDS_ON_KEY = "depends_on"
|
|
122
|
+
|
|
123
|
+
# Where `how-to-verify:` lives now (decision M10): a section of the
|
|
124
|
+
# spec's body, written by its author like every other, rather than a
|
|
125
|
+
# key in its frontmatter. `resync dirs` moves the key's items here.
|
|
126
|
+
HOW_TO_VERIFY_HEADING = "## How to tell it's working?"
|
|
127
|
+
|
|
128
|
+
# The same section, by the name the publisher refuses a pull request
|
|
129
|
+
# without: plan.md must carry its copy.
|
|
130
|
+
HOW_TO_TELL = HOW_TO_VERIFY_HEADING
|
|
131
|
+
|
|
132
|
+
# The heading on a line of its own. An author's editor may type a curly apostrophe (`it’s`) or leave the
|
|
133
|
+
# question mark off; either must still find the section, not drop the
|
|
134
|
+
# checks without a word.
|
|
135
|
+
HOW_TO_VERIFY_LINE = /^##[ \t]+how to tell it['‘’]s working\??[ \t]*$/i
|
|
136
|
+
|
|
109
137
|
FILENAME = "spec.md"
|
|
110
138
|
|
|
139
|
+
# A list item under the heading; +marker+ ends at its content column.
|
|
140
|
+
LIST_ITEM = /\A(?<marker>\s*(?:[-*]|\d+\.)\s+)(?<text>.+)/
|
|
141
|
+
|
|
142
|
+
# The opening of a fenced code block, and its fence.
|
|
143
|
+
FENCE = /\A(\s*)(`{3,}|~{3,})/
|
|
144
|
+
|
|
111
145
|
class << self
|
|
112
146
|
# @param subject [#read]
|
|
113
147
|
# @return [OpenLanes::Plans::Spec]
|
|
@@ -120,15 +154,68 @@ module OpenLanes
|
|
|
120
154
|
File.file?(path) ? parse(File.read(path, encoding: "UTF-8")) : new
|
|
121
155
|
end
|
|
122
156
|
|
|
157
|
+
# The spec's first paragraph of prose, which the dashboard shows when a
|
|
158
|
+
# person hovers a plan's title: headings, comments, lists, quotes and
|
|
159
|
+
# code are passed over, so a spec that opens with `## Problem
|
|
160
|
+
# statement` answers with the problem itself.
|
|
161
|
+
#
|
|
162
|
+
# @param text [String] a whole spec.md
|
|
163
|
+
# @param limit [Integer] characters kept, the rest cut at a word
|
|
164
|
+
# @return [String] "" when the spec has no prose paragraph
|
|
165
|
+
def summary(text, limit: 600)
|
|
166
|
+
_, body = Frontmatter.split(text)
|
|
167
|
+
paragraph = body.to_s.gsub(/<!--.*?-->/m, "").split(/\n\s*\n/).map(&:strip).find do |block|
|
|
168
|
+
!block.empty? && !block.match?(/\A(?:#|>|[-*+] |\d+\. |```|~~~|\||<)/)
|
|
169
|
+
end.to_s.gsub(/\s*\n\s*/, " ")
|
|
170
|
+
paragraph.length > limit ? "#{paragraph[0, limit].sub(/\s+\S*\z/, "")}…" : paragraph
|
|
171
|
+
rescue Psych::Exception
|
|
172
|
+
""
|
|
173
|
+
end
|
|
174
|
+
|
|
123
175
|
# @param text [String] a whole spec.md
|
|
124
176
|
# @return [OpenLanes::Plans::Spec]
|
|
125
177
|
def parse(text)
|
|
126
|
-
meta, = Frontmatter.split(text)
|
|
127
|
-
from(meta)
|
|
178
|
+
meta, body = Frontmatter.split(text)
|
|
179
|
+
spec = from(meta)
|
|
180
|
+
told = section_items(body.to_s)
|
|
181
|
+
checks = (spec.how_to_verify + told).uniq
|
|
182
|
+
# how_to_test prefers the body's own section; the frontmatter key
|
|
183
|
+
# stands in until plan 007 migrates old specs.
|
|
184
|
+
spec.with(how_to_verify: checks, how_to_test: told.empty? ? spec.how_to_verify : told)
|
|
128
185
|
rescue Psych::Exception => e
|
|
129
186
|
new(problems: ["spec.md frontmatter is not valid YAML (#{e.message.lines.first.strip})"])
|
|
130
187
|
end
|
|
131
188
|
|
|
189
|
+
# Move a `how-to-verify:` frontmatter key into the body (M10): its
|
|
190
|
+
# items are appended to `## How to tell it's working?`, which is added
|
|
191
|
+
# at the end when the spec has none. The rest of the frontmatter is
|
|
192
|
+
# kept as written, comments and all, and a frontmatter left empty is
|
|
193
|
+
# dropped.
|
|
194
|
+
#
|
|
195
|
+
# @param text [String] a whole spec.md
|
|
196
|
+
# @return [String] +text+ itself when there is no key to move, or no
|
|
197
|
+
# way to remove it that leaves the other keys exactly as they were
|
|
198
|
+
def migrate_how_to_verify(text)
|
|
199
|
+
match = Frontmatter::PATTERN.match(text) or return text
|
|
200
|
+
meta, = Frontmatter.split(text)
|
|
201
|
+
return text unless meta.key?(HOW_TO_VERIFY_KEY)
|
|
202
|
+
|
|
203
|
+
front = without_key("#{match[1]}\n", HOW_TO_VERIFY_KEY, meta) or return text
|
|
204
|
+
items = notes(meta, HOW_TO_VERIFY_KEY, [])
|
|
205
|
+
head = front.strip.empty? ? "" : "---\n#{front}---\n"
|
|
206
|
+
"#{head}#{append_checks(match[2], items)}"
|
|
207
|
+
rescue Psych::Exception
|
|
208
|
+
text
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# One check as a Markdown list item. A check written as a YAML block
|
|
212
|
+
# scalar spans lines; its continuation lines are indented under the
|
|
213
|
+
# item, so they stay part of it rather than becoming loose prose.
|
|
214
|
+
#
|
|
215
|
+
# @param item [String]
|
|
216
|
+
# @return [String] without a trailing newline
|
|
217
|
+
def list_item(item) = "- #{item.gsub("\n", "\n ")}"
|
|
218
|
+
|
|
132
219
|
# @param meta [Hash]
|
|
133
220
|
# @return [OpenLanes::Plans::Spec]
|
|
134
221
|
def from(meta)
|
|
@@ -141,11 +228,140 @@ module OpenLanes
|
|
|
141
228
|
requirements: notes(meta, REQUIREMENTS_KEY, problems),
|
|
142
229
|
completed_when: notes(meta, COMPLETED_WHEN_KEY, problems),
|
|
143
230
|
how_to_verify: notes(meta, HOW_TO_VERIFY_KEY, problems),
|
|
231
|
+
depends_on: depends_on(meta, problems),
|
|
232
|
+
how_to_test: notes(meta, HOW_TO_VERIFY_KEY, []),
|
|
144
233
|
problems:)
|
|
145
234
|
end
|
|
146
235
|
|
|
147
236
|
private
|
|
148
237
|
|
|
238
|
+
# The YAML source of +yaml+ with +key+'s entry cut out, from the
|
|
239
|
+
# key's line to the end of its value as Psych located them. A regex
|
|
240
|
+
# over the text took a blank line between list items for the end of
|
|
241
|
+
# the entry, leaving the later items behind, and never matched a
|
|
242
|
+
# quoted key at all. The cut is kept only when what is left loads as
|
|
243
|
+
# the same mapping less that key; anything else (a flow mapping, a key
|
|
244
|
+
# sharing a line) is not cut.
|
|
245
|
+
#
|
|
246
|
+
# @param yaml [String]
|
|
247
|
+
# @param key [String]
|
|
248
|
+
# @param meta [Hash] +yaml+, loaded
|
|
249
|
+
# @return [String, nil]
|
|
250
|
+
def without_key(yaml, key, meta)
|
|
251
|
+
root = Psych.parse(yaml)&.root
|
|
252
|
+
return nil unless root.is_a?(Psych::Nodes::Mapping)
|
|
253
|
+
|
|
254
|
+
name, value = root.children.each_slice(2).find { |k, _| k.is_a?(Psych::Nodes::Scalar) && k.value == key }
|
|
255
|
+
return nil unless name
|
|
256
|
+
|
|
257
|
+
stop = value.end_column.zero? ? value.end_line : value.end_line + 1
|
|
258
|
+
lines = yaml.lines
|
|
259
|
+
rest = (lines[0...name.start_line] + lines[stop..].to_a).join
|
|
260
|
+
left, = Frontmatter.split("---\n#{rest}\n---\n")
|
|
261
|
+
rest if left == meta.except(key)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The checks under `## How to tell it's working?`, in document order,
|
|
265
|
+
# up to the next heading of the same level. The owner wants how-to-test
|
|
266
|
+
# as specific and technical as it can be, and prose can be just as
|
|
267
|
+
# specific as a bullet (2026-10-14, on M10), so prose is a check too
|
|
268
|
+
# rather than the author's aside that the agents never see:
|
|
269
|
+
#
|
|
270
|
+
# - A list item is one check, with its indented continuation lines and
|
|
271
|
+
# any fenced block indented under it, blank lines in the fence
|
|
272
|
+
# included. Its lines lose the item's indent and keep the rest, so
|
|
273
|
+
# a command's own indentation survives.
|
|
274
|
+
# - A prose paragraph (consecutive non-blank, non-list lines) is one
|
|
275
|
+
# check, its lines as written.
|
|
276
|
+
# - A fenced block not indented under an item joins the prose
|
|
277
|
+
# paragraph just before it, or is a check of its own.
|
|
278
|
+
#
|
|
279
|
+
# Inside a fence nothing is a list item or a heading, so a
|
|
280
|
+
# `- not an item` line there stays in the code. {list_item} writes any
|
|
281
|
+
# of these back as one item, which this reads back unchanged.
|
|
282
|
+
#
|
|
283
|
+
# @param body [String]
|
|
284
|
+
# @return [Array<String>]
|
|
285
|
+
def section_items(body)
|
|
286
|
+
start = body =~ HOW_TO_VERIFY_LINE or return []
|
|
287
|
+
checks = []
|
|
288
|
+
state = { kind: nil, indent: 0, fence: nil }
|
|
289
|
+
body[start..].lines.drop(1).each do |line|
|
|
290
|
+
line = line.chomp
|
|
291
|
+
if state[:fence]
|
|
292
|
+
fence_line(checks, state, line)
|
|
293
|
+
elsif line.start_with?("## ")
|
|
294
|
+
break
|
|
295
|
+
else
|
|
296
|
+
section_line(checks, state, line)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
checks.map(&:strip)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A line inside a fenced block, the closing fence included.
|
|
303
|
+
def fence_line(checks, state, line)
|
|
304
|
+
checks[-1] = "#{checks.last}\n#{state[:kind] == :item ? dedent(line, state[:indent]) : line}"
|
|
305
|
+
fence = state[:fence]
|
|
306
|
+
state[:fence] = nil if line.strip.match?(/\A#{Regexp.escape(fence[0])}{#{fence.length},}\z/)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# A line outside any fence: it opens a fence, an item or a paragraph,
|
|
310
|
+
# or continues the one it follows.
|
|
311
|
+
def section_line(checks, state, line)
|
|
312
|
+
if (fence = line[FENCE, 2])
|
|
313
|
+
open_fence(checks, state, line, fence)
|
|
314
|
+
elsif (item = line.match(LIST_ITEM))
|
|
315
|
+
checks << item[:text]
|
|
316
|
+
state.merge!(kind: :item, indent: item.end(:marker))
|
|
317
|
+
elsif line.strip.empty?
|
|
318
|
+
state[:kind] = :after_prose if state[:kind] == :prose
|
|
319
|
+
elsif state[:kind] == :item && line.match?(/\A[ \t]/)
|
|
320
|
+
checks[-1] = "#{checks.last}\n#{dedent(line, state[:indent])}"
|
|
321
|
+
elsif state[:kind] == :prose
|
|
322
|
+
checks[-1] = "#{checks.last}\n#{line}"
|
|
323
|
+
else
|
|
324
|
+
checks << line
|
|
325
|
+
state[:kind] = :prose
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# An indented fence under an open item belongs to it; any other joins
|
|
330
|
+
# the paragraph before it, or starts a check of its own.
|
|
331
|
+
def open_fence(checks, state, line, fence)
|
|
332
|
+
if state[:kind] == :item && line.match?(/\A[ \t]/)
|
|
333
|
+
checks[-1] = "#{checks.last}\n#{dedent(line, state[:indent])}"
|
|
334
|
+
elsif %i[prose after_prose].include?(state[:kind])
|
|
335
|
+
checks[-1] = "#{checks.last}\n#{line}"
|
|
336
|
+
state[:kind] = :prose
|
|
337
|
+
else
|
|
338
|
+
checks << line
|
|
339
|
+
state[:kind] = :prose
|
|
340
|
+
end
|
|
341
|
+
state[:fence] = fence
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# +line+ less up to +indent+ leading blanks.
|
|
345
|
+
def dedent(line, indent) = line.sub(/\A[ \t]{0,#{indent}}/, "")
|
|
346
|
+
|
|
347
|
+
# @param body [String]
|
|
348
|
+
# @param items [Array<String>]
|
|
349
|
+
# @return [String]
|
|
350
|
+
def append_checks(body, items)
|
|
351
|
+
list = items.map { "#{list_item(it)}\n" }.join
|
|
352
|
+
body = "#{body}\n" unless body.end_with?("\n")
|
|
353
|
+
start = body =~ HOW_TO_VERIFY_LINE
|
|
354
|
+
return "#{body.rstrip}\n\n#{HOW_TO_VERIFY_HEADING}\n\n#{list}" unless start
|
|
355
|
+
|
|
356
|
+
heading_end = start + body[start..].index("\n").to_i + 1
|
|
357
|
+
rest = body[heading_end..].to_s
|
|
358
|
+
stop = rest =~ /^## / || rest.length
|
|
359
|
+
section = rest[0...stop].rstrip
|
|
360
|
+
joined = section.empty? ? "\n#{list}" : "#{section}\n#{list}"
|
|
361
|
+
tail = stop < rest.length ? "\n#{rest[stop..]}" : ""
|
|
362
|
+
"#{body[0...heading_end]}#{joined}#{tail}"
|
|
363
|
+
end
|
|
364
|
+
|
|
149
365
|
# @return [Symbol, nil]
|
|
150
366
|
def choice(meta, key, allowed, default, problems)
|
|
151
367
|
return default unless meta.key?(key)
|
|
@@ -182,6 +398,26 @@ module OpenLanes
|
|
|
182
398
|
items.map { |item| item.to_s.strip }.reject(&:empty?)
|
|
183
399
|
end
|
|
184
400
|
|
|
401
|
+
# Each entry must be a string {Ordinal} can read; anything else is
|
|
402
|
+
# dropped and named, never coerced, since `034` has already become 28
|
|
403
|
+
# by the time it reaches here. `"034"` is normalised to `"034.00"`, so
|
|
404
|
+
# every caller compares one form.
|
|
405
|
+
#
|
|
406
|
+
# @return [Array<String>, nil] nil when the key is absent
|
|
407
|
+
def depends_on(meta, problems)
|
|
408
|
+
return nil unless meta.key?(DEPENDS_ON_KEY)
|
|
409
|
+
|
|
410
|
+
value = meta[DEPENDS_ON_KEY]
|
|
411
|
+
items = value.nil? ? [] : Array(value)
|
|
412
|
+
items.filter_map do |item|
|
|
413
|
+
ordinal = item.is_a?(String) && Ordinal.parse(item)
|
|
414
|
+
next ordinal.to_s if ordinal
|
|
415
|
+
|
|
416
|
+
problems << "#{DEPENDS_ON_KEY}: #{item.inspect} is not a quoted plan number like \"034.00\""
|
|
417
|
+
nil
|
|
418
|
+
end.uniq.sort
|
|
419
|
+
end
|
|
420
|
+
|
|
185
421
|
# @return [Hash]
|
|
186
422
|
def phases(value, problems)
|
|
187
423
|
return {} if value.nil?
|