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