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
|
@@ -43,7 +43,9 @@ module OpenLanes
|
|
|
43
43
|
building: :building_ui,
|
|
44
44
|
building_ui: :ready_for_review,
|
|
45
45
|
ready_for_review: :in_review,
|
|
46
|
-
in_review: :
|
|
46
|
+
in_review: :verifying,
|
|
47
|
+
verifying: :completed,
|
|
48
|
+
completed: :approved,
|
|
47
49
|
approved: :deployed,
|
|
48
50
|
rejected: :building_ui,
|
|
49
51
|
rolled_back: :ready_for_review,
|
|
@@ -52,8 +54,8 @@ module OpenLanes
|
|
|
52
54
|
|
|
53
55
|
# One step back along the spine, for the one move that goes backwards: a
|
|
54
56
|
# person discarding an interrupted round (W9) sends the plan back to the
|
|
55
|
-
# state the work started from. Derived from {SPINE}, walking it from
|
|
56
|
-
# each state on that path steps back to the one before it, as far as
|
|
57
|
+
# state the work started from. Derived from {SPINE}, walking it from ⚪:
|
|
58
|
+
# each state on that path steps back to the one before it, as far as 🏁,
|
|
57
59
|
# the last state an agent works in. States off the path (🔴 💩 ⏪ and
|
|
58
60
|
# the birth state) have no single state behind them and step back
|
|
59
61
|
# nowhere.
|
|
@@ -67,8 +69,8 @@ module OpenLanes
|
|
|
67
69
|
# a block outranks progress, and `retroactive` is last because it is the
|
|
68
70
|
# absence of documents rather than the presence of any.
|
|
69
71
|
PREFERENCE = %i[
|
|
70
|
-
discarded rolled_back shit deferred blocked product_blocked
|
|
71
|
-
deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
|
|
72
|
+
discarded rolled_back shit deferred blocked product_blocked bomb
|
|
73
|
+
deployed approved rejected completed verifying in_review ready_for_review building_ui building planned ready_for_planning
|
|
72
74
|
researched new
|
|
73
75
|
retroactive
|
|
74
76
|
].freeze
|
|
@@ -77,10 +79,10 @@ module OpenLanes
|
|
|
77
79
|
# family the current name always wins, because re-deriving it would be a
|
|
78
80
|
# guess dressed up as a correction.
|
|
79
81
|
#
|
|
80
|
-
#
|
|
82
|
+
# ⭕ and 🛑 both mean "a human must decide before this moves"; *which*
|
|
81
83
|
# human is recorded nowhere but the emoji.
|
|
82
84
|
#
|
|
83
|
-
#
|
|
85
|
+
# ⭐ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
|
|
84
86
|
# units in it. Whether nobody has started, the back end is under way, or
|
|
85
87
|
# the interface is, is recorded only by the agent that renamed the folder
|
|
86
88
|
# when it started. 🟢 👀 🔴 add an open pull request and are just as
|
|
@@ -91,16 +93,16 @@ module OpenLanes
|
|
|
91
93
|
# arriving from outside lands on the weakest member its contents fit.
|
|
92
94
|
# Contents that fit the family justify only its floor, never its ceiling.
|
|
93
95
|
FAMILIES = [
|
|
94
|
-
%i[blocked product_blocked],
|
|
95
|
-
%i[planned building building_ui ready_for_review in_review rejected]
|
|
96
|
+
%i[blocked product_blocked bomb],
|
|
97
|
+
%i[planned building building_ui ready_for_review in_review verifying completed rejected]
|
|
96
98
|
].freeze
|
|
97
99
|
|
|
98
100
|
# States the agent loop leaves alone: work that is finished (✅ 😎), work
|
|
99
|
-
# that was dropped (❌), and work waiting on a human (
|
|
101
|
+
# that was dropped (❌), and work waiting on a human (⭕ 🛑 💣 💤).
|
|
100
102
|
# Everything else is fair game for a specialist.
|
|
101
103
|
#
|
|
102
|
-
# ✅ Approved is here deliberately.
|
|
103
|
-
#
|
|
104
|
+
# ✅ Approved is here deliberately. A plan reaching it, from 🧪 once its
|
|
105
|
+
# pull request merged, is the end of the loop, not a step in it: no agent
|
|
104
106
|
# handles `approved`. Merging is the one act in this lifecycle that changes
|
|
105
107
|
# a branch everyone else builds on, and an autonomous loop that does it
|
|
106
108
|
# unattended has no way to be wrong quietly.
|
|
@@ -108,7 +110,7 @@ module OpenLanes
|
|
|
108
110
|
# Moving `approved` out of this list is therefore a decision about blast
|
|
109
111
|
# radius rather than about topology. If it ever moves, something has to own
|
|
110
112
|
# `approved -> deployed`, and today nothing does.
|
|
111
|
-
SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
|
|
113
|
+
SETTLED = %i[approved deployed discarded blocked product_blocked bomb deferred].freeze
|
|
112
114
|
|
|
113
115
|
# Every state an agent that may sign `Blocked` works in. A block the
|
|
114
116
|
# machine refuses is reported as a failed attempt and parks nothing, so a
|
|
@@ -127,19 +129,19 @@ module OpenLanes
|
|
|
127
129
|
OpenLanes::Plans::STATUSES.each { |status| state status.key }
|
|
128
130
|
|
|
129
131
|
event :specify, guard: :justified? do
|
|
130
|
-
transitions from: %i[retroactive blocked product_blocked deferred], to: :new
|
|
132
|
+
transitions from: %i[retroactive blocked product_blocked bomb deferred], to: :new
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
event :research, guard: :justified? do
|
|
134
|
-
transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
|
|
136
|
+
transitions from: %i[new retroactive blocked product_blocked bomb deferred], to: :researched
|
|
135
137
|
end
|
|
136
138
|
|
|
137
139
|
# The specification is done and the planner has not started. Entered
|
|
138
140
|
# by the harness when `yoda-writer` completes; `resync` reaches it on
|
|
139
|
-
# a blank `plan.md`. Without it
|
|
140
|
-
# agent handling
|
|
141
|
+
# a blank `plan.md`. Without it ⭐ Planned required a file only the
|
|
142
|
+
# agent handling ⭐ Planned writes, and nothing could enter it.
|
|
141
143
|
event :ready_to_plan, guard: :justified? do
|
|
142
|
-
transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
|
|
144
|
+
transitions from: %i[researched new retroactive shit blocked product_blocked bomb deferred],
|
|
143
145
|
to: :ready_for_planning
|
|
144
146
|
end
|
|
145
147
|
|
|
@@ -149,12 +151,12 @@ module OpenLanes
|
|
|
149
151
|
# planned. What research buys is not enforced here; it is enforced by
|
|
150
152
|
# `yoda-writer` handling `researched` and nothing else.
|
|
151
153
|
event :plan, guard: :justified? do
|
|
152
|
-
transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
|
|
154
|
+
transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked bomb deferred],
|
|
153
155
|
to: :planned
|
|
154
156
|
end
|
|
155
157
|
|
|
156
158
|
event :build, guard: :justified? do
|
|
157
|
-
transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
|
|
159
|
+
transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked bomb deferred],
|
|
158
160
|
to: :building
|
|
159
161
|
end
|
|
160
162
|
|
|
@@ -178,12 +180,34 @@ module OpenLanes
|
|
|
178
180
|
transitions from: %i[ready_for_review], to: :in_review
|
|
179
181
|
end
|
|
180
182
|
|
|
183
|
+
# hansolo's approval no longer ends the loop (plan 014, M1): the app is
|
|
184
|
+
# run through the pull request's `## How to test` first, by
|
|
185
|
+
# chewie-verifier, before anything may merge.
|
|
186
|
+
event :verify, guard: :justified? do
|
|
187
|
+
transitions from: %i[in_review], to: :verifying
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Every pull request is `ready-to-merge` (P1): palpatine weighs each
|
|
191
|
+
# one, and the engine merges them one at a time.
|
|
192
|
+
event :complete, guard: :justified? do
|
|
193
|
+
transitions from: %i[verifying], to: :completed
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# `in_review` stays a source: a person who merges on GitHub while
|
|
197
|
+
# hansolo is still reading has approved it all the same (M4). So do
|
|
198
|
+
# 🧪 and 🏁, for a merge before the engine or palpatine has ruled,
|
|
199
|
+
# and for the `merge-manually` pull request a person merges (P3).
|
|
181
200
|
event :approve, guard: :justified? do
|
|
182
|
-
transitions from: %i[in_review retroactive], to: :approved
|
|
201
|
+
transitions from: %i[in_review verifying completed retroactive], to: :approved
|
|
183
202
|
end
|
|
184
203
|
|
|
204
|
+
# From 🧪 as well as 👀: a verification that fails sends the plan back
|
|
205
|
+
# to the builders exactly as a review that fails does. From 🏁 too:
|
|
206
|
+
# a pull request that no longer rebases cleanly onto `main` after
|
|
207
|
+
# its sibling merged goes back to luke and rey (P5), and a person may
|
|
208
|
+
# still reject one on GitHub while it waits for them.
|
|
185
209
|
event :request_changes, guard: :justified? do
|
|
186
|
-
transitions from: %i[in_review], to: :rejected
|
|
210
|
+
transitions from: %i[in_review verifying completed], to: :rejected
|
|
187
211
|
end
|
|
188
212
|
|
|
189
213
|
event :slop, guard: :justified? do
|
|
@@ -202,12 +226,21 @@ module OpenLanes
|
|
|
202
226
|
transitions from: %i[deployed], to: :rolled_back
|
|
203
227
|
end
|
|
204
228
|
|
|
229
|
+
# 💣 leaves back through the block it came from: once an answer is in,
|
|
230
|
+
# `blocked.md` says whether the question was technical or product, and
|
|
231
|
+
# the ordinary unblock path takes it from there.
|
|
205
232
|
event :block, guard: :justified? do
|
|
206
|
-
transitions from: BLOCKABLE, to: :blocked
|
|
233
|
+
transitions from: BLOCKABLE + %i[bomb], to: :blocked
|
|
207
234
|
end
|
|
208
235
|
|
|
209
236
|
event :block_on_product, guard: :justified? do
|
|
210
|
-
transitions from: BLOCKABLE, to: :product_blocked
|
|
237
|
+
transitions from: BLOCKABLE + %i[bomb], to: :product_blocked
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fired by the aging sweep alone, never by `resync`: a plan others
|
|
241
|
+
# depend on has sat blocked past the limit, and the run halts on it.
|
|
242
|
+
event :bomb, guard: :justified? do
|
|
243
|
+
transitions from: %i[blocked product_blocked], to: :bomb
|
|
211
244
|
end
|
|
212
245
|
|
|
213
246
|
event :defer, guard: :justified? do
|
|
@@ -372,21 +405,21 @@ module OpenLanes
|
|
|
372
405
|
# The state a folder's contents justify — the answer to "well, what should
|
|
373
406
|
# it be, then?". This is what `resync dirs` renames toward.
|
|
374
407
|
#
|
|
375
|
-
# Invariants are *minimum* requirements, not exact matches: a
|
|
376
|
-
# that has since grown a `plan.md` still satisfies
|
|
377
|
-
#
|
|
408
|
+
# Invariants are *minimum* requirements, not exact matches: a ⚪ folder
|
|
409
|
+
# that has since grown a `plan.md` still satisfies ⚪, and is nonetheless
|
|
410
|
+
# ⭐ now. So the furthest-justified state wins, and the caller compares it
|
|
378
411
|
# against the current one to decide whether anything should move.
|
|
379
412
|
#
|
|
380
413
|
# The exception is a {FAMILIES family}, whose members contents cannot tell
|
|
381
414
|
# apart. Two rules cover it:
|
|
382
415
|
#
|
|
383
416
|
# - **Already inside one** — the current name wins. Re-deriving it would be
|
|
384
|
-
# a guess dressed up as a correction, and every
|
|
417
|
+
# a guess dressed up as a correction, and every ⭕ would become 🛑.
|
|
385
418
|
# - **Arriving from outside** — the *weakest* member the contents fit
|
|
386
419
|
# wins, not the furthest. A 📋 folder whose `plan.md` grew work units
|
|
387
|
-
# lands on
|
|
420
|
+
# lands on ⭐, not 🟡, because nobody has started. A ✅ folder found
|
|
388
421
|
# with an open pull request is demonstrably back in the PR phase, and
|
|
389
|
-
#
|
|
422
|
+
# ⭐ refuses an open pull request, so it lands on 🟡 rather than
|
|
390
423
|
# claiming 🔴.
|
|
391
424
|
#
|
|
392
425
|
# @return [OpenLanes::Plans::Status, nil] nil when nothing fits at all
|
|
@@ -57,7 +57,7 @@ module OpenLanes
|
|
|
57
57
|
RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
|
|
58
58
|
|
|
59
59
|
# What proves `plan.md` holds a plan rather than the blank file
|
|
60
|
-
# `yoda-writer` leaves for `
|
|
60
|
+
# `yoda-writer` leaves for `luthen-rael-planner`: any section heading. The
|
|
61
61
|
# ledger notes are stripped first, so an agent's `Started` line does not
|
|
62
62
|
# count as a plan.
|
|
63
63
|
PLAN_HEADING = /^[ \t]{0,3}\#{1,3}[ \t]+\S/
|
|
@@ -143,7 +143,7 @@ module OpenLanes
|
|
|
143
143
|
STATUSES = [
|
|
144
144
|
Status.new(
|
|
145
145
|
key: :new,
|
|
146
|
-
emoji: "
|
|
146
|
+
emoji: "⚪",
|
|
147
147
|
label: "New",
|
|
148
148
|
requires: %w[spec.md],
|
|
149
149
|
note: "a specification exists; it has not been planned yet",
|
|
@@ -173,7 +173,7 @@ module OpenLanes
|
|
|
173
173
|
),
|
|
174
174
|
Status.new(
|
|
175
175
|
key: :planned,
|
|
176
|
-
emoji: "
|
|
176
|
+
emoji: "⭐",
|
|
177
177
|
label: "Planned",
|
|
178
178
|
requires: %w[spec.md plan.md],
|
|
179
179
|
note: "specified and planned; nobody has started building",
|
|
@@ -193,7 +193,7 @@ module OpenLanes
|
|
|
193
193
|
# written by opening a pull request, which happens once, when the last
|
|
194
194
|
# implementer advances the plan to Ready for Review. Requiring the file to
|
|
195
195
|
# *enter* Building would mean nothing could ever justify entering it, and
|
|
196
|
-
# `
|
|
196
|
+
# `luthen-rael-planner` finishing `plan.md` would loop forever between itself
|
|
197
197
|
# and a state it can never actually reach. Ready for Review is where the
|
|
198
198
|
# file becomes a real requirement, once something has had the chance to
|
|
199
199
|
# write it.
|
|
@@ -240,6 +240,28 @@ module OpenLanes
|
|
|
240
240
|
note: "a reviewer has picked it up and has not ruled yet",
|
|
241
241
|
invariant: open_pull_request("In Review")
|
|
242
242
|
),
|
|
243
|
+
Status.new(
|
|
244
|
+
key: :verifying,
|
|
245
|
+
emoji: "🧪",
|
|
246
|
+
label: "Verifying",
|
|
247
|
+
requires: %w[spec.md plan.md pull-requests.md],
|
|
248
|
+
note: "the review approved it; chewie runs the app through How to test before anything merges",
|
|
249
|
+
invariant: open_pull_request("Verifying")
|
|
250
|
+
),
|
|
251
|
+
# Completed is not done. `ready-to-merge` is a pull request's state
|
|
252
|
+
# (P2), and once every pull request of the plan carries it the plan is
|
|
253
|
+
# 🏁: palpatine rules on each, the engine merges the `auto` ones one at
|
|
254
|
+
# a time and rebases the rest onto `main`, and `merge-manually` ones
|
|
255
|
+
# wait for a person. At least one is still open, or the plan would be
|
|
256
|
+
# ✅ (P3).
|
|
257
|
+
Status.new(
|
|
258
|
+
key: :completed,
|
|
259
|
+
emoji: "🏁",
|
|
260
|
+
label: "Completed",
|
|
261
|
+
requires: %w[spec.md plan.md pull-requests.md],
|
|
262
|
+
note: "every pull request is `ready-to-merge`; palpatine weighs each, and they merge one at a time",
|
|
263
|
+
invariant: open_pull_request("Completed")
|
|
264
|
+
),
|
|
243
265
|
Status.new(
|
|
244
266
|
key: :rejected,
|
|
245
267
|
emoji: "🔴",
|
|
@@ -287,7 +309,7 @@ module OpenLanes
|
|
|
287
309
|
),
|
|
288
310
|
Status.new(
|
|
289
311
|
key: :blocked,
|
|
290
|
-
emoji: "
|
|
312
|
+
emoji: "⭕",
|
|
291
313
|
label: "Technical Block",
|
|
292
314
|
requires: %w[blocked.md],
|
|
293
315
|
note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
|
|
@@ -295,15 +317,28 @@ module OpenLanes
|
|
|
295
317
|
),
|
|
296
318
|
Status.new(
|
|
297
319
|
key: :product_blocked,
|
|
298
|
-
emoji: "
|
|
320
|
+
emoji: "🛑",
|
|
299
321
|
label: "Product Block",
|
|
300
322
|
requires: %w[blocked.md],
|
|
301
323
|
note: "cannot proceed; `blocked.md` names what a product manager must decide",
|
|
302
324
|
invariant: open_block("Product Block")
|
|
303
325
|
),
|
|
326
|
+
# 💣 is entered only by the aging sweep's `bomb` event, never by what a
|
|
327
|
+
# folder holds: its contents are a ⭕'s, and a check that read the clock
|
|
328
|
+
# would rename folders as days pass with nobody firing anything. Its
|
|
329
|
+
# check is a block's, open question and all: a weaker one would let
|
|
330
|
+
# `resync` carry a drained ⭕ sideways into 💣 instead of out.
|
|
331
|
+
Status.new(
|
|
332
|
+
key: :bomb,
|
|
333
|
+
emoji: "💣",
|
|
334
|
+
label: "Bomb",
|
|
335
|
+
requires: %w[blocked.md],
|
|
336
|
+
note: "blocked too long while other plans depend on it; the run halts until a human answers",
|
|
337
|
+
invariant: open_block("Bomb")
|
|
338
|
+
),
|
|
304
339
|
Status.new(
|
|
305
340
|
key: :deferred,
|
|
306
|
-
emoji: "
|
|
341
|
+
emoji: "💤",
|
|
307
342
|
label: "Deferred",
|
|
308
343
|
requires: %w[delayed.md],
|
|
309
344
|
note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
|
|
@@ -314,7 +349,7 @@ module OpenLanes
|
|
|
314
349
|
),
|
|
315
350
|
Status.new(
|
|
316
351
|
key: :retroactive,
|
|
317
|
-
emoji: "
|
|
352
|
+
emoji: "⏪",
|
|
318
353
|
label: "Retroactive",
|
|
319
354
|
requires: [],
|
|
320
355
|
note: "the feature is live, but has neither a specification nor a plan",
|
|
@@ -334,12 +369,26 @@ module OpenLanes
|
|
|
334
369
|
)
|
|
335
370
|
].freeze
|
|
336
371
|
|
|
337
|
-
# Emoji => status, folded so ⚪ and ⚪️ both resolve.
|
|
338
|
-
STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
|
|
339
|
-
|
|
340
372
|
# Symbol => status.
|
|
341
373
|
STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
|
|
342
374
|
|
|
375
|
+
# The emoji each state wore before every one became a single code point
|
|
376
|
+
# (decision N2). Folders, ledgers and snapshots written before then still
|
|
377
|
+
# carry them, and a plan named with one must keep its state until
|
|
378
|
+
# `resync dirs` renames it. Read only, never written. `⚪️ ⭐️ ⭕️` fold onto
|
|
379
|
+
# their successors anyway; the rest are different characters altogether,
|
|
380
|
+
# and `🅱` appears both with and without its variation selector.
|
|
381
|
+
LEGACY_EMOJI = {
|
|
382
|
+
"⚪️" => :new, "⭐️" => :planned, "⭕️" => :blocked, "🅱️" => :product_blocked, "🅱" => :product_blocked,
|
|
383
|
+
"☢️" => :deferred, "🕰️" => :retroactive
|
|
384
|
+
}.freeze
|
|
385
|
+
|
|
386
|
+
# Emoji => status, folded so ⚪ and ⚪️ both resolve, and so does every
|
|
387
|
+
# {LEGACY_EMOJI} spelling.
|
|
388
|
+
STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }
|
|
389
|
+
.merge(LEGACY_EMOJI.to_h { |e, key| [fold_emoji(e), STATUS_BY_KEY.fetch(key)] }) { |_, now, _| now }
|
|
390
|
+
.freeze
|
|
391
|
+
|
|
343
392
|
# @param emoji [String, nil]
|
|
344
393
|
# @return [OpenLanes::Plans::Status, nil]
|
|
345
394
|
def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
|
|
@@ -122,10 +122,14 @@ module OpenLanes
|
|
|
122
122
|
raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
from = @feature.status
|
|
125
126
|
@reads = {}
|
|
126
127
|
@pull_requests = nil
|
|
127
128
|
@feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
|
|
128
129
|
PlanState.for(target).state!(status.key)
|
|
130
|
+
# Every transition passes through here, whoever fires it, so this is
|
|
131
|
+
# the one line in the log that says a plan changed state.
|
|
132
|
+
UI.log("moved: #{from} → #{status}", plan: @feature.ordinal.to_s)
|
|
129
133
|
@feature
|
|
130
134
|
end
|
|
131
135
|
end
|
data/lib/openlanes/plans/tree.rb
CHANGED
|
@@ -3,20 +3,71 @@
|
|
|
3
3
|
module OpenLanes
|
|
4
4
|
module Plans
|
|
5
5
|
# A project's `.plans` directory: the folders in it, decoded and ordered.
|
|
6
|
+
#
|
|
7
|
+
# Finished, deferred and discarded plans live one level down, in the
|
|
8
|
+
# subfolders {INACTIVE_DIRS} names (decision N3), so the top level holds
|
|
9
|
+
# only the plans somebody may still work on. A tree reads the top level
|
|
10
|
+
# by default; `all: true` reads the subfolders too. Plan numbers span
|
|
11
|
+
# both, since a number is never reused.
|
|
6
12
|
class Tree
|
|
13
|
+
# Where a plan in an inactive state lives, keyed by status key. The one
|
|
14
|
+
# table `resync dirs` moves folders by and every reader skips by.
|
|
15
|
+
INACTIVE_DIRS = {
|
|
16
|
+
approved: "archive",
|
|
17
|
+
deployed: "archive",
|
|
18
|
+
deferred: "deferred",
|
|
19
|
+
discarded: "abandoned"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# The subfolders themselves, in the order `list-plans --all` shows them.
|
|
23
|
+
SUBFOLDERS = INACTIVE_DIRS.values.uniq.freeze
|
|
24
|
+
|
|
25
|
+
# @param status [OpenLanes::Plans::Status]
|
|
26
|
+
# @return [String, nil] the subfolder a plan in this state belongs in, nil for the top level
|
|
27
|
+
def self.subfolder_for(status) = INACTIVE_DIRS[status.key]
|
|
28
|
+
|
|
29
|
+
# The plan folder a repository-relative path is inside, looking through
|
|
30
|
+
# an inactive subfolder: `.plans/archive/003.00━━✅━━❯[…]━━x/spec.md`
|
|
31
|
+
# names the plan `003.00━━✅━━❯[…]━━x`, not a plan called `archive`.
|
|
32
|
+
#
|
|
33
|
+
# @param path [String] e.g. a path from a pull request's diff
|
|
34
|
+
# @return [String, nil] the folder's name, nil when the path is in no plan
|
|
35
|
+
def self.plan_folder(path)
|
|
36
|
+
parts = path.to_s.split("/")
|
|
37
|
+
index = parts.index(OpenLanes::PLANS_DIR) or return nil
|
|
38
|
+
parts = parts.drop(index + 1)
|
|
39
|
+
parts = parts.drop(1) if SUBFOLDERS.include?(parts.first)
|
|
40
|
+
parts.length >= 2 ? parts.first : nil # a file directly under .plans belongs to no plan
|
|
41
|
+
end
|
|
42
|
+
|
|
7
43
|
# @param dir [String] the `.plans` directory
|
|
8
|
-
|
|
44
|
+
# @param all [Boolean] also read the inactive subfolders
|
|
45
|
+
def initialize(dir:, all: false)
|
|
9
46
|
@dir = File.expand_path(dir)
|
|
47
|
+
@all = all
|
|
10
48
|
end
|
|
11
49
|
|
|
12
50
|
# @return [String] absolute path to the `.plans` directory
|
|
13
51
|
attr_reader :dir
|
|
14
52
|
|
|
53
|
+
# @return [Boolean] whether the inactive subfolders are read too
|
|
54
|
+
def all? = @all
|
|
55
|
+
|
|
56
|
+
# The same `.plans`, inactive subfolders included.
|
|
57
|
+
#
|
|
58
|
+
# @return [OpenLanes::Plans::Tree]
|
|
59
|
+
def everything = all? ? self : (@everything ||= Tree.new(dir:, all: true))
|
|
60
|
+
|
|
15
61
|
# @return [Boolean]
|
|
16
62
|
def exist? = File.directory?(dir)
|
|
17
63
|
|
|
18
|
-
#
|
|
19
|
-
|
|
64
|
+
# Open plans first, then each subfolder in {SUBFOLDERS} order; by
|
|
65
|
+
# number within each group.
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<OpenLanes::Plans::Feature>]
|
|
68
|
+
def features
|
|
69
|
+
@features ||= directories.filter_map { |p| Feature.parse(p) }.sort_by { |f| [group(f), f] }
|
|
70
|
+
end
|
|
20
71
|
|
|
21
72
|
# @return [Array<OpenLanes::Plans::Subject>] ordered by number
|
|
22
73
|
def subjects = @subjects ||= features.map { |f| Subject.new(f) }
|
|
@@ -24,6 +75,12 @@ module OpenLanes
|
|
|
24
75
|
# @return [Array<OpenLanes::Plans::Ordinal>]
|
|
25
76
|
def ordinals = features.map(&:ordinal)
|
|
26
77
|
|
|
78
|
+
# Every number in use, the inactive subfolders included: what a new
|
|
79
|
+
# plan's number must stay clear of.
|
|
80
|
+
#
|
|
81
|
+
# @return [Array<OpenLanes::Plans::Ordinal>]
|
|
82
|
+
def taken = everything.ordinals
|
|
83
|
+
|
|
27
84
|
# Directories that carry no plan number. Reported, never processed.
|
|
28
85
|
#
|
|
29
86
|
# @return [Array<String>]
|
|
@@ -36,11 +93,13 @@ module OpenLanes
|
|
|
36
93
|
# make every one of those joins ambiguous, and nothing downstream can tell
|
|
37
94
|
# which folder a `[002.00]` pull request belongs to.
|
|
38
95
|
#
|
|
96
|
+
# An archived plan still holds its number, so the subfolders count.
|
|
97
|
+
#
|
|
39
98
|
# @return [Hash{OpenLanes::Plans::Ordinal => Array<String>}] number => dirnames
|
|
40
99
|
def duplicates
|
|
41
|
-
features.group_by(&:ordinal)
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
everything.features.group_by(&:ordinal)
|
|
101
|
+
.select { |_, group| group.size > 1 }
|
|
102
|
+
.transform_values { |group| group.map(&:dirname) }
|
|
44
103
|
end
|
|
45
104
|
|
|
46
105
|
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
@@ -53,23 +112,55 @@ module OpenLanes
|
|
|
53
112
|
# @return [Boolean] whether a plan with this number exists on disk
|
|
54
113
|
def include?(ordinal) = !find(ordinal).nil?
|
|
55
114
|
|
|
115
|
+
# Built from {#everything}, not from the open plans alone. A shipped
|
|
116
|
+
# plan is moved into `archive/`, and read from the top level only, the
|
|
117
|
+
# prerequisite a dependent waits for would vanish the moment it was
|
|
118
|
+
# done: unknown, never done, and its dependent held forever.
|
|
119
|
+
#
|
|
120
|
+
# @return [OpenLanes::Plans::Dependencies] who waits for whom, read from
|
|
121
|
+
# every `depends_on:` in this tree, the inactive subfolders included
|
|
122
|
+
def dependencies = @dependencies ||= Dependencies.new(everything)
|
|
123
|
+
|
|
124
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
125
|
+
# @return [Array<String>] every plan that waits for this one; derived, never stored
|
|
126
|
+
def blocks_of(ordinal) = dependencies.blocks_of(ordinal)
|
|
127
|
+
|
|
56
128
|
# Forget everything read from disk, so a caller that has just renamed
|
|
57
129
|
# folders sees the new state.
|
|
58
130
|
#
|
|
59
131
|
# @return [self]
|
|
60
132
|
def reload
|
|
61
|
-
@features = @subjects = @skipped = nil
|
|
133
|
+
@features = @subjects = @skipped = @dependencies = nil
|
|
134
|
+
@everything&.reload
|
|
62
135
|
self
|
|
63
136
|
end
|
|
64
137
|
|
|
65
138
|
private
|
|
66
139
|
|
|
67
|
-
# @
|
|
140
|
+
# @param feature [OpenLanes::Plans::Feature]
|
|
141
|
+
# @return [Integer] 0 for the top level, else one past the subfolder's index
|
|
142
|
+
def group(feature)
|
|
143
|
+
parent = File.basename(File.dirname(feature.path))
|
|
144
|
+
File.dirname(feature.path) == dir ? 0 : SUBFOLDERS.index(parent).to_i + 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Array<String>] absolute paths of every plan-shaped subdirectory
|
|
68
148
|
def directories
|
|
69
149
|
return [] unless exist?
|
|
70
150
|
|
|
71
|
-
|
|
72
|
-
|
|
151
|
+
top = children(dir).reject { |p| SUBFOLDERS.include?(File.basename(p)) }
|
|
152
|
+
return top unless all?
|
|
153
|
+
|
|
154
|
+
top + SUBFOLDERS.flat_map { |name| children(File.join(dir, name)) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param parent [String]
|
|
158
|
+
# @return [Array<String>]
|
|
159
|
+
def children(parent)
|
|
160
|
+
return [] unless File.directory?(parent)
|
|
161
|
+
|
|
162
|
+
Dir.children(parent)
|
|
163
|
+
.map { |c| File.join(parent, c) }
|
|
73
164
|
.select { |p| File.directory?(p) }
|
|
74
165
|
.reject { |p| File.basename(p).start_with?(".") }
|
|
75
166
|
.sort
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# The review label the reviewers' comments add up to (decisions R2, R3).
|
|
6
|
+
#
|
|
7
|
+
# Every review comment opens with an emoji that declares how much it
|
|
8
|
+
# matters. Counted across all the reviewers together, in order: any ⛔️ or
|
|
9
|
+
# 💣 rejects (and a 💣 adds {ReviewLabel::VULNERABILITY} beside the
|
|
10
|
+
# rejection); otherwise more than `max_hmm` 🤔 is
|
|
11
|
+
# {ReviewLabel::OK_NOT_PERFECT}; otherwise {ReviewLabel::APPROVED}. A clean
|
|
12
|
+
# small pull request is approved even if it earned no ✅.
|
|
13
|
+
#
|
|
14
|
+
# Pure: it reads strings and answers labels. The threshold comes in as an
|
|
15
|
+
# argument, so where it is configured ({Config#max_hmm}) is not this
|
|
16
|
+
# class's business, and nothing here reads GitHub.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] labels
|
|
19
|
+
# @return [Array<ReviewLabel>] one verdict, or {ReviewLabel::REJECTED}
|
|
20
|
+
# and {ReviewLabel::VULNERABILITY} together
|
|
21
|
+
# @!attribute [r] tally
|
|
22
|
+
# @return [Hash{Symbol => Integer}] counted comments, by {MARKS} key
|
|
23
|
+
# @!attribute [r] ignored
|
|
24
|
+
# @return [Array<Comment>] 💣 or 🔓 comments from a reviewer other than
|
|
25
|
+
# {SECURITY_REVIEWER}, left out of the tally
|
|
26
|
+
Verdict = Data.define(:labels, :tally, :ignored) do
|
|
27
|
+
# @return [Boolean] whether the labels let the pull request go on
|
|
28
|
+
def passing? = labels.all?(&:passing?)
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] the label names, as `openlanes review` takes them
|
|
31
|
+
def names = labels.map(&:name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Verdict
|
|
35
|
+
# One review comment, and the agent that wrote it. The author has to be
|
|
36
|
+
# carried alongside the body: every reviewer comments from the same
|
|
37
|
+
# GitHub account (R2), so GitHub's author says nothing about who wrote it.
|
|
38
|
+
Comment = Data.define(:agent, :body)
|
|
39
|
+
|
|
40
|
+
# The opening emoji, by what they mean. ⛔ is matched with and without
|
|
41
|
+
# its variation selector (U+FE0F), since editors and models drop it as
|
|
42
|
+
# often as they keep it; the others tolerate a stray one too.
|
|
43
|
+
MARKS = {
|
|
44
|
+
great: "\u2705", # ✅ Great!
|
|
45
|
+
hmm: "\u{1F914}", # 🤔 Hmmm...
|
|
46
|
+
must_fix: "\u26D4", # ⛔️ Must Fix, with or without U+FE0F after it
|
|
47
|
+
flaw: "\u{1F4A3}", # 💣 Security Flaw
|
|
48
|
+
concern: "\u{1F513}" # 🔓 Security Concern
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# The words each emoji is written with, for a person reading the tally.
|
|
52
|
+
WORDS = {
|
|
53
|
+
great: "Great!", hmm: "Hmmm...", must_fix: "Must Fix", flaw: "Security Flaw", concern: "Security Concern"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# Marks only the security reviewer may use. From anyone else a 💣 would
|
|
57
|
+
# reject a pull request on a reviewer's say-so outside its remit, so it
|
|
58
|
+
# is dropped and reported rather than counted.
|
|
59
|
+
SECURITY_MARKS = %i[flaw concern].freeze
|
|
60
|
+
|
|
61
|
+
# The one agent whose 💣 and 🔓 count.
|
|
62
|
+
SECURITY_REVIEWER = "vader-security-reviewer"
|
|
63
|
+
|
|
64
|
+
# How many 🤔 a review may carry and still be approved.
|
|
65
|
+
DEFAULT_MAX_HMM = 5
|
|
66
|
+
|
|
67
|
+
# Markdown a comment may open with before its emoji: a heading, a
|
|
68
|
+
# quote, bold. Anything else first means the comment declares nothing.
|
|
69
|
+
LEAD = /\A[\s#>*_]*/
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# @param comments [Array<Comment, Hash>] each with `agent:` and `body:`
|
|
73
|
+
# @param max_hmm [Integer] the most 🤔 an approval may carry
|
|
74
|
+
# @return [Verdict]
|
|
75
|
+
def call(comments, max_hmm: DEFAULT_MAX_HMM)
|
|
76
|
+
tally = MARKS.keys.to_h { [it, 0] }
|
|
77
|
+
ignored = []
|
|
78
|
+
comments.each do |raw|
|
|
79
|
+
comment = coerce(raw)
|
|
80
|
+
mark = mark_of(comment.body)
|
|
81
|
+
next if mark.nil?
|
|
82
|
+
|
|
83
|
+
if SECURITY_MARKS.include?(mark) && comment.agent.to_s != SECURITY_REVIEWER
|
|
84
|
+
ignored << comment
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
tally[mark] += 1
|
|
88
|
+
end
|
|
89
|
+
new(labels: labels_for(tally, max_hmm), tally:, ignored:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The mark a comment opens with.
|
|
93
|
+
#
|
|
94
|
+
# @param body [String]
|
|
95
|
+
# @return [Symbol, nil] a {MARKS} key; nil for a comment that declares
|
|
96
|
+
# nothing, such as the "Reviewed by ..." header
|
|
97
|
+
def mark_of(body)
|
|
98
|
+
text = body.to_s.sub(LEAD, "")
|
|
99
|
+
MARKS.find { |_, emoji| text.start_with?(emoji) }&.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @return [Comment]
|
|
105
|
+
def coerce(raw)
|
|
106
|
+
return raw if raw.is_a?(Comment)
|
|
107
|
+
|
|
108
|
+
Comment.new(agent: raw[:agent] || raw["agent"], body: raw[:body] || raw["body"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Array<ReviewLabel>]
|
|
112
|
+
def labels_for(tally, max_hmm)
|
|
113
|
+
if tally[:must_fix].positive? || tally[:flaw].positive?
|
|
114
|
+
[ReviewLabel::REJECTED, *(ReviewLabel::VULNERABILITY if tally[:flaw].positive?)]
|
|
115
|
+
elsif tally[:hmm] > max_hmm
|
|
116
|
+
[ReviewLabel::OK_NOT_PERFECT]
|
|
117
|
+
else
|
|
118
|
+
[ReviewLabel::APPROVED]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/openlanes/plans.rb
CHANGED
|
@@ -35,5 +35,24 @@ module OpenLanes
|
|
|
35
35
|
# @param project [String, nil]
|
|
36
36
|
# @return [OpenLanes::Plans::Index]
|
|
37
37
|
def index(tree:, project: nil) = Index.new(tree:, project:)
|
|
38
|
+
|
|
39
|
+
# The review label the reviewers' comments add up to (R3). A 💣 or 🔓
|
|
40
|
+
# from anyone but the security reviewer is left out, and said in the log,
|
|
41
|
+
# so a reviewer straying outside its remit is visible rather than silent.
|
|
42
|
+
#
|
|
43
|
+
# @param comments [Array<OpenLanes::Plans::Verdict::Comment, Hash>] each
|
|
44
|
+
# with `agent:` and `body:`
|
|
45
|
+
# @param max_hmm [Integer] defaults to `review.max_hmm`, or
|
|
46
|
+
# `$OPENLANES_REVIEW_MAX_HMM` over it
|
|
47
|
+
# @return [OpenLanes::Plans::Verdict]
|
|
48
|
+
def verdict(comments, max_hmm: Config.current.max_hmm)
|
|
49
|
+
Verdict.call(comments, max_hmm:).tap do |verdict|
|
|
50
|
+
verdict.ignored.each do |comment|
|
|
51
|
+
UI.log("ignored #{Verdict::MARKS[Verdict.mark_of(comment.body)]} from #{comment.agent || "an unnamed reviewer"}: " \
|
|
52
|
+
"only #{Verdict::SECURITY_REVIEWER} may use it",
|
|
53
|
+
level: :warn)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
38
57
|
end
|
|
39
58
|
end
|