Initial upload - Untested.
Link between the Github repos and the bugzilla, runs as a web service, receives webhook on PR from github.
This commit is contained in:
		
							
								
								
									
										299
									
								
								webhook-endpoint.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								webhook-endpoint.py
									
									
									
									
									
										Normal file
									
								
							@@ -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"})
 | 
			
		||||
		Reference in New Issue
	
	Block a user