Name changed.

This commit is contained in:
2025-09-18 07:23:05 +02:00
parent 8a7c8ad75a
commit de4bf24f5b

View File

@@ -1,299 +0,0 @@
#!/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"
"Submitters 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"})