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.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /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 << "" << "![#{report.screenshot}](#{link})" 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