Skip to content

Commit 04600bf

Browse files
committed
Implement GitHub App integration with webhook handling and signature verification
1 parent 8435b24 commit 04600bf

8 files changed

Lines changed: 179 additions & 2 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ TRACING_ENDPOINT=
105105
API_KEY=
106106
ALLOWED_HOSTS=["*"]
107107

108+
# =============================================================================
109+
# App Store Configuration
110+
# =============================================================================
111+
112+
GITHUB_WEBHOOK_SECRET=
113+
108114
# =============================================================================
109115
# Feature Flags (Environment-level overrides)
110116
# Use these to override default feature flag behavior

apps/api/routes/v1/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastapi import APIRouter
2-
from apps.api.routes.v1 import ask, health, connect
2+
from apps.api.routes.v1 import ask, health, connect, integrations
33

44
v1_router = APIRouter(prefix="/v1")
55

66
# Include all v1 route modules
77
v1_router.include_router(health.router)
88
v1_router.include_router(connect.router)
9-
v1_router.include_router(ask.router)
9+
v1_router.include_router(ask.router)
10+
v1_router.include_router(integrations.router)

apps/api/routes/v1/integrations.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from fastapi import APIRouter, Request, Header, BackgroundTasks
2+
from packages.config.settings import Settings
3+
from packages.app_store.github.utils import verify_github_signature, SignatureVerificationError
4+
from packages.app_store.github.webhook import handle_github_event, run_ingestion
5+
from apps.api.utils.response import APIResponse
6+
from apps.api.utils.exceptions import BadRequestException, UnauthorizedException
7+
import logging
8+
9+
router = APIRouter(prefix="/integrations", tags=["Integrations"])
10+
settings = Settings()
11+
logger = logging.getLogger(__name__)
12+
13+
@router.post("/github/webhook")
14+
async def github_webhook(
15+
request: Request,
16+
background_tasks: BackgroundTasks,
17+
x_github_event: str = Header(None)
18+
):
19+
"""
20+
Handle GitHub Webhooks.
21+
"""
22+
if not x_github_event:
23+
raise BadRequestException(message="Missing X-GitHub-Event header")
24+
25+
# Always verify signature in production
26+
try:
27+
await verify_github_signature(request, settings.GITHUB_WEBHOOK_SECRET)
28+
except SignatureVerificationError as e:
29+
logger.warning(f"GitHub signature verification failed: {str(e)}")
30+
raise UnauthorizedException(message=str(e))
31+
32+
payload = await request.json()
33+
34+
result = handle_github_event(x_github_event, payload)
35+
36+
if result.get("status") == "triggered" and result.get("task") == "ingest":
37+
repo_url = result.get("repo_url")
38+
branch = result.get("branch")
39+
if repo_url:
40+
background_tasks.add_task(run_ingestion, repo_url, branch)
41+
return APIResponse.success(
42+
message=f"Ingestion triggered for {repo_url} on {branch}",
43+
data={"status": "accepted", "repo_url": repo_url, "branch": branch}
44+
)
45+
46+
return APIResponse.success(
47+
message="Webhook processed",
48+
data=result
49+
)

packages/app_store/__init__.py

Whitespace-only changes.

packages/app_store/github/__init__.py

Whitespace-only changes.

