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,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# What the engine does with a 🏁 Completed plan once palpatine has ruled
|
|
6
|
+
# (decisions P1 to P5).
|
|
7
|
+
#
|
|
8
|
+
# palpatine only writes `plan.md` and signs. Everything that leaves the
|
|
9
|
+
# machine happens here: labelling each `ready-to-merge` pull request
|
|
10
|
+
# `auto-merged` or `merge-manually`, merging the `auto` ones one at a
|
|
11
|
+
# time under `merge.by: auto`, and rebasing the siblings left open onto
|
|
12
|
+
# `main` after each merge. A rebase that raises leaves that sibling for a
|
|
13
|
+
# person, and every other open sibling is still rebased or left in the
|
|
14
|
+
# same pass, rather than retrying the merge on old green CI. A branch
|
|
15
|
+
# GitHub still reports as `BEHIND` is never merged. Every step is asked of
|
|
16
|
+
# GitHub as it stands, so {#advance} may be called on every poll and does
|
|
17
|
+
# nothing twice. That is also how a sibling waits for CI on its rebased
|
|
18
|
+
# head, and how a person's merge of a `merge-manually` pull request is
|
|
19
|
+
# noticed in either mode.
|
|
20
|
+
#
|
|
21
|
+
# The plan reaches ✅ through {PrOutcomes}, once nothing is left open,
|
|
22
|
+
# exactly as when a person merges the last pull request by hand.
|
|
23
|
+
class Merging
|
|
24
|
+
# The phase an agent definition declares to be the risk assessor. Its
|
|
25
|
+
# Completed note carries the verdicts.
|
|
26
|
+
PHASE = "completion"
|
|
27
|
+
|
|
28
|
+
# The two verdicts palpatine may give.
|
|
29
|
+
VERDICTS = %w[auto manual].freeze
|
|
30
|
+
|
|
31
|
+
# One verdict, with or without the pull request it is for.
|
|
32
|
+
VERDICT = /(auto|manual)(?:\s+#?(\d+))?/i
|
|
33
|
+
|
|
34
|
+
# The only notes read at all: `auto`, `manual #13`, `auto #12, manual #13`,
|
|
35
|
+
# with at most one bare verdict, first. Matched against the whole note,
|
|
36
|
+
# because a scan found the `auto` in "do not auto-merge" and would have
|
|
37
|
+
# merged the plan.
|
|
38
|
+
NOTE = /\A\s*#{VERDICT}(?:\s*,\s*(?:auto|manual)\s+#?\d+)*\s*\z/i
|
|
39
|
+
|
|
40
|
+
# How long a rebased sibling's zero checks still mean "CI has not
|
|
41
|
+
# registered on the new head yet", as {Verification::Outcome::NO_CHECKS_GRACE}
|
|
42
|
+
# does for the verification commit.
|
|
43
|
+
NO_CHECKS_GRACE = Verification::Outcome::NO_CHECKS_GRACE
|
|
44
|
+
|
|
45
|
+
# Who signs the comment carrying a rebase conflict.
|
|
46
|
+
SENDER = "openlanes"
|
|
47
|
+
|
|
48
|
+
# What one call did, for the log.
|
|
49
|
+
#
|
|
50
|
+
# @!attribute [r] note
|
|
51
|
+
# @return [String]
|
|
52
|
+
# @!attribute [r] to
|
|
53
|
+
# @return [Symbol, nil] the state the folder moved to, nil when it stayed
|
|
54
|
+
# @!attribute [r] merged
|
|
55
|
+
# @return [Boolean] whether the engine merged a pull request in this pass
|
|
56
|
+
Result = Data.define(:note, :to, :merged) do
|
|
57
|
+
def initialize(to: nil, merged: false, **rest) = super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# The verdict for each pull request, read off palpatine's note. A
|
|
62
|
+
# bare verdict covers every pull request it does not name; a pull
|
|
63
|
+
# request the note says nothing about, or a note that is missing or
|
|
64
|
+
# not in the {NOTE} shape, is `manual` (P4): a wrong `manual` costs a
|
|
65
|
+
# person ten minutes, a wrong `auto` can ship an outage.
|
|
66
|
+
#
|
|
67
|
+
# @param note [String, nil]
|
|
68
|
+
# @param numbers [Array<Integer>] the `ready-to-merge` pull requests
|
|
69
|
+
# @return [Hash{Integer => String}]
|
|
70
|
+
def verdicts(note, numbers)
|
|
71
|
+
return numbers.to_h { [it, "manual"] } unless readable?(note)
|
|
72
|
+
|
|
73
|
+
found = note.to_s.scan(VERDICT).map { |word, number| [word.downcase, number&.to_i] }
|
|
74
|
+
named = found.select(&:last).to_h { |word, number| [number, word] }
|
|
75
|
+
bare = found.find { |_, number| number.nil? }&.first || "manual"
|
|
76
|
+
numbers.to_h { |n| [n, named.fetch(n, bare)] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param note [String, nil]
|
|
80
|
+
# @return [Boolean] whether the note is in the {NOTE} shape
|
|
81
|
+
def readable?(note) = note.to_s.match?(NOTE)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param github [Vcs::GitHub]
|
|
85
|
+
# @param outcomes [PrOutcomes] records merges and rejections, and moves the folder
|
|
86
|
+
# @param merge_by [#call, Symbol] {Config#merger}, read live
|
|
87
|
+
# @param worktree [Vcs::Worktree, nil] where a plan's own branch is checked out
|
|
88
|
+
# @param publisher [Vcs::Publisher, nil] pushes a rebased branch, with the lease
|
|
89
|
+
# @param clock [#call]
|
|
90
|
+
# @param held [#call, nil] path -> who holds an alock lock in it, or nil;
|
|
91
|
+
# a merged plan's checkout somebody holds is not retired
|
|
92
|
+
def initialize(github:, outcomes:, merge_by:, worktree: nil, publisher: nil, clock: -> { Clock::System.now }, held: nil)
|
|
93
|
+
@github = github
|
|
94
|
+
@outcomes = outcomes
|
|
95
|
+
@merge_by = merge_by
|
|
96
|
+
@worktree = worktree
|
|
97
|
+
@publisher = publisher
|
|
98
|
+
@clock = clock
|
|
99
|
+
@held = held
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# palpatine signed, or its run ended without a signature: keep the
|
|
103
|
+
# verdicts for this visit to 🏁, then act on them.
|
|
104
|
+
#
|
|
105
|
+
# @param subject [Plans::Subject] at 🏁
|
|
106
|
+
# @param note [String, nil] palpatine's Completed note; nil reads as `manual` throughout
|
|
107
|
+
# @return [Result]
|
|
108
|
+
def assess(subject, note)
|
|
109
|
+
numbers = subject.pull_requests.select(&:open?).map { number_of(it) }
|
|
110
|
+
unless note.nil? || self.class.readable?(note)
|
|
111
|
+
UI.log("palpatine's note is not a verdict list, so every pull request is manual: #{note.inspect}",
|
|
112
|
+
level: :warn,
|
|
113
|
+
plan: subject.feature.ordinal.to_s)
|
|
114
|
+
end
|
|
115
|
+
Plans::PlanState.for(subject.feature.path).assess!(self.class.verdicts(note, numbers), at: @clock.call)
|
|
116
|
+
advance(subject)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param subject [Plans::Subject]
|
|
120
|
+
# @return [Boolean] whether palpatine has ruled on this visit to 🏁, so
|
|
121
|
+
# nobody is to start it again
|
|
122
|
+
def assessed?(subject) = subject.status.key == :completed && !record(subject).nil?
|
|
123
|
+
|
|
124
|
+
# One pass over a 🏁 plan's pull requests. At most one merge per pass:
|
|
125
|
+
# the siblings are rebased after it, and each must show green CI on
|
|
126
|
+
# its new head before it merges in turn.
|
|
127
|
+
#
|
|
128
|
+
# @param subject [Plans::Subject] at 🏁, assessed
|
|
129
|
+
# @return [Result]
|
|
130
|
+
def advance(subject)
|
|
131
|
+
record = record(subject) or return Result.new(note: "palpatine has not ruled yet")
|
|
132
|
+
|
|
133
|
+
notes = []
|
|
134
|
+
subject.pull_requests.select(&:open?).each do |pull|
|
|
135
|
+
step = step(subject, pull, record)
|
|
136
|
+
next unless step
|
|
137
|
+
|
|
138
|
+
notes << step.note
|
|
139
|
+
return Result.new(note: notes.join("; "), to: step.to, merged: step.merged) if step.to || step.merged
|
|
140
|
+
end
|
|
141
|
+
Result.new(note: notes.empty? ? "waiting on every pull request" : notes.join("; "))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The plans whose checkouts wait on a rebase after a merge.
|
|
145
|
+
#
|
|
146
|
+
# @return [Array<String>] ordinals
|
|
147
|
+
def pending_rebases = queued.keys
|
|
148
|
+
|
|
149
|
+
# Rebase every queued checkout no agent is running in onto `main`, and
|
|
150
|
+
# push the ones that moved. A busy checkout stays queued, so its
|
|
151
|
+
# `HEAD` moves only between runs, before its next boundary snapshot.
|
|
152
|
+
# A conflict is aborted and left for that plan's next round, whose own
|
|
153
|
+
# pre-run rebase hands it to bb8; uncommitted work is refused by
|
|
154
|
+
# {Vcs::Refresh} and never touched.
|
|
155
|
+
#
|
|
156
|
+
# @param busy [Array<String>] ordinals with an agent running
|
|
157
|
+
# @return [Array<String>] one line per checkout handled, for the log
|
|
158
|
+
def rebase_queued(busy:)
|
|
159
|
+
(queued.keys - busy).map do |ordinal|
|
|
160
|
+
other = queued.delete(ordinal)
|
|
161
|
+
rebase_checkout(other)
|
|
162
|
+
rescue OpenLanes::Error => e
|
|
163
|
+
"#{ordinal} not rebased onto main: #{e.message.lines.first.to_s.strip}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# @param other [Plans::Subject]
|
|
170
|
+
# @return [String]
|
|
171
|
+
def rebase_checkout(other)
|
|
172
|
+
feature = other.feature
|
|
173
|
+
checkout = @worktree.checkout_for(feature)
|
|
174
|
+
refresh = Vcs::Refresh.new(path: checkout.path)
|
|
175
|
+
outcome = refresh.call
|
|
176
|
+
ordinal = feature.ordinal.to_s
|
|
177
|
+
if outcome.conflicted?
|
|
178
|
+
refresh.abort!("conflicts after a merge to main")
|
|
179
|
+
return "#{ordinal} conflicts with main in #{outcome.files.join(", ")}; its next round resolves it"
|
|
180
|
+
end
|
|
181
|
+
return "#{ordinal} not rebased: #{outcome.reason}" unless outcome.done?
|
|
182
|
+
return "#{ordinal} already on main" if outcome.kind == :current
|
|
183
|
+
# A branch with no pull request was never published, and a rebase
|
|
184
|
+
# is no reason to publish it.
|
|
185
|
+
return "#{ordinal} rebased onto main" unless other.pull_requests.any?(&:open?)
|
|
186
|
+
|
|
187
|
+
@publisher.pushed!(checkout, "HEAD")
|
|
188
|
+
"#{ordinal} rebased onto main"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# One pull request, one step along its machine.
|
|
192
|
+
#
|
|
193
|
+
# @return [Result, nil] nil when there is nothing to do for it now
|
|
194
|
+
def step(subject, pull, record)
|
|
195
|
+
number = number_of(pull)
|
|
196
|
+
repo = Vcs::GitHub.repo_of(pull.url)
|
|
197
|
+
live = @github.pull_request(pull.url || number.to_s)
|
|
198
|
+
return settled_by_someone(subject, number, live[:state], live:) unless live[:open]
|
|
199
|
+
|
|
200
|
+
machine = Plans::PullRequestMachine.new(pull, live[:labels])
|
|
201
|
+
return nil unless machine.key == :ready_to_merge
|
|
202
|
+
|
|
203
|
+
verdict = record.dig("verdicts", number.to_s) || "manual"
|
|
204
|
+
return leave(machine, number, repo, "palpatine said manual") if verdict == "manual"
|
|
205
|
+
return leave(machine, number, repo, "merge.by is #{merge_by}") unless merge_by == :auto
|
|
206
|
+
|
|
207
|
+
merge(subject, machine, number, repo, record, live[:head], merge_state: live[:merge_state])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Merged or closed on GitHub, by the engine a moment ago or by a person
|
|
211
|
+
# at any time, in either mode.
|
|
212
|
+
#
|
|
213
|
+
# @param state [String] as {Vcs::GitHub.state_label} words it
|
|
214
|
+
# @param live [Hash, nil] the pull request as {Vcs::GitHub#pull_request} read it
|
|
215
|
+
# @param ref [String, nil] how to read it when +live+ is nil
|
|
216
|
+
# @return [Result]
|
|
217
|
+
def settled_by_someone(subject, number, state, live: nil, ref: nil)
|
|
218
|
+
kind = state.to_s.match?(/merged/i) ? :merged : :closed
|
|
219
|
+
applied = @outcomes.apply(PrWatch::Outcome.new(plan: subject.feature.ordinal.to_s, pr: number, kind:))
|
|
220
|
+
retire(subject, number, live || ref) if kind == :merged
|
|
221
|
+
Result.new(note: "PR ##{number} #{kind}", to: applied&.to)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A merged pull request ends its plan's worktree, once it is the
|
|
225
|
+
# plan's last open one: a sibling still open is rebased in that
|
|
226
|
+
# checkout after this merge. {Vcs::Worktree#retire} decides whether
|
|
227
|
+
# each checkout's work really landed; this only says when to ask.
|
|
228
|
+
# Never fatal: a checkout left behind costs disk, and a merge that
|
|
229
|
+
# failed over one would cost the plan.
|
|
230
|
+
#
|
|
231
|
+
# @param pull [Hash, String, nil] the merged pull request as read, or a ref to read it by
|
|
232
|
+
# @return [void]
|
|
233
|
+
def retire(subject, number, pull)
|
|
234
|
+
return if @worktree.nil? || pull.nil?
|
|
235
|
+
return if fresh(subject).pull_requests.any? { it.open? && number_of(it) != number }
|
|
236
|
+
|
|
237
|
+
ordinal = subject.feature.ordinal.to_s
|
|
238
|
+
live = pull.is_a?(Hash) ? pull : @github.pull_request(pull)
|
|
239
|
+
return if live[:merge_commit].to_s.empty?
|
|
240
|
+
|
|
241
|
+
@worktree.checkouts_of(subject.feature).each do |path|
|
|
242
|
+
UI.log(@worktree.retire(path, merge_commit: live[:merge_commit], base: live[:base], held: @held).to_s, plan: ordinal)
|
|
243
|
+
end
|
|
244
|
+
rescue OpenLanes::Error => e
|
|
245
|
+
UI.log("worktree not retired: #{e.message.lines.first.to_s.strip}", level: :warn, plan: ordinal)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @param comment [Boolean] post +why+ on the pull request, so a person
|
|
249
|
+
# looking at it sees why the engine stepped away
|
|
250
|
+
# @return [Result]
|
|
251
|
+
def leave(machine, number, repo, why, comment: false)
|
|
252
|
+
machine.fire!(:leave_for_a_person)
|
|
253
|
+
@github.swap_label(number:,
|
|
254
|
+
repo:,
|
|
255
|
+
add: Plans::ReviewLabel::MERGE_MANUALLY,
|
|
256
|
+
remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::MERGE_MANUALLY))
|
|
257
|
+
@github.comment(number:, repo:, body: why) if comment
|
|
258
|
+
Result.new(note: "PR ##{number} #{Plans::ReviewLabel::MERGE_MANUALLY}: #{why}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Merge once CI is green on the head as it stands, then rebase the
|
|
262
|
+
# siblings. A merge GitHub refuses, a conflict among the reasons, is
|
|
263
|
+
# left for a person rather than retried every poll. So is one with no
|
|
264
|
+
# CI at all: nobody watches this merge, and no checks is no evidence.
|
|
265
|
+
# The merge names the head read before the checks, so a push in
|
|
266
|
+
# between makes GitHub refuse it rather than merge a head CI never saw.
|
|
267
|
+
# A branch still behind `main` is the same: CI on that head never saw
|
|
268
|
+
# the commit just merged, so a person rebases it.
|
|
269
|
+
#
|
|
270
|
+
# @param head [String, nil] the head commit, as read before the checks
|
|
271
|
+
# @param merge_state [String, nil] GitHub's `mergeStateStatus`
|
|
272
|
+
# @return [Result, nil]
|
|
273
|
+
def merge(subject, machine, number, repo, record, head, merge_state: nil)
|
|
274
|
+
ci = Vcs::CiGate.verdict(@github.checks(number:, repo:))
|
|
275
|
+
ci = :registering if ci == :none && registering?(record, number)
|
|
276
|
+
return Result.new(note: "PR ##{number} waits on CI before it merges") if %i[pending registering].include?(ci)
|
|
277
|
+
return reject(subject, number, repo, "CI is red on PR ##{number} after its rebase onto main.") if ci == :red
|
|
278
|
+
return leave(machine, number, repo, "no CI checks ran") if ci == :none
|
|
279
|
+
return leave(machine, number, repo, "its head commit could not be read") if head.to_s.empty?
|
|
280
|
+
return leave(machine, number, repo, "its branch is behind main", comment: true) if behind?(merge_state)
|
|
281
|
+
return leave(machine, number, repo, "it does not merge clean onto main", comment: true) if dirty?(merge_state)
|
|
282
|
+
return Result.new(note: "PR ##{number} waits on GitHub to work out whether it merges clean") if unknown?(merge_state)
|
|
283
|
+
|
|
284
|
+
main = main_ci(repo)
|
|
285
|
+
return Result.new(note: "PR ##{number} waits on CI on main, which is #{main}") unless main == :green
|
|
286
|
+
|
|
287
|
+
begin
|
|
288
|
+
@github.merge(number:, repo:, head:)
|
|
289
|
+
rescue OpenLanes::Error => e
|
|
290
|
+
return leave(machine, number, repo, e.message)
|
|
291
|
+
end
|
|
292
|
+
queue_other_plans(subject)
|
|
293
|
+
machine.fire!(:merge_automatically)
|
|
294
|
+
@github.swap_label(number:,
|
|
295
|
+
repo:,
|
|
296
|
+
add: Plans::ReviewLabel::AUTO_MERGED,
|
|
297
|
+
remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::AUTO_MERGED))
|
|
298
|
+
merged = settled_by_someone(subject, number, "Merged", ref: repo ? "https://github.com/#{repo}/pull/#{number}" : number.to_s)
|
|
299
|
+
done = Result.new(note: "PR ##{number} #{Plans::ReviewLabel::AUTO_MERGED}", to: merged.to, merged: true)
|
|
300
|
+
return done if merged.to
|
|
301
|
+
|
|
302
|
+
rebased = rebase_siblings(fresh(subject), number)
|
|
303
|
+
rebased ? rebased.with(note: "#{done.note}; #{rebased.note}", merged: true) : done
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Every pull request of the plan still open, onto `main` (P5). The
|
|
307
|
+
# plan's own branch is rebased in its worktree and pushed with the
|
|
308
|
+
# lease; a sibling from any other branch is rebased on GitHub's side.
|
|
309
|
+
# A conflict still sends the plan to 🔴. Anything else that raises
|
|
310
|
+
# (a refused push, a GitHub update that did not finish) leaves that
|
|
311
|
+
# sibling for a person, and the walk continues: returning from the
|
|
312
|
+
# first raise used to skip later siblings, which kept `ready-to-merge`
|
|
313
|
+
# and the old green CI. GitHub's `BEHIND` is silent unless the base
|
|
314
|
+
# requires branches to be up to date, so each sibling must be rebased
|
|
315
|
+
# or left in this pass.
|
|
316
|
+
#
|
|
317
|
+
# @return [Result, nil] the rejection a conflict caused, or the leaves a
|
|
318
|
+
# rebase that raised caused; nil when every sibling rebased
|
|
319
|
+
def rebase_siblings(subject, merged)
|
|
320
|
+
notes = []
|
|
321
|
+
subject.pull_requests.select(&:open?).each do |pull|
|
|
322
|
+
number = number_of(pull)
|
|
323
|
+
repo = Vcs::GitHub.repo_of(pull.url)
|
|
324
|
+
why = rebase(subject, pull, number, repo)
|
|
325
|
+
return reject(subject, number, repo, why) if why
|
|
326
|
+
|
|
327
|
+
Plans::PlanState.for(subject.feature.path).rebased!(number, at: @clock.call)
|
|
328
|
+
rescue OpenLanes::Error => e
|
|
329
|
+
UI.log("rebase after merging ##{merged} not finished: #{e.message}",
|
|
330
|
+
level: :warn,
|
|
331
|
+
plan: subject.feature.ordinal.to_s)
|
|
332
|
+
notes << leave_unrebased(pull, number, repo, e.message).note
|
|
333
|
+
end
|
|
334
|
+
notes.empty? ? nil : Result.new(note: notes.join("; "))
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# @return [Result]
|
|
338
|
+
def leave_unrebased(pull, number, repo, error)
|
|
339
|
+
live = @github.pull_request(pull.url || number.to_s)
|
|
340
|
+
machine = Plans::PullRequestMachine.new(pull, live[:labels])
|
|
341
|
+
leave(machine, number, repo, "rebase onto main failed: #{error}", comment: true)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @param status [String, nil] GitHub's `mergeStateStatus`
|
|
345
|
+
# @return [Boolean]
|
|
346
|
+
def behind?(status) = status.to_s.upcase == "BEHIND"
|
|
347
|
+
|
|
348
|
+
# @param status [String, nil] GitHub's `mergeStateStatus`
|
|
349
|
+
# @return [Boolean] whether the merge would conflict
|
|
350
|
+
def dirty?(status) = status.to_s.upcase == "DIRTY"
|
|
351
|
+
|
|
352
|
+
# @param status [String, nil] GitHub's `mergeStateStatus`
|
|
353
|
+
# @return [Boolean] whether GitHub is still computing it
|
|
354
|
+
def unknown?(status) = status.to_s.upcase == "UNKNOWN"
|
|
355
|
+
|
|
356
|
+
# CI on `main` (R5). No checks at all reads as `:none`, which holds
|
|
357
|
+
# the merge like red does: nobody watches it, and no checks is no
|
|
358
|
+
# evidence `main` builds.
|
|
359
|
+
#
|
|
360
|
+
# @return [Symbol] as {Vcs::CiGate.verdict}
|
|
361
|
+
def main_ci(repo) = Vcs::CiGate.verdict(@github.branch_checks(branch: "main", repo:))
|
|
362
|
+
|
|
363
|
+
# Every other plan whose checkout exists, to be rebased onto `main`
|
|
364
|
+
# once no agent is running in it (R5). Never done here: a rebase moves
|
|
365
|
+
# `HEAD`, and an agent's after-check would blame the agent for it.
|
|
366
|
+
#
|
|
367
|
+
# @return [void]
|
|
368
|
+
def queue_other_plans(subject)
|
|
369
|
+
return if @worktree.nil? || @publisher.nil?
|
|
370
|
+
|
|
371
|
+
Plans::Tree.new(dir: File.dirname(subject.feature.path)).subjects.each do |other|
|
|
372
|
+
next if other.feature.ordinal == subject.feature.ordinal || other.status.terminal?
|
|
373
|
+
next unless File.directory?(@worktree.path_for(other.feature))
|
|
374
|
+
|
|
375
|
+
queued[other.feature.ordinal.to_s] = other
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# @return [Hash{String => Plans::Subject}] checkouts waiting on a rebase, by plan
|
|
380
|
+
def queued = @queued ||= {}
|
|
381
|
+
|
|
382
|
+
# @return [String, nil] why the rebase failed, nil when it went through
|
|
383
|
+
def rebase(subject, pull, number, repo)
|
|
384
|
+
checkout = own_checkout(subject, pull) or return github_rebase(number, repo)
|
|
385
|
+
|
|
386
|
+
refresh = Vcs::Refresh.new(path: checkout.path)
|
|
387
|
+
outcome = refresh.call
|
|
388
|
+
if outcome.conflicted?
|
|
389
|
+
refresh.abort!("conflicts after a sibling merged")
|
|
390
|
+
return "PR ##{number} no longer rebases onto #{outcome.base}: conflicts in #{outcome.files.join(", ")}."
|
|
391
|
+
end
|
|
392
|
+
# Skipped or refused locally (a dirty tree, no remote): GitHub can
|
|
393
|
+
# still rebase the branch it holds, and says so if it conflicts.
|
|
394
|
+
return github_rebase(number, repo) unless outcome.done?
|
|
395
|
+
|
|
396
|
+
@publisher.pushed!(checkout, "HEAD") unless outcome.kind == :current
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# The plan's own checkout, when it already exists and builds this
|
|
401
|
+
# pull request. Never made here: a poll that minted a worktree per
|
|
402
|
+
# 🏁 plan would leave one behind for every plan a person merges.
|
|
403
|
+
#
|
|
404
|
+
# @return [Vcs::Worktree::Checkout, nil]
|
|
405
|
+
def own_checkout(subject, pull)
|
|
406
|
+
return nil if @worktree.nil? || @publisher.nil? || !File.directory?(@worktree.path_for(subject.feature))
|
|
407
|
+
|
|
408
|
+
checkout = @worktree.checkout_for(subject.feature)
|
|
409
|
+
@publisher.branch_mismatch(pull, checkout) ? nil : checkout
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# @return [String, nil]
|
|
413
|
+
def github_rebase(number, repo)
|
|
414
|
+
@github.update_branch(number:, repo:)
|
|
415
|
+
nil
|
|
416
|
+
rescue OpenLanes::Error => e
|
|
417
|
+
"PR ##{number} no longer rebases onto main: #{e.message}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# A conflict or red CI after a rebase: `review-rejected` on the pull
|
|
421
|
+
# request, and the plan to 🔴 with the reason mailed to luke and rey,
|
|
422
|
+
# through {PrOutcomes} exactly as a person's rejection is.
|
|
423
|
+
#
|
|
424
|
+
# @return [Result]
|
|
425
|
+
def reject(subject, number, repo, why)
|
|
426
|
+
@github.swap_label(number:,
|
|
427
|
+
repo:,
|
|
428
|
+
add: Plans::ReviewLabel::REJECTED,
|
|
429
|
+
remove: Plans::ReviewLabel.displaced_by(Plans::ReviewLabel::REJECTED))
|
|
430
|
+
@github.comment(number:, repo:, body: why)
|
|
431
|
+
comments = [{ author: SENDER, url: "", body: why }]
|
|
432
|
+
applied = @outcomes.apply(PrWatch::Outcome.new(plan: subject.feature.ordinal.to_s, pr: number, kind: :rejected, comments:))
|
|
433
|
+
Result.new(note: "PR ##{number} back to the builders: #{why}", to: applied&.to)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Whether a rebased sibling's push is recent enough that CI may not
|
|
437
|
+
# have registered on its new head yet.
|
|
438
|
+
#
|
|
439
|
+
# @return [Boolean]
|
|
440
|
+
def registering?(record, number)
|
|
441
|
+
at = record.dig("rebased", number.to_s) or return false
|
|
442
|
+
|
|
443
|
+
@clock.call - Time.iso8601(at) < NO_CHECKS_GRACE
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# @return [Hash, nil]
|
|
447
|
+
def record(subject) = Plans::PlanState.for(subject.feature.path).merge_risk
|
|
448
|
+
|
|
449
|
+
# @return [Symbol]
|
|
450
|
+
def merge_by = @merge_by.respond_to?(:call) ? @merge_by.call : @merge_by
|
|
451
|
+
|
|
452
|
+
# @return [Integer]
|
|
453
|
+
def number_of(pull) = Vcs::GitHub.number_of(pull.url || pull.number.to_s)
|
|
454
|
+
|
|
455
|
+
# @return [Plans::Subject]
|
|
456
|
+
def fresh(subject) = Plans::Tree.new(dir: File.dirname(subject.feature.path)).find(subject.feature.ordinal.to_s) || subject
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -23,8 +23,8 @@ module OpenLanes
|
|
|
23
23
|
# @example
|
|
24
24
|
# narrator = Narrator.new
|
|
25
25
|
# UI.tap_log(narrator)
|
|
26
|
-
# UI.log("started", plan: "003.00", status: "
|
|
27
|
-
# # 13:39:10 003.00
|
|
26
|
+
# UI.log("started", plan: "003.00", status: "⭐ Planned", agent: "yoda-writer", round: "01")
|
|
27
|
+
# # 13:39:10 003.00 ⭐ Planned yoda-writer r01 started
|
|
28
28
|
class Narrator
|
|
29
29
|
# Between the parts of a line. Two spaces read as columns without
|
|
30
30
|
# holding open the ones a line does not have.
|
|
@@ -37,7 +37,7 @@ module OpenLanes
|
|
|
37
37
|
# @param stream [#puts, nil] where the lines go; nil means whatever
|
|
38
38
|
# `$stderr` is when a line is written, so a spec swapping it is heard
|
|
39
39
|
# @param clock [Proc] wall time, injectable for a fixed timestamp
|
|
40
|
-
def initialize(stream: nil, clock: -> {
|
|
40
|
+
def initialize(stream: nil, clock: -> { Clock::System.now })
|
|
41
41
|
@stream = stream
|
|
42
42
|
@clock = clock
|
|
43
43
|
@lock = Mutex.new
|
|
@@ -79,7 +79,7 @@ module OpenLanes
|
|
|
79
79
|
# @param agent [String, nil]
|
|
80
80
|
# @param round [String, nil] e.g. "01"
|
|
81
81
|
# @param to [String, nil] the state the plan moved to, emoji and label
|
|
82
|
-
# @return [String] e.g. `003.00
|
|
82
|
+
# @return [String] e.g. `003.00 ⭐ Planned yoda-writer r01 finished → 🟡 Building`
|
|
83
83
|
def render(message, plan: nil, status: nil, agent: nil, round: nil, to: nil, **)
|
|
84
84
|
# Every field, not just the message: a page command's plan and agent
|
|
85
85
|
# are logged before anything checks that they name real ones.
|
|
@@ -93,8 +93,8 @@ module OpenLanes
|
|
|
93
93
|
# The emoji fitted to two cells and followed by two spaces, then the
|
|
94
94
|
# label, exactly as the progress log draws it.
|
|
95
95
|
#
|
|
96
|
-
# @param status [String] emoji and label, e.g. "
|
|
97
|
-
# @return [String] e.g. "
|
|
96
|
+
# @param status [String] emoji and label, e.g. "⭐ Planned"
|
|
97
|
+
# @return [String] e.g. "⭐ Planned"
|
|
98
98
|
def self.status_cell(status) = ProgressLog.status_cell(status).to_s
|
|
99
99
|
|
|
100
100
|
private
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# The dispatcher's `locked-by.pid` files, counted per plan. Both halves of
|
|
6
|
+
# a pair work in one folder and so share one lock file; removing it when
|
|
7
|
+
# the first of them finished left the partner running in an unlocked
|
|
8
|
+
# folder, which a separate `resync dirs` was then free to rename. The
|
|
9
|
+
# file goes when the last job holding the plan does.
|
|
10
|
+
#
|
|
11
|
+
# The plan's alock (W31) follows the same count. It is taken first, by
|
|
12
|
+
# {#reserve}, on the folder as it is named before the job starts: the
|
|
13
|
+
# start may rename the folder, and a refusal found after the rename would
|
|
14
|
+
# leave a folder renamed under somebody else's work. {#hold} moves it to
|
|
15
|
+
# the new name.
|
|
16
|
+
class PlanLocks
|
|
17
|
+
# Raised when an agent outside openlanes holds the plan.
|
|
18
|
+
class Refused < Error; end
|
|
19
|
+
|
|
20
|
+
# @param agent_locks [AgentLocks, nil] nil leaves alock out
|
|
21
|
+
def initialize(agent_locks: nil)
|
|
22
|
+
@agent_locks = agent_locks
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@held = {}
|
|
25
|
+
@reserved = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Take the plan's alock before its first job starts. A partner joining
|
|
29
|
+
# a running plan already has it.
|
|
30
|
+
#
|
|
31
|
+
# @param ordinal [String]
|
|
32
|
+
# @param dir [String] the plan folder, before any rename
|
|
33
|
+
# @param intent [String] said to whoever reads the lock
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @raise [Refused] when somebody else holds the folder
|
|
36
|
+
def reserve(ordinal, dir, intent)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
next if @held.key?(ordinal) || @reserved.key?(ordinal) || @agent_locks.nil?
|
|
39
|
+
raise Refused, "#{File.basename(dir)} is held in alock" unless @agent_locks.acquire(dir, intent)
|
|
40
|
+
|
|
41
|
+
@reserved[ordinal] = dir
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param ordinal [String]
|
|
46
|
+
# @param dir [String] the plan folder, as the job about to start sees it
|
|
47
|
+
# @return [void]
|
|
48
|
+
def hold(ordinal, dir)
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
path, count = @held.fetch(ordinal) { [@reserved.fetch(ordinal, dir), 0] }
|
|
51
|
+
# A folder renamed since the first hold carried its lock along;
|
|
52
|
+
# writing at the new path again costs nothing and is never wrong.
|
|
53
|
+
begin
|
|
54
|
+
Plans::Lock.write(dir) if count.zero? || path != dir
|
|
55
|
+
rescue Plans::Lock::Held
|
|
56
|
+
drop_reservation(ordinal) if count.zero?
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
move_alock(path, dir) if path != dir
|
|
60
|
+
@reserved.delete(ordinal)
|
|
61
|
+
@held[ordinal] = [dir, count + 1]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Give back a reservation whose job never started.
|
|
66
|
+
#
|
|
67
|
+
# @param ordinal [String]
|
|
68
|
+
# @return [void]
|
|
69
|
+
def unreserve(ordinal) = @mutex.synchronize { drop_reservation(ordinal) }
|
|
70
|
+
|
|
71
|
+
# @param ordinal [String]
|
|
72
|
+
# @return [void]
|
|
73
|
+
def release(ordinal)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
path, count = @held[ordinal]
|
|
76
|
+
next unless path
|
|
77
|
+
|
|
78
|
+
if count > 1
|
|
79
|
+
@held[ordinal] = [path, count - 1]
|
|
80
|
+
else
|
|
81
|
+
@held.delete(ordinal)
|
|
82
|
+
Plans::Lock.remove(path)
|
|
83
|
+
@agent_locks&.release(path)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param ordinal [String]
|
|
89
|
+
# @return [Integer] how many running jobs hold it
|
|
90
|
+
def count(ordinal) = @mutex.synchronize { @held.fetch(ordinal, [nil, 0]).last }
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# @return [void]
|
|
95
|
+
def drop_reservation(ordinal)
|
|
96
|
+
dir = @reserved.delete(ordinal) or return
|
|
97
|
+
@agent_locks&.release(dir)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The folder was renamed under its lock. The new name is taken before
|
|
101
|
+
# the old is given back, so the plan is never left unheld; a refusal
|
|
102
|
+
# here means somebody claimed a name they could only have guessed, and
|
|
103
|
+
# is said rather than stopping a job that already started.
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
106
|
+
def move_alock(from, to)
|
|
107
|
+
return unless @agent_locks
|
|
108
|
+
|
|
109
|
+
UI.log("alock not moved to #{File.basename(to)}: refused", level: :warn) unless @agent_locks.acquire(to, "openlanes")
|
|
110
|
+
@agent_locks.release(from)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|