diff --git a/app/main.py b/app/main.py index 4c15c92..0d5955a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List @@ -11,6 +11,7 @@ from pydantic import BaseModel from app.core.config import get_db, settings from app.models import models from app.schemas import schemas +from app.services.webhook import fire_webhooks_sync app = FastAPI( title="HarborForge API", @@ -99,11 +100,13 @@ async def get_me(current_user: models.User = Depends(get_current_user)): # ============ Issues API ============ @app.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) -def create_issue(issue: schemas.IssueCreate, db: Session = Depends(get_db)): +def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): db_issue = models.Issue(**issue.model_dump()) db.add(db_issue) db.commit() db.refresh(db_issue) + event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created" + bg.add_task(fire_webhooks_sync, event, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, db_issue.project_id, db) return db_issue diff --git a/app/services/webhook.py b/app/services/webhook.py new file mode 100644 index 0000000..cf28ea7 --- /dev/null +++ b/app/services/webhook.py @@ -0,0 +1,56 @@ +import json +import hmac +import hashlib +import logging +from sqlalchemy.orm import Session +from app.models.webhook import Webhook, WebhookLog + +logger = logging.getLogger(__name__) + + +def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session): + """Find matching webhooks and send payloads (sync version).""" + import httpx + + webhooks = db.query(Webhook).filter(Webhook.is_active == True).all() + + matched = [] + for wh in webhooks: + events = [e.strip() for e in wh.events.split(",")] + if event not in events: + continue + if wh.project_id is not None and wh.project_id != project_id: + continue + matched.append(wh) + + if not matched: + return + + payload_json = json.dumps(payload, default=str) + + for wh in matched: + log = WebhookLog( + webhook_id=wh.id, + event=event, + payload=payload_json, + ) + try: + headers = {"Content-Type": "application/json"} + if wh.secret: + sig = hmac.new( + wh.secret.encode(), payload_json.encode(), hashlib.sha256 + ).hexdigest() + headers["X-Webhook-Signature"] = sig + + with httpx.Client(timeout=10.0) as client: + resp = client.post(wh.url, content=payload_json, headers=headers) + log.response_status = resp.status_code + log.response_body = resp.text[:1000] + log.success = 200 <= resp.status_code < 300 + except Exception as e: + log.response_body = str(e)[:1000] + log.success = False + logger.warning(f"Webhook delivery failed for {wh.url}: {e}") + + db.add(log) + db.commit() diff --git a/requirements.txt b/requirements.txt index 6689709..9db19f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ bcrypt==4.0.1 python-multipart==0.0.6 alembic==1.13.1 python-dotenv==1.0.0 +httpx==0.27.0