packages/app_store/github/utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import hashlib
2+
import hmac
3+
from typing import Optional
4+
from fastapi import Request
5+
6+
class SignatureVerificationError(Exception):
7+
"""Raised when GitHub signature verification fails."""
8+
pass
9+
10+
async def verify_github_signature(request: Request, secret: Optional[str]):
11+
"""
12+
Verify that the request came from GitHub.
13+
Raises SignatureVerificationError if verification fails.
14+
"""
15+
if not secret:
16+
# In a production environment, we must have a secret.
17+
raise SignatureVerificationError("Webhook secret is not configured")
18+
19+
signature = request.headers.get("X-Hub-Signature-256")
20+
if not signature:
21+
raise SignatureVerificationError("Missing X-Hub-Signature-256 header")
22+
23+
body = await request.body()
24+
25+
# Calculate expected signature
26+
hash_object = hmac.new(secret.encode("utf-8"), msg=body, digestmod=hashlib.sha256)
27+
expected_signature = "sha256=" + hash_object.hexdigest()
28+
29+
if not hmac.compare_digest(expected_signature, signature):
30+
raise SignatureVerificationError("Invalid signature")
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
from typing import Dict, Any, Optional
3+
from packages.ingest.full_ingest import full_ingest
4+
5+
logger = logging.getLogger(__name__)
6+
7+
def handle_github_event(event_type: str, payload: Dict[str, Any]):
8+
"""
9+
Dispatch GitHub events to specific handlers.
10+
"""
11+
logger.info(f"Received GitHub event: {event_type}")
12+
13+
if event_type == "pull_request":
14+
return handle_pull_request(payload)
15+
16+
if event_type == "push":
17+
return handle_push(payload)
18+
19+
return {"status": "ignored", "reason": f"Event type {event_type} not handled"}
20+
21+
def handle_pull_request(payload: Dict[str, Any]):
22+
"""
23+
Handle pull_request events.
24+
Specifically look for closed PRs that were merged.
25+
"""
26+
action = payload.get("action")
27+
pull_request = payload.get("pull_request", {})
28+
merged = pull_request.get("merged", False)
29+
30+
logger.info(f"Processing PR action: {action}, merged: {merged}")
31+
32+
if action == "closed" and merged:
33+
# PR was merged
34+
repo_info = payload.get("repository", {})
35+
repo_url = repo_info.get("clone_url") or repo_info.get("html_url")
36+
37+
# The branch where the PR was merged into (usually main/master)
38+
base_branch = pull_request.get("base", {}).get("ref")
39+
40+
if repo_url and base_branch:
41+
logger.info(f"Triggering ingestion for merged PR in {repo_url} on branch {base_branch}")
42+
43+
# TODO: Trigger ingestion
44+
45+
# Let's define a function that CAN be run in background.
46+
return {
47+
"status": "triggered",
48+
"task": "ingest",
49+
"repo_url": repo_url,
50+
"branch": base_branch
51+
}
52+
53+
return {"status": "ignored", "reason": "Not a merged PR"}
54+
55+
def handle_push(payload: Dict[str, Any]):
56+
"""
57+
Handle push events.
58+
"""
59+
ref = payload.get("ref")
60+
repo_info = payload.get("repository", {})
61+
repo_url = repo_info.get("clone_url") or repo_info.get("html_url")
62+
63+
# ref is usually "refs/heads/branch_name"
64+
branch = ref.replace("refs/heads/", "") if ref else None
65+
66+
if repo_url and branch:
67+
logger.info(f"Triggering ingestion for push to {repo_url} on branch {branch}")
68+
return {
69+
"status": "triggered",
70+
"task": "ingest",
71+
"repo_url": repo_url,
72+
"branch": branch
73+
}
74+
75+
return {"status": "ignored", "reason": "Invalid push payload"}
76+
77+
def run_ingestion(repo_url: str, branch: Optional[str]):
78+
"""
79+
Wrapper to run full_ingest, suitable for BackgroundTasks.
80+
"""
81+
try:
82+
logger.info(f"Starting background ingestion for {repo_url} branch {branch}")
83+
full_ingest(repo_url, branch=branch)
84+
logger.info(f"Finished background ingestion for {repo_url} branch {branch}")
85+
except Exception as e:
86+
logger.error(f"Error during background ingestion: {e}", exc_info=True)

packages/config/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,11 @@ class Settings(BaseSettings):
307307
default=None,
308308
description="API authentication key"
309309
)
310+
311+
GITHUB_WEBHOOK_SECRET: Optional[str] = Field(
312+
default=None,
313+
description="GitHub Webhook Secret for verifying payloads"
314+
)
310315

311316
ALLOWED_HOSTS: list[str] = Field(
312317
default_factory=lambda: ["*"],

0 commit comments

Comments
 (0)