Skip to content

refactor(webhook): give the module a service layer and a typed payload - #68

Merged
mariuspruvot merged 1 commit into
mainfrom
refactor/webhook-layering
Aug 1, 2026
Merged

refactor(webhook): give the module a service layer and a typed payload#68
mariuspruvot merged 1 commit into
mainfrom
refactor/webhook-layering

Conversation

@mariuspruvot

Copy link
Copy Markdown
Owner

The architecture lot from the 2026-08-01 audit. No behaviour changes intended — this is layering, naming and one deduplication.

The webhook module had no service layer

It was the only module where the router was the use case. receive_github_webhook did six jobs — header validation, JSON decoding, a isinstance type guard, field extraction, log-context binding, persistence and task scheduling — and returned an untyped dict[str, object] with no response_model. None of it could be exercised without an HTTP request carrying a valid HMAC signature.

webhook/service.py now holds parse_delivery (raw request → validated WebhookDelivery, raising 400s for anything malformed) and record_delivery (persist, returning None for a redelivery rather than raising). The router validates, calls them, and returns a WebhookAck.

That parsing now has direct tests, including the two cases that quietly produced a wrong value before: an installation that is a scalar rather than an object, and an id arriving as a string.

Raw GitHub JSON stopped reaching the domain

create_installation_from_webhook(session, webhook_data: dict) indexed the payload inside the use case:

inst_data = webhook_data["installation"]
account = inst_data["account"]

A GitHub shape change surfaced as a KeyError-turned-ValueError deep in a use case. Every other external payload in the codebase is parsed into a Pydantic model at a named boundary — this was the one hole.

It is now create_installation(session, payload: InstallationPayload). The model lives in installation/schemas.py, not the webhook module: it describes an installation, so putting it there keeps the dependency running webhook -> installation instead of inverting it.

Three handlers that were the same handler

handle_installation_deleted, _suspended and _unsuspended were byte-identical apart from which service function they called and two log-event strings — roughly 45 triplicated lines, sitting one layer above _apply_lifecycle_change, which already parameterises exactly that axis in the service. They share _handle_lifecycle_change now.

Smaller things in the same files

  • _announce_session had two unannotated parameters and is now typed.
  • installation/service.py's __all__ was out of date: it named the renamed function and omitted verify_session_access, which container/router.py imports seven times.
  • CLAUDE.md claimed a layering the webhook module did not have, still said Python 3.12, and still described per-module mypy overrides that no longer exist. It also gained the four invariants worth knowing before touching this code: empty module __init__s, PyJWT, SecretStr, and why the SSE route takes no DB dependency.

Verification

  • 398 tests pass (was 389), coverage 84%, ruff + ruff format + mypy clean with no overrides
  • 9 new tests covering parse_delivery / record_delivery directly

Still open after this

Two architecture findings from the audit are deliberately not in here, because each is a real change rather than a move:

  • create_container_session is still a 45-line router handler that decrypts a Fernet credential in the HTTP layer. Moving it into the service is worth doing, but it interacts with the commit-ordering fix in fix(api): break the import cycle, stop pinning DB connections, revive retries #66 and deserves its own diff.
  • Two scorecard extractors still coexist and both run inside finalize_session: a validated JSON parser in service.py and an unvalidated markdown regex in pr_comment.py. Collapsing them onto the JSON one changes what gets posted to PRs, so it needs a decision about the skill contract first.
  • cleanup_all_running / reconcile_stale_sessions are still unscoped per worker (carried over from fix(api): break the import cycle, stop pinning DB connections, revive retries #66) — one worker restarting cancels its peers' live sessions.

The webhook module was the only one without a service layer: the router
*was* the use case. One handler did header validation, JSON decoding, a
type guard, field extraction, log binding, persistence and task scheduling,
then returned an untyped dict with no response_model. None of that could be
tested without an HTTP request carrying a valid HMAC signature.

`parse_delivery` and `record_delivery` now hold those use cases, the router
validates and calls them, and the response is a `WebhookAck` model.

Raw GitHub JSON also stopped travelling into the domain. installation's
`create_installation_from_webhook` indexed the payload itself, which made a
GitHub shape change a KeyError in the middle of a use case -- the one place
in the codebase where a use case parsed an external payload. It is now
`create_installation(session, InstallationPayload)`. The model lives in
installation's own schemas, not webhook's, so the dependency runs
webhook -> installation rather than the other way round.

The three lifecycle handlers (deleted / suspended / unsuspended) were
byte-identical apart from one service call and two log-event names, sitting
directly above `_apply_lifecycle_change`, which already parameterises the
same axis. They share one helper now.

Docs: CLAUDE.md described a layering the webhook module did not have, and
still said Python 3.12 and "per-module mypy overrides".
@helprs-prod

helprs-prod Bot commented Aug 1, 2026

Copy link
Copy Markdown

helPRs session created for this PR.

Skill: challenge-me | Open session

@mariuspruvot
mariuspruvot merged commit 514aaa7 into main Aug 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant