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.
- willow_bot-1.2.4/.release-please-manifest.json +3 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/CHANGELOG.md +17 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/INSTALL.md +1 -1
- {willow_bot-1.2.2 → willow_bot-1.2.4}/PKG-INFO +1 -1
- {willow_bot-1.2.2 → willow_bot-1.2.4}/integrations/fleet_bridge.py +28 -5
- {willow_bot-1.2.2 → willow_bot-1.2.4}/pyproject.toml +1 -1
- {willow_bot-1.2.2 → willow_bot-1.2.4}/router.py +16 -6
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_comment.py +45 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_inbox.py +124 -3
- willow_bot-1.2.4/tests/test_check_prs.py +74 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_contributions.py +26 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_fleet_bridge.py +54 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/__init__.py +1 -1
- willow_bot-1.2.4/willow_bot/check_prs.py +38 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deposits.py +6 -2
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/status.py +5 -3
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/inbox.py +56 -11
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/tick.py +50 -1
- willow_bot-1.2.2/.release-please-manifest.json +0 -3
- {willow_bot-1.2.2 → willow_bot-1.2.4}/.env.example +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/release-please.yml +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/release.yml +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/.github/workflows/tests.yml +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/.gitignore +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/BOT-INVENTORY.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/LICENSE +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/README.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/SECURITY_AUDIT.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/bot.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/credentials.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/deterministic-policy.template.json +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/operator.example.json +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/deploy/operator.willow-memory.json +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/docs/MOVE-STAY-BORROW.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/docs/PRIOR-ART.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/github_app.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/horoscope.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/integrations/__init__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/SPEC.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/__init__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/accumulator.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/catalog.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/cerebras.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/context.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/poster.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/requirements.txt +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/semantic.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/system_prompt.txt +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/watcher.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/loki/write_key.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/losc/MANIFESTO.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/losc/checker.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/README.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/deep_thought.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/README.md +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/compare.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/gen.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/probe.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/foundation/run_prompt_tests.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/__init__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/__main__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/api.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/boot.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/escalate.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/gate.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/nestor_seal.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/predict.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/proposals.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/record.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/resolve.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/reverse.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/run.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/serve.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_api.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_capability.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_escalate.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_incoming.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_layers.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_main.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_md_split.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_nestor_seal.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_onescript.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_pooled.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_pr_scan.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_prompt.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_serve.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_wagers.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/tests/test_xref.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/view.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/onescript/xref.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/prompt.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/ruff.toml +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/batch_flow.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/flow.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/live_flow.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/make_flow.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/merge_flows.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/run_flow.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/flow/side_runner.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/four_ws.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/md_split.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/pr_scan.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/script_match.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/one-script/scripts/scan/shared_phrases.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/quips.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/rebase_shame.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/release-please-config.json +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/requirements.txt +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/runes.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/audit_app_config.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/fresh-check.sh +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/install-service.sh +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/list_installations.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/preflight.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/prove_deposit_once.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/repo_map.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/rotate_webhook_secret.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/scripts/sync_webhook_secret.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/secrets/willow-bot.env.example +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/sigh.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot-deterministic.service.template +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot-steward.service.template +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/systemd/willow-bot.service.template +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/conftest.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/conftest.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_app_grant.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_catchup.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_log.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_notify.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ci_residue.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_config_app_id.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_envelope_retire_sweep_wiring.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_grove_permission_block.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_heartbeat_seal_drain.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_ingress.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_install_step.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_mirror_and_audit.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_net_drain_wiring.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_resolve.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_tick_wiring.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_desk.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_enrich.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_upstream_triage.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/steward/test_voice.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_annul.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_bot_webhook.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_box_rule.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_catalog.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_contribute.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_credentials.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_delivery_dedup.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deposits.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deposits_chain.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_chain.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_resolvers.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_deterministic_runner.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_home_sandbox.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_horoscope.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_host_probe.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_install_receipt.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_loki_keys.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_operator_profile.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_persona_store.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_assign.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_labels.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_pr_voice.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_quips.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_quips_integration.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_rebase_shame.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_runes.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_semantic.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_sigh.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_socket_chat_op.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_status.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tests/test_troll.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/troll.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/tunnel.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow-bot.json +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/cli.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/contribute.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/contributions.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/delivery_dedup.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/__init__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/chain.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/chat_op.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/cli.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/host_probe.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/ollama.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/policy.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/resolvers.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/runner.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/socket_client.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/deterministic/socket_server.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/install_receipt.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/operator_profile.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/paths.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/persona_store.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_assign.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_labels.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/pr_voice.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/__init__.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/app_grant.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ci_comments.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ci_log.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/config.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/fleet.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/heartbeat.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/ingress.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/mcp_client.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/merge.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_checkout.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_ci_status.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pass2_comment.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/pr_watch.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/scan.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_config.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_desk.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/upstream_triage.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/steward/voice.py +0 -0
- {willow_bot-1.2.2 → willow_bot-1.2.4}/willow_bot/welcome.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
322
|
-
|
|
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,
|
|
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 [])
|
|
@@ -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
|
-
|
|
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
|
|
225
|
-
sigh.reset(repo, pr
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
290
|
-
|
|
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"]])
|
|
@@ -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
|
-
|
|
551
|
-
|
|
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
|
|
250
|
-
``work_id`` in the steward state's
|
|
251
|
-
|
|
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()
|