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,516 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Engine
|
|
9
|
+
module Verification
|
|
10
|
+
# What the engine does with a verification once chewie has signed, or
|
|
11
|
+
# once the app would not start (M1, M14, M15). chewie only writes files
|
|
12
|
+
# in its round's folder; everything that leaves the machine is done
|
|
13
|
+
# here, because the comment links the screenshot by the SHA of a commit
|
|
14
|
+
# only the engine may make, and `ready-to-merge` waits for a CI run that
|
|
15
|
+
# commit itself starts.
|
|
16
|
+
#
|
|
17
|
+
# Rejecting: commit the failing step's PNG, comment with it, label
|
|
18
|
+
# `review-rejected`, then send the plan to 🔴 and mail the comment to
|
|
19
|
+
# whoever handles 🔴, through {PrOutcomes}, exactly as a person's
|
|
20
|
+
# rejection on GitHub is handled.
|
|
21
|
+
#
|
|
22
|
+
# Passing: commit `final.png` ({#pass}), then, each time the engine
|
|
23
|
+
# asks ({#settle}), wait for green CI, run the coverage gate and label
|
|
24
|
+
# `ready-to-merge`. Merging is not here: plan 033 merges in agent mode,
|
|
25
|
+
# and in human mode the person does (M4).
|
|
26
|
+
class Outcome
|
|
27
|
+
# What one step did.
|
|
28
|
+
#
|
|
29
|
+
# @!attribute [r] kind
|
|
30
|
+
# @return [Symbol] :rejected, :committed (passed and pushed, CI not read yet),
|
|
31
|
+
# :waiting (CI pending), or :ready (`ready-to-merge` applied)
|
|
32
|
+
# @!attribute [r] note
|
|
33
|
+
# @return [String] for the attempt and the log
|
|
34
|
+
# @!attribute [r] to
|
|
35
|
+
# @return [Symbol, nil] the state the folder moved to
|
|
36
|
+
# @!attribute [r] sha
|
|
37
|
+
# @return [String, nil] the verification commit, on :committed
|
|
38
|
+
Result = Data.define(:kind, :note, :to, :sha) do
|
|
39
|
+
def initialize(to: nil, sha: nil, **rest) = super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What one look at a verified pull request found, and nothing more:
|
|
43
|
+
# {#look} reads GitHub and runs the coverage gate, which may take
|
|
44
|
+
# minutes, so it runs off the dispatcher's thread; {#settle} acts on
|
|
45
|
+
# it (labels, rejects, renames, mails) back on that thread, where
|
|
46
|
+
# every other lifecycle change happens.
|
|
47
|
+
#
|
|
48
|
+
# @!attribute [r] ci
|
|
49
|
+
# @return [Symbol] :labelled (already `ready-to-merge`), :registering
|
|
50
|
+
# (no checks yet, within {NO_CHECKS_GRACE}), or a {Vcs::CiGate} verdict
|
|
51
|
+
# @!attribute [r] verdict
|
|
52
|
+
# @return [CoverageGate::Verdict, nil] the gate's, once CI is green
|
|
53
|
+
Look = Data.define(:ci, :verdict) do
|
|
54
|
+
def initialize(verdict: nil, **rest) = super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Who the comment and the harness's own signature are from.
|
|
58
|
+
AGENT = "chewie-verifier"
|
|
59
|
+
|
|
60
|
+
# A signature that counts as a rejection: its note starts with the
|
|
61
|
+
# word, as `rejected`, `rejected 1/2` (hansolo) or `rejected: …` (the
|
|
62
|
+
# harness signing for chewie). Anchored rather than searched for: an
|
|
63
|
+
# approving note that says "nothing to reject" is not a rejection, and
|
|
64
|
+
# counting it would reach the arbitrator a round early.
|
|
65
|
+
REJECTION = %r{\Arejected(?=\z|[\s:/])}i
|
|
66
|
+
|
|
67
|
+
# How long after the verification commit zero checks still means
|
|
68
|
+
# "CI has not registered yet" rather than "this project has no CI".
|
|
69
|
+
# Right after {#pass} pushes `final.png` the new head can report no
|
|
70
|
+
# checks for a while; {Vcs::Review} counts none as green, so without
|
|
71
|
+
# this window `ready-to-merge` could go on before the run that commit
|
|
72
|
+
# starts. Past it, none is taken at its word, or a project with no CI
|
|
73
|
+
# would wait forever.
|
|
74
|
+
NO_CHECKS_GRACE = 5 * 60
|
|
75
|
+
|
|
76
|
+
# Lines of a withheld coverage run quoted in the comment.
|
|
77
|
+
TAIL = CoverageGate::TAIL
|
|
78
|
+
|
|
79
|
+
# What the builders are told when the branch's `.openlanes.yml` has
|
|
80
|
+
# no `coverage:` section. A plain rejection would only say the gate
|
|
81
|
+
# withheld, and the builder would go looking for a defect; this names
|
|
82
|
+
# the one thing to write, in both shapes, so the next round writes it.
|
|
83
|
+
# It is signed "sent back", not "rejected" ({REJECTION}), because
|
|
84
|
+
# this one does not count.
|
|
85
|
+
SETUP = <<~MARKDOWN
|
|
86
|
+
Coverage is not declared yet: this branch's `.openlanes.yml` has no `coverage:` section, so the coverage gate had nothing to measure and `ready-to-merge` waits on it.
|
|
87
|
+
|
|
88
|
+
Add a `coverage:` section to `.openlanes.yml` for this stack, in one of two shapes. `command` (runs the whole suite) and `minimum` (the lowest line percentage that merges) are required in both.
|
|
89
|
+
|
|
90
|
+
A command that prints the line percentage:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
coverage:
|
|
94
|
+
command: bundle exec rspec
|
|
95
|
+
report_command: jq '.total.lines.percent' coverage/coverage.json
|
|
96
|
+
minimum: 95
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or a report file, in one of the formats the gate reads:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
coverage:
|
|
103
|
+
command: npm test -- --coverage
|
|
104
|
+
report: coverage/lcov.info
|
|
105
|
+
format: lcov # lcov | cobertura | istanbul | simplecov
|
|
106
|
+
minimum: 95
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This send-back is a setup gap, not a defect, and does not count against `merge.max_rejections`.
|
|
110
|
+
MARKDOWN
|
|
111
|
+
|
|
112
|
+
# @param root [String] the main checkout, which the PNG's link is relative to
|
|
113
|
+
# @param publisher [OpenLanes::Vcs::Publisher]
|
|
114
|
+
# @param github [OpenLanes::Vcs::GitHub]
|
|
115
|
+
# @param outcomes [OpenLanes::Engine::PrOutcomes] moves to 🔴 and mails
|
|
116
|
+
# @param agents [OpenLanes::Agents::Registry] whose rejections count
|
|
117
|
+
# @param review [OpenLanes::Vcs::Review]
|
|
118
|
+
# @param coverage_gate [#call]
|
|
119
|
+
# @param max_rejections [Integer, #call] see {Config#max_rejections};
|
|
120
|
+
# a callable is asked at every rejection, so a quality answered or
|
|
121
|
+
# edited mid-run moves the cap when it moves the verdict
|
|
122
|
+
# @param arbitration [#call] given `plan:, count:, max:` at the cap
|
|
123
|
+
# @param screenshots [#call] a round's folder to a {Screenshots}
|
|
124
|
+
# @param clock [#call] the time now, for {NO_CHECKS_GRACE}
|
|
125
|
+
def initialize(root:, publisher:, github:, outcomes:, agents:, review: Vcs::Review.new(github:),
|
|
126
|
+
coverage_gate: CoverageGate.new, max_rejections: Config::DEFAULT_MAX_REJECTIONS,
|
|
127
|
+
arbitration: Arbitration.new, screenshots: ->(dir) { Screenshots.new(dir:) }, clock: -> { Clock::System.now })
|
|
128
|
+
@root = root
|
|
129
|
+
@publisher = publisher
|
|
130
|
+
@github = github
|
|
131
|
+
@outcomes = outcomes
|
|
132
|
+
@agents = agents
|
|
133
|
+
@review = review
|
|
134
|
+
@gate = coverage_gate
|
|
135
|
+
@max = max_rejections
|
|
136
|
+
@arbitration = arbitration
|
|
137
|
+
@screenshots = screenshots
|
|
138
|
+
@clock = clock
|
|
139
|
+
@verdicts = {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout] the plan's
|
|
143
|
+
# @param plan [OpenLanes::Plans::Subject] read fresh, in the main checkout
|
|
144
|
+
# @param pull [OpenLanes::Plans::PullRequest] the plan's open pull request
|
|
145
|
+
# @param round [Round]
|
|
146
|
+
# @param why [String, nil] Markdown saying what failed when chewie's
|
|
147
|
+
# report cannot: the app that did not start, red CI, the coverage gate
|
|
148
|
+
# @param counted [Boolean] false for a send-back that is a setup gap
|
|
149
|
+
# rather than a defect ({SETUP}): it goes to the builders like any
|
|
150
|
+
# rejection, but is not signed as one, so `merge.max_rejections`
|
|
151
|
+
# never reaches the arbitrator over a missing config section
|
|
152
|
+
# @return [Result]
|
|
153
|
+
def reject(checkout:, plan:, pull:, round:, why: nil, counted: true)
|
|
154
|
+
round.make!
|
|
155
|
+
report = round.report || Report.new
|
|
156
|
+
if why
|
|
157
|
+
# Over a verified report too (CI or coverage failing after it), so
|
|
158
|
+
# the round reads as rejected and a later 🧪 gets a new round
|
|
159
|
+
# rather than being taken for one still waiting on CI.
|
|
160
|
+
write_report(round, why) unless report.verdict == :rejected
|
|
161
|
+
sign_for_chewie(plan, round, why, counted:)
|
|
162
|
+
end
|
|
163
|
+
kept = keep(round, failing_step: report.step)
|
|
164
|
+
sha, committed = commit(checkout, plan, round, "rejected")
|
|
165
|
+
png = kept.find { File.basename(it) == report.screenshot }
|
|
166
|
+
body = comment(plan:, round:, report:, why:, link: link(pull, sha, png), committed:, counted:)
|
|
167
|
+
number = number_of(pull)
|
|
168
|
+
@github.comment(number:, body:, repo: Vcs::GitHub.repo_of(pull.url))
|
|
169
|
+
labelled = @review.call(pr: pull.url || number, label: Plans::ReviewLabel::REJECTED)
|
|
170
|
+
send_back(plan, pull, body, labelled, counted:)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# chewie verified it: keep `final.png`, and push it.
|
|
174
|
+
#
|
|
175
|
+
# A commit that failed is sent back rather than reported as
|
|
176
|
+
# committed: {#settle} would otherwise read the old head's CI and
|
|
177
|
+
# label it ready with no `final.png` on the branch, and the missing
|
|
178
|
+
# evidence would go unnoticed. It is a send-back, not a counted
|
|
179
|
+
# rejection, because the code may be fine; what is wrong is the
|
|
180
|
+
# checkout (a stray file, a push that would not go). Nothing past
|
|
181
|
+
# this point reads CI for a round whose push failed.
|
|
182
|
+
#
|
|
183
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
184
|
+
# @param plan [OpenLanes::Plans::Subject]
|
|
185
|
+
# @param pull [OpenLanes::Plans::PullRequest] the plan's open pull request
|
|
186
|
+
# @param round [Round]
|
|
187
|
+
# @return [Result] :committed, the folder staying 🧪 until {#settle};
|
|
188
|
+
# :rejected when `final.png` could not be committed
|
|
189
|
+
def pass(checkout:, plan:, pull:, round:)
|
|
190
|
+
keep(round, failing_step: nil)
|
|
191
|
+
sha, committed, failure = commit(checkout, plan, round, "verified")
|
|
192
|
+
return uncommitted(checkout:, plan:, pull:, round:, failure:) if failure
|
|
193
|
+
|
|
194
|
+
pushed = sha ? "#{Screenshots::FINAL} pushed in #{sha[0, 7]}" : committed
|
|
195
|
+
Result.new(kind: :committed, note: "verified PR ##{number_of(pull)}; #{pushed}; waiting on CI", sha:)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Read what decides a verified pull request: its labels, its CI and,
|
|
199
|
+
# once CI is green, the coverage gate. Changes nothing in the plan,
|
|
200
|
+
# on GitHub or in the round, so it is safe off the dispatcher's
|
|
201
|
+
# thread (the gate writes only what the project's suite writes).
|
|
202
|
+
#
|
|
203
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
204
|
+
# @param plan [OpenLanes::Plans::Subject]
|
|
205
|
+
# @param pull [OpenLanes::Plans::PullRequest] the plan's open pull request
|
|
206
|
+
# @param round [Round] the verified round
|
|
207
|
+
# @return [Look]
|
|
208
|
+
def look(checkout:, plan:, pull:, round:)
|
|
209
|
+
number = number_of(pull)
|
|
210
|
+
repo = Vcs::GitHub.repo_of(pull.url)
|
|
211
|
+
return Look.new(ci: :labelled) if labelled_ready?(number, repo)
|
|
212
|
+
|
|
213
|
+
ci = Vcs::CiGate.verdict(@github.checks(number:, repo:))
|
|
214
|
+
ci = :registering if ci == :none && registering?(round)
|
|
215
|
+
return Look.new(ci:) if %i[registering pending red].include?(ci)
|
|
216
|
+
|
|
217
|
+
Look.new(ci:, verdict: gate_verdict(checkout, plan, round))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Act on one look at a verified pull request, asked again until it
|
|
221
|
+
# is labelled or rejected: CI pending waits, and so do no checks at
|
|
222
|
+
# all within {NO_CHECKS_GRACE} of the verification; CI red or the
|
|
223
|
+
# coverage gate withholding rejects; otherwise `ready-to-merge` goes on.
|
|
224
|
+
#
|
|
225
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
226
|
+
# @param plan [OpenLanes::Plans::Subject]
|
|
227
|
+
# @param pull [OpenLanes::Plans::PullRequest] the plan's open pull request
|
|
228
|
+
# @param round [Round] the verified round
|
|
229
|
+
# @param seen [Look, nil] what {#look} found; nil looks now
|
|
230
|
+
# @return [Result]
|
|
231
|
+
def settle(checkout:, plan:, pull:, round:, seen: nil)
|
|
232
|
+
seen ||= look(checkout:, plan:, pull:, round:)
|
|
233
|
+
number = number_of(pull)
|
|
234
|
+
case seen.ci
|
|
235
|
+
when :labelled then ready(plan, number, "already #{Plans::ReviewLabel::READY_TO_MERGE}")
|
|
236
|
+
when :registering then Result.new(kind: :waiting, note: "🧪 no checks yet on PR ##{number}; waiting for CI to start")
|
|
237
|
+
when :pending then Result.new(kind: :waiting, note: "🧪 waiting on CI for PR ##{number}")
|
|
238
|
+
when :red then reject(checkout:, plan:, pull:, round:, why: "CI is red on PR ##{number} after verification.")
|
|
239
|
+
else gated(checkout:, plan:, pull:, round:, verdict: seen.verdict)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# A `review-ok-not-perfect` pull request under `high` (R4), sent back
|
|
244
|
+
# to the builders without verifying it. Signed and counted as a
|
|
245
|
+
# rejection: the builders may answer every comment with a rebuttal
|
|
246
|
+
# and no change, hansolo may label it the same again, and an
|
|
247
|
+
# uncounted send-back would then go round for ever, never reaching
|
|
248
|
+
# `merge.max_rejections` or the arbitrator.
|
|
249
|
+
#
|
|
250
|
+
# @param plan [OpenLanes::Plans::Subject] read fresh, at 👀
|
|
251
|
+
# @param pull [OpenLanes::Plans::PullRequest] the plan's open pull request
|
|
252
|
+
# @return [Result]
|
|
253
|
+
def send_back_for_quality(plan:, pull:)
|
|
254
|
+
why = "PR ##{number_of(pull)} is labelled #{ON_QUALITY.name}, and desired_codebase_quality is high"
|
|
255
|
+
sign_for_chewie(plan, Verification.round_for(plan.feature.path), why, counted: true)
|
|
256
|
+
body = <<~MARKDOWN
|
|
257
|
+
#{why}: reply in the thread of every review comment that is not ✅, with a fix or a backed-up rebuttal (R4).
|
|
258
|
+
MARKDOWN
|
|
259
|
+
send_back(plan, pull, body, nil, counted: true)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Why this checkout is not the pull request's code, asked before
|
|
263
|
+
# chewie is started on it ({Driver#hand_off}); {Vcs::Publisher#commit_plan}
|
|
264
|
+
# asks again before the screenshots are pushed.
|
|
265
|
+
#
|
|
266
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
267
|
+
# @param pull [OpenLanes::Plans::PullRequest]
|
|
268
|
+
# @return [String, nil] nil when the pull request is built from the checkout's branch
|
|
269
|
+
def mismatch(checkout:, pull:) = @publisher.branch_mismatch(pull, checkout, refused: "verification refused")
|
|
270
|
+
|
|
271
|
+
# @param pull [OpenLanes::Plans::PullRequest]
|
|
272
|
+
# @return [Array<String>] the pull request's labels, as GitHub has them now
|
|
273
|
+
# @raise [OpenLanes::Error] when GitHub cannot be asked
|
|
274
|
+
def labels(pull) = @github.labels(number: number_of(pull), repo: Vcs::GitHub.repo_of(pull.url))
|
|
275
|
+
|
|
276
|
+
# Every rejection of this plan so far, hansolo's and chewie's
|
|
277
|
+
# together (M6). Derived from their signatures every time, never
|
|
278
|
+
# stored: a count kept beside the ledger is a second answer to one
|
|
279
|
+
# question.
|
|
280
|
+
#
|
|
281
|
+
# @param plan [OpenLanes::Plans::Subject]
|
|
282
|
+
# @return [Integer]
|
|
283
|
+
def rejections(plan)
|
|
284
|
+
judges = @agents.all.select { %w[review verification].include?(it.phase.to_s) }
|
|
285
|
+
files = judges.flat_map(&:ledger).uniq
|
|
286
|
+
names = judges.map(&:name)
|
|
287
|
+
Plans::Ledger.read(plan.feature.path, files).entries
|
|
288
|
+
.select { names.include?(it.agent) && it.completed? && it.note.to_s.strip.match?(REJECTION) }
|
|
289
|
+
.uniq { [it.agent, it.note, it.at.to_i / 60] }
|
|
290
|
+
.size
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
# @return [Result]
|
|
296
|
+
def gated(checkout:, plan:, pull:, round:, verdict:)
|
|
297
|
+
return reject(checkout:, plan:, pull:, round:, why: SETUP, counted: false) if verdict.unconfigured?
|
|
298
|
+
return reject(checkout:, plan:, pull:, round:, why: withheld(verdict)) if verdict.withheld?
|
|
299
|
+
|
|
300
|
+
result = @review.call(pr: pull.url || number_of(pull), label: Plans::ReviewLabel::READY_TO_MERGE)
|
|
301
|
+
return Result.new(kind: :waiting, note: "🧪 #{Plans::ReviewLabel::READY_TO_MERGE} refused: #{result.reason}") if result.refused?
|
|
302
|
+
|
|
303
|
+
# Nothing merges here: the plan goes on to 🏁, where palpatine
|
|
304
|
+
# weighs each pull request and {Engine::Merging} acts on it (P1).
|
|
305
|
+
ready(plan, number_of(pull), "#{Plans::ReviewLabel::READY_TO_MERGE}: #{verdict.reason}")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The coverage gate's verdict on this round's commit, run once. A
|
|
309
|
+
# refused `ready-to-merge` (CI flipping to pending between two reads)
|
|
310
|
+
# answers :waiting, and without this every poll after it would run
|
|
311
|
+
# the project's whole suite again over code that has not changed. A
|
|
312
|
+
# new commit on the branch is a new key, so it is measured afresh; a
|
|
313
|
+
# head that cannot be read is never cached.
|
|
314
|
+
#
|
|
315
|
+
# @return [OpenLanes::Engine::CoverageGate::Verdict]
|
|
316
|
+
def gate_verdict(checkout, plan, round)
|
|
317
|
+
head = head_of(checkout) or return @gate.call(checkout:)
|
|
318
|
+
|
|
319
|
+
@verdicts[[plan.feature.ordinal.to_s, round.number, head]] ||= @gate.call(checkout:)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# @return [String, nil] the checkout's HEAD, nil when git cannot say
|
|
323
|
+
def head_of(checkout)
|
|
324
|
+
out, status = Open3.capture2("git", "-C", checkout.path.to_s, "rev-parse", "HEAD", err: File::NULL)
|
|
325
|
+
status.success? ? out.strip : nil
|
|
326
|
+
rescue SystemCallError
|
|
327
|
+
nil
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Whether the verification commit is recent enough that CI may not
|
|
331
|
+
# have registered on it yet. Timed from the round's publication
|
|
332
|
+
# record, written just after the push, or failing that its report;
|
|
333
|
+
# read from disk rather than remembered, so a restarted engine keeps
|
|
334
|
+
# the same window.
|
|
335
|
+
#
|
|
336
|
+
# @return [Boolean]
|
|
337
|
+
def registering?(round)
|
|
338
|
+
path = round && [round.published_path, round.report_path].find { File.exist?(it) }
|
|
339
|
+
return false unless path
|
|
340
|
+
|
|
341
|
+
@clock.call - File.mtime(path) < NO_CHECKS_GRACE
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @return [Result]
|
|
345
|
+
# `ready-to-merge` is on. Once every open pull request of the plan
|
|
346
|
+
# carries it, the plan is 🏁 Completed (P1), and palpatine is next.
|
|
347
|
+
#
|
|
348
|
+
# @return [Result]
|
|
349
|
+
def ready(plan, number, why)
|
|
350
|
+
# Waiting, not ready: the look comes round again, and finds this
|
|
351
|
+
# one labelled and the others perhaps labelled by then.
|
|
352
|
+
unless every_pull_ready?(plan, number)
|
|
353
|
+
return Result.new(kind: :waiting, note: "PR ##{number} #{why}; waiting for the plan's other pull requests")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
unless plan.machine.may?(:completed)
|
|
357
|
+
why_not = Plans::STATUS_BY_KEY.fetch(:completed).violation(plan) || "#{plan.status} cannot become 🏁"
|
|
358
|
+
return Result.new(kind: :ready, note: "PR ##{number} #{why}; stays 🧪: #{why_not}")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
plan.machine.promote!(:completed)
|
|
362
|
+
Result.new(kind: :ready, to: :completed, note: "PR ##{number} #{why}; on to 🏁, for palpatine to weigh")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# @param labelled [Integer] the pull request just labelled, not asked again
|
|
366
|
+
# @return [Boolean]
|
|
367
|
+
def every_pull_ready?(plan, labelled)
|
|
368
|
+
plan.pull_requests.select(&:open?).all? do |pull|
|
|
369
|
+
next true if number_of(pull) == labelled
|
|
370
|
+
|
|
371
|
+
@github.labels(number: number_of(pull), repo: Vcs::GitHub.repo_of(pull.url)).include?(Plans::ReviewLabel::READY_TO_MERGE.name)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# @return [Boolean]
|
|
376
|
+
def labelled_ready?(number, repo) = @github.labels(number:, repo:).include?(Plans::ReviewLabel::READY_TO_MERGE.name)
|
|
377
|
+
|
|
378
|
+
# @return [String]
|
|
379
|
+
def withheld(verdict)
|
|
380
|
+
lines = ["The coverage gate withheld #{Plans::ReviewLabel::READY_TO_MERGE}: #{verdict.reason}."]
|
|
381
|
+
lines.push("", "```text", *verdict.output.last(TAIL), "```") unless verdict.output.empty?
|
|
382
|
+
lines.join("\n")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Only what the round is allowed to keep, minus the engine's own note
|
|
386
|
+
# about the app, which names this machine's ports and is no part of
|
|
387
|
+
# the record.
|
|
388
|
+
#
|
|
389
|
+
# @return [Array<String>] the kept images
|
|
390
|
+
#
|
|
391
|
+
# A round holding an image that is not PNG has those images deleted
|
|
392
|
+
# before anything else: both callers commit the round's folder whole,
|
|
393
|
+
# so an image merely "not kept" would be published all the same,
|
|
394
|
+
# together with every step screenshot {Screenshots#prepare} would
|
|
395
|
+
# have pruned. The PNGs then go through {Screenshots#prepare} as
|
|
396
|
+
# usual, so the evidence that can be shown still is.
|
|
397
|
+
def keep(round, failing_step:)
|
|
398
|
+
return [] unless File.directory?(round.dir)
|
|
399
|
+
|
|
400
|
+
FileUtils.rm_f(File.join(round.dir, Round::APP_NOTE))
|
|
401
|
+
shots = @screenshots.call(round.dir)
|
|
402
|
+
begin
|
|
403
|
+
shots.prepare(failing_step:)
|
|
404
|
+
rescue Screenshots::Refused => e
|
|
405
|
+
removed = shots.discard_foreign!
|
|
406
|
+
UI.log("#{e.message}; deleted #{removed.join(", ")} rather than publish them", level: :warn)
|
|
407
|
+
shots.prepare(failing_step:)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @return [Array(String, String, String)] the SHA, nil when nothing
|
|
412
|
+
# was committed; what to say about it; and why the commit failed,
|
|
413
|
+
# nil when it did not
|
|
414
|
+
def commit(checkout, plan, round, verdict)
|
|
415
|
+
message = "Verify #{plan.feature.ordinal}, round #{round.number}: #{verdict}"
|
|
416
|
+
sha = @publisher.commit_plan(checkout:, plan:, message:)
|
|
417
|
+
[sha, sha ? "committed #{sha[0, 7]}" : "nothing committed on a dry run", nil]
|
|
418
|
+
rescue Error => e
|
|
419
|
+
[nil, "not committed: #{e.message}", e.message]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# @return [Result] the send-back for a verified round whose evidence
|
|
423
|
+
# never reached the branch, its note saying so first. A push that
|
|
424
|
+
# failed after a local commit lands here too: the commit waits in
|
|
425
|
+
# the checkout, and the builders' next publish carries it.
|
|
426
|
+
def uncommitted(checkout:, plan:, pull:, round:, failure:)
|
|
427
|
+
why = "Verified, but #{Screenshots::FINAL} never reached the pull request, so it carries no evidence: #{failure}"
|
|
428
|
+
result = reject(checkout:, plan:, pull:, round:, why:, counted: false)
|
|
429
|
+
result.with(note: "verified PR ##{number_of(pull)} but NOT published (#{failure}); #{result.note}")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# The engine writes the report chewie never got to write.
|
|
433
|
+
#
|
|
434
|
+
# @return [void]
|
|
435
|
+
def write_report(round, why)
|
|
436
|
+
File.write(round.report_path, <<~MARKDOWN)
|
|
437
|
+
# Verification, round #{round.number}
|
|
438
|
+
|
|
439
|
+
Verdict: rejected
|
|
440
|
+
|
|
441
|
+
Written by the engine: the round failed where chewie-verifier could not say so.
|
|
442
|
+
|
|
443
|
+
#{why}
|
|
444
|
+
MARKDOWN
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A rejection chewie did not sign (the app, CI, the coverage gate)
|
|
448
|
+
# still counts against the cap. A setup send-back is signed too, so
|
|
449
|
+
# the round has its record, but as "sent back", which {#rejections}
|
|
450
|
+
# does not count.
|
|
451
|
+
#
|
|
452
|
+
# @return [void]
|
|
453
|
+
def sign_for_chewie(plan, round, why, counted:)
|
|
454
|
+
first = why.lines.first.to_s.strip.delete("()").sub(/[.:]\z/, "")
|
|
455
|
+
Plans::PlanState.for(plan.feature.path)
|
|
456
|
+
.sign!(agent: AGENT,
|
|
457
|
+
round: round.number,
|
|
458
|
+
status: "Completed",
|
|
459
|
+
note: "#{counted ? "rejected" : "sent back"}: #{first}; signed by harness")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# @return [Result]
|
|
463
|
+
def send_back(plan, pull, body, labelled, counted:)
|
|
464
|
+
count = rejections(plan)
|
|
465
|
+
max = @max.respond_to?(:call) ? @max.call : @max
|
|
466
|
+
tally = counted ? "rejected #{count} of #{max}" : "sent back for setup, not counted (#{count} of #{max})"
|
|
467
|
+
@arbitration.call(plan:, count:, max:) if counted && count >= max
|
|
468
|
+
moved = @outcomes.apply(PrWatch::Outcome.new(plan: plan.feature.ordinal.to_s,
|
|
469
|
+
pr: number_of(pull),
|
|
470
|
+
kind: :rejected,
|
|
471
|
+
comments: [{ author: AGENT, url: pull.url.to_s, body: }]))
|
|
472
|
+
label = if labelled.nil? then "labels left as hansolo set them"
|
|
473
|
+
elsif labelled.refused? then "#{Plans::ReviewLabel::REJECTED} refused: #{labelled.reason}"
|
|
474
|
+
else Plans::ReviewLabel::REJECTED.name
|
|
475
|
+
end
|
|
476
|
+
Result.new(kind: :rejected,
|
|
477
|
+
note: "verification #{counted ? "rejected" : "sent back"} PR ##{number_of(pull)}, #{tally}; #{label}" \
|
|
478
|
+
"#{"; #{moved.note}" if moved}",
|
|
479
|
+
to: moved&.to)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# @return [String] Markdown
|
|
483
|
+
def comment(plan:, round:, report:, why:, link:, committed:, counted:)
|
|
484
|
+
lines = ["### 🧪 #{AGENT}: #{counted ? "rejected" : "sent back for setup"}, verification round #{round.number}", ""]
|
|
485
|
+
if why
|
|
486
|
+
lines << why
|
|
487
|
+
else
|
|
488
|
+
lines << "**Step #{report.step || "?"}:** #{report.what || "see #{round.relative}/#{Round::REPORT}"}" << ""
|
|
489
|
+
lines << "- **Expected:** #{report.expected || "not reported"}"
|
|
490
|
+
lines << "- **Seen:** #{report.seen || "not reported"}"
|
|
491
|
+
end
|
|
492
|
+
lines << "" << "" if link
|
|
493
|
+
lines << "" << "The screenshots were #{committed}." unless link || why
|
|
494
|
+
lines << "" << "Plan #{plan.feature.ordinal}. How to test is in the pull request's description."
|
|
495
|
+
lines.join("\n")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# M14: by commit SHA, so the image renders in a private repository
|
|
499
|
+
# too. Every byte of the path is escaped: plan folders carry emoji,
|
|
500
|
+
# `━` and `❯`, and one unescaped byte is a broken image.
|
|
501
|
+
#
|
|
502
|
+
# @return [String, nil]
|
|
503
|
+
def link(pull, sha, png)
|
|
504
|
+
repo = Vcs::GitHub.repo_of(pull.url)
|
|
505
|
+
return nil unless sha && png && repo
|
|
506
|
+
|
|
507
|
+
path = Pathname(File.realpath(png)).relative_path_from(Pathname(File.realpath(@root))).to_s
|
|
508
|
+
"https://github.com/#{repo}/blob/#{sha}/#{path.split("/").map { ERB::Util.url_encode(it) }.join("/")}?raw=true"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# @return [Integer]
|
|
512
|
+
def number_of(pull) = Vcs::GitHub.number_of(pull.url || pull.number)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
module Verification
|
|
6
|
+
# chewie's `verification/<round>/report.md`, read back for the comment
|
|
7
|
+
# the engine posts (M1, M14). chewie cannot post it: the comment links
|
|
8
|
+
# the screenshot by the SHA of a commit only the engine makes.
|
|
9
|
+
#
|
|
10
|
+
# The shape chewie-verifier.md asks for:
|
|
11
|
+
#
|
|
12
|
+
# # Verification, round 1
|
|
13
|
+
#
|
|
14
|
+
# Verdict: rejected
|
|
15
|
+
#
|
|
16
|
+
# ## Steps
|
|
17
|
+
#
|
|
18
|
+
# 1. Open the board: passed
|
|
19
|
+
# 2. Click New game: failed (step-02.png)
|
|
20
|
+
#
|
|
21
|
+
# ## Failing step
|
|
22
|
+
#
|
|
23
|
+
# - Step: 2
|
|
24
|
+
# - What: Click "New game"
|
|
25
|
+
# - Expected: a 9 by 9 grid appears
|
|
26
|
+
# - Seen: the page says "500 Internal Server Error"
|
|
27
|
+
#
|
|
28
|
+
# A report that leaves a field out reads as nil there, and the comment
|
|
29
|
+
# says what it does have. A missing verdict is nil too: the signature,
|
|
30
|
+
# not the report, is what the dispatcher acts on.
|
|
31
|
+
#
|
|
32
|
+
# @!attribute [r] verdict
|
|
33
|
+
# @return [Symbol, nil] :verified or :rejected
|
|
34
|
+
# @!attribute [r] step
|
|
35
|
+
# @return [Integer, nil] the failing step's number
|
|
36
|
+
# @!attribute [r] what
|
|
37
|
+
# @return [String, nil] the failing step, as How to test words it
|
|
38
|
+
# @!attribute [r] expected
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
# @!attribute [r] seen
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
Report = Data.define(:verdict, :step, :what, :expected, :seen) do
|
|
43
|
+
def initialize(verdict: nil, step: nil, what: nil, expected: nil, seen: nil) = super
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] the failing step's screenshot, `step-02.png`
|
|
46
|
+
def screenshot = step && format(Report::STEP_PNG, step)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Report
|
|
50
|
+
# How a step's screenshot is named (M14).
|
|
51
|
+
STEP_PNG = "step-%02d.png"
|
|
52
|
+
|
|
53
|
+
# The section holding the failure.
|
|
54
|
+
FAILING = "## Failing step"
|
|
55
|
+
|
|
56
|
+
# One `Key: value` line, bulleted or not.
|
|
57
|
+
FIELD = /\A\s*(?:[-*]\s+)?\**(?<key>Step|What|Expected|Seen)\**\s*:\s*\**\s*(?<value>.+?)\s*\z/i
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# @param path [String]
|
|
61
|
+
# @return [Report]
|
|
62
|
+
def read(path) = parse(File.read(path, encoding: "UTF-8"))
|
|
63
|
+
|
|
64
|
+
# @param text [String]
|
|
65
|
+
# @return [Report]
|
|
66
|
+
def parse(text)
|
|
67
|
+
lines = text.to_s.lines.map(&:chomp)
|
|
68
|
+
fields = failing(lines).filter_map { FIELD.match(it) }.to_h { [it[:key].downcase.to_sym, it[:value]] }
|
|
69
|
+
new(verdict: verdict(lines),
|
|
70
|
+
step: fields[:step]&.[](/\d+/)&.to_i,
|
|
71
|
+
what: fields[:what],
|
|
72
|
+
expected: fields[:expected],
|
|
73
|
+
seen: fields[:seen])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# @return [Symbol, nil]
|
|
79
|
+
def verdict(lines)
|
|
80
|
+
word = lines.filter_map { it[/\A\s*\**Verdict\**\s*:\s*\**\s*(\w+)/i, 1] }.first.to_s.downcase
|
|
81
|
+
return :verified if word.start_with?("verif", "pass")
|
|
82
|
+
|
|
83
|
+
:rejected if word.start_with?("reject", "fail")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Array<String>] the lines under {FAILING}, up to the next heading
|
|
87
|
+
def failing(lines)
|
|
88
|
+
start = lines.index { it.strip.casecmp?(FAILING) } or return []
|
|
89
|
+
lines[(start + 1)..].take_while { !it.start_with?("#") }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|