openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Vcs
|
|
7
|
+
# Brings one plan's worktree up to date with the default branch on
|
|
8
|
+
# `origin`, before an agent runs in it.
|
|
9
|
+
#
|
|
10
|
+
# A worktree reused round after round never moved: plan 008.01's branch
|
|
11
|
+
# had no commits of its own and sat 211 commits behind origin/main, so
|
|
12
|
+
# luke-backend went looking for code that had landed weeks earlier and
|
|
13
|
+
# could not find it. The agent could not catch up on its own either,
|
|
14
|
+
# because the boundary fails every run in which HEAD moved. So the
|
|
15
|
+
# harness moves HEAD, here, before {Execution::Boundary} takes its
|
|
16
|
+
# snapshot, and the agent's HEAD check still holds.
|
|
17
|
+
#
|
|
18
|
+
# Three cases, by what the branch carries:
|
|
19
|
+
#
|
|
20
|
+
# - nothing of its own: fast-forward. No agent is ever involved.
|
|
21
|
+
# - commits of its own, a clean tree: `git rebase`. A conflict leaves the
|
|
22
|
+
# rebase stopped for an agent to resolve; the harness continues it.
|
|
23
|
+
# - uncommitted changes and commits of its own: left alone. Rebasing
|
|
24
|
+
# needs a clean tree, and `--autostash` can strand an agent's
|
|
25
|
+
# uncommitted work in the stash when it fails to re-apply, which is
|
|
26
|
+
# exactly the silent loss worktrees exist to prevent.
|
|
27
|
+
#
|
|
28
|
+
# Nothing here commits anything of its own; `rebase --continue` replays
|
|
29
|
+
# commits that already exist, with the resolution an agent staged.
|
|
30
|
+
class Refresh
|
|
31
|
+
# The remote every plan branch is published to.
|
|
32
|
+
REMOTE = "origin"
|
|
33
|
+
|
|
34
|
+
# The default branch when `origin/HEAD` names none, as in a clone made
|
|
35
|
+
# with `git init` and `remote add` rather than `git clone`.
|
|
36
|
+
FALLBACK = "main"
|
|
37
|
+
|
|
38
|
+
# How git says a staged file still holds a conflict marker. `diff
|
|
39
|
+
# --check` also reports whitespace, which is no reason to stop a rebase.
|
|
40
|
+
MARKER = "leftover conflict marker"
|
|
41
|
+
|
|
42
|
+
# Seconds a fetch may take before it is abandoned. It runs on the
|
|
43
|
+
# dispatcher's tick thread, so a fetch stuck on a credential prompt or
|
|
44
|
+
# a stalled socket would stop every lane on the machine.
|
|
45
|
+
FETCH_TIMEOUT = 60
|
|
46
|
+
|
|
47
|
+
# git and ssh must fail rather than ask: nobody is there to answer.
|
|
48
|
+
# BatchMode is appended to the user's own ssh command, not put in its
|
|
49
|
+
# place, so a configured key or jump host still applies.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{String => String}]
|
|
52
|
+
def self.fetch_env
|
|
53
|
+
{ "GIT_TERMINAL_PROMPT" => "0", "GIT_SSH_COMMAND" => "#{ENV.fetch("GIT_SSH_COMMAND", "ssh")} -o BatchMode=yes" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# What one refresh did.
|
|
57
|
+
#
|
|
58
|
+
# @!attribute [r] kind
|
|
59
|
+
# @return [Symbol] :current, :fast_forwarded, :rebased, :conflicted,
|
|
60
|
+
# :skipped, :pending or :aborted. Only :conflicted leaves a rebase
|
|
61
|
+
# stopped; a :skipped from {#continue} still has one, for the
|
|
62
|
+
# caller to abort
|
|
63
|
+
# @!attribute [r] base
|
|
64
|
+
# @return [String, nil] e.g. "origin/main"
|
|
65
|
+
# @!attribute [r] target
|
|
66
|
+
# @return [String, nil] the commit `base` named, so a retry can tell
|
|
67
|
+
# whether the default branch has moved since
|
|
68
|
+
# @!attribute [r] commits
|
|
69
|
+
# @return [Integer] how many commits the branch was behind
|
|
70
|
+
# @!attribute [r] files
|
|
71
|
+
# @return [Array<String>] the conflicted paths, when it stopped
|
|
72
|
+
# @!attribute [r] reason
|
|
73
|
+
# @return [String, nil] why nothing happened, for :skipped and :pending
|
|
74
|
+
Outcome = Data.define(:kind, :base, :target, :commits, :files, :reason) do
|
|
75
|
+
def initialize(base: nil, target: nil, commits: 0, files: [], reason: nil, **rest) = super
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] the rebase is stopped, waiting on a resolution
|
|
78
|
+
def conflicted? = kind == :conflicted
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] the branch now sits on the latest default branch
|
|
81
|
+
def done? = %i[current fast_forwarded rebased].include?(kind)
|
|
82
|
+
|
|
83
|
+
# One line for the progress log.
|
|
84
|
+
#
|
|
85
|
+
# @return [String]
|
|
86
|
+
def summary
|
|
87
|
+
case kind
|
|
88
|
+
when :current then "worktree: up to date with #{base}"
|
|
89
|
+
when :fast_forwarded then "worktree: fast-forwarded #{commits} commit#{"s" unless commits == 1} to #{base}"
|
|
90
|
+
when :rebased then "worktree: rebased onto #{base} (#{commits} new upstream commit#{"s" unless commits == 1})"
|
|
91
|
+
when :conflicted then "worktree: rebase onto #{base} stopped on conflicts in #{files.join(", ")}"
|
|
92
|
+
when :aborted then "worktree: rebase onto #{base} aborted, branch restored: #{reason}"
|
|
93
|
+
when :pending then "worktree: rebase onto #{base} pending: #{reason}"
|
|
94
|
+
else "worktree: not refreshed: #{reason}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class << self
|
|
100
|
+
# The default branch, as `origin/HEAD` names it.
|
|
101
|
+
#
|
|
102
|
+
# @param path [String] any checkout of the repository
|
|
103
|
+
# @return [String] e.g. "main"
|
|
104
|
+
def default_branch(path)
|
|
105
|
+
out, status = Open3.capture2("git",
|
|
106
|
+
"-C",
|
|
107
|
+
path,
|
|
108
|
+
"symbolic-ref",
|
|
109
|
+
"--quiet",
|
|
110
|
+
"refs/remotes/#{REMOTE}/HEAD",
|
|
111
|
+
err: File::NULL)
|
|
112
|
+
name = out.strip.delete_prefix("refs/remotes/#{REMOTE}/")
|
|
113
|
+
status.success? && !name.empty? ? name : FALLBACK
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Fetch the default branch, and say where a new branch should start.
|
|
117
|
+
#
|
|
118
|
+
# A fetch only moves `refs/remotes`, logged as a fetch rather than a
|
|
119
|
+
# push, so {Execution::Boundary} does not blame an agent running in
|
|
120
|
+
# another worktree for it.
|
|
121
|
+
#
|
|
122
|
+
# @param path [String]
|
|
123
|
+
# @return [String, nil] "origin/<default>" once it exists, nil when
|
|
124
|
+
# the repository has no such remote branch
|
|
125
|
+
def fetch(path)
|
|
126
|
+
return nil unless system("git", "-C", path, "remote", "get-url", REMOTE, out: File::NULL, err: File::NULL)
|
|
127
|
+
|
|
128
|
+
base = default_branch(path)
|
|
129
|
+
fetched = fetch_within("git", "-C", path, "fetch", "--quiet", REMOTE, base)
|
|
130
|
+
UI.log("worktree: could not fetch #{REMOTE} #{base}; working from the last fetch") unless fetched
|
|
131
|
+
ref = "#{REMOTE}/#{base}"
|
|
132
|
+
if system("git",
|
|
133
|
+
"-C",
|
|
134
|
+
path,
|
|
135
|
+
"rev-parse",
|
|
136
|
+
"--verify",
|
|
137
|
+
"--quiet",
|
|
138
|
+
"refs/remotes/#{ref}",
|
|
139
|
+
out: File::NULL,
|
|
140
|
+
err: File::NULL)
|
|
141
|
+
ref
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Run a fetch, killing its whole process group (git, and the ssh or
|
|
148
|
+
# credential helper it started) once it has run past {FETCH_TIMEOUT}.
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] whether it finished, and succeeded, in time
|
|
151
|
+
def fetch_within(*argv, timeout: FETCH_TIMEOUT, spawner: Spawner::System)
|
|
152
|
+
pid = spawner.spawn(fetch_env, *argv, pgroup: true, in: File::NULL, out: File::NULL, err: File::NULL)
|
|
153
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
154
|
+
loop do
|
|
155
|
+
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
156
|
+
return status.success? if status
|
|
157
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
158
|
+
|
|
159
|
+
sleep 0.05
|
|
160
|
+
end
|
|
161
|
+
UI.log("worktree: git fetch timed out after #{timeout}s")
|
|
162
|
+
begin
|
|
163
|
+
Process.kill("KILL", -pid)
|
|
164
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
165
|
+
# Already gone, or not ours to end.
|
|
166
|
+
end
|
|
167
|
+
Process.wait(pid)
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @param path [String] the plan's worktree
|
|
173
|
+
def initialize(path:) = @path = path
|
|
174
|
+
|
|
175
|
+
# @return [String]
|
|
176
|
+
attr_reader :path
|
|
177
|
+
|
|
178
|
+
# Fetch, then fast-forward or rebase onto the default branch.
|
|
179
|
+
#
|
|
180
|
+
# @param hold [String, nil] a default-branch commit the rebase has
|
|
181
|
+
# already failed on as often as it may: while `origin/<default>` still
|
|
182
|
+
# names it, a branch that would need a rebase is left where it is
|
|
183
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
184
|
+
def call(hold: nil)
|
|
185
|
+
return resume if rebasing?
|
|
186
|
+
|
|
187
|
+
base = self.class.fetch(path) or return Outcome.new(kind: :skipped, reason: "no #{REMOTE} default branch")
|
|
188
|
+
target = rev(base)
|
|
189
|
+
behind = count("HEAD..#{base}")
|
|
190
|
+
return Outcome.new(kind: :current, base:, target:) if behind.zero?
|
|
191
|
+
return fast_forward(base, target, behind) if count("#{base}..HEAD").zero?
|
|
192
|
+
|
|
193
|
+
outcome = Outcome.new(kind: :skipped, base:, target:, commits: behind)
|
|
194
|
+
return outcome.with(reason: "uncommitted changes in the worktree") if dirty?
|
|
195
|
+
return outcome.with(kind: :pending, reason: "retries exhausted until #{base} moves") if hold == target
|
|
196
|
+
|
|
197
|
+
rebase(base, target, behind)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Carry a stopped rebase on, once an agent has resolved and staged.
|
|
201
|
+
#
|
|
202
|
+
# Refused while any path is unmerged or a staged file still holds a
|
|
203
|
+
# marker: continuing then would commit the marker into the branch.
|
|
204
|
+
#
|
|
205
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome] :rebased, or :conflicted
|
|
206
|
+
# on the files that still need a resolution (the same commit's, or
|
|
207
|
+
# the next one's)
|
|
208
|
+
def continue
|
|
209
|
+
base = onto
|
|
210
|
+
left = unresolved
|
|
211
|
+
return Outcome.new(kind: :conflicted, base:, target: rev(base), files: left) unless left.empty?
|
|
212
|
+
|
|
213
|
+
# A resolution identical to what is already on HEAD leaves the commit
|
|
214
|
+
# empty. Newer git drops it on continue, older git refuses and is
|
|
215
|
+
# skipped past. Either way it drops nothing the branch still needs
|
|
216
|
+
# (the index is HEAD), but the commit is gone, so it is named.
|
|
217
|
+
empty = git({}, "diff", "--cached", "--quiet")
|
|
218
|
+
stopped_at = capture("log", "-1", "--format=%h %s", "REBASE_HEAD") if empty
|
|
219
|
+
ok = git({ "GIT_EDITOR" => "true" }, "rebase", "--continue")
|
|
220
|
+
ok ||= empty && rebasing? && unmerged.empty? && git({}, "rebase", "--skip")
|
|
221
|
+
dropped(stopped_at, base)
|
|
222
|
+
stopped = rebasing? ? unmerged : []
|
|
223
|
+
return Outcome.new(kind: :conflicted, base:, target: rev(base), files: stopped) unless stopped.empty?
|
|
224
|
+
return Outcome.new(kind: :rebased, base:, target: rev(base)) if ok && !rebasing?
|
|
225
|
+
|
|
226
|
+
# Stopped with nothing to resolve (a hook refused, say): no agent can
|
|
227
|
+
# help, so the caller aborts.
|
|
228
|
+
Outcome.new(kind: :skipped, base:, reason: "git rebase --continue failed with nothing unmerged")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Abandon a stopped rebase. git puts the branch back exactly where it
|
|
232
|
+
# was before the rebase began.
|
|
233
|
+
#
|
|
234
|
+
# @param why [String]
|
|
235
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
236
|
+
def abort!(why)
|
|
237
|
+
base = onto
|
|
238
|
+
git({}, "rebase", "--abort") if rebasing?
|
|
239
|
+
Outcome.new(kind: :aborted, base:, target: rev(base), reason: why)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# @return [Boolean] whether a rebase is stopped in this worktree
|
|
243
|
+
def rebasing? = %w[rebase-merge rebase-apply].any? { File.directory?(git_path(it)) }
|
|
244
|
+
|
|
245
|
+
# @return [Array<String>] paths git still lists as unmerged
|
|
246
|
+
def unmerged = lines("diff", "--name-only", "--diff-filter=U")
|
|
247
|
+
|
|
248
|
+
# Unmerged paths, and staged files that still hold a conflict marker.
|
|
249
|
+
#
|
|
250
|
+
# @return [Array<String>]
|
|
251
|
+
def unresolved
|
|
252
|
+
marked = lines("diff", "--cached", "--check").filter_map { |line| line.split(":", 2).first if line.include?(MARKER) }
|
|
253
|
+
(unmerged + marked).uniq
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
# A rebase found stopped before anyone asked: a harness that died
|
|
259
|
+
# mid-resolution, or a round that ended before it continued. It is
|
|
260
|
+
# picked up where it stood rather than started again.
|
|
261
|
+
#
|
|
262
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
263
|
+
def resume
|
|
264
|
+
left = unresolved
|
|
265
|
+
return Outcome.new(kind: :conflicted, base: onto, target: rev(onto), files: left) unless left.empty?
|
|
266
|
+
|
|
267
|
+
continue
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
271
|
+
def fast_forward(base, target, behind)
|
|
272
|
+
# Safe with uncommitted changes: git refuses, touching nothing, when
|
|
273
|
+
# one of them is in a file the merge would change.
|
|
274
|
+
return Outcome.new(kind: :fast_forwarded, base:, target:, commits: behind) if git({}, "merge", "--ff-only", "--quiet", base)
|
|
275
|
+
|
|
276
|
+
Outcome.new(kind: :skipped,
|
|
277
|
+
base:,
|
|
278
|
+
target:,
|
|
279
|
+
commits: behind,
|
|
280
|
+
reason: "fast-forward refused; uncommitted changes touch files #{base} changed")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [OpenLanes::Vcs::Refresh::Outcome]
|
|
284
|
+
def rebase(base, target, behind)
|
|
285
|
+
return Outcome.new(kind: :rebased, base:, target:, commits: behind) if git({ "GIT_EDITOR" => "true" }, "rebase", "--quiet", base)
|
|
286
|
+
return Outcome.new(kind: :conflicted, base:, target:, commits: behind, files: unresolved) if rebasing?
|
|
287
|
+
|
|
288
|
+
Outcome.new(kind: :skipped, base:, target:, commits: behind, reason: "git rebase refused to start")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The branch the stopped rebase is replaying onto, as a name.
|
|
292
|
+
#
|
|
293
|
+
# @return [String]
|
|
294
|
+
def onto
|
|
295
|
+
sha = File.read(git_path("rebase-merge/onto")).strip if File.file?(git_path("rebase-merge/onto"))
|
|
296
|
+
base = "#{REMOTE}/#{self.class.default_branch(path)}"
|
|
297
|
+
sha.nil? || rev(base) == sha ? base : sha[0, 7]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Log a commit the rebase replayed as empty, once it is past it.
|
|
301
|
+
#
|
|
302
|
+
# @param stopped_at [String, nil] "<sha> <subject>" of the commit
|
|
303
|
+
# @param base [String]
|
|
304
|
+
# @return [void]
|
|
305
|
+
def dropped(stopped_at, base)
|
|
306
|
+
return if stopped_at.nil?
|
|
307
|
+
return if rebasing? && capture("log", "-1", "--format=%h %s", "REBASE_HEAD") == stopped_at
|
|
308
|
+
|
|
309
|
+
UI.log("worktree: rebase dropped #{stopped_at}: its changes are already on #{base}")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [Boolean]
|
|
313
|
+
def dirty? = !lines("status", "--porcelain").empty?
|
|
314
|
+
|
|
315
|
+
# @return [Integer]
|
|
316
|
+
def count(range) = capture("rev-list", "--count", range).to_i
|
|
317
|
+
|
|
318
|
+
# @return [String, nil]
|
|
319
|
+
def rev(ref) = capture("rev-parse", "--verify", "--quiet", ref).then { it.empty? ? nil : it }
|
|
320
|
+
|
|
321
|
+
# @return [String] a path inside this worktree's own git directory
|
|
322
|
+
def git_path(name) = File.expand_path(capture("rev-parse", "--git-path", name), path)
|
|
323
|
+
|
|
324
|
+
# @return [Array<String>]
|
|
325
|
+
def lines(*) = capture(*).lines(chomp: true).reject(&:empty?)
|
|
326
|
+
|
|
327
|
+
# @return [String]
|
|
328
|
+
def capture(*)
|
|
329
|
+
out, = Open3.capture2("git", "-C", path, *, err: File::NULL)
|
|
330
|
+
out.strip
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @return [Boolean]
|
|
334
|
+
def git(env, *) = system(env, "git", "-C", path, *, out: File::NULL, err: File::NULL)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Vcs
|
|
9
|
+
# The date a plan reached the state that sends it into an inactive
|
|
10
|
+
# subfolder, for the `[YYYY-MM-DD]` its archived name carries (N3).
|
|
11
|
+
#
|
|
12
|
+
# A state change is a folder rename, so the date of the folder's latest
|
|
13
|
+
# rename is the date it got there. Git knows it, through a file inside the
|
|
14
|
+
# folder: `--follow --diff-filter=R` returns the rename itself, where
|
|
15
|
+
# `git log -1 -- <folder>` returns whichever edit came last, and a spec
|
|
16
|
+
# touched up a month after the merge would date the archive by the edit.
|
|
17
|
+
#
|
|
18
|
+
# Git does not always know. The folder may never have been committed, the
|
|
19
|
+
# clone may be shallow, or the tree may not be a repository at all; then
|
|
20
|
+
# `state.json`'s `updated_at`, which the harness writes on every state
|
|
21
|
+
# change, and failing that, today.
|
|
22
|
+
module RenameDate
|
|
23
|
+
# Files tried first; a folder holding none of them (a ❌ plan may hold
|
|
24
|
+
# only `discarded.md`) is followed through whichever file it does hold.
|
|
25
|
+
PROBES = %w[spec.md plan.md pull-requests.md].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# @param dir [String] a plan folder
|
|
29
|
+
# @param today [Date] the last resort
|
|
30
|
+
# @return [Date]
|
|
31
|
+
def for(dir, today: Date.today) = from_git(dir) || from_state(dir) || today
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @param dir [String]
|
|
36
|
+
# @return [Date, nil]
|
|
37
|
+
def from_git(dir)
|
|
38
|
+
probe = probe_in(dir) or return nil
|
|
39
|
+
|
|
40
|
+
out, status = Open3.capture2("git",
|
|
41
|
+
"-C",
|
|
42
|
+
dir,
|
|
43
|
+
"log",
|
|
44
|
+
"--follow",
|
|
45
|
+
"--diff-filter=R",
|
|
46
|
+
"--format=%cI",
|
|
47
|
+
"-1",
|
|
48
|
+
"--",
|
|
49
|
+
probe,
|
|
50
|
+
err: File::NULL)
|
|
51
|
+
status.success? ? parse(out.strip) : nil
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param dir [String]
|
|
57
|
+
# @return [String, nil] a file name inside +dir+
|
|
58
|
+
def probe_in(dir)
|
|
59
|
+
PROBES.find { File.file?(File.join(dir, it)) } ||
|
|
60
|
+
Dir.children(dir).sort.find { File.file?(File.join(dir, it)) && it != Plans::Lock::FILENAME }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param dir [String]
|
|
64
|
+
# @return [Date, nil]
|
|
65
|
+
def from_state(dir)
|
|
66
|
+
parse(JSON.parse(File.read(File.join(dir, Plans::PlanState::FILENAME)))["updated_at"])
|
|
67
|
+
rescue SystemCallError, JSON::ParserError, TypeError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param text [String, nil] an ISO-8601 timestamp
|
|
72
|
+
# @return [Date, nil]
|
|
73
|
+
def parse(text)
|
|
74
|
+
return nil if text.to_s.empty?
|
|
75
|
+
|
|
76
|
+
Date.iso8601(text.to_s[0, 10])
|
|
77
|
+
rescue Date::Error
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Vcs
|
|
5
|
+
# Moves a pull request from one review label to the next (M3, R3).
|
|
6
|
+
#
|
|
7
|
+
# The only way a review label is set: `gh pr edit` stays forbidden to
|
|
8
|
+
# agents (L3), so the CI rule below cannot be skipped by labelling by hand
|
|
9
|
+
# from inside a round.
|
|
10
|
+
class Review
|
|
11
|
+
# What happened, or under a dry run what would have.
|
|
12
|
+
Result = Data.define(:pr, :from, :to, :ci, :refused, :reason, :dry_run) do
|
|
13
|
+
alias_method :refused?, :refused
|
|
14
|
+
alias_method :dry_run?, :dry_run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param github [GitHub]
|
|
18
|
+
def initialize(github: GitHub.new)
|
|
19
|
+
@github = github
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param pr [String, Integer] "12", "#12" or a pull request URL
|
|
23
|
+
# @param label [String] a {Plans::ReviewLabel} name
|
|
24
|
+
# @param dry_run [Boolean] compute the result, write nothing
|
|
25
|
+
# @return [Result]
|
|
26
|
+
# @raise [OpenLanes::Error] on an unknown label or a reference naming no
|
|
27
|
+
# pull request, before any `gh` call
|
|
28
|
+
def call(pr:, label:, dry_run: false) # rubocop:disable Naming/MethodParameterName -- `pr` is the contract with CLI::Review
|
|
29
|
+
to = Plans::ReviewLabel.fetch(label)
|
|
30
|
+
number = GitHub.number_of(pr)
|
|
31
|
+
repo = GitHub.repo_of(pr)
|
|
32
|
+
|
|
33
|
+
from = @github.labels(number:, repo:) & Plans::ReviewLabel.known
|
|
34
|
+
ci = CiGate.verdict(@github.checks(number:, repo:))
|
|
35
|
+
reason = refusal(to, ci)
|
|
36
|
+
result = Result.new(pr: number,
|
|
37
|
+
from:,
|
|
38
|
+
to: to.name,
|
|
39
|
+
ci:,
|
|
40
|
+
refused: !reason.nil?,
|
|
41
|
+
reason: reason || (ci == :none ? NO_CHECKS : nil),
|
|
42
|
+
dry_run:)
|
|
43
|
+
return result if result.refused? || dry_run
|
|
44
|
+
|
|
45
|
+
@github.swap_label(number:, repo:, add: to, remove: from & Plans::ReviewLabel.displaced_by(to))
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Zero checks, or only skipped ones, counts as green (open question 2),
|
|
50
|
+
# said out loud.
|
|
51
|
+
NO_CHECKS = "no CI checks ran; treated as green"
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] why `to` may not be set now
|
|
56
|
+
def refusal(label, verdict)
|
|
57
|
+
return unless label.passing?
|
|
58
|
+
|
|
59
|
+
case verdict
|
|
60
|
+
when :red then "CI is red"
|
|
61
|
+
when :pending then "CI pending"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|