Name changed.
This commit is contained in:
@@ -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"
|
||||
"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