From 3b4dcabe669b4ebfb5ed15ebad1856851b798fff Mon Sep 17 00:00:00 2001 From: Kalle Bracht Date: Sat, 2 May 2026 10:44:51 +0200 Subject: [PATCH] Initial commit Creates an agent bot that reviews code to repositories that is has access to if it gets added as code reviewer Co-authored-by: Copilot --- .dockerignore | 6 + .gitignore | 2 + BOT_README.md | 40 +++ Dockerfile | 16 + README.md | 4 +- docker-compose.yml | 24 ++ gitea_bot/__init__.py | 0 .../__pycache__/gemini_client.cpython-313.pyc | Bin 0 -> 1517 bytes .../__pycache__/gitea_client.cpython-313.pyc | Bin 0 -> 6380 bytes gitea_bot/__pycache__/main.cpython-313.pyc | Bin 0 -> 4530 bytes gitea_bot/__pycache__/poller.cpython-313.pyc | Bin 0 -> 7870 bytes gitea_bot/__pycache__/server.cpython-312.pyc | Bin 0 -> 4391 bytes gitea_bot/__pycache__/server.cpython-313.pyc | Bin 0 -> 4532 bytes gitea_bot/gemini_client.py | 21 ++ gitea_bot/gitea_client.py | 93 ++++++ gitea_bot/poller.py | 304 ++++++++++++++++++ requirements.txt | 5 + 17 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 BOT_README.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 gitea_bot/__init__.py create mode 100644 gitea_bot/__pycache__/gemini_client.cpython-313.pyc create mode 100644 gitea_bot/__pycache__/gitea_client.cpython-313.pyc create mode 100644 gitea_bot/__pycache__/main.cpython-313.pyc create mode 100644 gitea_bot/__pycache__/poller.cpython-313.pyc create mode 100644 gitea_bot/__pycache__/server.cpython-312.pyc create mode 100644 gitea_bot/__pycache__/server.cpython-313.pyc create mode 100644 gitea_bot/gemini_client.py create mode 100644 gitea_bot/gitea_client.py create mode 100644 gitea_bot/poller.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca35f02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +.venv +*.pyc +*.pyo +dist +build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..266a116 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.poller_seen.json \ No newline at end of file diff --git a/BOT_README.md b/BOT_README.md new file mode 100644 index 0000000..9b7defa --- /dev/null +++ b/BOT_README.md @@ -0,0 +1,40 @@ +Gitea Bot +---------- + +This repository contains a Python-based Gitea bot that listens for pull request events and posts an automated review when the bot account is requested as a reviewer. The bot uses a configurable Google AI Studio / Gemini REST endpoint to generate review text. + +Files added: +- [gitea_bot/main.py](gitea_bot/main.py#L1) - FastAPI webhook server +- [gitea_bot/gitea_client.py](gitea_bot/gitea_client.py#L1) - minimal Gitea API helper +- [gitea_bot/gemini_client.py](gitea_bot/gemini_client.py#L1) - wrapper for Google AI Studio REST endpoint +- [Dockerfile](Dockerfile) - container image +- [requirements.txt](requirements.txt) - Python deps + +Quick setup + +1. Build the Docker image: + +```bash +docker build -t gitea-bot:latest . +``` + +2. Run the container (example): + +```bash +docker run -e GITEA_API_URL="https://gitea.example.com/api/v1" \ + -e GITEA_TOKEN="${GITEA_TOKEN}" \ + -e BOT_USERNAME="your-bot-username" \ + -e GOOGLE_AI_ENDPOINT="https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \ + -e GOOGLE_API_KEY="YOUR_KEY" \ + -p 8000:8000 gitea-bot:latest +``` + +3. Configure a webhook in your Gitea repository pointing to `http://:8000/webhook` and enable the `pull_request` event. When you request a review from the bot account the service will fetch the PR diff and post a review comment. + +Notes & configuration +- Set `GITEA_API_URL` to your Gitea API base (usually `https://gitea.example.com/api/v1`). +- The bot posts a single comment on the PR; for per-line review comments the Gitea API endpoint may differ and needs adjustment in `gitea_client.py`. +- Configure `GOOGLE_AI_ENDPOINT` and `GOOGLE_API_KEY` to point to your Generative AI Studio model endpoint. + +Security +- Keep `GITEA_TOKEN` and `GOOGLE_API_KEY` secret and prefer injecting via environment or secret manager. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cff5d63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY gitea_bot ./gitea_bot + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 +CMD ["uvicorn", "gitea_bot.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 7be3ab8..c16772e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# KARL +# KARL der Computer -Karl der Computer is a AI agent to review PRs. \ No newline at end of file +Karl is a AI agent to review PRs. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40a8923 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' +services: + gitea-bot: + build: . + image: gitea-bot:latest + env_file: + - .env + ports: + - "8000:8000" + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://localhost:8000/ || exit 1" ] + interval: 30s + timeout: 10s + retries: 3 + poller: + build: . + image: gitea-bot:latest + env_file: + - .env + command: [ "python", "-u", "-m", "gitea_bot.poller" ] + restart: unless-stopped + depends_on: + - gitea-bot diff --git a/gitea_bot/__init__.py b/gitea_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gitea_bot/__pycache__/gemini_client.cpython-313.pyc b/gitea_bot/__pycache__/gemini_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5848ba0361a85fe0d50a4bfcdbb2656e928a9bae GIT binary patch literal 1517 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 b!|NQ&vVURf|IQrx#y!HatzR>k(~18D;M{!{ literal 0 HcmV?d00001 diff --git a/gitea_bot/__pycache__/main.cpython-313.pyc b/gitea_bot/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5cafcba84ba066cda0bd0900064cbdd08ae069a GIT binary patch literal 4530 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 Z1k#!o7fSTrPqq3#^KQ{d>y)qfe*t`ta~S{t literal 0 HcmV?d00001 diff --git a/gitea_bot/__pycache__/server.cpython-312.pyc b/gitea_bot/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0faf881f02262125fbb84ef0cd56f8596606c46f GIT binary patch literal 4391 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 YNG12n-?$Dopbr|V4z^l8*hNA6U(K5_g#Z8m literal 0 HcmV?d00001 diff --git a/gitea_bot/__pycache__/server.cpython-313.pyc b/gitea_bot/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4340f3a82ae4cc07581a8479797f06e34b33620e GIT binary patch literal 4532 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 literal 0 HcmV?d00001 diff --git a/gitea_bot/gemini_client.py b/gitea_bot/gemini_client.py new file mode 100644 index 0000000..19d9490 --- /dev/null +++ b/gitea_bot/gemini_client.py @@ -0,0 +1,21 @@ +import os +import google.genai as genai + + +class GeminiClient: + def __init__(self): + self.api_key = os.getenv("GOOGLE_API_KEY") + if not self.api_key: + raise RuntimeError("GOOGLE_API_KEY must be set") + + # Google Developer AI model (configurable via env). + self.model = os.getenv("GOOGLE_MODEL", "gemini-2.5-pro") + self.client = genai.Client(api_key=self.api_key) + + def generate_review(self, prompt: str) -> str: + """Send prompt to Gemini and return the review.""" + response = self.client.models.generate_content( + model=self.model, + contents=prompt + ) + return response.text diff --git a/gitea_bot/gitea_client.py b/gitea_bot/gitea_client.py new file mode 100644 index 0000000..923abca --- /dev/null +++ b/gitea_bot/gitea_client.py @@ -0,0 +1,93 @@ +import requests +from typing import Iterator, List + + +class GiteaClient: + def __init__(self, api_url: str, token: str): + self.api_url = api_url.rstrip("/") + self.token = token + + def _headers(self): + return {"Authorization": f"token {self.token}", "Content-Type": "application/json"} + + def available_repositories(self) -> Iterator[tuple[str, str]]: + """List all repository URLs available to the token.""" + url = f"{self.api_url}/user/repos" + r = requests.get(url, headers=self._headers(), timeout=30) + r.raise_for_status() + + for repo in r.json(): + owner = repo.get("owner", {}).get("login") + name = repo.get("name") + if owner and name: # Skip repos with missing owner or name + yield owner, name + else: + print(f"Warning: Skipping repo with missing owner or name: {repo}") + + def list_pull_request_files(self, owner: str, repo: str, pr_number: int) -> List[dict]: + """Try to list changed files for a pull request. If the endpoint differs, adjust.""" + # Many Gitea instances expose PR files at /repos/{owner}/{repo}/pulls/{index}/files + url = f"{self.api_url}/repos/{owner}/{repo}/pulls/{pr_number}/files" + r = requests.get(url, headers=self._headers(), timeout=30) + if r.status_code == 200: + return r.json() + # Fallback: try issues comments or single PR object + r.raise_for_status() + + def get_pull_request_diff(self, owner: str, repo: str, pr_number: int) -> str: + """Fetch unified diff text for a pull request.""" + url = f"{self.api_url}/repos/{owner}/{repo}/pulls/{pr_number}.diff" + headers = {"Authorization": f"token {self.token}"} + r = requests.get(url, headers=headers, timeout=30) + r.raise_for_status() + return r.text + + def create_issue_comment(self, owner: str, repo: str, issue_index: int, body: str) -> dict: + url = f"{self.api_url}/repos/{owner}/{repo}/issues/{issue_index}/comments" + r = requests.post(url, headers=self._headers(), json={"body": body}, timeout=30) + r.raise_for_status() + return r.json() + + def list_open_pull_requests(self, owner: str, repo: str) -> List[dict]: + """List open pull requests for a repository.""" + url = f"{self.api_url}/repos/{owner}/{repo}/pulls?state=open" + r = requests.get(url, headers=self._headers(), timeout=30) + r.raise_for_status() + return r.json() + + def list_repos_for_owner(self, owner: str) -> List[dict]: + """Try to list repos for an owner (org or user). Returns list of repo dicts.""" + # Try orgs endpoint first + url_org = f"{self.api_url}/orgs/{owner}/repos" + r = requests.get(url_org, headers=self._headers(), timeout=30) + if r.status_code == 200: + return r.json() + # Fallback to users endpoint + url_user = f"{self.api_url}/users/{owner}/repos" + r = requests.get(url_user, headers=self._headers(), timeout=30) + r.raise_for_status() + return r.json() + + def create_pull_request_review(self, owner: str, repo: str, pr_number: int, body: str, comments: List[dict] = None) -> dict: + """Create a PR review with optional line-specific comments. + + Args: + owner: Repository owner + repo: Repository name + pr_number: PR number/index + body: General review comment + comments: List of line comments. Each comment dict should have: + - path: file path + - new_position: line number in new version + - body: comment text + """ + url = f"{self.api_url}/repos/{owner}/{repo}/pulls/{pr_number}/reviews" + payload = { + "body": body, + "event": "COMMENT" + } + if comments: + payload["comments"] = comments + r = requests.post(url, headers=self._headers(), json=payload, timeout=30) + r.raise_for_status() + return r.json() diff --git a/gitea_bot/poller.py b/gitea_bot/poller.py new file mode 100644 index 0000000..2286338 --- /dev/null +++ b/gitea_bot/poller.py @@ -0,0 +1,304 @@ +import os +import time +import json +import re +from pathlib import Path +from typing import List, Optional + +import requests +from gitea_client import GiteaClient +from gemini_client import GeminiClient +from dotenv import load_dotenv + +# Load environment variables from parent directory .env (project root) +env_path = Path(__file__).resolve().parents[1] / ".env" +if env_path.exists(): + load_dotenv(dotenv_path=env_path) + +# Configuration +API_URL = os.getenv("GITEA_API_URL") +TOKEN = os.getenv("GITEA_TOKEN") +BOT = os.getenv("BOT_USERNAME") +POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "60")) +POLL_OWNER = os.getenv("POLL_OWNER") +POLL_REPOS = os.getenv("POLL_REPOS") # comma-separated owner/repo + +ROOT = Path(__file__).resolve().parent.parent +SEEN_PATH = ROOT / ".poller_seen.json" + +if not (API_URL and TOKEN and BOT): + raise RuntimeError("GITEA_API_URL, GITEA_TOKEN and BOT_USERNAME must be set for poller") + +gitea = GiteaClient(API_URL, TOKEN) +gemini = GeminiClient() + + +def load_seen() -> set: + if SEEN_PATH.exists(): + try: + with open(SEEN_PATH, "r", encoding="utf-8") as f: + return set(tuple(x) for x in json.load(f)) + except Exception: + return set() + return set() + + +def save_seen(seen: set): + with open(SEEN_PATH, "w", encoding="utf-8") as f: + json.dump([list(x) for x in seen], f) + + +def build_prompt_from_file(file_dict: dict) -> str: + """Build a structured prompt for reviewing a single file diff.""" + filename = file_dict.get("filename") or file_dict.get("path") or "unknown" + patch = file_dict.get("patch") or file_dict.get("diff") or "" + + if len(patch) > 30000: + patch = patch[:30000] + "\n...TRUNCATED..." + + prompt = ( + "You are a senior code reviewer. Analyze exactly one file diff and return ONLY JSON.\n" + "You review C++ code with the Qt framework\n" + "Rules:\n" + "1) Only report real issues or actionable improvements.\n" + "2) Use diff positions (line index in the unified diff hunk) for comment anchoring.\n" + "3) Keep each comment short and specific.\n" + "4) If there are no findings, return an empty findings array.\n\n" + "JSON schema:\n" + "{\n" + " \"summary\": \"short summary\",\n" + " \"findings\": [\n" + " {\n" + " \"diff_position\": 12,\n" + " \"severity\": \"high|medium|low\",\n" + " \"comment\": \"text\"\n" + " }\n" + " ]\n" + "}\n\n" + f"File: {filename}\n" + "Unified diff:\n" + f"{patch}" + ) + return prompt + + +def extract_json_object(text: str) -> Optional[dict]: + """Extract a JSON object from model output, including fenced JSON blocks.""" + if not text: + return None + + raw = text.strip() + if raw.startswith("```"): + lines = raw.splitlines() + if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```": + raw = "\n".join(lines[1:-1]).strip() + if raw.startswith("json"): + raw = raw[4:].strip() + + try: + data = json.loads(raw) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + pass + + start = raw.find("{") + end = raw.rfind("}") + if start == -1 or end == -1 or end <= start: + return None + + candidate = raw[start:end + 1] + try: + data = json.loads(candidate) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +def parse_structured_review(ai_response: str) -> dict: + """Parse model output into normalized review structure.""" + parsed = extract_json_object(ai_response) or {} + summary = str(parsed.get("summary") or "No summary provided.").strip() + findings_raw = parsed.get("findings") or [] + findings = [] + + if isinstance(findings_raw, list): + for item in findings_raw: + if not isinstance(item, dict): + continue + + try: + diff_position = int(item.get("diff_position")) + except (TypeError, ValueError): + continue + + comment = str(item.get("comment") or "").strip() + severity = str(item.get("severity") or "low").strip().lower() + if not comment: + continue + + findings.append( + { + "diff_position": diff_position, + "severity": severity, + "comment": comment, + } + ) + + return {"summary": summary, "findings": findings} + + +def split_unified_diff_by_file(unified_diff: str) -> dict: + """Split a PR unified diff into per-file diff chunks keyed by new path.""" + file_diffs = {} + current_lines: List[str] = [] + current_path: Optional[str] = None + + def flush_current() -> None: + if current_path and current_lines: + file_diffs[current_path] = "\n".join(current_lines).strip() + + for line in unified_diff.splitlines(): + if line.startswith("diff --git "): + flush_current() + current_lines = [line] + current_path = None + continue + + if current_lines is not None: + current_lines.append(line) + + # Example: +++ b/src/main.cpp + if line.startswith("+++ "): + raw_path = line[4:].strip() + if raw_path == "/dev/null": + # Deleted file; fallback to old path if needed. + continue + current_path = raw_path[2:] if raw_path.startswith("b/") else raw_path + + # Fallback for rename/deletion edge cases. + if current_path is None and line.startswith("diff --git "): + match = re.match(r"diff --git a/(.+?) b/(.+)", line) + if match: + current_path = match.group(2) + + flush_current() + return file_diffs + + +def handle_assignment(owner: str, repo: str, pr: dict): + pr_number = pr.get("number") or pr.get("index") or pr.get("id") + try: + files = gitea.list_pull_request_files(owner, repo, pr_number) + except Exception as e: + print(f"failed to fetch files for {owner}/{repo}#{pr_number}: {e}") + return False + + if not files: + print(f"No files found for {owner}/{repo}#{pr_number}") + return False + + # Some Gitea setups return filenames but no patch in /pulls/{n}/files. + fallback_patches = {} + if files and all(not (f.get("patch") or f.get("diff") or "").strip() for f in files): + try: + unified_diff = gitea.get_pull_request_diff(owner, repo, pr_number) + fallback_patches = split_unified_diff_by_file(unified_diff) + print( + f"Loaded fallback unified diff for {owner}/{repo}#{pr_number} " + f"({len(fallback_patches)} file patches)" + ) + except Exception as e: + print(f"failed to load fallback diff for {owner}/{repo}#{pr_number}: {e}") + + # Analyze each file individually based on its diff. + review_comments: List[dict] = [] + file_summaries: List[str] = [] + + for file_dict in files: + filename = file_dict.get("filename") or file_dict.get("path") + if not filename: + continue + + patch = file_dict.get("patch") or file_dict.get("diff") or "" + if not patch.strip() and fallback_patches: + patch = fallback_patches.get(filename, "") + + if not patch.strip(): + file_summaries.append(f"**{filename}**: No textual diff available.") + continue + + file_for_prompt = dict(file_dict) + file_for_prompt["patch"] = patch + + print(f"Analyzing {filename} for {owner}/{repo}#{pr_number}") + prompt = build_prompt_from_file(file_for_prompt) + + try: + ai_response = gemini.generate_review(prompt) + parsed_review = parse_structured_review(ai_response) + file_summaries.append(f"**{filename}**: {parsed_review['summary']}") + + for finding in parsed_review["findings"]: + severity = finding["severity"].upper() + body = f"[{severity}] {finding['comment']}" + review_comments.append({ + "path": filename, + "new_position": finding["diff_position"], + "body": body, + }) + except Exception as e: + print(f"failed to generate review for {filename}: {e}") + file_summaries.append(f"**{filename}**: Error analyzing file - {e}") + + # Create one PR review containing summary + line-anchored comments. + review_body = "AI Code Review\n\n" + "\n".join(file_summaries) + + try: + gitea.create_pull_request_review( + owner, repo, pr_number, + body=review_body, + comments=review_comments if review_comments else None + ) + print(f"Posted review for {owner}/{repo}#{pr_number} with {len(review_comments)} line comments") + return True + except Exception as e: + print(f"failed to post review for {owner}/{repo}#{pr_number}: {e}") + return False + + +def run(): + seen = load_seen() + print("Starting poller; checking repos...") + try: + while True: + repos = list(gitea.available_repositories()) + print(f"Found {len(repos)} accessible repositories") + for owner, repo in repos: + try: + prs = gitea.list_open_pull_requests(owner, repo) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + # Repo exists but is not accessible (permission or deleted) + continue + print(f"failed to list PRs for {owner}/{repo}: {e}") + continue + except Exception as e: + print(f"failed to list PRs for {owner}/{repo}: {e}") + continue + + for pr in prs: + key = (f"{owner}/{repo}", pr.get("number")) + reviewers = [r.get("login") or r.get("username") for r in (pr.get("requested_reviewers") or [])] + if BOT in reviewers and key not in seen: + print(f"Detected assignment: {key}") + ok = handle_assignment(owner, repo, pr) + if ok: + seen.add(key) + save_seen(seen) + time.sleep(POLL_INTERVAL) + except KeyboardInterrupt: + print("Poller stopped") + + +if __name__ == "__main__": + run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5f7133 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.95.2 +uvicorn[standard]==0.22.0 +requests==2.31.0 +python-dotenv==1.0.1 +google-genai