Skip to content
19 changes: 19 additions & 0 deletions src/docverse/dependencies/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ async def initialize( # noqa: PLR0913
if github_webhook_secret is not None:
self._github_webhook_secret = github_webhook_secret

def set_github_secrets(
self,
*,
app_id: int | None,
private_key: SecretStr | None,
webhook_secret: SecretStr | None,
) -> None:
"""Set the three GitHub-App secret slots in one call.

Unlike :meth:`initialize`, every argument is honored as-is — passing
``None`` clears that slot. This is the supported way to toggle the
GitHub-App feature on or off from a test fixture, in lieu of poking
``_github_app_id`` / ``_github_app_private_key`` /
``_github_webhook_secret`` directly on the singleton.
"""
self._github_app_id = app_id
self._github_app_private_key = private_key
self._github_webhook_secret = webhook_secret

async def aclose(self) -> None:
"""Clean up the per-process configuration."""
self._initialized = False
Expand Down
70 changes: 61 additions & 9 deletions src/docverse/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DashboardSyncEnqueuer,
DashboardTemplateBindingService,
DashboardTemplateSyncer,
PushEventProcessor,
TemplateResolver,
)
from .services.edition import EditionService
Expand Down Expand Up @@ -276,6 +277,34 @@ def create_lock_service(self) -> LockService:
"""Create a LockService bound to this factory's session."""
return LockService(session=self._session, logger=self._logger)

def _require_github_app_config(
self,
) -> tuple[int, SecretStr, SecretStr]:
"""Return the three GitHub App secrets, or raise if any is unset.

The GitHub App feature is all-or-nothing: callers that touch
any of the three secrets must treat them as a single bundle so
a partial configuration cannot silently degrade behaviour.

Raises
------
GitHubAppNotConfiguredError
If any of ``github_app_id``, ``github_app_private_key``, or
``github_webhook_secret`` is unset.
"""
if (
self._github_app_id is None
or self._github_app_private_key is None
or self._github_webhook_secret is None
):
msg = "GitHub App is not configured"
raise GitHubAppNotConfiguredError(msg)
return (
self._github_app_id,
self._github_app_private_key,
self._github_webhook_secret,
)

def create_github_app_client(self) -> GitHubAppClient:
"""Create a GitHubAppClient from the configured GitHub App secrets.

Expand All @@ -296,19 +325,13 @@ def create_github_app_client(self) -> GitHubAppClient:
If no shared ``httpx.AsyncClient`` is configured on the
factory — the GitHub REST calls need one.
"""
if (
self._github_app_id is None
or self._github_app_private_key is None
or self._github_webhook_secret is None
):
msg = "GitHub App is not configured"
raise GitHubAppNotConfiguredError(msg)
app_id, private_key, _ = self._require_github_app_config()
if self._http_client is None:
msg = "HTTP client is required to build a GitHubAppClient"
raise RuntimeError(msg)
factory = GitHubAppClientFactory(
id=self._github_app_id,
key=self._github_app_private_key.get_secret_value(),
id=app_id,
key=private_key.get_secret_value(),
name=self._github_app_name,
http_client=self._http_client,
)
Expand Down Expand Up @@ -416,6 +439,35 @@ def create_dashboard_template_syncer(self) -> DashboardTemplateSyncer:
logger=self._logger,
)

def create_webhook_dispatch(self) -> tuple[str, PushEventProcessor]:
"""Return the GitHub webhook secret and a :class:`PushEventProcessor`.

The webhook handler needs both the HMAC secret (to verify
``x-hub-signature-256``) and the processor (to fan a push out
to ``dashboard_sync`` enqueues). Bundling them into one
accessor gives the handler a single ``GitHubAppNotConfiguredError``
raise site to translate into its 404 feature-disabled response.

Raises
------
GitHubAppNotConfiguredError
If any of the three GitHub App secrets is unset.
RuntimeError
If the shared HTTP client is not configured.
"""
_, _, webhook_secret = self._require_github_app_config()
if self._http_client is None:
msg = "HTTP client is required to build a PushEventProcessor"
raise RuntimeError(msg)
processor = PushEventProcessor(
binding_store=self.create_dashboard_github_template_binding_store(),
enqueuer=self.create_dashboard_sync_enqueuer(),
app_client=self.create_github_app_client(),
http_client=self._http_client,
logger=self._logger,
)
return webhook_secret.get_secret_value(), processor

def create_dashboard_publisher(self) -> DashboardPublisher:
"""Create a DashboardPublisher for one render.

Expand Down
7 changes: 7 additions & 0 deletions src/docverse/handlers/webhooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""HTTP handlers for GitHub webhooks."""

from __future__ import annotations

from .github import router as webhook_router

__all__ = ["webhook_router"]
124 changes: 124 additions & 0 deletions src/docverse/handlers/webhooks/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""GitHub webhook endpoint dispatching push events to the sync queue."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Annotated

