Summary
Replace the if-branch dispatch in webhook.py with registry-based dispatch, and add the HTTP invoke endpoint. This is where the old special-casing actually gets deleted.
Parent issue: #208
Depends on: #228 (registry), #230 (PR review module), #231 (issue triage module)
Current dispatch code to replace
webhook.py lines 657-800 (_process_github_event_for_user) contain the if-branch dispatch:
# Line 688: PR review routing
if settings["pr_review"] and event_type == "pull_request":
action = payload.get("action", "")
if action in ("opened", "reopened", "synchronize"):
# ... cooldown check, resolve local repo, create_task(review.review_pr(...))
# Line 748: Issue triage routing
if settings["issue_triage"] and event_type == "issues":
action = payload.get("action", "")
if action == "opened":
# ... cooldown check, create_task(triage.triage_issue(...))
# Line 785: Standard notification fallback
formatter = _GITHUB_FORMATTERS.get(event_type)
This also includes the cooldown infrastructure (lines 93-199): _review_cooldowns, _triage_cooldowns, and six associated functions that get replaced by the generic CooldownTracker from #228.
Proposed changes
New dispatch flow
async def _process_github_event_for_user(
request, payload, event_type, bot, config, chat_id
):
settings = await sessions.resolve_github_settings(chat_id, config)
target_chat_id = settings["notify_chat_id"]
action = payload.get("action", "")
# 1. Ask the registry for matching modules
registry: ModuleRegistry = request.app["module_registry"]
bindings = registry.get_modules_for_trigger("webhook", event_type, action)
# 2. For each matching module, check user feature flags and cooldown,
# then dispatch as a background task
for binding in bindings:
module = binding.module
# Feature flag check: user must have the relevant capability enabled.
# Map module name to user setting (e.g., "pr-review" -> settings["pr_review"]).
if not _user_allows_module(settings, module.name):
continue
# Cooldown check (generic, replaces per-module cooldown functions)
entity_key = _entity_key_for_event(event_type, payload)
cooldown_seconds = binding.trigger_config.get("cooldown", 0)
cooldown_tracker: CooldownTracker = request.app["cooldown_tracker"]
if cooldown_tracker.should_skip(module.name, entity_key, cooldown_seconds):
log.info("Skipping %s for %s (cooldown)", module.name, entity_key)
continue
cooldown_tracker.record(module.name, entity_key, cooldown_seconds)
# Build context and dispatch
ctx = ModuleContext(
trigger_type="webhook",
trigger_data=payload,
config=config,
chat_id=target_chat_id,
webhook_port=request.app["webhook_port"],
webhook_secret=request.app["webhook_secret"],
claude_user=request.app.get("claude_user"),
state={},
journal=ModuleJournal(module.name, DATA_DIR / "modules" / module.name / "events"),
)
task = asyncio.create_task(_run_module(module, ctx))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# 3. Standard notification fallback (for events no module claims,
# or for actions that modules filter out like "closed")
# Only send if no module handled this event
if not any_module_handled:
formatter = _GITHUB_FORMATTERS.get(event_type)
...
_user_allows_module mapping
The user feature-flag check needs a mapping from module name to user setting:
_MODULE_FEATURE_FLAGS: dict[str, str] = {
"pr-review": "pr_review",
"issue-triage": "issue_triage",
}
New modules added later register their feature flag name in the manifest. For now, this mapping is hardcoded for the two existing modules. Future work could make this fully declarative via the manifest.
_run_module wrapper
A thin wrapper that calls module.handle(ctx), catches exceptions, logs the result, and sends a crash notification to Telegram if the module raises. This replaces the per-module try/except in the current review_pr() and triage_issue() top-level functions.
HTTP invoke endpoint
Add POST /api/modules/{name}/invoke to webhook.py routes:
async def _handle_module_invoke(request: web.Request) -> web.Response:
"""Invoke a module directly via HTTP API."""
module_name = request.match_info["name"]
registry: ModuleRegistry = request.app["module_registry"]
module = registry.get_module(module_name)
if not module:
return web.json_response({"error": f"Unknown module: {module_name}"}, status=404)
body = await request.json()
# Build ModuleContext from request body
# Dispatch as background task
# Return 202 Accepted with task tracking info
This lets inner Claude (or scheduled jobs) trigger modules directly without going through a webhook event.
Registry initialization
In webhook.py start(), scan src/kai/modules/*/module.yaml, instantiate module classes, build the registry, and store it in request.app["module_registry"]. Also create the CooldownTracker instance in request.app["cooldown_tracker"].
Code to delete
After the migration:
_review_cooldowns dict and _should_skip_review, _record_review functions (lines 93-152)
_triage_cooldowns dict and _should_skip_triage, _record_triage functions (lines 155-199)
_prune_expired helper (lines 100-115)
- The if-branches in
_process_github_event_for_user (lines 683-783)
- Direct imports of
review and triage in webhook.py (line 54) - these move to the module packages
That is roughly 120 lines of dispatch code and 110 lines of cooldown code replaced by ~50 lines of generic dispatch + the CooldownTracker from #228.
Tests
- Registry-based dispatch routes a
pull_request:opened event to the PR review module
- Registry-based dispatch routes an
issues:opened event to the issue triage module
- Action filtering:
pull_request:closed does NOT trigger PR review, falls through to formatter
- User feature flag check: user with
pr_review: false does not trigger review
- Cooldown: duplicate events within cooldown window are skipped
- Standard notification fallback: events with no matching module get the formatter path
/api/modules/{name}/invoke returns 202 for valid module, 404 for unknown
- Existing
test_webhook.py tests (1660 lines) updated to use registry instead of direct imports
- No regressions: full test suite passes
Rollback safety
The old dispatch code gets deleted in this issue. If something breaks, git revert restores it. The module wrappers from #230 and #231 are additive (they do not modify review.py or triage.py), so reverting #232 alone restores the old behavior while keeping the module packages available for re-integration.
Summary
Replace the if-branch dispatch in
webhook.pywith registry-based dispatch, and add the HTTP invoke endpoint. This is where the old special-casing actually gets deleted.Parent issue: #208
Depends on: #228 (registry), #230 (PR review module), #231 (issue triage module)
Current dispatch code to replace
webhook.pylines 657-800 (_process_github_event_for_user) contain the if-branch dispatch:This also includes the cooldown infrastructure (lines 93-199):
_review_cooldowns,_triage_cooldowns, and six associated functions that get replaced by the genericCooldownTrackerfrom #228.Proposed changes
New dispatch flow
_user_allows_modulemappingThe user feature-flag check needs a mapping from module name to user setting:
New modules added later register their feature flag name in the manifest. For now, this mapping is hardcoded for the two existing modules. Future work could make this fully declarative via the manifest.
_run_modulewrapperA thin wrapper that calls
module.handle(ctx), catches exceptions, logs the result, and sends a crash notification to Telegram if the module raises. This replaces the per-module try/except in the currentreview_pr()andtriage_issue()top-level functions.HTTP invoke endpoint
Add
POST /api/modules/{name}/invoketo webhook.py routes:This lets inner Claude (or scheduled jobs) trigger modules directly without going through a webhook event.
Registry initialization
In
webhook.py start(), scansrc/kai/modules/*/module.yaml, instantiate module classes, build the registry, and store it inrequest.app["module_registry"]. Also create theCooldownTrackerinstance inrequest.app["cooldown_tracker"].Code to delete
After the migration:
_review_cooldownsdict and_should_skip_review,_record_reviewfunctions (lines 93-152)_triage_cooldownsdict and_should_skip_triage,_record_triagefunctions (lines 155-199)_prune_expiredhelper (lines 100-115)_process_github_event_for_user(lines 683-783)reviewandtriagein webhook.py (line 54) - these move to the module packagesThat is roughly 120 lines of dispatch code and 110 lines of cooldown code replaced by ~50 lines of generic dispatch + the
CooldownTrackerfrom #228.Tests
pull_request:openedevent to the PR review moduleissues:openedevent to the issue triage modulepull_request:closeddoes NOT trigger PR review, falls through to formatterpr_review: falsedoes not trigger review/api/modules/{name}/invokereturns 202 for valid module, 404 for unknowntest_webhook.pytests (1660 lines) updated to use registry instead of direct importsRollback safety
The old dispatch code gets deleted in this issue. If something breaks,
git revertrestores it. The module wrappers from #230 and #231 are additive (they do not modify review.py or triage.py), so reverting #232 alone restores the old behavior while keeping the module packages available for re-integration.