From c8ea6d728881b0955226a0cc52c1b1ca0c65a016 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 1 Jan 2026 04:39:29 +0000 Subject: [PATCH] Initial commit of gitea-ai-webhook bot with CI/CD --- .gitea/workflows/docker-build-push.yaml | 33 ++++++ .gitignore | 5 + Dockerfile | 29 +++++ compose.yaml | 31 ++++++ requirements.txt | 5 + webhook_server.py | 136 ++++++++++++++++++++++++ 6 files changed, 239 insertions(+) create mode 100644 .gitea/workflows/docker-build-push.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 requirements.txt create mode 100644 webhook_server.py diff --git a/.gitea/workflows/docker-build-push.yaml b/.gitea/workflows/docker-build-push.yaml new file mode 100644 index 0000000..d89f16e --- /dev/null +++ b/.gitea/workflows/docker-build-push.yaml @@ -0,0 +1,33 @@ + +name: Build and Push Docker Image +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v2 + with: + registry: gitea.ext.ben.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: gitea.ext.ben.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9002937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +__pycache__/ +*.pyc +.env +.venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eac42f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ + +FROM python:3.11-slim + +# Install system dependencies +# git is required for cloning repos +RUN apt-get update && apt-get install -y \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install the AI Review tool +# Installing directly from PyPI +RUN pip install --no-cache-dir xai-review + +# Copy application code +COPY webhook_server.py . + +# Expose port +EXPOSE 3000 + +# Command to run the server +CMD ["uvicorn", "webhook_server:app", "--host", "0.0.0.0", "--port", "3000"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e2a7d62 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,31 @@ + +services: + ai-webhook: + build: . + container_name: gitea-ai-webhook + restart: always + ports: + - "3000:3000" + environment: + - GITEA_TOKEN=${GITEA_TOKEN} + # LLM Configuration + - LLM__PROVIDER=${LLM_PROVIDER:-OPENAI} + - LLM__META__MODEL=${LLM_MODEL:-gpt-4o} + # OpenAI / Compatible API Config + - LLM__HTTP_CLIENT__API_TOKEN=${OPENAI_API_KEY} + - LLM__HTTP_CLIENT__API_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1/} + # VCS Config + - VCS__PROVIDER=GITEA + - VCS__HTTP_CLIENT__API_TOKEN=${GITEA_TOKEN} + - VCS__HTTP_CLIENT__API_URL=${GITEA_API_URL:-http://gitea-server:3000/api/v1} + volumes: + - xai-cache:/root/.cache + networks: + - gitea_network + +volumes: + xai-cache: + +networks: + gitea_network: + external: true # Assuming Gitea is on a network, or remove if standalone diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..210be8b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ + +fastapi +uvicorn +requests +pydantic diff --git a/webhook_server.py b/webhook_server.py new file mode 100644 index 0000000..8daa953 --- /dev/null +++ b/webhook_server.py @@ -0,0 +1,136 @@ + +import os +import shutil +import subprocess +import tempfile +import logging +from typing import Optional, Dict, Any +from fastapi import FastAPI, Request, HTTPException, BackgroundTasks +from pydantic import BaseModel + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app = FastAPI() + +# Configuration from environment variables +GITEA_TOKEN = os.getenv("GITEA_TOKEN") +# XAI Review configuration should also be in env vars (e.g. OPENAI_API_KEY, etc.) + +class GiteaWebhookPayload(BaseModel): + action: str + number: int + pull_request: Dict[str, Any] + repository: Dict[str, Any] + sender: Dict[str, Any] + +def run_review_task(payload: Dict[str, Any]): + repo_name = payload["repository"]["full_name"] + pr_number = payload["number"] + clone_url = payload["repository"]["clone_url"] + head_sha = payload["pull_request"]["head"]["sha"] + base_ref = payload["pull_request"]["base"]["ref"] + head_ref = payload["pull_request"]["head"]["ref"] # Branch name + + logger.info(f"Starting review for PR #{pr_number} in {repo_name} (SHA: {head_sha})") + + # Create a temporary directory for the repo + work_dir = tempfile.mkdtemp(prefix="gitea-review-") + try: + # Clone the repository + # We might need authentication for private repos. + # For simplicity, assuming public or token usage in URL if needed. + # Ideally, use the GITEA_TOKEN to authenticate. + + # Inject token into URL if provided and not already present + clean_clone_url = clone_url + if GITEA_TOKEN and "://" in clone_url: + protocol, address = clone_url.split("://", 1) + # Basic check to avoid double auth + if "@" not in address: + clean_clone_url = f"{protocol}://oauth2:{GITEA_TOKEN}@{address}" + + logger.info(f"Cloning {repo_name}...") + subprocess.check_call(["git", "clone", clean_clone_url, "."], cwd=work_dir) + + # Checkout the head commit + logger.info(f"Checking out {head_sha}...") + subprocess.check_call(["git", "checkout", head_sha], cwd=work_dir) + + # Run XAI Review + # Assuming 'ai-review' alias or 'xai-review' binary is available + # The command might be 'ai-review run' or similar depending on the tool ver. + # Based on research: 'ai-review run-summary' or just 'ai-review' + logger.info("Running AI Review...") + + # We need to set environment variables for ai-review to know context if it wasn't implicit + # But usually it reads from git. + # We might need to pass specific flags. + + # NOTE: This command is a placeholder based on general usage. + # We might need to adjust arguments based on actual help output. + cmd = ["ai-review", "run"] + + # Prepare environment variables for ai-review + # We need to explicitly pass the pipeline config as env vars because they are dynamic per PR + env_vars = { + **os.environ, + "GitHub_Actions": "false", + "VCS__PIPELINE__PULL_NUMBER": str(pr_number), + "VCS__PIPELINE__OWNER": payload["repository"]["owner"]["login"], + "VCS__PIPELINE__REPO": payload["repository"]["name"] + } + + # Log the env vars for debugging (excluding secrets) + debug_env = {k: v for k, v in env_vars.items() if "TOKEN" not in k and "KEY" not in k} + logger.info(f"Running ai-review with env: {debug_env}") + + result = subprocess.run( + cmd, + cwd=work_dir, + capture_output=True, + text=True, + env=env_vars + ) + + if result.returncode == 0: + logger.info("AI Review completed successfully.") + logger.info(result.stdout) + else: + logger.error("AI Review failed.") + logger.error(result.stderr) + + except subprocess.CalledProcessError as e: + logger.error(f"Git operation failed: {e}") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + finally: + # Cleanup + logger.info(f"Cleaning up {work_dir}...") + shutil.rmtree(work_dir) + +@app.post("/webhook") +async def handle_webhook(request: Request, background_tasks: BackgroundTasks): + try: + payload_json = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + # Gitea sends headers like X-Gitea-Event: pull_request + event_type = request.headers.get("X-Gitea-Event") + if event_type != "pull_request": + return {"status": "ignored", "reason": "Not a pull_request event"} + + action = payload_json.get("action") + if action not in ["opened", "synchronize", "reopened"]: + return {"status": "ignored", "reason": f"Action '{action}' ignored"} + + # Run the review in the background to avoid timing out the webhook + background_tasks.add_task(run_review_task, payload_json) + + return {"status": "accepted", "message": "Review queued"} + +@app.get("/health") +def health_check(): + return {"status": "ok"}