- Exclude generated SDK (sdks/python) and operational scripts from ruff lint - Add targeted per-file ignores for ASYNC*, S310, S603, S607, S108, S314, S102, PIE810, SIM102 in scripts/ - Auto-fix safe categories (I001, F401, W292, F841, PIE790, RUF100, etc.) - Bulk-fix S110 (try-except-pass), S112 (try-except-continue), S311 (random), S324 (md5/sha1), S301 (pickle) and similar lint categories - Rename N806 non-lowercase locals, including ML X/y variables preserved with noqa for scikit-learn conventions - Replace urllib.request calls with httpx.AsyncClient / httpx.Client (S310) - Wrap blocking os.path/os calls in asyncio.get_running_loop().run_in_executor - Replace subprocess.run with asyncio.create_subprocess_exec in async contexts - Store asyncio.create_task return values in _background_tasks set (RUF006) - Convert hardcoded subprocess binary names to absolute paths (S607) where appropriate; add noqa where path is config-driven (CAST_PATH, etc.) - Parameterize SQL queries with placeholders and add noqa for sanitized inputs - Fix all mechanical categories: SIM102, PIE810, TC001/2/3, S108, S314, S107, S306, S301, N802/N815/N817, S104, S605, S501, RUF022, UP031 - Add missing 'import asyncio' where referenced but not imported (F821) - Fix E402 module-import-not-at-top by adding '# noqa: E402' for circular-import safe cases and code-defined imports - Remove hardcoded Redis password in databus_warm_cron.py; use env vars Tests: - Add tests/unit/core/test_ai_router.py (8 tests): model resolution, chat completion with mocked httpx, fallback to OpenRouter, no-provider error, streaming - Add tests/unit/core/test_tracing.py (7 tests): setup_otel disabled/enabled, shutdown_otel, span helpers, tracing-enabled route registration - Add tests/unit/core/test_langfuse.py (2 tests): no-env init, noop flush - Fix tests/unit/domain/scanner/test_service.py to import from the moved app.domains.scanners.core.service Result: 'ruff check .' passes with 0 errors (was 1470). Pytest: 808 passed, 1 skipped (no regressions).
125 lines
4.1 KiB
Python
125 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Post an AI-generated review comment on a pull request from a diff file.
|
|
|
|
Environment variables:
|
|
AI_REVIEW_API_KEY API key for the chat-completions endpoint.
|
|
AI_REVIEW_MODEL Model string (default: openrouter/openai/gpt-4o-mini).
|
|
AI_REVIEW_BASE_URL Base URL for the endpoint (default: https://openrouter.ai/api/v1).
|
|
GITHUB_TOKEN GitHub token used to post the PR comment.
|
|
PR_NUMBER Pull request number to comment on.
|
|
|
|
Usage:
|
|
python3 .github/scripts/ai_review.py /path/to/pr.diff
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
DEFAULT_MODEL = "openrouter/openai/gpt-4o-mini"
|
|
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
MAX_DIFF_CHARS = 30_000
|
|
|
|
SYSTEM_PROMPT = (
|
|
"You are a senior code reviewer for a FastAPI crypto-intelligence backend. "
|
|
"Review the provided diff for correctness, security, maintainability, and "
|
|
"conformance to the project standards. Keep the response concise, actionable, "
|
|
"and focused on the most important issues. If the diff is large, prioritize "
|
|
"architecture and security concerns."
|
|
)
|
|
|
|
|
|
def _call_llm(diff_text: str) -> str:
|
|
api_key = os.environ.get("AI_REVIEW_API_KEY")
|
|
if not api_key:
|
|
return "AI_REVIEW_API_KEY not set; skipping AI review."
|
|
|
|
base_url = os.environ.get("AI_REVIEW_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
model = os.environ.get("AI_REVIEW_MODEL", DEFAULT_MODEL)
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": f"Pull request diff:\n\n```diff\n{diff_text}\n```"},
|
|
],
|
|
"temperature": 0.2,
|
|
"max_tokens": 2_000,
|
|
}
|
|
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
try:
|
|
with httpx.Client(timeout=120) as client:
|
|
resp = client.post(f"{base_url}/chat/completions", content=data, headers=headers)
|
|
result = resp.json()
|
|
except httpx.HTTPError as exc:
|
|
return f"AI review API request failed: {exc}"
|
|
except Exception as exc:
|
|
return f"AI review API request failed: {exc}"
|
|
|
|
try:
|
|
return result["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError):
|
|
return f"AI review returned unexpected response: {result}"
|
|
|
|
|
|
def _post_comment(body: str) -> None:
|
|
pr_number = os.environ.get("PR_NUMBER")
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
if not pr_number or not token or not repo:
|
|
print("PR_NUMBER/GITHUB_TOKEN/GITHUB_REPOSITORY not set; printing review to stdout.")
|
|
print(body)
|
|
return
|
|
|
|
payload = json.dumps({"body": body}).encode("utf-8")
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
try:
|
|
with httpx.Client(timeout=60) as client:
|
|
resp = client.post(
|
|
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
|
|
content=payload,
|
|
headers=headers,
|
|
)
|
|
print(f"Posted AI review comment: {resp.status_code}")
|
|
except httpx.HTTPError as exc:
|
|
print(f"Failed to post comment: {exc}", file=sys.stderr)
|
|
except Exception as exc:
|
|
print(f"Failed to post comment: {exc}", file=sys.stderr)
|
|
|
|
|
|
def main() -> int:
|
|
diff_path = Path(sys.argv[1]) if len(sys.argv) > 1 else None
|
|
if diff_path:
|
|
diff_text = diff_path.read_text(encoding="utf-8", errors="ignore")
|
|
else:
|
|
diff_text = sys.stdin.read()
|
|
|
|
if not diff_text.strip():
|
|
print("No diff provided; skipping AI review.")
|
|
return 0
|
|
|
|
if len(diff_text) > MAX_DIFF_CHARS:
|
|
diff_text = diff_text[:MAX_DIFF_CHARS] + "\n\n... [diff truncated for token limits]"
|
|
|
|
review = _call_llm(diff_text)
|
|
_post_comment(review)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|