openlanes 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# What a {PrWatch::Outcome} does to the plan folder (M4): the side
|
|
6
|
+
# effects the watch itself refuses to have.
|
|
7
|
+
#
|
|
8
|
+
# A person's verdict arrives on GitHub, not in a ledger, so this is where
|
|
9
|
+
# it is written down: the pull request's row in `pull-requests.md`, the
|
|
10
|
+
# folder's state, and for a rejection the comments, mailed to the agents
|
|
11
|
+
# who fix it. Each step is asked of the folder as it stands, so applying
|
|
12
|
+
# the same outcome twice changes nothing the second time; that is what
|
|
13
|
+
# lets the watch ask GitHub again every 15 seconds without remembering
|
|
14
|
+
# what it already did.
|
|
15
|
+
class PrOutcomes
|
|
16
|
+
# What changed, for the log.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] plan
|
|
19
|
+
# @return [String] ordinal
|
|
20
|
+
# @!attribute [r] pr
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
# @!attribute [r] to
|
|
23
|
+
# @return [Symbol, nil] the state the folder moved to, nil when it stayed
|
|
24
|
+
# @!attribute [r] note
|
|
25
|
+
# @return [String]
|
|
26
|
+
Applied = Data.define(:plan, :pr, :to, :note)
|
|
27
|
+
|
|
28
|
+
# Who signs a mail carrying a person's review comments.
|
|
29
|
+
SENDER = "github"
|
|
30
|
+
|
|
31
|
+
# @param tree_dir [String] the `.plans` directory, reread after each rename
|
|
32
|
+
# @param agents [Agents::Registry] who handles 🔴, and so gets the comments
|
|
33
|
+
# @param clock [#call] the time a merge is recorded at
|
|
34
|
+
def initialize(tree_dir:, agents:, clock: -> { Clock::System.now })
|
|
35
|
+
@tree_dir = tree_dir
|
|
36
|
+
@agents = agents
|
|
37
|
+
@clock = clock
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param outcome [PrWatch::Outcome]
|
|
41
|
+
# @return [Applied, nil] nil when the folder already said so
|
|
42
|
+
def apply(outcome)
|
|
43
|
+
case outcome.kind
|
|
44
|
+
when :rejected then reject(outcome)
|
|
45
|
+
when :merged then merged(outcome)
|
|
46
|
+
when :closed then closed(outcome)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# `review-rejected` with comments: 🔴, and the comments to the builders.
|
|
53
|
+
# A plan already at 🔴 has been told; one at 🟢 passes through 👀, the
|
|
54
|
+
# only state the machine rejects from.
|
|
55
|
+
def reject(outcome)
|
|
56
|
+
subject = find(outcome.plan) or return nil
|
|
57
|
+
return nil if subject.status.key == :rejected
|
|
58
|
+
|
|
59
|
+
moved = walk(subject, :rejected) or return nil
|
|
60
|
+
# A person overruled the approval: the board must stop showing it
|
|
61
|
+
# as finished.
|
|
62
|
+
Plans::PlanState.for(moved.feature.path).unfinish!
|
|
63
|
+
mailed = mail(moved, outcome)
|
|
64
|
+
Applied.new(plan: outcome.plan,
|
|
65
|
+
pr: outcome.pr,
|
|
66
|
+
to: :rejected,
|
|
67
|
+
note: "PR ##{outcome.pr} labelled #{Plans::ReviewLabel::REJECTED.name}; mailed #{mailed.join(", ")}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Merged on GitHub: the row says so, `state.json` says when (the
|
|
71
|
+
# watch learns of it on the poll after, which is as close as a
|
|
72
|
+
# local clock gets), and once no pull request is left open the plan is
|
|
73
|
+
# ✅, by the person who merged it.
|
|
74
|
+
def merged(outcome)
|
|
75
|
+
subject = find(outcome.plan) or return nil
|
|
76
|
+
record(subject, outcome.pr, "merged")
|
|
77
|
+
Plans::PlanState.for(subject.feature.path).finish!("merged", at: @clock.call)
|
|
78
|
+
subject = find(outcome.plan)
|
|
79
|
+
return applied(outcome, nil, "merged") if subject.pull_requests.any?(&:open?)
|
|
80
|
+
|
|
81
|
+
to = walk(subject, :approved) ? :approved : nil
|
|
82
|
+
applied(outcome, to, "merged")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Closed unmerged: the row says so, and a plan with nothing merged and
|
|
86
|
+
# nothing open is ❌, with `discarded.md` saying who closed what.
|
|
87
|
+
def closed(outcome)
|
|
88
|
+
subject = find(outcome.plan) or return nil
|
|
89
|
+
record(subject, outcome.pr, "closed")
|
|
90
|
+
subject = find(outcome.plan)
|
|
91
|
+
prs = subject.pull_requests
|
|
92
|
+
return applied(outcome, nil, "closed unmerged") if prs.any? { it.open? || it.merged? }
|
|
93
|
+
|
|
94
|
+
return applied(outcome, nil, "closed unmerged") if subject.status.key == :discarded
|
|
95
|
+
|
|
96
|
+
note = File.join(subject.feature.path, "discarded.md")
|
|
97
|
+
written = !File.exist?(note) && File.write(note, <<~MARKDOWN)
|
|
98
|
+
# Discarded
|
|
99
|
+
|
|
100
|
+
Every pull request was closed on GitHub without merging: #{prs.map { "##{it.number}" }.join(", ")}.
|
|
101
|
+
MARKDOWN
|
|
102
|
+
to = walk(subject, :discarded) ? :discarded : nil
|
|
103
|
+
# ❌ requires the note, so it is written before the move; a refused
|
|
104
|
+
# move takes it back, or the folder would carry a discarded note
|
|
105
|
+
# while claiming some other state.
|
|
106
|
+
File.delete(note) if written && to.nil?
|
|
107
|
+
applied(outcome, to, "closed unmerged")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Applied]
|
|
111
|
+
def applied(outcome, to, what) = Applied.new(plan: outcome.plan, pr: outcome.pr, to:, note: "PR ##{outcome.pr} #{what}")
|
|
112
|
+
|
|
113
|
+
# Rewrite one row's state in `pull-requests.md`, worded as
|
|
114
|
+
# {Plans::PullRequests::STATES} words it, leaving every other row and
|
|
115
|
+
# every line around the table alone.
|
|
116
|
+
def record(subject, number, word)
|
|
117
|
+
pair = Plans::PullRequests::STATES.find { |pattern, _| pattern.match?(word) } or
|
|
118
|
+
raise(Error, "no pull request state matches #{word.inspect}; Plans::PullRequests::STATES must word it")
|
|
119
|
+
state = pair.last
|
|
120
|
+
rows = subject.pull_requests.map do |pr|
|
|
121
|
+
mine = pr.number.to_s.delete("#").to_i == number
|
|
122
|
+
{ number: pr.number, title: pr.title, url: pr.url, state: mine ? state : pr.state }
|
|
123
|
+
end
|
|
124
|
+
Plans::PullRequests.upsert(File.join(subject.feature.path, Plans::PullRequests::FILENAME), rows)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Move to +to+, through 👀 when the plan is still at 🟢: a person who
|
|
128
|
+
# ruled on GitHub before hansolo did has reviewed it all the same.
|
|
129
|
+
#
|
|
130
|
+
# @return [Plans::Subject, nil] the plan after the move, nil when the
|
|
131
|
+
# machine refused it
|
|
132
|
+
def walk(subject, to)
|
|
133
|
+
subject.machine.promote!(:in_review) if subject.status.key == :ready_for_review && !subject.machine.may?(to)
|
|
134
|
+
subject = find(subject.feature.ordinal.to_s)
|
|
135
|
+
return nil unless subject.machine.may?(to)
|
|
136
|
+
|
|
137
|
+
subject.machine.promote!(to)
|
|
138
|
+
find(subject.feature.ordinal.to_s)
|
|
139
|
+
rescue Plans::StateMachine::Refused
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Array<String>] who was mailed
|
|
144
|
+
def mail(subject, outcome)
|
|
145
|
+
body = ["PR ##{outcome.pr} was labelled #{Plans::ReviewLabel::REJECTED.name} on GitHub.", *comments(outcome)].join("\n\n")
|
|
146
|
+
status = Plans::STATUS_BY_KEY.fetch(:rejected)
|
|
147
|
+
@agents.all.select { it.handles?(status) }.map(&:name).each do |name|
|
|
148
|
+
Plans::Mailbox.new(dir: subject.feature.path).append(from: SENDER, to: name, body:)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def comments(outcome)
|
|
153
|
+
return ["No comment was left; read the review on GitHub."] if outcome.comments.empty?
|
|
154
|
+
|
|
155
|
+
outcome.comments.map { |c| "#{c[:author]} (#{c[:url]}):\n\n#{c[:body].strip}" }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def find(ordinal) = Plans::Tree.new(dir: @tree_dir).find(ordinal)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# Human mode (M4): while a person reviews and merges, the engine watches
|
|
6
|
+
# each plan's open pull request and says what happened to it.
|
|
7
|
+
#
|
|
8
|
+
# Paced by elapsed time rather than by ticks, so a faster tick loop does
|
|
9
|
+
# not become a faster stream of `gh` calls. It only reads; the dispatcher
|
|
10
|
+
# fires the events and sends the mail.
|
|
11
|
+
class PrWatch
|
|
12
|
+
# What one pull request turned out to be.
|
|
13
|
+
# :rejected — carries `review-rejected`; `comments` are what to fix
|
|
14
|
+
# :merged, :closed — no longer open, merged or not
|
|
15
|
+
# :waiting — still open, waiting on a person
|
|
16
|
+
Outcome = Data.define(:plan, :pr, :kind, :comments) do
|
|
17
|
+
def initialize(comments: [], **rest) = super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param github [Vcs::GitHub]
|
|
21
|
+
# @param clock [#call] monotonic seconds
|
|
22
|
+
# @param interval [Numeric] seconds between polls
|
|
23
|
+
def initialize(github:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, interval: 15)
|
|
24
|
+
@github = github
|
|
25
|
+
@clock = clock
|
|
26
|
+
@interval = interval
|
|
27
|
+
@last = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] whether a poll is due; the first one waits a full interval
|
|
31
|
+
def due?
|
|
32
|
+
now = @clock.call
|
|
33
|
+
@last ||= now
|
|
34
|
+
now - @last >= @interval
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One `gh pr list` for every watched plan, and one `gh pr view` for each
|
|
38
|
+
# watched pull request that list left out.
|
|
39
|
+
#
|
|
40
|
+
# @param subjects [Array<Plans::Subject>]
|
|
41
|
+
# @return [Array<Outcome>] one per open pull request a plan records
|
|
42
|
+
def poll(subjects)
|
|
43
|
+
@last = @clock.call
|
|
44
|
+
watched = subjects.flat_map { |s| s.pull_requests.select(&:open?).map { |pr| [s, pr.number.to_s.delete("#").to_i] } }
|
|
45
|
+
return [] if watched.empty?
|
|
46
|
+
|
|
47
|
+
by_number = @github.pulls(state: "all").to_h { |row| [row[:number], row] }
|
|
48
|
+
watched.filter_map do |subject, number|
|
|
49
|
+
row = by_number[number] || lookup(number) or next
|
|
50
|
+
outcome(subject, number, row)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# `gh pr list` returns only the newest pull requests, up to its limit,
|
|
57
|
+
# so a plan whose pull request is older than that would drop out of
|
|
58
|
+
# every poll and its merge or rejection would never be applied. Asked
|
|
59
|
+
# by number instead. One that cannot be read is skipped this poll and
|
|
60
|
+
# asked again on the next, rather than stopping the watch for all.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash, nil] a row shaped like {Vcs::GitHub#pulls}
|
|
63
|
+
def lookup(number)
|
|
64
|
+
@github.pull_request(number.to_s)
|
|
65
|
+
rescue Error
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def outcome(subject, number, row)
|
|
70
|
+
plan = subject.feature.ordinal.to_s
|
|
71
|
+
if row[:open]
|
|
72
|
+
return Outcome.new(plan:, pr: number, kind: :waiting) unless rejected?(row)
|
|
73
|
+
|
|
74
|
+
Outcome.new(plan:, pr: number, kind: :rejected, comments: @github.review_comments(number:))
|
|
75
|
+
else
|
|
76
|
+
Outcome.new(plan:, pr: number, kind: row[:state].to_s.start_with?("Merged") ? :merged : :closed)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rejected?(row) = Array(row[:labels]).include?(Plans::ReviewLabel::REJECTED.name)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -5,133 +5,46 @@ require "tmpdir"
|
|
|
5
5
|
|
|
6
6
|
module OpenLanes
|
|
7
7
|
module Engine
|
|
8
|
-
#
|
|
8
|
+
# What is left of the run's own column-format log now that every event
|
|
9
|
+
# goes through {Log}: the clock and the status cell the terminal's lines
|
|
10
|
+
# are drawn with, and where a spawned server's raw output is kept.
|
|
9
11
|
#
|
|
10
|
-
# The
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# process id column is for. Without it a reader cannot tell one run's agents
|
|
14
|
-
# from another's, and "started 003.00" twice looks like a bug rather than two
|
|
15
|
-
# machines doing the same work.
|
|
16
|
-
#
|
|
17
|
-
# Fixed columns are what make the file readable by eye and by `awk` alike. A
|
|
18
|
-
# value wider than its column is cut rather than allowed to push the columns
|
|
19
|
-
# out, and a value that is missing still occupies its width, so a round header
|
|
20
|
-
# with no plan or agent of its own lines up with the lines underneath it.
|
|
21
|
-
#
|
|
22
|
-
# @example
|
|
23
|
-
# ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
|
|
24
|
-
# agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
|
|
25
|
-
# #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
|
|
12
|
+
# The fixed-column file it used to write is gone. Two files saying the
|
|
13
|
+
# same thing in two formats is the duplication this codebase keeps
|
|
14
|
+
# paying for; `log/development.log` is now the one record of a run.
|
|
26
15
|
module ProgressLog
|
|
27
|
-
# Wall clock only. A
|
|
16
|
+
# Wall clock only. A terminal line answers "when", and the date is the day's.
|
|
28
17
|
TIME_FORMAT = "%H:%M:%S"
|
|
29
18
|
|
|
30
|
-
# Width of {TIME_FORMAT}'s output.
|
|
31
|
-
TIME_WIDTH = 8
|
|
32
|
-
|
|
33
|
-
# "003.00".
|
|
34
|
-
PLAN_WIDTH = 6
|
|
35
|
-
|
|
36
19
|
# Cells given to the emoji, and the gap after it. Many terminal fonts draw
|
|
37
20
|
# a colour emoji wider than its two cells, and with one space it overlapped
|
|
38
21
|
# the label's first letter ("🔴Changes").
|
|
39
22
|
EMOJI_WIDTH = 2
|
|
40
23
|
EMOJI_GAP = " "
|
|
41
24
|
|
|
42
|
-
#
|
|
43
|
-
# added to {STATUSES} with a longer label cannot silently knock the columns
|
|
44
|
-
# after it out of line. Untrimmed: cut to fit, "Changes Requested" and
|
|
45
|
-
# "Ready for Planning" read as "Changes Reque" and "Ready for Pla".
|
|
46
|
-
STATUS_WIDTH = Plans::STATUSES.map { |status| EMOJI_WIDTH + EMOJI_GAP.length + UI.display_width(status.label) }.max
|
|
47
|
-
|
|
48
|
-
# The longest specialist name in `agents/` is `palpatine-planner` at 17,
|
|
49
|
-
# and the names are hyphenated words rather than a bounded vocabulary, so
|
|
50
|
-
# this leaves room for one more without a reflow.
|
|
51
|
-
AGENT_WIDTH = 20
|
|
52
|
-
|
|
53
|
-
# Enough for a 32-bit process id, right justified.
|
|
54
|
-
PID_WIDTH = 7
|
|
55
|
-
|
|
56
|
-
# "01". Rounds cap at two digits; a run that reaches a third has bigger
|
|
57
|
-
# problems than this column.
|
|
58
|
-
ROUND_WIDTH = 2
|
|
59
|
-
|
|
60
|
-
# " 907s". Anything longer than four digits of seconds is an agent nobody
|
|
61
|
-
# is still waiting on.
|
|
62
|
-
SECONDS_WIDTH = 6
|
|
63
|
-
|
|
64
|
-
# The keywords {.render} takes besides the message. {UI.log} hands its
|
|
65
|
-
# taps more than these, and the file only these.
|
|
66
|
-
FIELDS = %i[plan status agent seconds round pid at].freeze
|
|
67
|
-
|
|
68
|
-
# Between columns, inside the brackets.
|
|
69
|
-
SEPARATOR = " | "
|
|
70
|
-
|
|
71
|
-
# Cells from the opening bracket to the closing one, separators included.
|
|
72
|
-
# The message starts one space after this on every line, whatever fields
|
|
73
|
-
# that line happens to be missing, so a reader indenting a wrapped message
|
|
74
|
-
# has a number to indent by.
|
|
75
|
-
COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
|
|
76
|
-
SECONDS_WIDTH].freeze
|
|
77
|
-
|
|
78
|
-
# @see COLUMN_WIDTHS
|
|
79
|
-
LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
|
|
80
|
-
|
|
81
|
-
# Hex digits of the checkout's path in a default log's name.
|
|
25
|
+
# Hex digits of the checkout's path in a default file's name.
|
|
82
26
|
CHECKOUT_DIGITS = 6
|
|
83
27
|
|
|
84
28
|
class << self
|
|
85
|
-
# Where a
|
|
29
|
+
# Where a spawned process's raw STDOUT and STDERR go: the temp
|
|
86
30
|
# directory, under the checkout's directory name and a short digest
|
|
87
31
|
# of its full path. The name alone collided: `~/a/minesweeper` and
|
|
88
32
|
# `~/b/minesweeper` both appended to `openlanes-minesweeper.log`, and
|
|
89
33
|
# two runs' lines read as one.
|
|
90
34
|
#
|
|
91
35
|
# @param root [String] the checkout
|
|
92
|
-
# @param kind [String, nil] which process
|
|
93
|
-
# @return [String] e.g. "/tmp/openlanes-minesweeper-3f9a1c.log"
|
|
36
|
+
# @param kind [String, nil] which process writes it, e.g. "web"
|
|
37
|
+
# @return [String] e.g. "/tmp/openlanes-web-minesweeper-3f9a1c.log"
|
|
94
38
|
def default_path(root, kind: nil)
|
|
95
39
|
root = File.expand_path(root)
|
|
96
40
|
digest = Digest::SHA256.hexdigest(root)[0, CHECKOUT_DIGITS]
|
|
97
41
|
File.join(Dir.tmpdir, "#{["openlanes", kind, File.basename(root), digest].compact.join("-")}.log")
|
|
98
42
|
end
|
|
99
43
|
|
|
100
|
-
# Render one log line: bracketed fixed-width columns, then the message.
|
|
101
|
-
#
|
|
102
|
-
# No colour. The line goes to a file, where an escape sequence is neither
|
|
103
|
-
# readable nor the width it claims to be.
|
|
104
|
-
#
|
|
105
|
-
# @param message [String] the free-form tail, the only variable-width part
|
|
106
|
-
# @param plan [String, nil] the plan's ordinal, e.g. "003.00"
|
|
107
|
-
# @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
|
|
108
|
-
# @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
|
|
109
|
-
# @param seconds [Numeric, nil] how long this agent has been alive
|
|
110
|
-
# @param round [String, nil] which pass over the tree, e.g. "01"
|
|
111
|
-
# @param pid [Integer, nil] which run wrote the line
|
|
112
|
-
# @param at [Time] injectable, so a caller can render a fixed clock
|
|
113
|
-
# @return [String] one line, with no trailing newline
|
|
114
|
-
def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
|
|
115
|
-
round: nil, pid: Process.pid, at: Time.now)
|
|
116
|
-
columns = [
|
|
117
|
-
left(at.strftime(TIME_FORMAT), TIME_WIDTH),
|
|
118
|
-
left(plan, PLAN_WIDTH),
|
|
119
|
-
left(status_cell(status), STATUS_WIDTH),
|
|
120
|
-
left(agent, AGENT_WIDTH),
|
|
121
|
-
right(round, ROUND_WIDTH),
|
|
122
|
-
right(pid, PID_WIDTH),
|
|
123
|
-
right(duration(seconds), SECONDS_WIDTH)
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
text = message.to_s
|
|
127
|
-
prefix = "[#{columns.join(SEPARATOR)}]"
|
|
128
|
-
text.empty? ? prefix : "#{prefix} #{text}"
|
|
129
|
-
end
|
|
130
|
-
|
|
131
44
|
# The emoji fitted to {EMOJI_WIDTH} and followed by {EMOJI_GAP}, then the
|
|
132
45
|
# label. A value with no label (or none at all) passes through.
|
|
133
46
|
#
|
|
134
|
-
# @param status [String, nil] emoji and label, e.g. "
|
|
47
|
+
# @param status [String, nil] emoji and label, e.g. "⭐ Planned"
|
|
135
48
|
# @return [String, nil]
|
|
136
49
|
def status_cell(status)
|
|
137
50
|
emoji, label = status.to_s.split(" ", 2)
|
|
@@ -139,27 +52,6 @@ module OpenLanes
|
|
|
139
52
|
|
|
140
53
|
"#{UI.fit(emoji, EMOJI_WIDTH)}#{EMOJI_GAP}#{label}"
|
|
141
54
|
end
|
|
142
|
-
|
|
143
|
-
# @param seconds [Numeric, nil]
|
|
144
|
-
# @return [String] whole seconds with a trailing "s", or blank for nil
|
|
145
|
-
def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
|
|
146
|
-
|
|
147
|
-
# @param value [Object, nil]
|
|
148
|
-
# @param width [Integer] terminal cells
|
|
149
|
-
# @return [String] padded on the right to exactly +width+ cells
|
|
150
|
-
def left(value, width) = UI.fit(value, width)
|
|
151
|
-
|
|
152
|
-
# {UI.fit} pads on the right, which is the wrong end for a number. Fit
|
|
153
|
-
# first so an over-long value is still truncated to the column, then move
|
|
154
|
-
# the padding to the front.
|
|
155
|
-
#
|
|
156
|
-
# @param value [Object, nil]
|
|
157
|
-
# @param width [Integer] terminal cells
|
|
158
|
-
# @return [String] padded on the left to exactly +width+ cells
|
|
159
|
-
def right(value, width)
|
|
160
|
-
text = UI.fit(value, width).rstrip
|
|
161
|
-
UI.fit("", width - UI.display_width(text)) + text
|
|
162
|
-
end
|
|
163
55
|
end
|
|
164
56
|
end
|
|
165
57
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# R4's question, `desired_codebase_quality`, put to a person once per
|
|
6
|
+
# project: on the first pull request labelled `review-ok-not-perfect`
|
|
7
|
+
# while `.openlanes.yml` has no key. It hangs on the plan whose pull
|
|
8
|
+
# request raised it, kept in that plan's `state.json` so it survives a
|
|
9
|
+
# restart, and every other plan waiting on the same answer stays 👀
|
|
10
|
+
# without asking again. The answer is written into `.openlanes.yml`,
|
|
11
|
+
# after which nothing asks.
|
|
12
|
+
module QualityQuestion
|
|
13
|
+
# The question's name on its plan, as `X<n>` is an extension's.
|
|
14
|
+
NAME = "Q1"
|
|
15
|
+
|
|
16
|
+
# R4's wording, verbatim where it fits a card.
|
|
17
|
+
HEADING = "Please indicate the desired quality of the codebase"
|
|
18
|
+
|
|
19
|
+
# @return [String]
|
|
20
|
+
BODY = "This affects the severity of items found during review that will require rework. " \
|
|
21
|
+
"Critical blocking issues or security issues identified during review will be addressed regardless."
|
|
22
|
+
|
|
23
|
+
# One choice, the letter the page sends back and the key it writes.
|
|
24
|
+
#
|
|
25
|
+
# @!attribute [r] letter
|
|
26
|
+
# @return [String]
|
|
27
|
+
# @!attribute [r] quality
|
|
28
|
+
# @return [Symbol] one of {Config::QUALITIES}
|
|
29
|
+
# @!attribute [r] label
|
|
30
|
+
# @return [String]
|
|
31
|
+
Choice = Data.define(:letter, :quality, :label)
|
|
32
|
+
|
|
33
|
+
# @return [Array<Choice>]
|
|
34
|
+
CHOICES = [
|
|
35
|
+
Choice.new(letter: "a",
|
|
36
|
+
quality: :high,
|
|
37
|
+
label: "High quality, but slower and more expensive. A PR review labelled \"review-ok-not-perfect\" " \
|
|
38
|
+
"returns to developers for fixes."),
|
|
39
|
+
Choice.new(letter: "b",
|
|
40
|
+
quality: :mvp,
|
|
41
|
+
label: "Good enough, but faster and cheaper, think MVP (\"review-ok-not-perfect\" is accepted, " \
|
|
42
|
+
"as it contains no blocking issues).")
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Ask, unless some plan in the same tree already has the question open.
|
|
48
|
+
#
|
|
49
|
+
# @param plan_dir [String] the asking plan's folder
|
|
50
|
+
# @param at [Time]
|
|
51
|
+
# @return [Boolean] whether this plan holds the open question
|
|
52
|
+
def ask!(plan_dir, at: Clock::System.now)
|
|
53
|
+
holder = open_in(File.dirname(plan_dir))
|
|
54
|
+
return holder == plan_dir if holder
|
|
55
|
+
|
|
56
|
+
Plans::PlanState.for(plan_dir).ask_quality!(at:)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param plans_dir [String]
|
|
61
|
+
# @return [String, nil] the folder of the plan whose question is open
|
|
62
|
+
def open_in(plans_dir)
|
|
63
|
+
Dir.glob(File.join(plans_dir, "*", Plans::PlanState::FILENAME)).map { File.dirname(it) }.find do |dir|
|
|
64
|
+
ask = Plans::PlanState.for(dir).quality_ask
|
|
65
|
+
ask && !ask.key?("settled_at")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The open question on this plan, as the page's `Question`s.
|
|
70
|
+
#
|
|
71
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
72
|
+
# @param plan [OpenLanes::Plans::PlanState]
|
|
73
|
+
# @return [Array<Hash>] empty unless this plan holds it, unanswered
|
|
74
|
+
def questions(subject, plan)
|
|
75
|
+
ask = plan.quality_ask
|
|
76
|
+
return [] if ask.nil? || ask.key?("settled_at")
|
|
77
|
+
|
|
78
|
+
[{ id: "#{subject.feature.ordinal}/#{NAME}",
|
|
79
|
+
number: 1,
|
|
80
|
+
kind: "quality",
|
|
81
|
+
heading: HEADING,
|
|
82
|
+
body: BODY,
|
|
83
|
+
agent: nil,
|
|
84
|
+
owner: "product",
|
|
85
|
+
options: CHOICES.map do |choice|
|
|
86
|
+
{ letter: choice.letter, label: choice.label, recommended: choice.quality == Config::QUALITIES.first,
|
|
87
|
+
answer: "#{choice.letter}) #{choice.label}" }
|
|
88
|
+
end,
|
|
89
|
+
held: false,
|
|
90
|
+
asked_at: ask["asked_at"] }]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Settle the question and write the key, unless the file has one by now.
|
|
94
|
+
#
|
|
95
|
+
# @param plan_dir [String]
|
|
96
|
+
# @param letter [String] "a" or "b"
|
|
97
|
+
# @param by [String]
|
|
98
|
+
# @param root [String] the project root, where `.openlanes.yml` lives
|
|
99
|
+
# @param at [Time]
|
|
100
|
+
# @return [Symbol, nil] the quality chosen; nil when the question was not open
|
|
101
|
+
# @raise [ArgumentError] on a letter that is not one of {CHOICES}
|
|
102
|
+
def answer!(plan_dir, letter, by:, root:, at: Clock::System.now)
|
|
103
|
+
choice = CHOICES.find { it.letter == letter.to_s.downcase } or
|
|
104
|
+
raise ArgumentError, "#{letter} is not one of #{CHOICES.map { "#{it.letter})" }.join(", ")}"
|
|
105
|
+
Plans::PlanState.for(plan_dir).settle_quality_ask!(answer: choice.quality, by:, at:) or return nil
|
|
106
|
+
|
|
107
|
+
write_key(root, choice.quality)
|
|
108
|
+
choice.quality
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Appended rather than rewritten through {Config#to_yaml}, which
|
|
112
|
+
# would drop the comments a person keeps in the file.
|
|
113
|
+
#
|
|
114
|
+
# @return [void]
|
|
115
|
+
def write_key(root, quality)
|
|
116
|
+
path = File.join(root, Config::FILE)
|
|
117
|
+
return unless Config.load(root).quality_unset?
|
|
118
|
+
|
|
119
|
+
text = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
|
|
120
|
+
text += "\n" unless text.empty? || text.end_with?("\n")
|
|
121
|
+
File.write(path, "#{text}desired_codebase_quality: #{quality}\n")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|