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
data/lib/openlanes/vcs/github.rb
CHANGED
|
@@ -12,12 +12,23 @@ module OpenLanes
|
|
|
12
12
|
# repository.
|
|
13
13
|
class GitHub
|
|
14
14
|
# Fields asked of `gh pr list`.
|
|
15
|
-
FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
|
|
15
|
+
FIELDS = %w[number title url headRefName files state isDraft mergedAt labels].freeze
|
|
16
|
+
|
|
17
|
+
# A runner that runs every command in one directory. `gh` with no `-R`
|
|
18
|
+
# asks the checkout it runs in which repository it means, and the
|
|
19
|
+
# engine's working directory is the plans project, which `--root` can
|
|
20
|
+
# point away from: label sync then wrote to the wrong repository.
|
|
21
|
+
Rooted = Data.define(:command, :chdir) do
|
|
22
|
+
def run(*argv, **) = command.run(*argv, chdir:, **)
|
|
23
|
+
def run!(*argv, **) = command.run!(*argv, chdir:, **)
|
|
24
|
+
end
|
|
16
25
|
|
|
17
26
|
# @param command [TTY::Command] runner, injectable for tests
|
|
18
27
|
# @param limit [Integer] how many pull requests to fetch
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
# @param chdir [String, nil] the checkout `gh` runs in; nil means the
|
|
29
|
+
# current directory
|
|
30
|
+
def initialize(command: TTY::Command.new(printer: :null), limit: 200, chdir: nil)
|
|
31
|
+
@command = chdir ? Rooted.new(command:, chdir:) : command
|
|
21
32
|
@limit = limit
|
|
22
33
|
end
|
|
23
34
|
|
|
@@ -53,7 +64,8 @@ module OpenLanes
|
|
|
53
64
|
branch: pr["headRefName"].to_s,
|
|
54
65
|
files: Array(pr["files"]).map { |f| f["path"] }.compact,
|
|
55
66
|
state: self.class.state_label(pr),
|
|
56
|
-
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
|
|
67
|
+
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN",
|
|
68
|
+
labels: Array(pr["labels"]).map { |l| l["name"] }
|
|
57
69
|
}
|
|
58
70
|
end
|
|
59
71
|
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
@@ -79,7 +91,10 @@ module OpenLanes
|
|
|
79
91
|
|
|
80
92
|
# Fields asked of `gh pr view`, which unlike `pr list` can be told about
|
|
81
93
|
# one pull request in another repository.
|
|
82
|
-
VIEW_FIELDS = %w[
|
|
94
|
+
VIEW_FIELDS = %w[
|
|
95
|
+
number title url headRefName headRefOid baseRefName mergeCommit state isDraft mergedAt body labels
|
|
96
|
+
mergeStateStatus
|
|
97
|
+
].freeze
|
|
83
98
|
|
|
84
99
|
# A reference to one pull request: a bare number, a `#`-prefixed number,
|
|
85
100
|
# or a full URL to a GitHub pull request or a GitLab merge request.
|
|
@@ -107,7 +122,8 @@ module OpenLanes
|
|
|
107
122
|
# One pull request, by number or URL.
|
|
108
123
|
#
|
|
109
124
|
# @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
|
|
110
|
-
# @return [Hash] `{number:, title:, url:, branch:, state:, body:}
|
|
125
|
+
# @return [Hash] `{number:, title:, url:, branch:, head:, state:, open:, labels:, body:, merge_state:}`,
|
|
126
|
+
# the keys {#pulls} gives a row plus the body and GitHub's `mergeStateStatus`
|
|
111
127
|
# @raise [OpenLanes::Error]
|
|
112
128
|
def pull_request(ref)
|
|
113
129
|
out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
|
|
@@ -115,12 +131,18 @@ module OpenLanes
|
|
|
115
131
|
|
|
116
132
|
pr = JSON.parse(out)
|
|
117
133
|
{
|
|
118
|
-
number:
|
|
119
|
-
title:
|
|
120
|
-
url:
|
|
121
|
-
branch:
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
number: pr["number"],
|
|
135
|
+
title: pr["title"].to_s,
|
|
136
|
+
url: pr["url"].to_s,
|
|
137
|
+
branch: pr["headRefName"].to_s,
|
|
138
|
+
head: pr["headRefOid"].to_s,
|
|
139
|
+
state: self.class.state_label(pr),
|
|
140
|
+
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN",
|
|
141
|
+
labels: Array(pr["labels"]).map { |l| l["name"] },
|
|
142
|
+
body: pr["body"].to_s,
|
|
143
|
+
merge_state: pr["mergeStateStatus"].to_s,
|
|
144
|
+
base: pr["baseRefName"].to_s,
|
|
145
|
+
merge_commit: pr.dig("mergeCommit", "oid").to_s
|
|
124
146
|
}
|
|
125
147
|
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
126
148
|
raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
|
|
@@ -135,6 +157,30 @@ module OpenLanes
|
|
|
135
157
|
refs.map { |ref| pull_request(ref) }
|
|
136
158
|
end
|
|
137
159
|
|
|
160
|
+
# The pull request a branch merged through, newest first, for
|
|
161
|
+
# `worktree prune` to ask whether a checkout's work landed.
|
|
162
|
+
#
|
|
163
|
+
# @param branch [String]
|
|
164
|
+
# @return [Hash, nil] `{number:, base:, merge_commit:}`; nil when none merged
|
|
165
|
+
def merged_pull_for(branch)
|
|
166
|
+
out = @command.run("gh",
|
|
167
|
+
"pr",
|
|
168
|
+
"list",
|
|
169
|
+
"--state",
|
|
170
|
+
"merged",
|
|
171
|
+
"--head",
|
|
172
|
+
branch,
|
|
173
|
+
"--limit",
|
|
174
|
+
"1",
|
|
175
|
+
"--json",
|
|
176
|
+
"number,baseRefName,mergeCommit").out
|
|
177
|
+
pr = JSON.parse(out.to_s.strip.empty? ? "[]" : out).first or return nil
|
|
178
|
+
|
|
179
|
+
{ number: pr["number"], base: pr["baseRefName"].to_s, merge_commit: pr.dig("mergeCommit", "oid").to_s }
|
|
180
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
181
|
+
raise Error, "could not list the pull requests of #{branch}: #{e.message.lines.first.to_s.strip}"
|
|
182
|
+
end
|
|
183
|
+
|
|
138
184
|
# `gh` speaks in enums; `pull-requests.md` speaks in the words the state
|
|
139
185
|
# machine parses. Translate once, here, rather than at each call site.
|
|
140
186
|
#
|
|
@@ -162,10 +208,314 @@ module OpenLanes
|
|
|
162
208
|
raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
|
|
163
209
|
end
|
|
164
210
|
|
|
211
|
+
# Create a review label when the repository lacks it. Never `--force`:
|
|
212
|
+
# this runs before every label swap, and recolouring is {LabelSync}'s
|
|
213
|
+
# job, done once per run where the change is announced, not as a side
|
|
214
|
+
# effect of labelling one pull request.
|
|
215
|
+
#
|
|
216
|
+
# @param label [Plans::ReviewLabel]
|
|
217
|
+
# @param repo [String, nil] "owner/repo"; nil means the current checkout's
|
|
218
|
+
# @return [void]
|
|
219
|
+
# @raise [OpenLanes::Error]
|
|
220
|
+
def ensure_label(label, repo: nil)
|
|
221
|
+
@command.run("gh",
|
|
222
|
+
"label",
|
|
223
|
+
"create",
|
|
224
|
+
label.name,
|
|
225
|
+
"--color",
|
|
226
|
+
label.colour,
|
|
227
|
+
"--description",
|
|
228
|
+
label.meaning,
|
|
229
|
+
*repo_flag(repo))
|
|
230
|
+
rescue TTY::Command::ExitError, SystemCallError => e
|
|
231
|
+
return if e.message.match?(/already exists/i)
|
|
232
|
+
|
|
233
|
+
raise Error, "could not create label #{label.name}: #{first_line(e)}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Every label the repository has, and its colour.
|
|
237
|
+
#
|
|
238
|
+
# @param repo [String, nil]
|
|
239
|
+
# @return [Hash{String => String}] name to colour, lower-case hex without `#`
|
|
240
|
+
# @raise [OpenLanes::Error]
|
|
241
|
+
def label_colours(repo: nil)
|
|
242
|
+
out = @command.run("gh", "label", "list", "--limit", "1000", "--json", "name,color", *repo_flag(repo)).out
|
|
243
|
+
JSON.parse(out).to_h { [it["name"].to_s, it["color"].to_s.downcase] }
|
|
244
|
+
# A machine without `gh` (a CI image, say) is a reason to skip the sync,
|
|
245
|
+
# not to stop the run: {LabelSync}'s caller warns on Error.
|
|
246
|
+
rescue TTY::Command::ExitError, JSON::ParserError, SystemCallError => e
|
|
247
|
+
raise Error, "could not list labels: #{first_line(e)}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The open pull requests that carry one label, asked of GitHub rather
|
|
251
|
+
# than of `pull-requests.md`, which only knows the plans' own.
|
|
252
|
+
#
|
|
253
|
+
# Every one of them, through the issues API with `--paginate`: `gh pr
|
|
254
|
+
# list` stops at its `--limit`, and a migration that saw only the first
|
|
255
|
+
# thousand would mark itself done with the rest still carrying the label.
|
|
256
|
+
# The issues API lists pull requests among the issues; `pull_request` is
|
|
257
|
+
# what tells them apart.
|
|
258
|
+
#
|
|
259
|
+
# @param name [String] the label
|
|
260
|
+
# @param repo [String, nil]
|
|
261
|
+
# @return [Hash{Integer => Array<String>}] each one's number, and every
|
|
262
|
+
# label it carries
|
|
263
|
+
# @raise [OpenLanes::Error]
|
|
264
|
+
def open_pulls_labelled(name, repo: nil)
|
|
265
|
+
out = @command.run("gh",
|
|
266
|
+
"api",
|
|
267
|
+
"--paginate",
|
|
268
|
+
"-X",
|
|
269
|
+
"GET",
|
|
270
|
+
"repos/#{repo || "{owner}/{repo}"}/issues",
|
|
271
|
+
"-f",
|
|
272
|
+
"state=open",
|
|
273
|
+
"-f",
|
|
274
|
+
"labels=#{name}",
|
|
275
|
+
"-f",
|
|
276
|
+
"per_page=100",
|
|
277
|
+
"--jq",
|
|
278
|
+
".[] | select(.pull_request) | {number, labels: [.labels[].name]}").out
|
|
279
|
+
out.each_line.reject { it.strip.empty? }.to_h do |line|
|
|
280
|
+
pull = JSON.parse(line)
|
|
281
|
+
[pull["number"], Array(pull["labels"])]
|
|
282
|
+
end
|
|
283
|
+
rescue TTY::Command::ExitError, JSON::ParserError, SystemCallError => e
|
|
284
|
+
raise Error, "could not list pull requests labelled #{name}: #{first_line(e)}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Give an existing label its colour and description back (R3). Only
|
|
288
|
+
# {LabelSync} calls this, and only for the labels openlanes owns.
|
|
289
|
+
#
|
|
290
|
+
# @param label [Plans::ReviewLabel]
|
|
291
|
+
# @param repo [String, nil]
|
|
292
|
+
# @return [void]
|
|
293
|
+
# @raise [OpenLanes::Error]
|
|
294
|
+
def recolour_label(label, repo: nil)
|
|
295
|
+
@command.run("gh",
|
|
296
|
+
"label",
|
|
297
|
+
"edit",
|
|
298
|
+
label.name,
|
|
299
|
+
"--color",
|
|
300
|
+
label.colour,
|
|
301
|
+
"--description",
|
|
302
|
+
label.meaning,
|
|
303
|
+
*repo_flag(repo))
|
|
304
|
+
rescue TTY::Command::ExitError, SystemCallError => e
|
|
305
|
+
raise Error, "could not recolour label #{label.name}: #{first_line(e)}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Put one label on a pull request and take others off, in one edit.
|
|
309
|
+
#
|
|
310
|
+
# @param number [Integer]
|
|
311
|
+
# @param repo [String, nil]
|
|
312
|
+
# @param add [Plans::ReviewLabel]
|
|
313
|
+
# @param remove [Array<String>] label names to take off
|
|
314
|
+
# @param create_missing [Boolean] create +add+ on the repository first;
|
|
315
|
+
# false when the caller already has, once, for many pull requests
|
|
316
|
+
# @return [Array<String>] the labels the pull request is left with
|
|
317
|
+
# @raise [OpenLanes::Error]
|
|
318
|
+
def swap_label(number:, add:, repo: nil, remove: [], create_missing: true)
|
|
319
|
+
ensure_label(add, repo:) if create_missing
|
|
320
|
+
removing = remove.map(&:to_s) - [add.name]
|
|
321
|
+
begin
|
|
322
|
+
edit_labels(number, add, removing, repo)
|
|
323
|
+
rescue Error
|
|
324
|
+
raise if removing.empty?
|
|
325
|
+
|
|
326
|
+
# Removing a label the pull request does not carry fails the whole
|
|
327
|
+
# edit (open question 6). Adding alone would leave any of `removing`
|
|
328
|
+
# it does carry beside the new one, two verdicts at once, so ask what
|
|
329
|
+
# is still there and take exactly that off. A second failure is real.
|
|
330
|
+
edit_labels(number, add, labels(number:, repo:) & removing, repo)
|
|
331
|
+
end
|
|
332
|
+
labels(number:, repo:)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# @param number [Integer]
|
|
336
|
+
# @param repo [String, nil]
|
|
337
|
+
# @return [Array<String>] label names on the pull request
|
|
338
|
+
def labels(number:, repo: nil)
|
|
339
|
+
out = @command.run("gh", "pr", "view", number.to_s, "--json", "labels", *repo_flag(repo)).out
|
|
340
|
+
Array(JSON.parse(out)["labels"]).map { |l| l["name"] }
|
|
341
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
342
|
+
raise Error, "could not read labels of ##{number}: #{first_line(e)}"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Post one conversation comment. The engine, not chewie, says why a
|
|
346
|
+
# verification failed: only the engine has the SHA the screenshot link
|
|
347
|
+
# needs, because only the engine pushes.
|
|
348
|
+
#
|
|
349
|
+
# @param number [Integer]
|
|
350
|
+
# @param body [String]
|
|
351
|
+
# @param repo [String, nil]
|
|
352
|
+
# @return [void]
|
|
353
|
+
def comment(number:, body:, repo: nil)
|
|
354
|
+
@command.run("gh", "pr", "comment", number.to_s, "--body", body, *repo_flag(repo))
|
|
355
|
+
rescue TTY::Command::ExitError => e
|
|
356
|
+
raise Error, "could not comment on ##{number}: #{first_line(e)}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# @param number [Integer]
|
|
360
|
+
# @param repo [String, nil]
|
|
361
|
+
# @return [Array<Hash>] `{name:, bucket:}`, bucket as `gh` reports it
|
|
362
|
+
def checks(number:, repo: nil)
|
|
363
|
+
# `gh pr checks` exits non-zero when a check failed or is pending, and
|
|
364
|
+
# the JSON it printed is still the answer, so its status is no verdict.
|
|
365
|
+
result = @command.run!("gh", "pr", "checks", number.to_s, "--json", "name,state,bucket", *repo_flag(repo))
|
|
366
|
+
out = result.out.to_s
|
|
367
|
+
if out.strip.empty?
|
|
368
|
+
return [] if result.success? || result.err.to_s.match?(/no checks reported/i)
|
|
369
|
+
|
|
370
|
+
raise Error, "could not read checks of ##{number}: #{result.err.to_s.lines.first.to_s.strip}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
JSON.parse(out).map { |c| { name: c["name"].to_s, bucket: c["bucket"].to_s } }
|
|
374
|
+
rescue JSON::ParserError => e
|
|
375
|
+
raise Error, "could not read checks of ##{number}: #{e.message.lines.first.to_s.strip}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# CI on a branch's tip, `main` above all (R5): a pull request green on
|
|
379
|
+
# its own head still lands on whatever `main` is, so a red `main` holds
|
|
380
|
+
# every merge. Check runs and commit statuses both, mapped onto the
|
|
381
|
+
# buckets `gh pr checks` uses, so {CiGate.verdict} reads them alike.
|
|
382
|
+
# CircleCI reports statuses only: reading check runs alone saw no CI on
|
|
383
|
+
# `main` and held every merge, and missed a red status beside a green run.
|
|
384
|
+
#
|
|
385
|
+
# @param branch [String]
|
|
386
|
+
# @param repo [String, nil]
|
|
387
|
+
# @return [Array<Hash>] `{name:, bucket:}`
|
|
388
|
+
# @raise [OpenLanes::Error] when GitHub cannot be read
|
|
389
|
+
def branch_checks(branch:, repo: nil)
|
|
390
|
+
commit = "repos/#{repo || "{owner}/{repo}"}/commits/#{branch}"
|
|
391
|
+
runs = JSON.parse(@command.run("gh", "api", "#{commit}/check-runs").out)["check_runs"]
|
|
392
|
+
statuses = JSON.parse(@command.run("gh", "api", "#{commit}/status").out)["statuses"]
|
|
393
|
+
Array(runs).map { |c| { name: c["name"].to_s, bucket: bucket_of(c) } } +
|
|
394
|
+
Array(statuses).map { |s| { name: s["context"].to_s, bucket: status_bucket(s) } }
|
|
395
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
396
|
+
raise Error, "could not read checks of #{branch}: #{first_line(e)}"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Merge one pull request, as the engine does on palpatine's `auto`
|
|
400
|
+
# (P4). A merge commit, which is how this repository's history reads;
|
|
401
|
+
# the branch is left alone, since the plan's worktree still has it
|
|
402
|
+
# checked out. Agents never reach this: `gh pr merge` is ungrantable.
|
|
403
|
+
#
|
|
404
|
+
# @param number [Integer]
|
|
405
|
+
# @param head [String] the head commit CI was read for; GitHub refuses
|
|
406
|
+
# the merge once the branch has moved past it
|
|
407
|
+
# @param repo [String, nil]
|
|
408
|
+
# @return [void]
|
|
409
|
+
# @raise [OpenLanes::Error] when GitHub refuses, a conflict among the reasons
|
|
410
|
+
def merge(number:, head:, repo: nil)
|
|
411
|
+
@command.run("gh", "pr", "merge", number.to_s, "--merge", "--match-head-commit", head, *repo_flag(repo))
|
|
412
|
+
rescue TTY::Command::ExitError => e
|
|
413
|
+
raise Error, "could not merge ##{number}: #{first_line(e)}"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Rebase a pull request's branch onto its base on GitHub's side (P5),
|
|
417
|
+
# for a sibling built from a branch no worktree of this run holds.
|
|
418
|
+
#
|
|
419
|
+
# @param number [Integer]
|
|
420
|
+
# @param repo [String, nil]
|
|
421
|
+
# @return [void]
|
|
422
|
+
# @raise [OpenLanes::Error] when the rebase conflicts or GitHub refuses
|
|
423
|
+
def update_branch(number:, repo: nil)
|
|
424
|
+
@command.run("gh", "pr", "update-branch", number.to_s, "--rebase", *repo_flag(repo))
|
|
425
|
+
rescue TTY::Command::ExitError => e
|
|
426
|
+
raise Error, "could not rebase ##{number} onto its base: #{first_line(e)}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @param number [Integer]
|
|
430
|
+
# @param repo [String, nil]
|
|
431
|
+
# @return [Array<Hash>] `{author:, body:, url:}`, conversation comments then reviews
|
|
432
|
+
def review_comments(number:, repo: nil)
|
|
433
|
+
out = @command.run("gh", "pr", "view", number.to_s, "--json", "comments,reviews", *repo_flag(repo)).out
|
|
434
|
+
data = JSON.parse(out)
|
|
435
|
+
(Array(data["comments"]) + Array(data["reviews"])).filter_map do |c|
|
|
436
|
+
next if c["body"].to_s.strip.empty?
|
|
437
|
+
|
|
438
|
+
{ author: c.dig("author", "login").to_s, body: c["body"].to_s, url: c["url"].to_s }
|
|
439
|
+
end
|
|
440
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
441
|
+
raise Error, "could not read comments of ##{number}: #{first_line(e)}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# @param url [String] a pull request URL
|
|
445
|
+
# @return [String, nil] "owner/repo", or nil when the URL is not GitHub's
|
|
446
|
+
def self.repo_of(url) = url.to_s[%r{github\.com/([^/]+/[^/]+)/pull/}, 1]
|
|
447
|
+
|
|
448
|
+
# The number a pull request reference names: the `/pull/N` segment of a
|
|
449
|
+
# URL, or a bare or `#`-prefixed number. Never "the trailing digits",
|
|
450
|
+
# which read `…/pull/7#issuecomment-1234` as #1234 and relabel a
|
|
451
|
+
# different pull request.
|
|
452
|
+
#
|
|
453
|
+
# @param ref [String, Integer] "12", "#12" or a pull request URL
|
|
454
|
+
# @return [Integer]
|
|
455
|
+
# @raise [OpenLanes::Error] when `ref` names no pull request
|
|
456
|
+
def self.number_of(ref)
|
|
457
|
+
text = ref.to_s.strip
|
|
458
|
+
found = text[%r{\Ahttps?://\S+?/(?:pull|merge_requests)/(\d+)(?:[/?#]\S*)?\z}, 1] || text[/\A#?(\d+)\z/, 1]
|
|
459
|
+
raise Error, "not a pull request number or URL: #{ref}" unless found
|
|
460
|
+
|
|
461
|
+
found.to_i
|
|
462
|
+
end
|
|
463
|
+
|
|
165
464
|
# @return [Boolean] whether `gh` is installed and authenticated
|
|
166
465
|
def available?
|
|
167
466
|
@command.run!("gh", "auth", "status").success?
|
|
168
467
|
end
|
|
468
|
+
|
|
469
|
+
private
|
|
470
|
+
|
|
471
|
+
def repo_flag(repo) = repo ? ["-R", repo] : []
|
|
472
|
+
|
|
473
|
+
# A check run as a `gh pr checks` bucket. An unlisted conclusion is a
|
|
474
|
+
# failure, as {CiGate} treats an unknown bucket.
|
|
475
|
+
#
|
|
476
|
+
# @param run [Hash] one entry of `check_runs`
|
|
477
|
+
# @return [String]
|
|
478
|
+
def bucket_of(run)
|
|
479
|
+
return "pending" unless run["status"].to_s == "completed"
|
|
480
|
+
|
|
481
|
+
case run["conclusion"].to_s
|
|
482
|
+
when "success", "neutral" then "pass"
|
|
483
|
+
when "skipped" then "skipping"
|
|
484
|
+
else "fail"
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# A commit status as a `gh pr checks` bucket. `error`, and any state
|
|
489
|
+
# GitHub adds later, is a failure.
|
|
490
|
+
#
|
|
491
|
+
# @param status [Hash] one entry of `statuses`
|
|
492
|
+
# @return [String]
|
|
493
|
+
def status_bucket(status)
|
|
494
|
+
case status["state"].to_s
|
|
495
|
+
when "success" then "pass"
|
|
496
|
+
when "pending" then "pending"
|
|
497
|
+
else "fail"
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# The first line `gh` wrote to stderr, which is the one naming the problem.
|
|
502
|
+
def first_line(error)
|
|
503
|
+
stderr = error.message[/stderr: (.*)/, 1].to_s.strip
|
|
504
|
+
stderr.empty? ? error.message.lines.first.to_s.strip : stderr
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def edit_labels(number, add, removing, repo)
|
|
508
|
+
@command.run("gh",
|
|
509
|
+
"pr",
|
|
510
|
+
"edit",
|
|
511
|
+
number.to_s,
|
|
512
|
+
"--add-label",
|
|
513
|
+
add.name,
|
|
514
|
+
*removing.flat_map { |r| ["--remove-label", r] },
|
|
515
|
+
*repo_flag(repo))
|
|
516
|
+
rescue TTY::Command::ExitError => e
|
|
517
|
+
raise Error, "could not label ##{number}: #{first_line(e)}"
|
|
518
|
+
end
|
|
169
519
|
end
|
|
170
520
|
end
|
|
171
521
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Vcs
|
|
5
|
+
# Moves the open pull requests that still carry M3's review labels onto
|
|
6
|
+
# R3's, once per repository, before anything reads a label in R3's sense.
|
|
7
|
+
#
|
|
8
|
+
# Under M3 `ready-to-merge` was a review verdict, "passed with nothing
|
|
9
|
+
# open"; under R3 it is chewie-verifier's, and the merge gate reads it as
|
|
10
|
+
# "verified". Left on a pull request no verifier ever saw, it would be
|
|
11
|
+
# read as a verification that never happened. So it goes back to
|
|
12
|
+
# `review-approved`, and a pull request chewie did verify earns it again
|
|
13
|
+
# from the coverage gate: an ambiguous label is re-checked, never trusted.
|
|
14
|
+
# M3's two other names map straight across ({Plans::ReviewLabel::RENAMED}).
|
|
15
|
+
#
|
|
16
|
+
# The steps run in order, and a pull request that carries `ok-to-merge`
|
|
17
|
+
# is left out of the first two: the third puts `ready-to-merge` on it and
|
|
18
|
+
# leaves `ok-to-merge` where it is, so a run interrupted after the third
|
|
19
|
+
# step cannot read the `ready-to-merge` it just put there as M3's and take
|
|
20
|
+
# it off again. `ok-to-merge` comes off once the migration is marked done.
|
|
21
|
+
#
|
|
22
|
+
# Done is a label on the repository, {Plans::ReviewLabel::MIGRATED},
|
|
23
|
+
# created after the last step and never touched by {LabelSync}. Not the
|
|
24
|
+
# colour of `ready-to-merge`: an earlier release recoloured that without
|
|
25
|
+
# moving a pull request, which made the old meaning look migrated on
|
|
26
|
+
# every repository it had run on. Nothing is kept on this machine, so a
|
|
27
|
+
# second machine starting on the same repository agrees.
|
|
28
|
+
class LabelMigration
|
|
29
|
+
# Every open pull request carrying +from+ moves to +to+.
|
|
30
|
+
Step = Data.define(:from, :to) do
|
|
31
|
+
# @return [String]
|
|
32
|
+
def to_s = "#{from} → #{to}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# M3's chewie verification, kept beside `ready-to-merge` until the end.
|
|
36
|
+
VERIFIED = "ok-to-merge"
|
|
37
|
+
|
|
38
|
+
STEPS = [
|
|
39
|
+
Step.new(from: Plans::ReviewLabel::READY_TO_MERGE.name, to: Plans::ReviewLabel::APPROVED),
|
|
40
|
+
*Plans::ReviewLabel::RENAMED.map { |from, to| Step.new(from:, to:) }
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# What the migration did, or under a dry run would do.
|
|
44
|
+
#
|
|
45
|
+
# @!attribute [r] pending
|
|
46
|
+
# @return [Boolean] whether the repository was not yet migrated
|
|
47
|
+
# @!attribute [r] moves
|
|
48
|
+
# @return [Hash{Step => Array<Integer>}] each step's pull request numbers
|
|
49
|
+
Outcome = Data.define(:pending, :moves) do
|
|
50
|
+
alias_method :pending?, :pending
|
|
51
|
+
|
|
52
|
+
# @return [Array<Integer>] every pull request relabelled, in step order
|
|
53
|
+
def relabelled = moves.values.flatten
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] one line for the startup notice; nil when nothing moved
|
|
56
|
+
def to_s
|
|
57
|
+
return if relabelled.empty?
|
|
58
|
+
|
|
59
|
+
"relabel #{moves.map { |step, numbers| "#{step} on #{numbered(numbers)}" }.join("; ")}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# @param numbers [Array<Integer>]
|
|
65
|
+
# @return [String] "#7, #9"
|
|
66
|
+
def numbered(numbers) = numbers.map { "##{it}" }.join(", ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param github [GitHub]
|
|
70
|
+
def initialize(github: GitHub.new)
|
|
71
|
+
@github = github
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param repo [String, nil] "owner/repo"; nil means the current checkout's
|
|
75
|
+
# @param dry_run [Boolean] read the pull requests, relabel none, mark nothing
|
|
76
|
+
# @return [Outcome]
|
|
77
|
+
# @raise [OpenLanes::Error] when `gh` cannot list or relabel; the
|
|
78
|
+
# repository stays unmarked, so the next start tries again
|
|
79
|
+
def call(repo: nil, dry_run: false)
|
|
80
|
+
return Outcome.new(pending: false, moves: {}) if done?(repo)
|
|
81
|
+
|
|
82
|
+
moves = STEPS.to_h { [it, move(it, repo:, dry_run:)] }.reject { |_, numbers| numbers.empty? }
|
|
83
|
+
finish(repo) unless dry_run
|
|
84
|
+
Outcome.new(pending: true, moves:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def done?(repo) = @github.label_colours(repo:).key?(Plans::ReviewLabel::MIGRATED.name)
|
|
91
|
+
|
|
92
|
+
# @param step [Step]
|
|
93
|
+
# @return [Array<Integer>] the pull requests moved, or under a dry run to be
|
|
94
|
+
def move(step, repo:, dry_run:)
|
|
95
|
+
numbers = @github.open_pulls_labelled(step.from, repo:).reject { |_, labels| held?(step, labels) }.keys
|
|
96
|
+
return numbers if dry_run || numbers.empty?
|
|
97
|
+
|
|
98
|
+
@github.ensure_label(step.to, repo:)
|
|
99
|
+
remove = Plans::ReviewLabel.displaced_by(step.to) - [VERIFIED]
|
|
100
|
+
numbers.each { @github.swap_label(number: it, add: step.to, remove:, repo:, create_missing: false) }
|
|
101
|
+
numbers
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether a pull request waits for the `ok-to-merge` step instead.
|
|
105
|
+
#
|
|
106
|
+
# @param step [Step]
|
|
107
|
+
# @param labels [Array<String>]
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def held?(step, labels) = step.from != VERIFIED && labels.include?(VERIFIED)
|
|
110
|
+
|
|
111
|
+
# Mark the repository done, then take `ok-to-merge` off. The mark first:
|
|
112
|
+
# once `ok-to-merge` is gone a `ready-to-merge` beside it is no longer
|
|
113
|
+
# told apart from M3's. Taking it off is tidying, so it may fail: the
|
|
114
|
+
# next verdict on that pull request takes it off anyway.
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def finish(repo)
|
|
118
|
+
@github.ensure_label(Plans::ReviewLabel::MIGRATED, repo:)
|
|
119
|
+
tidy(repo)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
def tidy(repo)
|
|
124
|
+
@github.open_pulls_labelled(VERIFIED, repo:).each_key do |number|
|
|
125
|
+
@github.swap_label(number:, add: Plans::ReviewLabel::READY_TO_MERGE, remove: [VERIFIED], repo:, create_missing: false)
|
|
126
|
+
end
|
|
127
|
+
rescue Error => e
|
|
128
|
+
UI.log("#{VERIFIED} left on open pull requests: #{e.message}", level: :warn)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Vcs
|
|
5
|
+
# Makes the repository's review labels match {Plans::ReviewLabel::ALL}
|
|
6
|
+
# (R3): creates the missing, recolours the mismatched, leaves the rest.
|
|
7
|
+
#
|
|
8
|
+
# Run once when the engine starts on a project. Labelling a pull request
|
|
9
|
+
# still creates a missing label on first use, but never recolours one, so
|
|
10
|
+
# without this a repository labelled under M3 would keep `review-rejected`
|
|
11
|
+
# in M3's red forever and a person reading the board would see two reds
|
|
12
|
+
# meaning one thing. One `gh label list`, and a write only per label that
|
|
13
|
+
# is wrong, so a second run asks once and changes nothing.
|
|
14
|
+
class LabelSync
|
|
15
|
+
# What was done to one label, or under a dry run what would have been.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] action
|
|
18
|
+
# @return [Symbol] :create, :recolour or :keep
|
|
19
|
+
# @!attribute [r] was
|
|
20
|
+
# @return [String, nil] the colour the repository had, for :recolour
|
|
21
|
+
Change = Data.define(:label, :action, :was) do
|
|
22
|
+
# @return [Boolean] whether this label needs a write
|
|
23
|
+
def write? = action != :keep
|
|
24
|
+
|
|
25
|
+
# @return [String] one line for the startup notice
|
|
26
|
+
def to_s
|
|
27
|
+
case action
|
|
28
|
+
when :create then "create #{label.name} (##{label.colour})"
|
|
29
|
+
when :recolour then "recolour #{label.name} ##{was} → ##{label.colour}"
|
|
30
|
+
else "keep #{label.name}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param github [GitHub]
|
|
36
|
+
# @param labels [Array<Plans::ReviewLabel>]
|
|
37
|
+
def initialize(github: GitHub.new, labels: Plans::ReviewLabel::ALL)
|
|
38
|
+
@github = github
|
|
39
|
+
@labels = labels
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param repo [String, nil] "owner/repo"; nil means the current checkout's
|
|
43
|
+
# @param dry_run [Boolean] read the repository's labels, write nothing
|
|
44
|
+
# @return [Array<Change>] one per label, in R3 order
|
|
45
|
+
# @raise [OpenLanes::Error] when `gh` cannot list or write a label
|
|
46
|
+
def call(repo: nil, dry_run: false)
|
|
47
|
+
have = @github.label_colours(repo:)
|
|
48
|
+
changes = @labels.map { change_for(it, have) }
|
|
49
|
+
return changes if dry_run
|
|
50
|
+
|
|
51
|
+
changes.each do |change|
|
|
52
|
+
case change.action
|
|
53
|
+
when :create then @github.ensure_label(change.label, repo:)
|
|
54
|
+
when :recolour then @github.recolour_label(change.label, repo:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
changes
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# GitHub answers colours in lower case and R3 writes some in upper, so
|
|
63
|
+
# they are compared without case; otherwise every run would "fix" them.
|
|
64
|
+
#
|
|
65
|
+
# @return [Change]
|
|
66
|
+
def change_for(label, have)
|
|
67
|
+
colour = have[label.name]
|
|
68
|
+
return Change.new(label:, action: :create, was: nil) if colour.nil?
|
|
69
|
+
return Change.new(label:, action: :keep, was: colour) if colour.casecmp?(label.colour)
|
|
70
|
+
|
|
71
|
+
Change.new(label:, action: :recolour, was: colour)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|