Skip to content

Commit d929f14

Browse files
committed
refactor(webhook): give the module a service layer and a typed payload
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".
1 parent ff52364 commit d929f14

9 files changed

Lines changed: 352 additions & 121 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ make typecheck # mypy (API only)
1313
Monorepo with two apps, skills, and shared infra. See [ADR-001](docs/adr-001-claude-code-container-pivot.md) for the pivot decision.
1414

1515
```
16-
apps/api/ — FastAPI backend (Python 3.12, uv)
16+
apps/api/ — FastAPI backend (Python 3.13, uv)
1717
src/helprs/
1818
core/ — config, database, dependencies, exceptions, middleware, security
1919
modules/ — domain modules: identity, installation, webhook, container
@@ -45,7 +45,7 @@ infra/
4545
## Key Patterns
4646

4747
- **App factory**: `helprs.main:create_app()` — module-level `_lifespan` owns the engine and background loops via `AsyncExitStack`: each resource registers its cleanup at acquisition, teardown runs LIFO (cancel loops → drain replay tasks → stop containers → clear factory → dispose engine)
48-
- **Layered modules**: each domain module is `router.py` (thin — validate, call one use case, shape the response) → `service.py` (use cases, no SQL, no HTTP) → `repository.py` (every query, including the soft-delete predicate) → boundary modules for external systems (`github.py`, `anthropic.py`, `docker_client.py`), all returning typed objects rather than dicts. `container` additionally splits `streaming.py` (SSE pipeline) and `cleanup.py` (reaping) out of the service.
48+
- **Layered modules**: each domain module is `router.py` (thin — validate, call one use case, shape the response) → `service.py` (use cases, no SQL, no HTTP) → `repository.py` (every query, including the soft-delete predicate) → boundary modules for external systems (`github.py`, `anthropic.py`, `docker_client.py`), all returning typed objects rather than dicts. `container` additionally splits `streaming.py` (SSE pipeline) and `cleanup.py` (reaping) out of the service. Every module now has this layering, `webhook` included.
4949
- **Container orchestration**: `container` module manages ephemeral Docker lifecycle, credential injection, result relay. `finalize_session()` (mark completed → scorecard → PR comment) is deliberately detached from the HTTP request, so a client disconnect cannot leave a session stuck RUNNING.
5050
- **Skills as agents**: each skill is a self-contained folder with workflow definitions, mounted into containers
5151
- **SSE passthrough**: backend relays container output to frontend (no AI response generation in backend)
@@ -59,6 +59,10 @@ infra/
5959
- **Dashboard**: user-facing installation management at `/installations` -- installation list, session history, session replay. Authenticated users redirect from `/` to `/installations`. SQLAdmin remains at `/admin` as superadmin escape hatch.
6060
- **Cross-module queries**: a module never writes SQL over another module's tables. `container/repository.py` owns every `ContainerSession` query, including the aggregates the identity dashboard and the installation router consume.
6161
- **Auth on all REST routes**: identity and installation routers use `Depends(get_current_user)`, container router uses it too. The webhook handler bypasses REST routes entirely — it calls `create_session()` directly (DB record only, no container start). Container start happens when the authenticated frontend calls the REST endpoint.
62+
- **No module `__init__` imports**: the four `modules/*/__init__.py` are docstring-only. Re-exporting a router there pulled the whole router graph back through `core.dependencies` (which imports `identity.models`), so `import helprs.core.dependencies` failed on its own and startup depended on `main.py`'s import order. `tests/test_import_graph.py` guards this.
63+
- **JWT**: PyJWT, not python-jose (unmaintained since 2021, and the source of an unfixable `ecdsa` advisory). `PyJWTError` is the failure type.
64+
- **Secrets are `SecretStr`**: read them with `.get_secret_value()`. `SecretStr` defines `__len__`, so truthiness checks work unchanged. `repr(Settings())` used to print every credential, and Sentry uploads locals on any unhandled 500.
65+
- **SSE takes no DB dependency**: FastAPI tears yield-dependencies down only after the streaming body ends, so `Depends(get_db)` — including one behind an auth dependency — pins a pooled connection for the whole stream. The SSE route calls `authenticate_token`/`stream_token` inside a short `get_db_context()` instead.
6266
- **Production env validation**: `Settings` has a `model_validator` that enforces non-empty secrets when `ENVIRONMENT=production`. Tests use `ENVIRONMENT=test` to skip this.
6367
- **Graceful lifecycle**: lifespan reconciles stale RUNNING/PENDING sessions on boot (marks FAILED), and stops all running containers on shutdown. Periodic cleanup uses configurable `CONTAINER_TTL_SECONDS` from settings.
6468

@@ -76,9 +80,9 @@ Skills are pluggable Claude Code agent definitions in `skills/`. See `skills/SKI
7680

7781
## Code Style
7882

79-
- Python: ruff with `line-length = 120`, target Python 3.12
83+
- Python: ruff with `line-length = 120`, target Python 3.13
8084
- Lint rules: E, F, I, N, UP, B, A, SIM, TCH
81-
- mypy: non-strict with pydantic plugin, per-module overrides for third-party lib typing issues (see `pyproject.toml`)
85+
- mypy: non-strict with pydantic plugin, **no per-module overrides** — the five that existed were hiding four real errors, all since fixed
8286
- `asyncio_mode = "auto"` in pytest — no need for `@pytest.mark.asyncio`
8387

8488
## Testing

apps/api/src/helprs/modules/installation/schemas.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import uuid
55
from datetime import datetime
66

7-
from pydantic import BaseModel, field_validator
7+
from pydantic import BaseModel, ConfigDict, Field, field_validator
88

99

1010
class BYOKConfigureRequest(BaseModel):
@@ -129,3 +129,32 @@ class PaginatedSessionsResponse(BaseModel):
129129
page: int
130130
per_page: int
131131
total_pages: int
132+
133+
134+
# --- Inbound: the GitHub App installation object -----------------------------
135+
#
136+
# Modelled here rather than in the webhook module so the dependency runs
137+
# webhook -> installation. Only the fields helPRs reads are declared; the rest
138+
# of GitHub's payload is ignored.
139+
140+
141+
class InstallationAccount(BaseModel):
142+
model_config = ConfigDict(extra="ignore")
143+
144+
account_id: int = Field(alias="id")
145+
login: str
146+
account_type: str = Field(alias="type")
147+
148+
149+
class InstallationPayload(BaseModel):
150+
"""The ``installation`` object GitHub sends with App lifecycle events."""
151+
152+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
153+
154+
github_installation_id: int = Field(alias="id")
155+
account: InstallationAccount
156+
app_slug: str = ""
157+
target_type: str = ""
158+
repository_selection: str = "all"
159+
permissions: dict[str, str] = Field(default_factory=dict)
160+
events: list[str] = Field(default_factory=list)

apps/api/src/helprs/modules/installation/service.py

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from helprs.modules.installation import anthropic, github, repository
2525
from helprs.modules.installation.github import RUNNER_TOKEN_PERMISSIONS
2626
from helprs.modules.installation.models import BYOKConfig, Installation
27+
from helprs.modules.installation.schemas import InstallationPayload
2728

2829
if TYPE_CHECKING:
2930
from helprs.modules.container.models import ContainerSession
@@ -38,7 +39,7 @@
3839
__all__ = [
3940
"RUNNER_TOKEN_PERMISSIONS",
4041
"configure_byok",
41-
"create_installation_from_webhook",
42+
"create_installation",
4243
"decrypt_byok_key",
4344
"delete_byok_config",
4445
"get_byok_config",
@@ -54,6 +55,7 @@
5455
"verify_admin_permission",
5556
"verify_installation_access",
5657
"verify_repo_access",
58+
"verify_session_access",
5759
]
5860

5961
# Re-exported so callers that only need to post a comment do not have to know
@@ -64,19 +66,18 @@
6466
# --- Lifecycle -------------------------------------------------------------
6567

6668

67-
async def create_installation_from_webhook(session: AsyncSession, webhook_data: dict) -> Installation:
68-
"""Create an installation from an ``installation.created`` webhook.
69+
async def create_installation(session: AsyncSession, payload: InstallationPayload) -> Installation:
70+
"""Create an installation from a parsed ``installation.created`` event.
71+
72+
Takes a validated model rather than a raw webhook body. Parsing external
73+
JSON is a boundary concern: doing it here turned a GitHub shape change
74+
into a KeyError in the middle of a use case, and this was the only place
75+
in the codebase where a use case read an unparsed payload.
6976
7077
Idempotent: a duplicate delivery, or a concurrent one racing us to the
7178
unique index, resolves to the existing row.
7279
"""
73-
try:
74-
inst_data = webhook_data["installation"]
75-
account = inst_data["account"]
76-
github_installation_id = inst_data["id"]
77-
except (KeyError, TypeError) as e:
78-
await logger.awarning("webhook_payload_malformed", error=str(e))
79-
raise ValueError(f"Malformed webhook payload: missing {e}") from e
80+
github_installation_id = payload.github_installation_id
8081

8182
existing = await repository.get_by_github_id(session, github_installation_id)
8283
if existing:
@@ -87,14 +88,14 @@ async def create_installation_from_webhook(session: AsyncSession, webhook_data:
8788
session,
8889
Installation(
8990
github_installation_id=github_installation_id,
90-
account_login=account["login"],
91-
account_id=account["id"],
92-
account_type=account["type"],
93-
repository_selection=inst_data.get("repository_selection", "all"),
94-
app_slug=inst_data.get("app_slug", ""),
95-
target_type=inst_data.get("target_type", "Organization"),
96-
permissions=inst_data.get("permissions"),
97-
events=inst_data.get("events"),
91+
account_login=payload.account.login,
92+
account_id=payload.account.account_id,
93+
account_type=payload.account.account_type,
94+
repository_selection=payload.repository_selection,
95+
app_slug=payload.app_slug,
96+
target_type=payload.target_type or "Organization",
97+
permissions=payload.permissions,
98+
events=payload.events,
9899
suspended_at=None,
99100
),
100101
)
@@ -108,7 +109,7 @@ async def create_installation_from_webhook(session: AsyncSession, webhook_data:
108109
await logger.ainfo(
109110
"installation_created",
110111
github_installation_id=github_installation_id,
111-
account_login=account["login"],
112+
account_login=created.account_login,
112113
)
113114
return created
114115

apps/api/src/helprs/modules/webhook/handlers.py

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
"""Webhook event handlers."""
22

3+
from collections.abc import Awaitable, Callable
4+
from uuid import UUID
5+
36
import structlog
7+
from pydantic import ValidationError
48
from sqlalchemy.ext.asyncio import AsyncSession
59

610
from helprs.core.config import get_settings
711
from helprs.modules.container.service import create_session
12+
from helprs.modules.installation.models import Installation
13+
from helprs.modules.installation.schemas import InstallationPayload
814
from helprs.modules.installation.service import (
9-
create_installation_from_webhook,
15+
create_installation,
1016
get_installation_by_github_id,
1117
mint_installation_token,
1218
post_pr_comment_with_retry,
@@ -31,60 +37,72 @@ def _extract_installation_id(payload: dict) -> int:
3137

3238
async def handle_installation_created(payload: dict, session: AsyncSession) -> None:
3339
"""Handle installation.created webhook event."""
34-
installation = await create_installation_from_webhook(session, payload)
40+
try:
41+
parsed = InstallationPayload.model_validate(payload["installation"])
42+
except (KeyError, TypeError, ValidationError) as e:
43+
raise ValueError(f"Malformed installation payload: {e}") from e
44+
45+
installation = await create_installation(session, parsed)
3546
await logger.ainfo(
3647
"webhook_installation_created",
3748
installation_id=str(installation.id),
3849
github_installation_id=installation.github_installation_id,
3950
)
4051

4152

42-
async def handle_installation_deleted(payload: dict, session: AsyncSession) -> None:
43-
"""Handle installation.deleted webhook event."""
53+
async def _handle_lifecycle_change(
54+
payload: dict,
55+
session: AsyncSession,
56+
*,
57+
apply: Callable[[AsyncSession, int], Awaitable[Installation | None]],
58+
applied_event: str,
59+
missing_event: str,
60+
) -> None:
61+
"""Shared body of the delete/suspend/unsuspend handlers.
62+
63+
The three differed only in which service call they made and which two log
64+
events they emitted, so they were byte-identical otherwise -- and they sit
65+
one layer above ``_apply_lifecycle_change``, which already parameterises
66+
the same axis in the service.
67+
"""
4468
github_id = _extract_installation_id(payload)
45-
result = await soft_delete_installation(session, github_id)
46-
if result:
47-
await logger.ainfo(
48-
"webhook_installation_deleted",
49-
github_installation_id=github_id,
50-
)
69+
if await apply(session, github_id):
70+
await logger.ainfo(applied_event, github_installation_id=github_id)
5171
else:
52-
await logger.awarning(
53-
"webhook_installation_delete_not_found",
54-
github_installation_id=github_id,
55-
)
72+
await logger.awarning(missing_event, github_installation_id=github_id)
73+
74+
75+
async def handle_installation_deleted(payload: dict, session: AsyncSession) -> None:
76+
"""Handle installation.deleted webhook event."""
77+
await _handle_lifecycle_change(
78+
payload,
79+
session,
80+
apply=soft_delete_installation,
81+
applied_event="webhook_installation_deleted",
82+
missing_event="webhook_installation_delete_not_found",
83+
)
5684

5785

5886
async def handle_installation_suspended(payload: dict, session: AsyncSession) -> None:
5987
"""Handle installation.suspended webhook event."""
60-
github_id = _extract_installation_id(payload)
61-
result = await suspend_installation(session, github_id)
62-
if result:
63-
await logger.ainfo(
64-
"webhook_installation_suspended",
65-
github_installation_id=github_id,
66-
)
67-
else:
68-
await logger.awarning(
69-
"webhook_installation_suspend_not_found",
70-
github_installation_id=github_id,
71-
)
88+
await _handle_lifecycle_change(
89+
payload,
90+
session,
91+
apply=suspend_installation,
92+
applied_event="webhook_installation_suspended",
93+
missing_event="webhook_installation_suspend_not_found",
94+
)
7295

7396

7497
async def handle_installation_unsuspended(payload: dict, session: AsyncSession) -> None:
7598
"""Handle installation.unsuspended webhook event."""
76-
github_id = _extract_installation_id(payload)
77-
result = await unsuspend_installation(session, github_id)
78-
if result:
79-
await logger.ainfo(
80-
"webhook_installation_unsuspended",
81-
github_installation_id=github_id,
82-
)
83-
else:
84-
await logger.awarning(
85-
"webhook_installation_unsuspend_not_found",
86-
github_installation_id=github_id,
87-
)
99+
await _handle_lifecycle_change(
100+
payload,
101+
session,
102+
apply=unsuspend_installation,
103+
applied_event="webhook_installation_unsuspended",
104+
missing_event="webhook_installation_unsuspend_not_found",
105+
)
88106

89107

90108
def _pr_label_names(pr: dict) -> set[str]:
@@ -146,7 +164,7 @@ async def handle_pull_request_opened(payload: dict, session: AsyncSession) -> No
146164
await _announce_session(installation, cs.id, repo_full_name, pr_number)
147165

148166

149-
async def _announce_session(installation, session_id, repo_full_name: str, pr_number: int) -> None:
167+
async def _announce_session(installation: Installation, session_id: UUID, repo_full_name: str, pr_number: int) -> None:
150168
"""Post the session link on the PR. Best effort: never fails the event."""
151169
settings = get_settings()
152170
owner, repo_name = repo_full_name.split("/", 1)

0 commit comments

Comments
 (0)