import gidgethub
from fastapi import APIRouter, Depends, HTTPException, Request, status
from gidgethub import sansio
from gidgethub.routing import Router as GidgethubRouter

from docverse.dependencies.context import RequestContext, context_dependency
from docverse.services.dashboard_templates import PushEventProcessor
from docverse.storage.github import GitHubAppNotConfiguredError

__all__ = ["router"]

router = APIRouter(include_in_schema=False)
"""FastAPI router for GitHub webhook endpoints.

Mounted under ``config.path_prefix`` from ``main.py`` so the public
URL is ``POST {path_prefix}/webhooks/github``. Excluded from the
OpenAPI schema because the API surface is GitHub's webhook contract,
not the Docverse REST API.
"""


_event_router = GidgethubRouter()
"""Module-level gidgethub router for event-type dispatch.

Using a fresh router per request would defeat gidgethub's intended
registration model and force every callback to re-bind on every
delivery. Holding it at module scope mirrors the pattern in
``safir.github`` and Times Square / Semaphore: register handlers
once, dispatch many.
"""


@_event_router.register("push")
async def _handle_push(
event: sansio.Event,
*,
processor: PushEventProcessor,
context: RequestContext,
) -> None:
"""Translate a push event into ``dashboard_sync`` enqueues.

The processor owns transaction-free DB writes through the
enqueuer; the handler wraps both the binding lookup and the
enqueue in a single ``session.begin()`` so a failure aborts the
whole webhook delivery cleanly.
"""
async with context.session.begin():
jobs = await processor.process(event.data)
await context.session.commit()
context.logger.info("Processed push webhook", enqueued=len(jobs))


@router.post(
"/webhooks/github",
status_code=status.HTTP_200_OK,
summary="GitHub App webhook receiver",
name="github_webhook",
)
async def post_github_webhook(
request: Request,
context: Annotated[RequestContext, Depends(context_dependency)],
) -> dict[str, str]:
"""Receive a GitHub webhook delivery and dispatch by event type.

Returns ``404`` when the GitHub App feature is not configured
(any of ``github_app_id`` / ``github_app_private_key`` /
``github_webhook_secret`` unset). This keeps the URL effectively
invisible to a misconfigured deployment without surfacing a 5xx
that would page operators on every GitHub redelivery attempt.

Returns ``401`` when the request is unsigned or the HMAC does
not match the configured webhook secret. ``415`` is returned by
``gidgethub`` directly when the content-type is wrong.

Returns ``200`` for all signed deliveries — including event
types this app does not subscribe to — so GitHub's redelivery
machinery does not retry deliveries we have intentionally
chosen not to act on.
"""
try:
secret, processor = context.factory.create_webhook_dispatch()
except GitHubAppNotConfiguredError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="GitHub App is not configured",
) from exc

body = await request.body()
try:
event = sansio.Event.from_http(
_lowercase_headers(request.headers), body, secret=secret
)
except gidgethub.ValidationFailure as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid webhook signature",
) from exc

context.rebind_logger(
github_event=event.event, github_delivery_id=event.delivery_id
)

await _event_router.dispatch(event, processor=processor, context=context)

return {"status": "ok"}


def _lowercase_headers(headers: Mapping[str, str]) -> dict[str, str]:
"""Return a dict of request headers with lower-cased keys.

``gidgethub.sansio.Event.from_http`` expects a mapping that
supports lower-cased keys; FastAPI's ``Request.headers`` already
is case-insensitive but ``Event.from_http`` indexes via
``headers["x-github-event"]`` directly, so a plain dict with
pre-lower-cased keys is the safest contract.
"""
return {k.lower(): v for k, v in headers.items()}
2 changes: 2 additions & 0 deletions src/docverse/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .handlers.internal import internal_router
from .handlers.orgs import orgs_router
from .handlers.queue import queue_router
from .handlers.webhooks import webhook_router
from .services.credential_encryptor import CredentialEncryptor
from .storage.user_info_store import GafaelfawrUserInfoStore

Expand Down Expand Up @@ -138,6 +139,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
app.include_router(admin_router, prefix=config.path_prefix)
app.include_router(orgs_router, prefix=config.path_prefix)
app.include_router(queue_router, prefix=config.path_prefix)
app.include_router(webhook_router, prefix=config.path_prefix)
app.add_middleware(XForwardedMiddleware)

if config.slack_webhook:
Expand Down
2 changes: 2 additions & 0 deletions src/docverse/services/dashboard_templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
)
from .enqueue import DashboardSyncEnqueuer
from .fanout import DashboardRebuildFanout
from .push_processor import PushEventProcessor
from .resolver import (
ResolvedTemplate,
ResolvedTemplateOrigin,
Expand All @@ -22,6 +23,7 @@
"DashboardTemplateBindingService",
"DashboardTemplateSyncError",
"DashboardTemplateSyncer",
"PushEventProcessor",
"ResolvedTemplate",
"ResolvedTemplateOrigin",
"TemplateResolver",
Expand Down
Loading
Loading