From a791e4c15c38bde4f148ce26c1fc625fe5ef82e7 Mon Sep 17 00:00:00 2001 From: John Burton Date: Sun, 3 May 2026 09:41:47 +0100 Subject: [PATCH] Update --- Jenkinsfile | 3 + README.md | 18 ++++++ scripts/ai_code_review.py | 133 +++++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 8ecd76b..f16bd79 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,7 +2,10 @@ pipeline { agent any environment { OPENAI_API_KEY = credentials('OPENAI_API_KEY') + GITEA_TOKEN = credentials('GITEA_TOKEN') + GITEA_URL = 'https://git.jb9.uk' CODEX_MODEL = 'gpt-4' + AI_REVIEW_FAIL_ON_FINDINGS = 'false' } stages { diff --git a/README.md b/README.md index c247f94..d3fb24f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ # Hello World C++ Project +## Jenkins AI Review + +The Jenkins pipeline can run an OpenAI-powered review and report back to Gitea. + +Required Jenkins secrets and variables: + +- `OPENAI_API_KEY` +- `GITEA_TOKEN` +- `GITEA_URL` + +Optional variables: + +- `CODEX_MODEL` +- `AI_REVIEW_FAIL_ON_FINDINGS` +- `GITEA_REPO_OWNER` +- `GITEA_REPO_NAME` +- `GITEA_PR_NUMBER` + ## Build ```bash diff --git a/scripts/ai_code_review.py b/scripts/ai_code_review.py index 9ac07f4..ed8a4fd 100644 --- a/scripts/ai_code_review.py +++ b/scripts/ai_code_review.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 +import json import os import subprocess import sys from typing import Optional +from urllib import error, parse, request MAX_DIFF_CHARS = 12000 +STATUS_CONTEXT = "ai/code-review" def run_git_command(*args: str) -> str: @@ -59,6 +62,110 @@ def collect_diff() -> str: return "" +def get_current_commit() -> Optional[str]: + return os.getenv("GIT_COMMIT") or try_git_command("rev-parse", "HEAD") + + +def parse_repo_from_remote() -> tuple[Optional[str], Optional[str]]: + remote_url = try_git_command("remote", "get-url", "origin") + if not remote_url: + return None, None + + cleaned = remote_url.strip() + if cleaned.endswith(".git"): + cleaned = cleaned[:-4] + + if cleaned.startswith("git@") and ":" in cleaned: + cleaned = cleaned.split(":", 1)[1] + elif "://" in cleaned: + parsed = parse.urlparse(cleaned) + cleaned = parsed.path.lstrip("/") + + parts = [part for part in cleaned.split("/") if part] + if len(parts) < 2: + return None, None + + return parts[-2], parts[-1] + + +def get_gitea_repo() -> tuple[Optional[str], Optional[str]]: + owner = os.getenv("GITEA_REPO_OWNER") + name = os.getenv("GITEA_REPO_NAME") + if owner and name: + return owner, name + + return parse_repo_from_remote() + + +def post_gitea_json(api_path: str, payload: dict) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + url = f"{base_url.rstrip('/')}{api_path}" + data = json.dumps(payload).encode("utf-8") + req = request.Request( + url, + data=data, + headers={ + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(req) as response: + response.read() + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gitea API request failed: HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise RuntimeError(f"Gitea API request failed: {exc.reason}") from exc + + +def publish_commit_status(state: str, description: str) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + owner, repo = get_gitea_repo() + commit = get_current_commit() + if not owner or not repo or not commit: + return + + build_url = os.getenv("BUILD_URL") + payload = { + "state": state, + "context": STATUS_CONTEXT, + "description": description[:255], + } + if build_url: + payload["target_url"] = build_url + + post_gitea_json(f"/api/v1/repos/{owner}/{repo}/statuses/{commit}", payload) + + +def publish_pr_comment(body: str) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + owner, repo = get_gitea_repo() + pr_number = os.getenv("GITEA_PR_NUMBER") or os.getenv("CHANGE_ID") + if not owner or not repo or not pr_number: + return + + post_gitea_json( + f"/api/v1/repos/{owner}/{repo}/issues/{pr_number}/comments", + {"body": body}, + ) + + def build_prompt(diff_text: str) -> str: truncated_diff = diff_text[:MAX_DIFF_CHARS] suffix = "" @@ -117,15 +224,28 @@ def request_review(diff_text: str) -> str: return response.output_text.strip() +def classify_review(review: str) -> tuple[str, str]: + normalized = (review or "").strip().lower() + if normalized == "no issues found.": + return "success", "No issues found" + return "failure", "AI review reported findings" + + def main() -> int: diff_text = collect_diff() if not diff_text: - print("No git changes detected. Skipping AI review.") + message = "No git changes detected. Skipping AI review." + print(message) + publish_commit_status("success", "No changes to review") return 0 try: review = request_review(diff_text) except Exception as exc: + try: + publish_commit_status("error", "AI review failed") + except Exception as gitea_exc: + print(f"Gitea reporting failed: {gitea_exc}", file=sys.stderr) print(f"AI review failed: {exc}", file=sys.stderr) return 1 @@ -133,8 +253,15 @@ def main() -> int: print(review or "No issues found.") fail_on_findings = os.getenv("AI_REVIEW_FAIL_ON_FINDINGS", "false").lower() == "true" - normalized = (review or "").strip().lower() - no_findings = normalized == "no issues found." + state, description = classify_review(review) + + try: + publish_commit_status(state, description) + publish_pr_comment(f"AI review result:\n\n{review or 'No issues found.'}") + except Exception as exc: + print(f"Gitea reporting failed: {exc}", file=sys.stderr) + + no_findings = state == "success" if fail_on_findings and not no_findings: return 1