diff --git a/webhook-endpoint.py b/webhook-endpoint.py new file mode 100644 index 0000000..0308ffa --- /dev/null +++ b/webhook-endpoint.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +import os +import json +import base64 +import sqlite3 +import logging +import hmac +import hashlib +import datetime as dt +from typing import Optional, Tuple + +import httpx +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.responses import JSONResponse, PlainTextResponse +from github import Github, Auth + +# ------------- Logging ------------- +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="[%(asctime)s] %(levelname)s %(message)s", + datefmt="%Y-%m-%dT%H:%M:%SZ", +) +logging.Formatter.converter = dt.datetime.utctimetuple +logger = logging.getLogger(__name__) + +# ------------- Config via env ------------- + +GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] +GITHUB_OWNER = os.environ["GITHUB_OWNER"] # e.g. Koozali-SME-Server +WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"] # same secret configured in GitHub webhook + +# For canonical link in messages (where to direct contributors) +GITEA_BASE_URL = os.environ.get("GITEA_BASE_URL", "https://src.koozali.org") +GITEA_ORG = os.environ.get("GITEA_ORG", "smeserver") + +# Bugzilla config +BUGZILLA_BASE_URL = os.environ.get("BUGZILLA_BASE_URL", "https://bugs.koozali.org") +BUGZILLA_AUTH_MODE = os.environ.get("BUGZILLA_AUTH_MODE", "basic") # "basic" or "api_key" +BUGZILLA_USER = os.environ.get("BUGZILLA_USER", "brianr") +BUGZILLA_PASSWORD = os.environ.get("BUGZILLA_PASSWORD", "") +BUGZILLA_API_KEY = os.environ.get("BUGZILLA_API_KEY", "") +BUGZILLA_PRODUCT = os.environ.get("BUGZILLA_PRODUCT", "SME11") +BUGZILLA_COMPONENT_FALLBACK = os.environ.get("BUGZILLA_COMPONENT_FALLBACK", "General") +BUGZILLA_ATTACH_DIFF = os.environ.get("BUGZILLA_ATTACH_DIFF", "true").lower() == "true" + +# Policy: keep PR open on Bugzilla failure (your chosen behavior) +KEEP_PR_OPEN_ON_BZ_FAIL = True +BUGZILLA_FAILURE_LABEL = os.environ.get("BUGZILLA_FAILURE_LABEL", "bugzilla-needed") + +# SQLite for PR->Bug mapping +STATE_PATH = os.environ.get("STATE_PATH", "./webhook_state.sqlite") + +# ------------- Clients ------------- + +class State: + def __init__(self, path: str): + self.conn = sqlite3.connect(path, check_same_thread=False) + self._init() + + def _init(self): + c = self.conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS pr_map ( + pr_key TEXT PRIMARY KEY, + bug_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + self.conn.commit() + + def get_bug(self, pr_key: str) -> Optional[int]: + c = self.conn.cursor() + c.execute("SELECT bug_id FROM pr_map WHERE pr_key=?", (pr_key,)) + row = c.fetchone() + return row[0] if row else None + + def set_bug(self, pr_key: str, bug_id: int): + now = dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + c = self.conn.cursor() + c.execute(""" + INSERT INTO pr_map (pr_key, bug_id, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(pr_key) DO UPDATE SET bug_id=excluded.bug_id, updated_at=excluded.updated_at + """, (pr_key, bug_id, now, now)) + self.conn.commit() + +class Bugzilla: + def __init__(self): + self.base = BUGZILLA_BASE_URL.rstrip("/") + self.auth_mode = BUGZILLA_AUTH_MODE + + def _headers(self): + h = {"Accept": "application/json"} + if self.auth_mode == "api_key" and BUGZILLA_API_KEY: + h["X-BUGZILLA-API-KEY"] = BUGZILLA_API_KEY + return h + + def _auth(self) -> Optional[Tuple[str, str]]: + if self.auth_mode == "basic": + return (BUGZILLA_USER, BUGZILLA_PASSWORD) + return None + + def create_bug(self, summary: str, description: str, component: str) -> int: + url = f"{self.base}/rest/bug" + payload = { + "product": BUGZILLA_PRODUCT, + "component": component or BUGZILLA_COMPONENT_FALLBACK, + "summary": summary, + "description": description, + } + r = httpx.post(url, headers=self._headers(), auth=self._auth(), json=payload, timeout=60) + if r.status_code not in (200, 201): + raise RuntimeError(f"Bugzilla create failed: {r.status_code} {r.text}") + data = r.json() + bug_id = data.get("id") or (data.get("bugs") and data["bugs"][0]["id"]) + if not bug_id: + raise RuntimeError("Bugzilla response missing bug id") + return int(bug_id) + + def add_attachment(self, bug_id: int, filename: str, summary: str, data_bytes: bytes): + url = f"{self.base}/rest/bug/{bug_id}/attachment" + payload = { + "ids": [bug_id], + "data": base64.b64encode(data_bytes).decode("ascii"), + "file_name": filename, + "summary": summary, + "content_type": "text/x-patch", + "is_patch": True, + } + r = httpx.post(url, headers=self._headers(), auth=self._auth(), json=payload, timeout=120) + if r.status_code not in (200, 201): + raise RuntimeError(f"Bugzilla attach failed: {r.status_code} {r.text}") + + def add_comment(self, bug_id: int, comment: str): + url = f"{self.base}/rest/bug/{bug_id}/comment" + payload = {"comment": comment} + r = httpx.post(url, headers=self._headers(), auth=self._auth(), json=payload, timeout=60) + if r.status_code not in (200, 201): + raise RuntimeError(f"Bugzilla comment failed: {r.status_code} {r.text}") + +# ------------- GitHub client ------------- + +gh = Github(auth=Auth.Token(GITHUB_TOKEN)) +bz = Bugzilla() +state = State(STATE_PATH) + +# ------------- App ------------- + +app = FastAPI() + +def verify_signature(secret: str, body: bytes, signature: str): + if not signature: + raise HTTPException(status_code=401, detail="Missing signature") + expected = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, signature): + raise HTTPException(status_code=401, detail="Invalid signature") + +def pr_comment_success(gitea_repo_url: str, bug_id: int) -> str: + return ( + "Thanks for the contribution!\n\n" + "This repository is a read-only mirror of the canonical repo on Gitea:\n" + f"- Canonical: {gitea_repo_url}\n" + f"- Please file and discuss changes in Bugzilla: {BUGZILLA_BASE_URL}\n\n" + f"We created Bug {bug_id} to track this proposal:\n" + f"- {BUGZILLA_BASE_URL}/show_bug.cgi?id={bug_id}\n\n" + "This pull request will be closed here. Further pushes to this PR branch will be mirrored as updated attachments on the Bug.\n" + ) + +def pr_comment_failure(gitea_repo_url: str, repo: str, pr_url: str, target_branch: str) -> str: + return ( + "Thanks for the contribution!\n\n" + "This repository is a read-only mirror of the canonical repo on Gitea:\n" + f"- Canonical: {gitea_repo_url}\n\n" + "We were unable to create a Bugzilla ticket automatically at this time.\n" + f"Please open a bug at {BUGZILLA_BASE_URL} (Product: {BUGZILLA_PRODUCT}, Component: {repo}) and include:\n" + f"- GitHub PR: {pr_url}\n" + f"- Target branch: {target_branch}\n" + "- Summary and rationale for the change\n\n" + "This pull request will remain open for now. Once a Bugzilla ticket exists, our maintainers will reference it here and proceed with review on Bugzilla.\n" + ) + +@app.get("/healthz") +async def healthz(): + return PlainTextResponse("ok") + +@app.post("/webhook/github") +async def github_webhook( + request: Request, + x_hub_signature_256: str = Header(None), + x_github_event: str = Header(None), +): + body = await request.body() + verify_signature(WEBHOOK_SECRET, body, x_hub_signature_256) + try: + payload = json.loads(body.decode("utf-8")) + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + if x_github_event != "pull_request": + return JSONResponse({"status": "ignored", "event": x_github_event}) + + action = payload.get("action") + if action not in ("opened", "reopened", "synchronize", "ready_for_review"): + return JSONResponse({"status": "ignored", "action": action}) + + repo_full = payload["repository"]["full_name"] # owner/repo on GitHub + owner = payload["repository"]["owner"]["login"] + repo = payload["repository"]["name"] + pr = payload["pull_request"] + pr_num = pr["number"] + pr_url = pr["html_url"] + pr_title = pr.get("title") or "" + pr_body = pr.get("body") or "" + base = pr["base"] + head = pr["head"] + target_branch = base["ref"] + head_sha = head["sha"][:7] + gitea_repo_url = f"{GITEA_BASE_URL}/{GITEA_ORG}/{repo}" + + pr_key = f"{repo_full}#{pr_num}" + bug_id = state.get_bug(pr_key) + + gh_repo = gh.get_repo(repo_full) + + def fetch_pr_patch() -> bytes: + url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_num}" + r = httpx.get(url, headers={"Accept": "application/vnd.github.v3.patch", "Authorization": f"token {GITHUB_TOKEN}"}, timeout=120) + r.raise_for_status() + return r.content + + if bug_id is None and action in ("opened", "reopened", "ready_for_review", "synchronize"): + # Create Bugzilla bug + summary = f"[GH PR #{pr_num}] {GITEA_ORG}/{repo}: {pr_title}" + description = ( + "Source\n" + f"- Canonical repo (Gitea): {gitea_repo_url}\n" + f"- GitHub mirror PR: {pr_url}\n\n" + "Project policy\n" + "This GitHub repository is a read-only mirror. Reviews and decisions happen in Bugzilla.\n\n" + "Submitter’s notes\n" + f"{pr_body}\n" + ) + component = repo # change if you need fallback + + try: + bug_id = bz.create_bug(summary, description, component) + state.set_bug(pr_key, bug_id) + logger.info(f"Created Bugzilla bug {bug_id} for {pr_key}") + except Exception as e: + logger.error(f"Bugzilla create failed for {pr_key}: {e}") + # Post failure comment and keep PR open + gh_repo.get_pull(pr_num).create_issue_comment(pr_comment_failure(gitea_repo_url, repo, pr_url, target_branch)) + if BUGZILLA_FAILURE_LABEL: + with contextlib.suppress(Exception): + gh_repo.create_label(BUGZILLA_FAILURE_LABEL, "ededed") + with contextlib.suppress(Exception): + gh_repo.get_issue(pr_num).add_to_labels(BUGZILLA_FAILURE_LABEL) + return JSONResponse({"status": "bugzilla_failed"}) + + # Attach patch (optional) + if BUGZILLA_ATTACH_DIFF: + try: + patch = fetch_pr_patch() + bz.add_attachment( + bug_id=bug_id, + filename=f"PR-{pr_num}-{head_sha}.patch", + summary=f"Patch for PR #{pr_num} ({head_sha})", + data_bytes=patch + ) + except Exception as e: + logger.warning(f"Attach patch failed for bug {bug_id}: {e}") + + # Comment and close PR + comment = pr_comment_success(gitea_repo_url, bug_id) + pr_obj = gh_repo.get_pull(pr_num) + pr_obj.create_issue_comment(comment) + if pr_obj.state != "closed": + pr_obj.edit(state="closed") + return JSONResponse({"status": "ok", "bug_id": bug_id}) + + elif bug_id is not None and action == "synchronize": + # Attach updated patch to existing bug and ensure PR closed + try: + patch = fetch_pr_patch() + bz.add_attachment( + bug_id=bug_id, + filename=f"PR-{pr_num}-{head_sha}.patch", + summary=f"Updated patch for PR #{pr_num} ({head_sha})", + data_bytes=patch + ) + pr_obj = gh_repo.get_pull(pr_num) + if pr_obj.state != "closed": + pr_obj.edit(state="closed") + return JSONResponse({"status": "ok", "bug_id": bug_id}) + except Exception as e: + logger.error(f"Attach update failed for bug {bug_id}: {e}") + return JSONResponse({"status": "attach_failed", "bug_id": bug_id}) + + return JSONResponse({"status": "noop"}) \ No newline at end of file