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,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Lifecycle
|
|
5
|
+
# `openlanes unblock NNN…` without the command: resolve the plans named,
|
|
6
|
+
# refuse the ones that cannot be drained, say what each is waiting on,
|
|
7
|
+
# hand the rest to the draining agent, and report what moved.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately not part of `run`. ⭕ and 🛑 are {Plans::StateMachine::SETTLED},
|
|
10
|
+
# so the loop never offers a blocked plan to anybody, and that is the
|
|
11
|
+
# property that makes the state mean anything: the plan waits for a human,
|
|
12
|
+
# and no agent quietly decides otherwise. Answers arriving is not a fact
|
|
13
|
+
# the tool can observe, so a human typing the command *is* the signal.
|
|
14
|
+
class UnblockWorkflow
|
|
15
|
+
include UI
|
|
16
|
+
include Dry::Monads[:result]
|
|
17
|
+
|
|
18
|
+
# The agent that knows the shape of `blocked.md`.
|
|
19
|
+
DEFAULT_AGENT = "lando-broker"
|
|
20
|
+
|
|
21
|
+
# What each token turned out to be, for whoever has to fix it.
|
|
22
|
+
PROBLEMS = {
|
|
23
|
+
missing: "no plan of that number",
|
|
24
|
+
not_blocked: "no blocked.md, so there is nothing to drain"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
28
|
+
# @param agent [String] the name of the agent that drains
|
|
29
|
+
# @param root [String, nil] repository root (default: the .plans parent)
|
|
30
|
+
# @param dry_run [Boolean] invoke no agent, change nothing
|
|
31
|
+
# @param quiet [Boolean] draw nothing on STDERR
|
|
32
|
+
def initialize(tree:, agent: DEFAULT_AGENT, root: nil, dry_run: false, quiet: false)
|
|
33
|
+
@tree = tree
|
|
34
|
+
@agent = agent
|
|
35
|
+
@root = root
|
|
36
|
+
@dry_run = dry_run
|
|
37
|
+
@quiet = quiet
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The deliverable rows go to STDOUT and the progress to STDERR as the
|
|
41
|
+
# work happens; the answer is the exit status the command ends with.
|
|
42
|
+
#
|
|
43
|
+
# @param tokens [Array<String>] the plans named: NNN or NNN.MM
|
|
44
|
+
# @return [Dry::Monads::Result] Success(Integer) the exit status, or
|
|
45
|
+
# Failure({Refusal}) when no agent has the name given
|
|
46
|
+
def call(tokens)
|
|
47
|
+
agent = OpenLanes::Agents.registry.find(@agent) or return Failure(unknown_agent)
|
|
48
|
+
|
|
49
|
+
unblocker = Lifecycle.unblocker(tree: @tree,
|
|
50
|
+
agent:,
|
|
51
|
+
root: @root,
|
|
52
|
+
commit: !@dry_run,
|
|
53
|
+
executor: Execution.executor(root: @root || File.dirname(@tree.dir), dry_run: @dry_run))
|
|
54
|
+
targets = unblocker.resolve(tokens)
|
|
55
|
+
refused(targets)
|
|
56
|
+
subjects = targets.select(&:drainable?).map(&:subject)
|
|
57
|
+
return Success(worst(targets, [])) if subjects.empty?
|
|
58
|
+
|
|
59
|
+
credentials unless @dry_run || @quiet
|
|
60
|
+
preflight(subjects)
|
|
61
|
+
|
|
62
|
+
outcomes = Execution.on_interrupt { unblocker.call(subjects) }
|
|
63
|
+
outcomes.each { |outcome| report(outcome) }
|
|
64
|
+
footer(outcomes) unless @quiet
|
|
65
|
+
Success(worst(targets, outcomes))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# @return [OpenLanes::Refusal]
|
|
71
|
+
def unknown_agent
|
|
72
|
+
known = OpenLanes::Agents.registry.all.map(&:name).join(", ")
|
|
73
|
+
Refusal.new(message: "No agent called #{@agent}.\n\nKnown: #{known}", status: 65)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [void]
|
|
77
|
+
def credentials
|
|
78
|
+
notice = Execution.credentials_notice
|
|
79
|
+
warn(notice) if notice
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A number that names no folder, or a folder that was never stopped, is
|
|
83
|
+
# refused rather than skipped. Whoever typed this believes an answer has
|
|
84
|
+
# arrived; running against a folder nobody blocked and saying nothing is
|
|
85
|
+
# how you come back later to a plan nobody touched and no record of why.
|
|
86
|
+
#
|
|
87
|
+
# Every bad token is reported, not just the first, because stopping at
|
|
88
|
+
# the first one hides the rest of the answer.
|
|
89
|
+
#
|
|
90
|
+
# @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
|
|
91
|
+
# @return [void]
|
|
92
|
+
def refused(targets)
|
|
93
|
+
problems = targets.reject(&:drainable?)
|
|
94
|
+
return if problems.empty?
|
|
95
|
+
|
|
96
|
+
problems.each do |target|
|
|
97
|
+
state = target.subject ? target.subject.status.key : "unknown"
|
|
98
|
+
puts "#{target.token}\t#{state}\t0 open\t#{PROBLEMS.fetch(target.problem)}"
|
|
99
|
+
end
|
|
100
|
+
return if @quiet
|
|
101
|
+
|
|
102
|
+
error("#{problems.size} of the plans named cannot be drained:\n\n" +
|
|
103
|
+
problems.map { |t| " #{t.token} — #{PROBLEMS.fetch(t.problem)}" }.join("\n") +
|
|
104
|
+
"\n\nThe tree holds: #{@tree.ordinals.join(", ")}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What each folder is waiting on, said BEFORE the agent is invoked.
|
|
108
|
+
#
|
|
109
|
+
# A real run hands the folder to an agent that can take a quarter of an
|
|
110
|
+
# hour, and until it came back the terminal used to show nothing at
|
|
111
|
+
# all: a run that had found nothing to do and a run still working
|
|
112
|
+
# looked exactly alike.
|
|
113
|
+
#
|
|
114
|
+
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
115
|
+
# @return [void]
|
|
116
|
+
def preflight(subjects)
|
|
117
|
+
return if @quiet
|
|
118
|
+
|
|
119
|
+
subjects.each do |subject|
|
|
120
|
+
questions = Unblocker.questions(subject)
|
|
121
|
+
answered = questions.count(&:answered)
|
|
122
|
+
say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
|
|
123
|
+
"#{subject.feature.title} — #{summary(questions.size, answered)}")
|
|
124
|
+
detail(subject, questions)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param open [Integer]
|
|
129
|
+
# @param answered [Integer]
|
|
130
|
+
# @return [String]
|
|
131
|
+
def summary(open, answered)
|
|
132
|
+
return "nothing open" if open.zero?
|
|
133
|
+
|
|
134
|
+
"#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
138
|
+
# @param questions [Array<OpenLanes::Lifecycle::Unblocker::Question>]
|
|
139
|
+
# @return [void]
|
|
140
|
+
def detail(subject, questions)
|
|
141
|
+
if subject.unreadable_block?
|
|
142
|
+
say(" #{paint("blocked.md names no `## B<n>` question, so nothing here can be drained", :red)}",
|
|
143
|
+
bullet: " ")
|
|
144
|
+
say(" #{paint("Number each open question `## B1`, `## B2`, and each answer `## A1`, `## A2`.", :yellow)}",
|
|
145
|
+
bullet: " ")
|
|
146
|
+
elsif questions.empty?
|
|
147
|
+
say(" #{paint("nothing left open", :green)}", bullet: " ")
|
|
148
|
+
else
|
|
149
|
+
questions.each { |question| say(" #{paint(question.to_s, :yellow)}", bullet: " ") }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# One plan, after the fact: the deliverable line on STDOUT, and what
|
|
154
|
+
# changed on STDERR.
|
|
155
|
+
#
|
|
156
|
+
# @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
|
|
157
|
+
# @return [void]
|
|
158
|
+
def report(outcome)
|
|
159
|
+
moved = movement(outcome)
|
|
160
|
+
puts "#{outcome.ordinal}\t#{outcome.ok ? outcome.subject.status.key : "failed"}\t" \
|
|
161
|
+
"#{outcome.after.size} open\t#{moved}\t#{outcome.note}"
|
|
162
|
+
# A dry run changed nothing, so the preflight above it is still the
|
|
163
|
+
# whole truth. Printing the same lines again reads as a second pass
|
|
164
|
+
# that found the same thing, which is not what happened.
|
|
165
|
+
return if @quiet || @dry_run
|
|
166
|
+
|
|
167
|
+
say("#{paint(outcome.ordinal.to_s, :bright_black)} #{outcome.subject.status.emoji} " \
|
|
168
|
+
"#{outcome.subject.feature.title} — #{paint(moved, outcome.ok ? :green : :red)}")
|
|
169
|
+
detail(outcome.subject, outcome.after)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# What the file says happened, rather than what the agent claims. The
|
|
173
|
+
# questions are counted off disk on both sides of the run.
|
|
174
|
+
#
|
|
175
|
+
# @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
|
|
176
|
+
# @return [String]
|
|
177
|
+
def movement(outcome)
|
|
178
|
+
return "failed" unless outcome.ok
|
|
179
|
+
return "not attempted" if @dry_run
|
|
180
|
+
return "blocked.md retired" if outcome.cleared?
|
|
181
|
+
return "nothing folded" if outcome.folded.empty?
|
|
182
|
+
|
|
183
|
+
"folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
|
|
187
|
+
# @return [void]
|
|
188
|
+
def footer(outcomes)
|
|
189
|
+
if @dry_run
|
|
190
|
+
warn("Dry run: no agent was invoked, and #{outcomes.size} " \
|
|
191
|
+
"plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
|
|
192
|
+
"Re-run without --dry-run to fold in whatever has been answered.")
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
failed = outcomes.reject(&:ok)
|
|
197
|
+
return error(failed.map { |o| "#{o.ordinal}: #{o.note}" }.join("\n")) unless failed.empty?
|
|
198
|
+
|
|
199
|
+
success(tally(outcomes))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
|
|
203
|
+
# @return [String]
|
|
204
|
+
def tally(outcomes)
|
|
205
|
+
cleared = outcomes.count(&:cleared?)
|
|
206
|
+
folded = outcomes.sum { |o| o.folded.size }
|
|
207
|
+
waiting = outcomes.sum { |o| o.after.size }
|
|
208
|
+
text = "#{folded} question#{"s" unless folded == 1} folded in · " \
|
|
209
|
+
"#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
|
|
210
|
+
"#{waiting} still waiting on a human."
|
|
211
|
+
return text unless folded.zero?
|
|
212
|
+
|
|
213
|
+
"#{text}\n\nNothing moved. An answer has to be written into blocked.md as its own " \
|
|
214
|
+
"`## A<n>` section, answering the `## B<n>` of the same number, before " \
|
|
215
|
+
"there is anything to fold."
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The most serious thing that happened, as an exit status. A tree that
|
|
219
|
+
# was partly drained still exits non-zero when part of it could not be.
|
|
220
|
+
#
|
|
221
|
+
# @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
|
|
222
|
+
# @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
|
|
223
|
+
# @return [Integer]
|
|
224
|
+
def worst(targets, outcomes)
|
|
225
|
+
problems = targets.map(&:problem)
|
|
226
|
+
return 66 if problems.include?(:missing)
|
|
227
|
+
return 65 if problems.include?(:not_blocked)
|
|
228
|
+
return 1 unless outcomes.all?(&:ok)
|
|
229
|
+
|
|
230
|
+
0
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -118,7 +118,7 @@ module OpenLanes
|
|
|
118
118
|
# is as much a thing the user needs told about as a folder that drained, and
|
|
119
119
|
# stopping at the first bad one hides the rest of the report.
|
|
120
120
|
#
|
|
121
|
-
# The gate is `blocked.md` itself, not the folder's emoji.
|
|
121
|
+
# The gate is `blocked.md` itself, not the folder's emoji. ⭕ is *derived*
|
|
122
122
|
# from the file, so gating on the emoji made the tool circular: a
|
|
123
123
|
# `blocked.md` whose questions are not numbered `## B<n>` never earns the
|
|
124
124
|
# emoji, so `unblock` refused it, so the file could never drain and the
|
|
@@ -187,11 +187,85 @@ module OpenLanes
|
|
|
187
187
|
# @param progress [OpenLanes::UI::Line]
|
|
188
188
|
# @return [Array(Boolean, String)]
|
|
189
189
|
def invoke(subject, progress)
|
|
190
|
-
|
|
190
|
+
journal_answers(subject)
|
|
191
|
+
forward = progress.to_proc
|
|
192
|
+
kept = { written: 0, number: nil }
|
|
193
|
+
@executor.call(agent, subject, root:) do |update|
|
|
194
|
+
record_journal(subject, update, kept)
|
|
195
|
+
forward.call(update)
|
|
196
|
+
end
|
|
191
197
|
rescue StandardError => e
|
|
192
198
|
[false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
|
|
193
199
|
end
|
|
194
200
|
|
|
201
|
+
# The broker's own journal lines (M2), written as they arrive, the way
|
|
202
|
+
# {Engine::Dispatcher} writes every other agent's: its prompt asks it to
|
|
203
|
+
# keep one like any agent's, and neither the run loop nor `openlanes
|
|
204
|
+
# unblock` hands its progress to the dispatcher. As they arrive, so a
|
|
205
|
+
# broker that fails or is killed keeps what it said.
|
|
206
|
+
#
|
|
207
|
+
# A journal that cannot be written costs the record, never the drain;
|
|
208
|
+
# the lines stay unwritten for the next update to try again.
|
|
209
|
+
#
|
|
210
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
211
|
+
# @param update [Object] whatever the executor reports
|
|
212
|
+
# @param kept [Hash] how many lines are written, and the journal round
|
|
213
|
+
# @return [void]
|
|
214
|
+
def record_journal(subject, update, kept)
|
|
215
|
+
lines = update.respond_to?(:journal) ? Array(update.journal) : []
|
|
216
|
+
return if !applying? || lines.size <= kept[:written]
|
|
217
|
+
|
|
218
|
+
entries, rejected = lines[kept[:written]..].partition { it.is_a?(Plans::Journal::Entry) }
|
|
219
|
+
unless entries.empty?
|
|
220
|
+
kept[:number], refused = Plans::PlanState.for(subject.feature.path)
|
|
221
|
+
.journal!(agent: agent.name,
|
|
222
|
+
entries:,
|
|
223
|
+
number: kept[:number],
|
|
224
|
+
state: subject.feature.status)
|
|
225
|
+
rejected += refused
|
|
226
|
+
end
|
|
227
|
+
kept[:written] = lines.size
|
|
228
|
+
rejected.each { UI.log("journal line ignored, #{it.reason}: #{it.line[0, 160]}", level: :warn, **log_fields(subject)) }
|
|
229
|
+
rescue SystemCallError, OpenLanes::Error => e
|
|
230
|
+
UI.log("journal not written yet: #{e.message}", level: :warn, **log_fields(subject))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Every answered question goes into the journal of the agent that asked
|
|
234
|
+
# it (M3), B1 with A1, before the broker folds it in and deletes it:
|
|
235
|
+
# afterwards the words are gone from `blocked.md`, and the asker's next
|
|
236
|
+
# round would know only that the plan was unblocked, not what it was
|
|
237
|
+
# told. Here, rather than where the page writes an answer, because an
|
|
238
|
+
# answer typed into the file by hand is drained the same way.
|
|
239
|
+
#
|
|
240
|
+
# A journal that cannot be written costs the record, never the drain.
|
|
241
|
+
#
|
|
242
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
243
|
+
# @return [void]
|
|
244
|
+
def journal_answers(subject)
|
|
245
|
+
return unless applying?
|
|
246
|
+
|
|
247
|
+
state = Plans::PlanState.for(subject.feature.path)
|
|
248
|
+
asker = state.blocked_by or return
|
|
249
|
+
text = subject.read("blocked.md").to_s
|
|
250
|
+
bodies = Plans::BlockedFile.bodies(text)
|
|
251
|
+
answers = Plans::BlockedFile.answers(text).transform_values { plain(it) }
|
|
252
|
+
self.class.questions(subject).each do |question|
|
|
253
|
+
answer = answers[question.number]
|
|
254
|
+
next if answer.nil? || answer.empty?
|
|
255
|
+
|
|
256
|
+
state.answer!(agent: asker,
|
|
257
|
+
ref: "B#{question.number}",
|
|
258
|
+
question: plain("#{question.heading} #{bodies[question.number]}"),
|
|
259
|
+
answer:)
|
|
260
|
+
end
|
|
261
|
+
rescue SystemCallError, OpenLanes::Error => e
|
|
262
|
+
UI.log("journal not written: #{e.message}", level: :warn, **log_fields(subject))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @param text [String] a block's body
|
|
266
|
+
# @return [String] one line, without the page's command markers
|
|
267
|
+
def plain(text) = Plans::Journal.clip(text.gsub(/<!--.*?-->/m, "").split.join(" "))
|
|
268
|
+
|
|
195
269
|
# The same pass the run loop makes after every round, for the same reason:
|
|
196
270
|
# a drained folder is only *named* differently once something reads the
|
|
197
271
|
# file the agent just deleted.
|
data/lib/openlanes/lifecycle.rb
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module OpenLanes
|
|
4
4
|
# Operations that change which plans exist or what they are called:
|
|
5
5
|
# minting a folder, drafting its brief, adopting an orphan pull request,
|
|
6
|
-
# reconciling names with contents,
|
|
6
|
+
# reconciling names with contents, draining answered questions, and
|
|
7
|
+
# aging the ones nobody answers.
|
|
7
8
|
module Lifecycle
|
|
8
9
|
module_function
|
|
9
10
|
|
|
@@ -12,6 +13,10 @@ module OpenLanes
|
|
|
12
13
|
# @return [Dry::Monads::Result] Success(path) or Failure(message)
|
|
13
14
|
def create(dir:, **) = Creator.new(dir:).create(**)
|
|
14
15
|
|
|
16
|
+
# @param options [Hash] passed to {CreateWorkflow#initialize}
|
|
17
|
+
# @return [OpenLanes::Lifecycle::CreateWorkflow] `openlanes create`, without the command
|
|
18
|
+
def create_plan(**) = CreateWorkflow.new(**)
|
|
19
|
+
|
|
15
20
|
# @param tree [OpenLanes::Plans::Tree]
|
|
16
21
|
# @return [OpenLanes::Lifecycle::Resync::Dirs]
|
|
17
22
|
def resync_dirs(tree:, **) = Resync::Dirs.new(tree:, **)
|
|
@@ -19,10 +24,41 @@ module OpenLanes
|
|
|
19
24
|
# @return [OpenLanes::Lifecycle::Resync::Prs]
|
|
20
25
|
def resync_prs(**) = Resync::Prs.new(**)
|
|
21
26
|
|
|
27
|
+
# `resync prs` as one step: retitle (and adopt) on GitHub, or say why not.
|
|
28
|
+
#
|
|
29
|
+
# @param commit [Boolean] false previews and changes nothing
|
|
30
|
+
# @param options [Hash] passed to {Resync::Prs#initialize}
|
|
31
|
+
# @return [Dry::Monads::Result] Success(Array<Resync::Prs::Change>), or
|
|
32
|
+
# Failure({Refusal}) with status 69 when GitHub refused, or 73 when an
|
|
33
|
+
# orphan would be adopted in a linked worktree, where no plan is minted
|
|
34
|
+
def retitle_prs(commit:, **)
|
|
35
|
+
Dry::Monads::Result::Success.new(Resync::Prs.new(**).call(commit:))
|
|
36
|
+
rescue Allocator::Refused => e
|
|
37
|
+
Dry::Monads::Result::Failure.new(Refusal.new(message: e.message, status: 73))
|
|
38
|
+
rescue OpenLanes::Error => e
|
|
39
|
+
Dry::Monads::Result::Failure.new(Refusal.new(message: e.message, status: 69))
|
|
40
|
+
end
|
|
41
|
+
|
|
22
42
|
# @return [OpenLanes::Lifecycle::Brief]
|
|
23
43
|
def brief(**) = Brief.new(**)
|
|
24
44
|
|
|
45
|
+
# @param options [Hash] passed to {UnblockWorkflow#initialize}
|
|
46
|
+
# @return [OpenLanes::Lifecycle::UnblockWorkflow] `openlanes unblock`, without the command
|
|
47
|
+
def unblock(**) = UnblockWorkflow.new(**)
|
|
48
|
+
|
|
25
49
|
# @return [OpenLanes::Lifecycle::Unblocker]
|
|
26
50
|
def unblocker(**) = Unblocker.new(**)
|
|
51
|
+
|
|
52
|
+
# Bomb or defer the blocked plans past their limits (plan 009).
|
|
53
|
+
#
|
|
54
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
55
|
+
# @param subjects [Array<OpenLanes::Plans::Subject>, nil] nil for every plan
|
|
56
|
+
# @param since [Date, nil] `plans.aging_since`
|
|
57
|
+
# @param now [Time]
|
|
58
|
+
# @param commit [Boolean] false reports and changes nothing
|
|
59
|
+
# @return [OpenLanes::Lifecycle::Aging::Result]
|
|
60
|
+
def age(tree:, subjects: nil, since: nil, now: Time.now, commit: true)
|
|
61
|
+
Aging.new(tree:, since:, now:).call(subjects:, commit:)
|
|
62
|
+
end
|
|
27
63
|
end
|
|
28
64
|
end
|
|
@@ -12,7 +12,7 @@ module OpenLanes
|
|
|
12
12
|
# contract, and the name we would prefer if the team happens to have one.
|
|
13
13
|
# {Push} resolves the name first and falls back to the type.
|
|
14
14
|
#
|
|
15
|
-
# Several states have no Linear equivalent at all —
|
|
15
|
+
# Several states have no Linear equivalent at all — ⭕ Technical Block is
|
|
16
16
|
# a reason, not a position in a workflow — so they map to the nearest
|
|
17
17
|
# position and carry a label saying which reason.
|
|
18
18
|
#
|
|
@@ -50,11 +50,14 @@ module OpenLanes
|
|
|
50
50
|
building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
|
|
51
51
|
ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
|
|
52
52
|
in_review: Placement.new(type: "started", name: "In Review", labels: []),
|
|
53
|
+
verifying: Placement.new(type: "started", name: "In Review", labels: []),
|
|
54
|
+
completed: Placement.new(type: "started", name: "In Review", labels: %w[ready-to-merge]),
|
|
53
55
|
rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
|
|
54
56
|
approved: Placement.new(type: "completed", name: "Done", labels: []),
|
|
55
57
|
deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
|
|
56
58
|
blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
|
|
57
59
|
product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
|
|
60
|
+
bomb: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked bomb]),
|
|
58
61
|
deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
|
|
59
62
|
retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
|
|
60
63
|
discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
|
|
@@ -55,7 +55,7 @@ module OpenLanes
|
|
|
55
55
|
# the moment the work moves.
|
|
56
56
|
GLYPH_NOTE = /\s*\((?:[^\w\s(][^)]*)\)\s*\z/
|
|
57
57
|
|
|
58
|
-
# The same thing without the brackets: `… over the law plane
|
|
58
|
+
# The same thing without the brackets: `… over the law plane 💤 deferred`.
|
|
59
59
|
TRAILING_GLYPH = /\s*[\u{2190}-\u{2BFF}\u{1F000}-\u{1FAFF}][^\n]*\z/
|
|
60
60
|
|
|
61
61
|
# A plan number the author typed into the heading as well. The issue for
|
data/lib/openlanes/live/chat.rb
CHANGED
|
@@ -95,6 +95,7 @@ module OpenLanes
|
|
|
95
95
|
@outgoing = {}
|
|
96
96
|
@beat = nil
|
|
97
97
|
@running = false
|
|
98
|
+
@unavailable = nil
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
# @return [String]
|
|
@@ -109,12 +110,7 @@ module OpenLanes
|
|
|
109
110
|
@running = true
|
|
110
111
|
@thread = Thread.new do
|
|
111
112
|
while @running
|
|
112
|
-
|
|
113
|
-
step(POLL)
|
|
114
|
-
rescue Unavailable
|
|
115
|
-
close_subscriber
|
|
116
|
-
sleep 1
|
|
117
|
-
end
|
|
113
|
+
listen(POLL) or sleep 1
|
|
118
114
|
end
|
|
119
115
|
end
|
|
120
116
|
self
|
|
@@ -135,7 +131,8 @@ module OpenLanes
|
|
|
135
131
|
end
|
|
136
132
|
|
|
137
133
|
# Handle whatever has arrived, waiting up to +timeout+ for the first,
|
|
138
|
-
# then expire, prune and heartbeat
|
|
134
|
+
# then expire, prune and heartbeat, and nudge the event streams when a
|
|
135
|
+
# page was handed something to show.
|
|
139
136
|
#
|
|
140
137
|
# @param timeout [Float]
|
|
141
138
|
# @return [void]
|
|
@@ -147,8 +144,31 @@ module OpenLanes
|
|
|
147
144
|
message = sub.next_message(0)
|
|
148
145
|
end
|
|
149
146
|
housekeep
|
|
147
|
+
nudge
|
|
150
148
|
end
|
|
151
149
|
|
|
150
|
+
# {#step}, kept from raising when Redis refuses the subscription (an ACL
|
|
151
|
+
# without `PSUBSCRIBE` answers `NOPERM`) or drops the connection. The
|
|
152
|
+
# board does not need the chat, so the server carries on serving it:
|
|
153
|
+
# one warning per outage, `unavailable` in {#state} until a step works
|
|
154
|
+
# again, and a fresh subscription on the next try.
|
|
155
|
+
#
|
|
156
|
+
# @param timeout [Float]
|
|
157
|
+
# @return [Boolean] whether the step went through
|
|
158
|
+
def listen(timeout = 0)
|
|
159
|
+
step(timeout)
|
|
160
|
+
@lock.synchronize { @unavailable = nil }
|
|
161
|
+
true
|
|
162
|
+
rescue Unavailable => e
|
|
163
|
+
close_subscriber
|
|
164
|
+
first = @lock.synchronize { @unavailable.nil?.tap { @unavailable = e.message } }
|
|
165
|
+
warn_unavailable(e.message) if first
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [String, nil] why the chat cannot listen, while it cannot
|
|
170
|
+
def unavailable = @lock.synchronize { @unavailable }
|
|
171
|
+
|
|
152
172
|
# ---- the event stream -------------------------------------------------
|
|
153
173
|
|
|
154
174
|
# A page's event stream opened.
|
|
@@ -184,7 +204,8 @@ module OpenLanes
|
|
|
184
204
|
# ---- what the page asks -----------------------------------------------
|
|
185
205
|
|
|
186
206
|
# `ChatState`: the chat this page is in, the invites open to this
|
|
187
|
-
# machine,
|
|
207
|
+
# machine, the ones this page is waiting on, and, while Redis refuses
|
|
208
|
+
# the subscription, why the chat is unavailable.
|
|
188
209
|
#
|
|
189
210
|
# @param page [String]
|
|
190
211
|
# @return [Hash]
|
|
@@ -194,7 +215,8 @@ module OpenLanes
|
|
|
194
215
|
chat = me.chat && { id: me.chat, me: participant(me), participants: roster(me.chat) }
|
|
195
216
|
{ chat:,
|
|
196
217
|
invites: @incoming.values.map { it[:invite] },
|
|
197
|
-
outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) }
|
|
218
|
+
outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) },
|
|
219
|
+
unavailable: @unavailable }
|
|
198
220
|
end
|
|
199
221
|
end
|
|
200
222
|
|
|
@@ -533,6 +555,20 @@ module OpenLanes
|
|
|
533
555
|
def push(page, event)
|
|
534
556
|
page.events << event
|
|
535
557
|
page.events.shift(page.events.size - BACKLOG) if page.events.size > BACKLOG
|
|
558
|
+
@pushed = true
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# A page's events wait in this process, where an event stream blocked
|
|
562
|
+
# on {Keys#news} cannot see them, so a step that queued any says so on
|
|
563
|
+
# that channel: one nudge per step, however many it queued. Nearly
|
|
564
|
+
# every push happens inside a step, on the listening thread; the one
|
|
565
|
+
# that does not (an invite nobody was there to hear, expired on the
|
|
566
|
+
# request's own thread) is nudged by the next step, {POLL} later.
|
|
567
|
+
#
|
|
568
|
+
# @return [void]
|
|
569
|
+
def nudge
|
|
570
|
+
pushed = @lock.synchronize { @pushed.tap { @pushed = false } }
|
|
571
|
+
News.publish(store:, keys: @keys, kind: "chat") if pushed
|
|
536
572
|
end
|
|
537
573
|
|
|
538
574
|
# @return [Hash] `ChatParticipant`
|
|
@@ -586,10 +622,23 @@ module OpenLanes
|
|
|
586
622
|
end
|
|
587
623
|
|
|
588
624
|
# @return [Integer] receivers
|
|
589
|
-
def publish(channel, data) = store.publish(channel, JSON.generate(data))
|
|
625
|
+
def publish(channel, data) = EventBus.for(store).publish(channel, JSON.generate(data))
|
|
590
626
|
|
|
591
627
|
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
592
|
-
def subscriber = @subscriber ||= store.
|
|
628
|
+
def subscriber = @subscriber ||= EventBus.for(store).subscribe(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
|
|
629
|
+
|
|
630
|
+
# {UI.log} alone reaches only a log file or a tap, and `openlanes web`
|
|
631
|
+
# sets up neither, so there the one warning an outage gets would be
|
|
632
|
+
# dropped and nobody would learn why the chat went dark. With nothing
|
|
633
|
+
# listening it goes to STDERR instead.
|
|
634
|
+
#
|
|
635
|
+
# @param reason [String]
|
|
636
|
+
# @return [void]
|
|
637
|
+
def warn_unavailable(reason)
|
|
638
|
+
message = "chat unavailable: #{reason}"
|
|
639
|
+
UI.log(message)
|
|
640
|
+
UI.line(message, bullet: "!") unless UI.log_path || UI.log_taps.any?
|
|
641
|
+
end
|
|
593
642
|
|
|
594
643
|
# @return [void]
|
|
595
644
|
def close_subscriber
|