willow-bot 1.2.2__tar.gz → 1.2.4__tar.gz

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 (221) hide show
  1. willow_bot-1.2.4/.release-please-manifest.json +3 -0
  2. {willow_bot-1.2.2 → willow_bot-1.2.4}/CHANGELOG.md +17 -0
  3. {willow_bot-1.2.2 → willow_bot-1.2.4}/INSTALL.md +1 -1
  4. {willow_bot-1.2.2 → willow_bot-1.2.4}/PKG-INFO +1 -1
  5. {willow_bot-1.2.2 → willow_bot-1.2.4}/integrations/fleet_bridge.py +28 -5
  6. {willow_bot-1.2.2 → willow_bot-1.2.4}/pyproject.toml +1 -1
  7. {willow_bot-1.2.2 → willow_bot-1.2.4}/router.py +16 -6
  8. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_comment.py +45 -0
  9. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_inbox.py +124 -3
  10. willow_bot-1.2.4/tests/test_check_prs.py +74 -0
  11. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_contributions.py +26 -0
  12. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_fleet_bridge.py +54 -0
  13. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/__init__.py +1 -1
  14. willow_bot-1.2.4/willow_bot/check_prs.py +38 -0
  15. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deposits.py +6 -2
  16. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/status.py +5 -3
  17. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/inbox.py +56 -11
  18. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/tick.py +50 -1
  19. willow_bot-1.2.2/.release-please-manifest.json +0 -3
  20. {willow_bot-1.2.2 → willow_bot-1.2.4}/.env.example +0 -0
  21. {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/release-please.yml +0 -0
  22. {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/release.yml +0 -0
  23. {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/tests.yml +0 -0
  24. {willow_bot-1.2.2 → willow_bot-1.2.4}/.gitignore +0 -0
  25. {willow_bot-1.2.2 → willow_bot-1.2.4}/BOT-INVENTORY.md +0 -0
  26. {willow_bot-1.2.2 → willow_bot-1.2.4}/LICENSE +0 -0
  27. {willow_bot-1.2.2 → willow_bot-1.2.4}/README.md +0 -0
  28. {willow_bot-1.2.2 → willow_bot-1.2.4}/SECURITY_AUDIT.md +0 -0
  29. {willow_bot-1.2.2 → willow_bot-1.2.4}/bot.py +0 -0
  30. {willow_bot-1.2.2 → willow_bot-1.2.4}/credentials.py +0 -0
  31. {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/deterministic-policy.template.json +0 -0
  32. {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/operator.example.json +0 -0
  33. {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/operator.willow-memory.json +0 -0
  34. {willow_bot-1.2.2 → willow_bot-1.2.4}/docs/MOVE-STAY-BORROW.md +0 -0
  35. {willow_bot-1.2.2 → willow_bot-1.2.4}/docs/PRIOR-ART.md +0 -0
  36. {willow_bot-1.2.2 → willow_bot-1.2.4}/github_app.py +0 -0
  37. {willow_bot-1.2.2 → willow_bot-1.2.4}/horoscope.py +0 -0
  38. {willow_bot-1.2.2 → willow_bot-1.2.4}/integrations/__init__.py +0 -0
  39. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/SPEC.md +0 -0
  40. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/__init__.py +0 -0
  41. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/accumulator.py +0 -0
  42. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/catalog.py +0 -0
  43. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/cerebras.py +0 -0
  44. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/context.py +0 -0
  45. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/poster.py +0 -0
  46. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/requirements.txt +0 -0
  47. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/semantic.py +0 -0
  48. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/system_prompt.txt +0 -0
  49. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/watcher.py +0 -0
  50. {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/write_key.py +0 -0
  51. {willow_bot-1.2.2 → willow_bot-1.2.4}/losc/MANIFESTO.md +0 -0
  52. {willow_bot-1.2.2 → willow_bot-1.2.4}/losc/checker.py +0 -0
  53. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/README.md +0 -0
  54. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/deep_thought.py +0 -0
  55. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/README.md +0 -0
  56. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/compare.py +0 -0
  57. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/gen.py +0 -0
  58. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/probe.py +0 -0
  59. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/run_prompt_tests.py +0 -0
  60. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/__init__.py +0 -0
  61. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/__main__.py +0 -0
  62. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/api.py +0 -0
  63. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/boot.py +0 -0
  64. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/escalate.py +0 -0
  65. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/gate.py +0 -0
  66. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/nestor_seal.py +0 -0
  67. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/predict.py +0 -0
  68. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/proposals.py +0 -0
  69. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/record.py +0 -0
  70. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/resolve.py +0 -0
  71. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/reverse.py +0 -0
  72. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/run.py +0 -0
  73. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/serve.py +0 -0
  74. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_api.py +0 -0
  75. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_capability.py +0 -0
  76. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_escalate.py +0 -0
  77. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_incoming.py +0 -0
  78. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_layers.py +0 -0
  79. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_main.py +0 -0
  80. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_md_split.py +0 -0
  81. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_nestor_seal.py +0 -0
  82. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_onescript.py +0 -0
  83. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_pooled.py +0 -0
  84. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_pr_scan.py +0 -0
  85. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_prompt.py +0 -0
  86. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_serve.py +0 -0
  87. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_wagers.py +0 -0
  88. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_xref.py +0 -0
  89. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/view.py +0 -0
  90. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/xref.py +0 -0
  91. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/prompt.py +0 -0
  92. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/ruff.toml +0 -0
  93. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/batch_flow.py +0 -0
  94. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/flow.py +0 -0
  95. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/live_flow.py +0 -0
  96. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/make_flow.py +0 -0
  97. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/merge_flows.py +0 -0
  98. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/run_flow.py +0 -0
  99. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/side_runner.py +0 -0
  100. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/four_ws.py +0 -0
  101. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/md_split.py +0 -0
  102. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/pr_scan.py +0 -0
  103. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/script_match.py +0 -0
  104. {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/shared_phrases.py +0 -0
  105. {willow_bot-1.2.2 → willow_bot-1.2.4}/quips.py +0 -0
  106. {willow_bot-1.2.2 → willow_bot-1.2.4}/rebase_shame.py +0 -0
  107. {willow_bot-1.2.2 → willow_bot-1.2.4}/release-please-config.json +0 -0
  108. {willow_bot-1.2.2 → willow_bot-1.2.4}/requirements.txt +0 -0
  109. {willow_bot-1.2.2 → willow_bot-1.2.4}/runes.py +0 -0
  110. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/audit_app_config.py +0 -0
  111. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/fresh-check.sh +0 -0
  112. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/install-service.sh +0 -0
  113. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/list_installations.py +0 -0
  114. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/preflight.py +0 -0
  115. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/prove_deposit_once.py +0 -0
  116. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/repo_map.py +0 -0
  117. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/rotate_webhook_secret.py +0 -0
  118. {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/sync_webhook_secret.py +0 -0
  119. {willow_bot-1.2.2 → willow_bot-1.2.4}/secrets/willow-bot.env.example +0 -0
  120. {willow_bot-1.2.2 → willow_bot-1.2.4}/sigh.py +0 -0
  121. {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot-deterministic.service.template +0 -0
  122. {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot-steward.service.template +0 -0
  123. {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot.service.template +0 -0
  124. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/conftest.py +0 -0
  125. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/conftest.py +0 -0
  126. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_app_grant.py +0 -0
  127. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_catchup.py +0 -0
  128. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci.py +0 -0
  129. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_log.py +0 -0
  130. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_notify.py +0 -0
  131. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_residue.py +0 -0
  132. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_config_app_id.py +0 -0
  133. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_envelope_retire_sweep_wiring.py +0 -0
  134. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_grove_permission_block.py +0 -0
  135. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_heartbeat_seal_drain.py +0 -0
  136. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ingress.py +0 -0
  137. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_install_step.py +0 -0
  138. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_mirror_and_audit.py +0 -0
  139. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_net_drain_wiring.py +0 -0
  140. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_resolve.py +0 -0
  141. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_tick_wiring.py +0 -0
  142. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_desk.py +0 -0
  143. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_enrich.py +0 -0
  144. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_triage.py +0 -0
  145. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_voice.py +0 -0
  146. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_annul.py +0 -0
  147. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_bot_webhook.py +0 -0
  148. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_box_rule.py +0 -0
  149. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_catalog.py +0 -0
  150. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_contribute.py +0 -0
  151. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_credentials.py +0 -0
  152. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_delivery_dedup.py +0 -0
  153. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deposits.py +0 -0
  154. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deposits_chain.py +0 -0
  155. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_chain.py +0 -0
  156. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_resolvers.py +0 -0
  157. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_runner.py +0 -0
  158. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_home_sandbox.py +0 -0
  159. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_horoscope.py +0 -0
  160. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_host_probe.py +0 -0
  161. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_install_receipt.py +0 -0
  162. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_loki_keys.py +0 -0
  163. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_operator_profile.py +0 -0
  164. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_persona_store.py +0 -0
  165. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_assign.py +0 -0
  166. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_labels.py +0 -0
  167. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_voice.py +0 -0
  168. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_quips.py +0 -0
  169. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_quips_integration.py +0 -0
  170. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_rebase_shame.py +0 -0
  171. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_runes.py +0 -0
  172. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_semantic.py +0 -0
  173. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_sigh.py +0 -0
  174. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_socket_chat_op.py +0 -0
  175. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_status.py +0 -0
  176. {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_troll.py +0 -0
  177. {willow_bot-1.2.2 → willow_bot-1.2.4}/troll.py +0 -0
  178. {willow_bot-1.2.2 → willow_bot-1.2.4}/tunnel.py +0 -0
  179. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow-bot.json +0 -0
  180. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/cli.py +0 -0
  181. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/contribute.py +0 -0
  182. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/contributions.py +0 -0
  183. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/delivery_dedup.py +0 -0
  184. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/__init__.py +0 -0
  185. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/chain.py +0 -0
  186. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/chat_op.py +0 -0
  187. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/cli.py +0 -0
  188. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/host_probe.py +0 -0
  189. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/ollama.py +0 -0
  190. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/policy.py +0 -0
  191. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/resolvers.py +0 -0
  192. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/runner.py +0 -0
  193. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/socket_client.py +0 -0
  194. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/socket_server.py +0 -0
  195. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/install_receipt.py +0 -0
  196. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/operator_profile.py +0 -0
  197. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/paths.py +0 -0
  198. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/persona_store.py +0 -0
  199. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_assign.py +0 -0
  200. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_labels.py +0 -0
  201. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_voice.py +0 -0
  202. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/__init__.py +0 -0
  203. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/app_grant.py +0 -0
  204. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ci_comments.py +0 -0
  205. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ci_log.py +0 -0
  206. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/config.py +0 -0
  207. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/fleet.py +0 -0
  208. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/heartbeat.py +0 -0
  209. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ingress.py +0 -0
  210. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/mcp_client.py +0 -0
  211. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/merge.py +0 -0
  212. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_checkout.py +0 -0
  213. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_ci_status.py +0 -0
  214. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_comment.py +0 -0
  215. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pr_watch.py +0 -0
  216. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/scan.py +0 -0
  217. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_config.py +0 -0
  218. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_desk.py +0 -0
  219. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_triage.py +0 -0
  220. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/voice.py +0 -0
  221. {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/welcome.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.4"
3
+ }
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.2.4](https://github.com/willow-memory/willow-bot/compare/v1.2.3...v1.2.4) (2026-10-11)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * a check run's PR is only one whose base is the check's repo ([9aa1e48](https://github.com/willow-memory/willow-bot/commit/9aa1e48666b7631ba29900774a57a8cafe0ab80b))
11
+ * a check run's PR is only one whose base is the check's repo (stalled [#1702](https://github.com/willow-memory/willow-bot/issues/1702)) ([36c52a9](https://github.com/willow-memory/willow-bot/commit/36c52a9f1cc1d7516e4a5e69ec771fed05cfd589))
12
+
13
+ ## [1.2.3](https://github.com/willow-memory/willow-bot/compare/v1.2.2...v1.2.3) (2026-10-11)
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **bridge:** one inbox item per event; archive kinds no step reads ([54cb180](https://github.com/willow-memory/willow-bot/commit/54cb180705edff027aa186243bae59d17d54fde2))
19
+ * inbox replay, one item per event, unhandled-kind pile, ungated gap_filed quip ([47945e8](https://github.com/willow-memory/willow-bot/commit/47945e877cd912f88faa32441906b2695f422fa7))
20
+ * **router:** the gap_filed quip passes the voice's gate ([c15934c](https://github.com/willow-memory/willow-bot/commit/c15934ccd416dc21e4c58e95b30938ca237bb7ca))
21
+
5
22
  ## [1.2.2](https://github.com/willow-memory/willow-bot/compare/v1.2.1...v1.2.2) (2026-10-11)
6
23
 
7
24
 
@@ -174,7 +174,7 @@ Verified webhooks fan out into local queues under `$WILLOW_HOME`:
174
174
 
175
175
  | Event | Local action |
176
176
  |-------|----------------|
177
- | `pull_request`, `issues`, `issue_comment`, `check_run` | `upstream_steward/webhook_inbox/*.json` |
177
+ | `pull_request`, `issues`, `issue_comment`, `check_run` | `upstream_steward/webhook_inbox/*.json`, one item per event; the steward's inbox step moves consumed items to `webhook_inbox/consumed/<YYYY-MM>/` and kinds no step reads (`issue`, `issue_comment`, `installation*`) to `webhook_inbox/unhandled/<YYYY-MM>/` |
178
178
  | `push` to `main`/`master` (local clone exists) | `gitsync/trigger-<owner>-<repo>.flag` |
179
179
  | all events | `willow-bot/event-log.jsonl` audit trail |
180
180
  | PRs, issues, comments, reviews, forks, stars, releases, discussions | `willow-bot/contributions.jsonl` ledger (`willow-bot-steward contributions`) |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: willow-bot
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Willow GitHub App webhook + orchestrator-feeding steward (propose-only deposits)
5
5
  Project-URL: Homepage, https://github.com/willow-memory/willow-bot
6
6
  Project-URL: Repository, https://github.com/willow-memory/willow-bot
@@ -106,6 +106,13 @@ def _work_id(repo: str, kind: str, number: int | str) -> str:
106
106
  return f"wh-{safe_repo}-{kind}-{number}-{digest}"
107
107
 
108
108
 
109
+ def _event_key(number: object, *parts: object) -> str:
110
+ """`<number>-<digest of parts>`: the same for a redelivery of one webhook,
111
+ different for the next event on the same PR, issue or installation."""
112
+ digest = hashlib.sha256("|".join(str(p or "") for p in parts).encode()).hexdigest()[:10]
113
+ return f"{number}-{digest}"
114
+
115
+
109
116
  def _append_log(record: dict) -> None:
110
117
  event_log = _event_log()
111
118
  event_log.parent.mkdir(parents=True, exist_ok=True)
@@ -251,7 +258,13 @@ def handle(event: str, payload: dict) -> None:
251
258
  _queue_upstream(
252
259
  {
253
260
  **base,
254
- "work_id": _work_id(repo, "pr", number),
261
+ # One item per event, not per PR: the action, the head and
262
+ # GitHub's updated_at name it, so a redelivery of the same
263
+ # webhook is the same item and a later synchronize, close
264
+ # or reopen is a new one. Keyed on the number alone, the
265
+ # file of the first event made every later one skip.
266
+ "work_id": _work_id(repo, "pr", _event_key(
267
+ number, action, (pr.get("head") or {}).get("sha"), pr.get("updated_at"))),
255
268
  "kind": "pull_request",
256
269
  "number": number,
257
270
  "title": pr.get("title", ""),
@@ -278,7 +291,8 @@ def handle(event: str, payload: dict) -> None:
278
291
  _queue_upstream(
279
292
  {
280
293
  **base,
281
- "work_id": _work_id(repo, "issue", number),
294
+ "work_id": _work_id(repo, "issue", _event_key(
295
+ number, action, (payload.get("label") or {}).get("name"), issue.get("updated_at"))),
282
296
  "kind": "issue",
283
297
  "number": number,
284
298
  "title": issue.get("title", ""),
@@ -318,8 +332,12 @@ def handle(event: str, payload: dict) -> None:
318
332
  # is unique per run and stable across redeliveries, so the dedup
319
333
  # still holds where it should (the same completion twice) and
320
334
  # nowhere it should not.
321
- prs = check.get("pull_requests") or []
322
- pr_number = prs[0].get("number") if prs else None
335
+ # Only a PR of THIS repo: a check on a fork also lists the fork's
336
+ # PR into upstream, whose number names nothing here.
337
+ from willow_bot.check_prs import pr_numbers
338
+
339
+ prs = pr_numbers(check, repo)
340
+ pr_number = prs[0] if prs else None
323
341
  _queue_upstream(
324
342
  {
325
343
  **base,
@@ -363,7 +381,12 @@ def handle(event: str, payload: dict) -> None:
363
381
  _queue_upstream(
364
382
  {
365
383
  **base,
366
- "work_id": _work_id("app", event, payload.get("action", "change")),
384
+ "work_id": _work_id("app", event, _event_key(
385
+ payload.get("action", "change"),
386
+ (payload.get("installation") or {}).get("id"),
387
+ ",".join(sorted(str(r.get("full_name")) for r in (payload.get("repositories_added") or []))),
388
+ ",".join(sorted(str(r.get("full_name")) for r in (payload.get("repositories_removed") or []))),
389
+ )),
367
390
  "kind": event,
368
391
  "repositories_added": [
369
392
  r.get("full_name") for r in (payload.get("repositories_added") or [])
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "willow-bot"
7
- version = "1.2.2"
7
+ version = "1.2.4"
8
8
  description = "Willow GitHub App webhook + orchestrator-feeding steward (propose-only deposits)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,6 +14,7 @@ import sigh
14
14
  import troll
15
15
  from integrations import fleet_bridge
16
16
  from willow_bot import contributions, welcome
17
+ from willow_bot.check_prs import pr_numbers
17
18
 
18
19
  log = logging.getLogger("willow-bot.router")
19
20
 
@@ -215,26 +216,28 @@ def _handle_check_run(payload: dict, post: Callable) -> None:
215
216
  conclusion = check.get("conclusion")
216
217
  repo = payload.get("repository", {}).get("full_name", "")
217
218
 
218
- prs = check.get("pull_requests") or []
219
+ # Only PRs of this repo: a fork's check also lists its PR into upstream,
220
+ # whose number names nothing here (or an unrelated issue that shares it).
221
+ prs = pr_numbers(check, repo)
219
222
 
220
223
  if action == "completed":
221
224
  if conclusion == "success":
222
225
  msg = quips.pick("ci_pass")
223
226
  for pr in prs:
224
- reds = sigh.get(repo, pr["number"])
225
- sigh.reset(repo, pr["number"])
227
+ reds = sigh.get(repo, pr)
228
+ sigh.reset(repo, pr)
226
229
  # Once per recovery: the first green leg resets the streak,
227
230
  # so later legs of the same head see 0 and say nothing.
228
231
  head = str(check.get("head_sha") or "")
229
232
  if (reds >= troll.RECOVERY_REDS and not _mode_override()
230
- and _may(repo, pr["number"], f"recovered:{head}", None, bot=True)):
233
+ and _may(repo, pr, f"recovered:{head}", None, bot=True)):
231
234
  line = _troll_line("ci_recovered", repo, {"reds": reds}, head)
232
235
  if line:
233
- post(repo, pr["number"], line)
236
+ post(repo, pr, line)
234
237
  elif conclusion in ("failure", "timed_out", "startup_failure"):
235
238
  msg = quips.pick("ci_fail")
236
239
  for pr in prs:
237
- streak = sigh.bump_fail(repo, pr["number"])
240
+ streak = sigh.bump_fail(repo, pr)
238
241
  line = sigh.sigh_line(streak)
239
242
  if line:
240
243
  msg = line
@@ -270,6 +273,13 @@ def _handle_issues(payload: dict, post: Callable) -> None:
270
273
  # A first-timer gets the welcome instead of a quip: one comment, not two.
271
274
  if _welcome(repo, issue.get("number"), issue.get("user"), issue.get("author_association"), post):
272
275
  return
276
+ # The same gate every other line passes: the voice's orgs, never an
277
+ # outside contributor, once per issue, under the daily cap. This line
278
+ # used to post on every issue in every repo the App can see.
279
+ user = issue.get("user") or {}
280
+ if not _may(repo, issue.get("number"), "gap_filed", issue.get("author_association"),
281
+ bot=user.get("type") == "Bot"):
282
+ return
273
283
  msg = quips.pick("gap_filed")
274
284
  if msg:
275
285
  post(repo, issue.get("number"), msg)
@@ -1258,3 +1258,48 @@ def test_verdict_empty_live_head_is_unverified_not_confirmed(home, monkeypatch):
1258
1258
  def test_verdict_confirmed_live_head_has_no_note(home, monkeypatch):
1259
1259
  monkeypatch.setattr(tick, "_ci_fetch_pull", _Pulls({(RAT, 48): ("open", False, SHA)}))
1260
1260
  assert tick._ci_head_verdict(RAT, 48, SHA, {}, SEEN) == (None, None)
1261
+
1262
+
1263
+ # ── ci-comments-retire: the one explicit, receipted way to stop owing ────────
1264
+
1265
+
1266
+ def _owe(where: str, sha: str = "a" * 40) -> None:
1267
+ from willow_bot.steward import ci_comments
1268
+
1269
+ owed, _ = ci_comments.load()
1270
+ repo, pr = where.split("#")
1271
+ e = ci_comments.entry_for(owed, f"{where}@{sha}", repo=repo, pr=int(pr), head_sha=sha, where=where)
1272
+ e["comment"].update(status="stalled", attempts=275, last_error="list: 404 Client Error")
1273
+ ci_comments.save(owed)
1274
+
1275
+
1276
+ def test_retire_is_a_dry_run_by_default_and_needs_a_reason(capsys):
1277
+ from willow_bot.steward import ci_comments, tick
1278
+
1279
+ _owe("o/fork#1702")
1280
+ with pytest.raises(SystemExit):
1281
+ tick.main(["ci-comments-retire", "o/fork#1702"]) # no --reason
1282
+ capsys.readouterr()
1283
+ assert tick.main(["ci-comments-retire", "o/fork#1702", "--reason", "upstream PR number"]) == 0
1284
+ assert "would_retire" in capsys.readouterr().out
1285
+ owed, _ = ci_comments.load()
1286
+ assert ci_comments.head_keys(owed) # nothing removed
1287
+
1288
+
1289
+ def test_retire_apply_removes_only_that_pr_and_leaves_a_receipt(capsys):
1290
+ from willow_bot.steward import ci_comments, tick
1291
+
1292
+ _owe("o/fork#1702")
1293
+ _owe("o/fork#5")
1294
+ assert tick.main(["ci-comments-retire", "o/fork#1702", "--reason", "upstream PR number", "--apply"]) == 0
1295
+ owed, _ = ci_comments.load()
1296
+ assert [owed[k]["where"] for k in ci_comments.head_keys(owed)] == ["o/fork#5"]
1297
+ receipt = [json.loads(ln) for ln in tick._receipts_path().read_text().splitlines()][-1]
1298
+ assert receipt["event"] == "steward_ci_comments_retire"
1299
+ assert receipt["reason"] == "upstream PR number" and receipt["retired"][0]["attempts"] == 275
1300
+
1301
+
1302
+ def test_retire_with_nothing_owed_says_so(capsys):
1303
+ from willow_bot.steward import tick
1304
+
1305
+ assert tick.main(["ci-comments-retire", "o/none#1", "--reason", "x", "--apply"]) == 1
@@ -282,12 +282,13 @@ def test_ingest_check_run_and_pull_request_share_state(
282
282
  assert check_signals[0]["conclusion"] == "failure"
283
283
 
284
284
 
285
- def test_ingest_unknown_kind_stays_in_inbox(
285
+ def test_ingest_unknown_kind_is_archived_unemitted(
286
286
  tmp_path: Path, monkeypatch, capsys
287
287
  ) -> None:
288
288
  """An installation / issue_comment / other-kind row is not consumed
289
- here — this step names only the kinds it knows how to emit for. The
290
- row stays for a later step and its work_id stays out of consumed."""
289
+ here — this step names only the kinds it knows how to emit for. No
290
+ step reads those kinds, so the row moves to unhandled/<YYYY-MM>/
291
+ (kept, not emitted) and its work_id stays out of consumed."""
291
292
  home = tmp_path / "willow"
292
293
  inbox = home / "upstream_steward" / "webhook_inbox"
293
294
  inbox.mkdir(parents=True)
@@ -312,3 +313,123 @@ def test_ingest_unknown_kind_stays_in_inbox(
312
313
  assert capsys.readouterr().out.strip() == ""
313
314
  saved = json.loads(state.read_text())
314
315
  assert item["work_id"] not in saved.get("inbox_consumed", [])
316
+ assert not (inbox / f"{item['work_id']}.json").exists()
317
+ assert [p.name for p in (inbox / "unhandled").glob("*/*.json")] == [f"{item['work_id']}.json"]
318
+
319
+
320
+ # ── consumed items leave the inbox ────────────────────────────────────────
321
+
322
+
323
+ def _box(tmp_path: Path, monkeypatch) -> tuple[Path, Path]:
324
+ home = tmp_path / "willow"
325
+ inbox = home / "upstream_steward" / "webhook_inbox"
326
+ inbox.mkdir(parents=True)
327
+ monkeypatch.setenv("WILLOW_HOME", str(home))
328
+ monkeypatch.delenv("LOKI_PR_WATCH_CALL_WATCHER", raising=False)
329
+ monkeypatch.delenv("WILLOW_BOT_STEWARD_CALL_WATCHER", raising=False)
330
+ return inbox, tmp_path / "state.json"
331
+
332
+
333
+ def _pr(n: int) -> dict:
334
+ return {"kind": "pull_request", "work_id": f"wh-o-r-pr-{n}-x", "repo": "o/r", "number": n,
335
+ "action": "opened", "received_at": "2026-10-11T00:00:00+00:00"}
336
+
337
+
338
+ def _check(cid: int) -> dict:
339
+ return {"kind": "check_run", "work_id": f"wh-o-r-check-{cid}-x", "repo": "o/r", "check_id": cid,
340
+ "conclusion": "success", "status": "completed", "head_sha": "abc"}
341
+
342
+
343
+ def _put(inbox: Path, item: dict) -> Path:
344
+ p = inbox / f"{item['work_id']}.json"
345
+ p.write_text(json.dumps(item), encoding="utf-8")
346
+ return p
347
+
348
+
349
+ def _emitted(capsys) -> list[dict]:
350
+ return [json.loads(ln) for ln in capsys.readouterr().out.splitlines() if ln.strip()]
351
+
352
+
353
+ def test_consumed_items_move_to_the_month_archive(tmp_path, monkeypatch, capsys):
354
+ import time
355
+
356
+ inbox, state = _box(tmp_path, monkeypatch)
357
+ _put(inbox, _pr(1))
358
+ _put(inbox, _check(7))
359
+ other = _put(inbox, {"kind": "issue_comment", "work_id": "wh-o-r-comment-1-x"})
360
+ assert ingest(state) == 0
361
+ assert len(_emitted(capsys)) == 2
362
+ stamp = time.strftime("%Y-%m", time.gmtime())
363
+ month = inbox / "consumed" / stamp
364
+ assert sorted(p.name for p in month.iterdir()) == ["wh-o-r-check-7-x.json", "wh-o-r-pr-1-x.json"]
365
+ assert list(inbox.glob("*.json")) == [] # the inbox holds only what waits
366
+ assert [p.name for p in (inbox / "unhandled" / stamp).iterdir()] == [other.name]
367
+
368
+
369
+ def test_a_later_webhook_for_a_consumed_work_id_is_archived_not_re_emitted(tmp_path, monkeypatch, capsys):
370
+ """fleet_bridge keys a PR by its number. Once the first item leaves the
371
+ inbox, a later action for the same PR writes the file again; emitting once
372
+ per work_id is unchanged, and the file still leaves the inbox."""
373
+ inbox, state = _box(tmp_path, monkeypatch)
374
+ _put(inbox, _pr(1))
375
+ ingest(state)
376
+ capsys.readouterr()
377
+ again = _put(inbox, {**_pr(1), "action": "closed"})
378
+ ingest(state)
379
+ assert _emitted(capsys) == []
380
+ assert not again.exists()
381
+
382
+
383
+ def test_the_cap_keeps_the_most_recent_ids_and_nothing_replays(tmp_path, monkeypatch, capsys):
384
+ """The old cap kept the alphabetically last ids and left files in place,
385
+ so every evicted item was emitted again on the next tick."""
386
+ import willow_bot.steward.inbox as inbox_mod
387
+
388
+ monkeypatch.setattr(inbox_mod, "_MAX_CONSUMED", 3)
389
+ inbox, state = _box(tmp_path, monkeypatch)
390
+ for n in (5, 4, 3, 2, 1): # names sort opposite to the order they arrive in
391
+ _put(inbox, _pr(n))
392
+ ingest(state)
393
+ assert len(_emitted(capsys)) == 5
394
+ saved = json.loads(state.read_text())["inbox_consumed"]
395
+ assert saved == ["wh-o-r-pr-3-x", "wh-o-r-pr-2-x", "wh-o-r-pr-1-x"] # newest three, in order
396
+ for _ in range(3):
397
+ ingest(state)
398
+ assert _emitted(capsys) == [] # nothing left in the inbox to replay
399
+
400
+
401
+ def test_a_failed_move_is_not_emitted_twice(tmp_path, monkeypatch, capsys):
402
+ import willow_bot.steward.inbox as inbox_mod
403
+
404
+ inbox, state = _box(tmp_path, monkeypatch)
405
+ stuck = _put(inbox, _pr(1))
406
+ monkeypatch.setattr(inbox_mod, "_archive", lambda *a, **k: False)
407
+ ingest(state)
408
+ ingest(state)
409
+ assert len(_emitted(capsys)) == 1 and stuck.exists()
410
+
411
+
412
+ def test_a_legacy_sorted_consumed_list_is_kept_and_ages_out_first(tmp_path, monkeypatch, capsys):
413
+ """A box upgraded from the old cap: its sorted list is read as-is, those
414
+ items are archived without being emitted, and new ids go after them."""
415
+ inbox, state = _box(tmp_path, monkeypatch)
416
+ old = _put(inbox, _pr(9))
417
+ state.write_text(json.dumps({"inbox_consumed": ["wh-o-r-pr-9-x"]}))
418
+ _put(inbox, _pr(1))
419
+ ingest(state)
420
+ assert [e["repo_pr"] for e in _emitted(capsys)] == ["o/r#1"]
421
+ assert not old.exists()
422
+ assert json.loads(state.read_text())["inbox_consumed"] == ["wh-o-r-pr-9-x", "wh-o-r-pr-1-x"]
423
+
424
+
425
+ def test_status_pending_counts_only_what_is_left(tmp_path, monkeypatch, capsys):
426
+ from willow_bot import status
427
+
428
+ inbox, _ = _box(tmp_path, monkeypatch)
429
+ from willow_bot.steward.config import state_path
430
+
431
+ _put(inbox, _pr(1))
432
+ _put(inbox, {"kind": "issue", "work_id": "wh-o-r-issue-1-x"})
433
+ ingest(state_path())
434
+ r = status.report()["inbox"]
435
+ assert r["total"] == 0 and r["pending"] == 0 # consumed and unhandled both left
@@ -0,0 +1,74 @@
1
+ """A check run's `pull_requests` lists open PRs whose HEAD matches, so a
2
+ fork's check also lists the fork's PR into upstream. Only a PR whose BASE
3
+ is the check's own repo is a PR of that repo."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import pytest
9
+
10
+ import router
11
+ from integrations import fleet_bridge
12
+ from willow_bot import deposits
13
+ from willow_bot.check_prs import pr_numbers
14
+
15
+ FORK = "rudi193-cmd/codebase-memory-mcp"
16
+ UPSTREAM = "DeusData/codebase-memory-mcp"
17
+ API = "https://api.github.com/repos"
18
+
19
+
20
+ def _pr(number: int, base: str) -> dict:
21
+ return {"number": number, "url": f"{API}/{base}/pulls/{number}",
22
+ "base": {"ref": "main", "repo": {"url": f"{API}/{base}", "name": base.split("/")[1]}},
23
+ "head": {"ref": "fix", "repo": {"url": f"{API}/{FORK}"}}}
24
+
25
+
26
+ def _check(*prs, conclusion="failure") -> dict:
27
+ return {"action": "completed", "repository": {"full_name": FORK}, "sender": {"type": "Bot"},
28
+ "check_run": {"id": 77, "name": "Tests", "status": "completed", "conclusion": conclusion,
29
+ "head_sha": "a" * 40, "html_url": "https://x/77", "pull_requests": list(prs)}}
30
+
31
+
32
+ def test_only_prs_based_on_the_checks_repo_count():
33
+ check = _check(_pr(1702, UPSTREAM), _pr(3, FORK))["check_run"]
34
+ assert pr_numbers(check, FORK) == [3]
35
+ assert pr_numbers(check, FORK.upper()) == [3] # GitHub names are case-insensitive
36
+
37
+
38
+ def test_a_fork_check_listing_only_its_upstream_pr_has_no_pr_here():
39
+ assert pr_numbers(_check(_pr(1702, UPSTREAM))["check_run"], FORK) == []
40
+
41
+
42
+ def test_an_entry_naming_no_repo_is_kept():
43
+ assert pr_numbers({"pull_requests": [{"number": 4}]}, FORK) == [4]
44
+
45
+
46
+ def test_the_deposit_files_no_foreign_pr(tmp_path):
47
+ deposits.deposit_from_check_run_payload(_check(_pr(1702, UPSTREAM)))
48
+ rows = [json.loads(ln) for ln in deposits.deposits_jsonl().read_text().splitlines() if ln.strip()]
49
+ assert rows[-1]["repo"] == FORK and rows[-1]["pr_number"] is None
50
+
51
+
52
+ def test_the_bridge_inbox_item_names_no_foreign_pr(tmp_path, monkeypatch):
53
+ monkeypatch.setattr(deposits, "deposit_from_check_run_payload", lambda p: None)
54
+ fleet_bridge.handle("check_run", _check(_pr(1702, UPSTREAM), _pr(3, FORK)))
55
+ inbox = tmp_path / "upstream_steward" / "webhook_inbox"
56
+ [item] = [json.loads(p.read_text()) for p in inbox.glob("*.json")]
57
+ assert item["pr_number"] == 3
58
+
59
+
60
+ @pytest.fixture
61
+ def posts(monkeypatch):
62
+ monkeypatch.delenv("FRANK_MODE", raising=False)
63
+ monkeypatch.delenv("PROPHET_MODE", raising=False)
64
+ monkeypatch.setattr(router.fleet_bridge, "handle", lambda *a, **k: None)
65
+ out = []
66
+ return out, (lambda repo, number, body: out.append((repo, number, body)))
67
+
68
+
69
+ def test_the_router_never_counts_or_speaks_on_a_foreign_pr(posts, monkeypatch):
70
+ out, post = posts
71
+ bumped = []
72
+ monkeypatch.setattr(router.sigh, "bump_fail", lambda repo, pr: bumped.append((repo, pr)) or 1)
73
+ router.route("check_run", _check(_pr(1702, UPSTREAM)), post)
74
+ assert bumped == [] and out == []
@@ -360,3 +360,29 @@ def test_a_custom_message_and_a_broken_one(posts, welcoming):
360
360
  welcoming.write_text(json.dumps({"welcome": {"orgs": ["willow-memory"], "message": "Hi {nope}"}}))
361
361
  operator_profile.reset()
362
362
  assert "first contribution" in welcome.line_for(REPO, {"login": "cy", "type": "User"}, "FIRST_TIMER")
363
+
364
+
365
+ # ── the gap_filed quip passes the voice's gate ────────────────────────────
366
+
367
+
368
+ def _issue_opened(assoc, typ="User", repo=REPO, number=4):
369
+ return {"action": "opened", "repository": {"full_name": repo},
370
+ "issue": {"number": number, "author_association": assoc, "user": {"login": "x", "type": typ}}}
371
+
372
+
373
+ def test_gap_filed_speaks_to_an_insider_once_in_scope(posts):
374
+ out, post = posts
375
+ router.route("issues", _issue_opened("OWNER"), post)
376
+ router.route("issues", _issue_opened("OWNER"), post) # the same issue again
377
+ assert len(out) == 1
378
+
379
+
380
+ @pytest.mark.parametrize("assoc,repo", [
381
+ ("NONE", REPO), # an outside contributor is never quipped at
382
+ ("CONTRIBUTOR", REPO),
383
+ ("OWNER", "someone-else/their-repo"), # outside the voice's orgs
384
+ ])
385
+ def test_gap_filed_is_silent_out_of_scope(posts, assoc, repo):
386
+ out, post = posts
387
+ router.route("issues", _issue_opened(assoc, repo=repo), post)
388
+ assert out == []
@@ -262,3 +262,57 @@ def test_vault_only_env_files_where_the_steward_reads(monkeypatch, tmp_path):
262
262
  fleet_bridge.handle("pull_request", _PR)
263
263
  assert webhook_inbox_dir() == box / "upstream_steward" / "webhook_inbox"
264
264
  assert list(webhook_inbox_dir().glob("*.json"))
265
+
266
+
267
+ # ── one inbox item per event, not per PR / issue / installation ─────────────
268
+
269
+
270
+ def _pr_event(action: str, sha: str, updated: str, number: int = 4) -> dict:
271
+ return {"action": action, "repository": {"full_name": REPO},
272
+ "pull_request": {"number": number, "state": "open", "head": {"sha": sha}, "updated_at": updated}}
273
+
274
+
275
+ def test_every_pr_event_reaches_the_inbox_not_only_the_first(home):
276
+ """Keyed on the PR number, the first event's file made every later one
277
+ skip: a synchronize's new head and the close never arrived."""
278
+ fleet_bridge.handle("pull_request", _pr_event("opened", "a" * 40, "2026-10-11T00:00:00Z"))
279
+ fleet_bridge.handle("pull_request", _pr_event("synchronize", "b" * 40, "2026-10-11T01:00:00Z"))
280
+ fleet_bridge.handle("pull_request", _pr_event("closed", "b" * 40, "2026-10-11T02:00:00Z"))
281
+ items = _inbox(home)
282
+ assert sorted(i["action"] for i in items) == ["closed", "opened", "synchronize"]
283
+ assert {i["head_sha"] for i in items} == {"a" * 40, "b" * 40}
284
+ assert len({i["work_id"] for i in items}) == 3
285
+
286
+
287
+ def test_a_redelivered_pr_webhook_is_still_one_item(home):
288
+ ev = _pr_event("opened", "a" * 40, "2026-10-11T00:00:00Z")
289
+ fleet_bridge.handle("pull_request", ev)
290
+ fleet_bridge.handle("pull_request", ev)
291
+ assert len(_inbox(home)) == 1
292
+
293
+
294
+ def test_each_issue_event_and_each_label_is_its_own_item(home):
295
+ def issue(action, updated, label=None):
296
+ p = {"action": action, "repository": {"full_name": REPO},
297
+ "issue": {"number": 9, "updated_at": updated}}
298
+ if label:
299
+ p["label"] = {"name": label}
300
+ return p
301
+
302
+ fleet_bridge.handle("issues", issue("opened", "t0"))
303
+ fleet_bridge.handle("issues", issue("labeled", "t1", "bug"))
304
+ fleet_bridge.handle("issues", issue("labeled", "t2", "help wanted"))
305
+ fleet_bridge.handle("issues", issue("labeled", "t2", "help wanted")) # redelivery
306
+ assert len(_inbox(home)) == 3
307
+
308
+
309
+ def test_each_installation_change_is_its_own_item(home):
310
+ def change(*names):
311
+ return {"action": "added", "installation": {"id": 7},
312
+ "repositories_added": [{"full_name": n} for n in names]}
313
+
314
+ fleet_bridge.handle("installation_repositories", change("o/a"))
315
+ fleet_bridge.handle("installation_repositories", change("o/b"))
316
+ fleet_bridge.handle("installation_repositories", change("o/b")) # redelivery
317
+ assert [i["repositories_added"] for i in _inbox(home)] in (
318
+ [["o/a"], ["o/b"]], [["o/b"], ["o/a"]])
@@ -1,3 +1,3 @@
1
1
  """willow-bot — GitHub App webhook + orchestrator-feeding steward."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.2.4"
@@ -0,0 +1,38 @@
1
+ """check_prs.py — which of a check run's `pull_requests` belong to its repo.
2
+
3
+ GitHub lists, on a check run, the open PRs whose HEAD matches the check's
4
+ sha or branch. A check that runs on a fork therefore also lists the fork's
5
+ PR into the upstream repo, whose number means nothing in the fork: filed
6
+ as ``<fork>#<n>``, the bot commented on a PR that does not exist (a 404
7
+ retried every tick, forever) or, worse, on an unrelated issue that happens
8
+ to share the number. Only a PR whose BASE repo is the check's own repo is
9
+ a PR of that repo.
10
+ """
11
+ from __future__ import annotations
12
+
13
+
14
+ def _base_full_name(pr: dict) -> str:
15
+ """``owner/name`` of the PR's base repo, from what GitHub sends: the base
16
+ repo's API url (``…/repos/owner/name``), else the PR's own url
17
+ (``…/repos/owner/name/pulls/N``). "" when the entry carries neither."""
18
+ base_repo = ((pr.get("base") or {}).get("repo") or {})
19
+ for url in (base_repo.get("url"), pr.get("url")):
20
+ if isinstance(url, str) and "/repos/" in url:
21
+ rest = url.split("/repos/", 1)[1].split("/")
22
+ if len(rest) >= 2:
23
+ return f"{rest[0]}/{rest[1]}"
24
+ return ""
25
+
26
+
27
+ def pr_numbers(check: dict, repo: str) -> list[int]:
28
+ """The numbers of the check's PRs that are PRs of ``repo``. An entry that
29
+ names no repo at all is kept (older payload shapes, fixtures)."""
30
+ out: list[int] = []
31
+ for pr in check.get("pull_requests") or []:
32
+ if not isinstance(pr, dict) or pr.get("number") is None:
33
+ continue
34
+ base = _base_full_name(pr)
35
+ if base and base.lower() != str(repo).lower():
36
+ continue
37
+ out.append(pr["number"])
38
+ return out
@@ -547,8 +547,12 @@ def deposit_from_check_run_payload(payload: dict) -> dict[str, Any] | None:
547
547
  repo = (payload.get("repository") or {}).get("full_name") or ""
548
548
  if not repo:
549
549
  return None
550
- prs = check.get("pull_requests") or []
551
- pr_number = prs[0].get("number") if prs else None
550
+ from willow_bot.check_prs import pr_numbers
551
+
552
+ # Only a PR of THIS repo (`check_prs`): a fork's check also lists its PR
553
+ # into upstream, and filed under the fork that number named nothing.
554
+ prs = pr_numbers(check, repo)
555
+ pr_number = prs[0] if prs else None
552
556
  sender_type = str((payload.get("sender") or {}).get("type") or "")
553
557
  suite = check.get("check_suite") if isinstance(check.get("check_suite"), dict) else {}
554
558
  rec = ci_outcome_record(
@@ -246,9 +246,11 @@ def _read_inbox_depth() -> dict[str, Any]:
246
246
  ``depth_by_kind``), and how many of them the inbox step has not yet
247
247
  consumed (``pending``, ``pending_by_kind``).
248
248
 
249
- The inbox step never deletes a file: it records each consumed
250
- ``work_id`` in the steward state's ``inbox_consumed``. So ``total`` is
251
- what sits on disk, and ``pending`` is what is actually waiting. When
249
+ The inbox step moves each item it consumes into ``consumed/<YYYY-MM>/``
250
+ (out of this glob) and records its ``work_id`` in the steward state's
251
+ ``inbox_consumed``. So ``total`` is what sits in the inbox, and
252
+ ``pending`` is what is actually waiting (a failed move stays in
253
+ ``total`` but not in ``pending``). When
252
254
  the state cannot be read, ``pending`` is ``None`` (unknown), never a
253
255
  guess."""
254
256
  consumed = _read_inbox_consumed()