From 607739373f23bb2bb91be69dcc16a9898de75071 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:35:34 +0800 Subject: [PATCH 01/21] feat: add GitHub App manifest for AI code review (#862) --- integrations/github-app/manifest.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 integrations/github-app/manifest.json diff --git a/integrations/github-app/manifest.json b/integrations/github-app/manifest.json new file mode 100644 index 000000000..ee0685de2 --- /dev/null +++ b/integrations/github-app/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "SolFoundry AI Code Review", + "url": "https://github.com/SolFoundry/solfoundry", + "hook_attributes": { + "url": "https://solfoundry.org/api/github/webhook" + }, + "redirect_url": "https://solfoundry.org/api/github/callback", + "description": "Multi-LLM AI code review for every PR. Security checks, performance analysis, and best practices.", + "public": true, + "default_permissions": { + "pull_requests": "write", + "checks": "write", + "contents": "read", + "metadata": "read", + "issues": "read" + }, + "default_events": [ + "pull_request", + "pull_request_review", + "check_suite" + ], + "request_oauth_on_install": true, + "setup_url": "https://solfoundry.org/setup" +} \ No newline at end of file From f55dca8a18e0eaca1b78f1d53f8fb60a17e25fdd Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:35:42 +0800 Subject: [PATCH 02/21] feat: add integrations/github-app/requirements.txt for AI code review GitHub App (#862) --- integrations/github-app/requirements.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 integrations/github-app/requirements.txt diff --git a/integrations/github-app/requirements.txt b/integrations/github-app/requirements.txt new file mode 100644 index 000000000..2058abdf5 --- /dev/null +++ b/integrations/github-app/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +httpx>=0.27.0 +pydantic>=2.7.0 +pydantic-settings>=2.2.0 +PyGithub>=2.3.0 +cryptography>=42.0.0 +anthropic>=0.30.0 +openai>=1.30.0 +google-genai>=0.3.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +respx>=0.21.0 From a1e335b9946b2720181978fa82770cdf4e63ebbc Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:35:43 +0800 Subject: [PATCH 03/21] feat: add integrations/github-app/__init__.py for AI code review GitHub App (#862) --- integrations/github-app/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 integrations/github-app/__init__.py diff --git a/integrations/github-app/__init__.py b/integrations/github-app/__init__.py new file mode 100644 index 000000000..e69de29bb From 69289c63524b096408c71c551a463f7de32a2635 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:35:55 +0800 Subject: [PATCH 04/21] feat: add integrations/github-app/config.py for AI code review GitHub App (#862) --- integrations/github-app/config.py | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 integrations/github-app/config.py diff --git a/integrations/github-app/config.py b/integrations/github-app/config.py new file mode 100644 index 000000000..5805fdffb --- /dev/null +++ b/integrations/github-app/config.py @@ -0,0 +1,66 @@ +"""Review configuration models for the AI Code Review GitHub App.""" + +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field + + +class ReviewMode(str, Enum): + """Review mode determines how many LLM models are used.""" + QUICK = "quick" # 1 model, fast turnaround + STANDARD = "standard" # 3 models, balanced + THOROUGH = "thorough" # 5 models, deep analysis + + +class StrictnessLevel(str, Enum): + """Strictness level for reviews.""" + LENIENT = "lenient" # Flag only critical/high issues + BALANCED = "balanced" # Default — flag all issues + STRICT = "strict" # Flag everything, including style nits + + +class CommentStyle(str, Enum): + """How review comments are posted.""" + INLINE = "inline" # Comments on specific lines + SUMMARY = "summary" # Single summary comment + BOTH = "both" # Inline comments + summary + + +class ReviewConfig(BaseModel): + """Per-repository review configuration.""" + mode: ReviewMode = ReviewMode.STANDARD + strictness: StrictnessLevel = StrictnessLevel.BALANCED + comment_style: CommentStyle = CommentStyle.INLINE + max_reviewers: int = Field(default=3, ge=1, le=5) + auto_approve_threshold: Optional[float] = Field(default=8.0, ge=0, le=10) + block_threshold: Optional[float] = Field(default=4.0, ge=0, le=10) + skip_paths: list[str] = Field(default_factory=lambda: [ + "*.lock", "*.min.js", "*.min.css", "vendor/*", "node_modules/*", + "package-lock.json", "yarn.lock", "pnpm-lock.yaml" + ]) + languages: list[str] = Field(default_factory=lambda: [ + "python", "javascript", "typescript", "go", "rust", "java", + "solidity", "ruby", "kotlin", "swift", "cpp", "c" + ]) + + @classmethod + def default(cls) -> "ReviewConfig": + return cls() + + def get_score_threshold(self) -> float: + """Return the minimum score for a passing review.""" + if self.strictness == StrictnessLevel.LENIENT: + return 6.0 + elif self.strictness == StrictnessLevel.BALANCED: + return 7.0 + return 8.0 # strict + + +class AppConfig(BaseModel): + """Global application configuration.""" + github_app_id: str = "" + github_private_key: str = "" + github_webhook_secret: str = "" + anthropic_api_key: str = "" + openai_api_key: str = "" + gemini_api_key: str = "" From 54beb124e2bb15c5992039cb90f9c9b684f814bb Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:36:21 +0800 Subject: [PATCH 05/21] feat: add integrations/github-app/models.py for AI code review GitHub App (#862) --- integrations/github-app/models.py | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 integrations/github-app/models.py diff --git a/integrations/github-app/models.py b/integrations/github-app/models.py new file mode 100644 index 000000000..47c4ca8ec --- /dev/null +++ b/integrations/github-app/models.py @@ -0,0 +1,79 @@ +"""Data models shared across the AI Code Review app.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class Severity(str, Enum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + +@dataclass +class Finding: + """A single review finding/issue.""" + severity: Severity + category: str + message: str + file: Optional[str] = None + line: Optional[int] = None + model: Optional[str] = None + suggestion: Optional[str] = None + + def to_line_comment(self) -> dict: + """Convert to a GitHub review comment payload.""" + comment = { + "path": self.file or "", + "line": self.line or 1, + "side": "RIGHT", + "body": f"**{self.severity.value.upper()}** · {self.category} + +{self.message}" + } + if self.suggestion: + comment["body"] += f" + +**Suggestion:** {self.suggestion}" + return comment + + +@dataclass +class DiffFile: + """A parsed diff file for analysis.""" + filename: str + patch: str + additions: int = 0 + deletions: int = 0 + + @property + def extension(self) -> str: + return self.filename.rsplit(".", 1)[-1].lower() if "." in self.filename else "" + + +@dataclass +class ReviewResult: + """Aggregated result from all reviewers.""" + score: float + findings: list[Finding] = field(default_factory=list) + summary: str = "" + model_scores: dict[str, float] = field(default_factory=dict) + + @property + def critical_count(self) -> int: + return sum(1 for f in self.findings if f.severity == Severity.CRITICAL) + + @property + def high_count(self) -> int: + return sum(1 for f in self.findings if f.severity == Severity.HIGH) + + @property + def medium_count(self) -> int: + return sum(1 for f in self.findings if f.severity == Severity.MEDIUM) + + @property + def low_count(self) -> int: + return sum(1 for f in self.findings if f.severity == Severity.LOW) From c3d9aaa5056a3b564a8f888bb1d25cafffa73f9a Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:36:22 +0800 Subject: [PATCH 06/21] feat: add integrations/github-app/github_client.py for AI code review GitHub App (#862) --- integrations/github-app/github_client.py | 130 +++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 integrations/github-app/github_client.py diff --git a/integrations/github-app/github_client.py b/integrations/github-app/github_client.py new file mode 100644 index 000000000..f1054ba26 --- /dev/null +++ b/integrations/github-app/github_client.py @@ -0,0 +1,130 @@ +"""GitHub API client for the AI Code Review GitHub App.""" + +import base64 +import json +import logging +import time +from typing import Optional + +import httpx +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + +logger = logging.getLogger(__name__) + + +class GitHubAppClient: + """Handles GitHub App authentication and API interactions.""" + + API_BASE = "https://api.github.com" + + def __init__(self, app_id: str, private_key: str, timeout: float = 15.0): + self.app_id = app_id + self.private_key = private_key + self.timeout = timeout + self._installation_tokens: dict[int, dict] = {} + + def _generate_jwt(self) -> str: + """Create a short-lived JWT for GitHub App authentication.""" + import jwt as pyjwt + now = int(time.time()) + payload = { + "iat": now, + "exp": now + (9 * 60), # 9 minutes max + "iss": self.app_id + } + private_key = serialization.load_pem_private_key( + self.private_key.encode(), password=None + ) + return pyjwt.encode(payload, private_key, algorithm="RS256") + + def _get_headers(self, token: str) -> dict: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _get_installation_token(self, installation_id: int) -> str: + """Get or refresh an installation access token (cached ~55 min).""" + cache = self._installation_tokens.get(installation_id) + if cache and cache["expires"] > time.time() + 60: + return cache["token"] + + jwt = self._generate_jwt() + url = f"{self.API_BASE}/app/installations/{installation_id}/access_tokens" + with httpx.Client(timeout=self.timeout) as client: + resp = client.post(url, headers=self._get_headers(jwt)) + resp.raise_for_status() + data = resp.json() + token = data["token"] + self._installation_tokens[installation_id] = { + "token": token, + "expires": time.time() + (data.get("expires_in", 3600) or 3600), + } + return token + + async def get_pull_request_diff(self, installation_id: int, repo: str, pr_number: int) -> str: + """Fetch the unified diff for a pull request.""" + token = self._get_installation_token(installation_id) + url = f"{self.API_BASE}/repos/{repo}/pulls/{pr_number}" + headers = {**self._get_headers(token), "Accept": "application/vnd.github.v3.diff"} + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + return resp.text + + async def get_pull_request_files(self, installation_id: int, repo: str, pr_number: int) -> list[dict]: + """List the files changed in a pull request.""" + token = self._get_installation_token(installation_id) + url = f"{self.API_BASE}/repos/{repo}/pulls/{pr_number}/files?per_page=100" + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(url, headers=self._get_headers(token)) + resp.raise_for_status() + return resp.json() + + async def get_repo_config(self, installation_id: int, repo: str) -> Optional[dict]: + """Read .github/ai-code-review.yml from the repo, if it exists.""" + token = self._get_installation_token(installation_id) + url = f"{self.API_BASE}/repos/{repo}/contents/.github/ai-code-review.yml" + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(url, headers=self._get_headers(token)) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + return json.loads(base64.b64decode(data["content"]).decode()) + + async def submit_review(self, installation_id: int, repo: str, pr_number: int, + body: str, comments: list[dict], event: str = "COMMENT") -> None: + """Submit a pull request review with comments.""" + token = self._get_installation_token(installation_id) + url = f"{self.API_BASE}/repos/{repo}/pulls/{pr_number}/reviews" + payload = { + "body": body, + "event": event, + "comments": comments, + } + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(url, headers=self._get_headers(token), json=payload) + resp.raise_for_status() + + async def create_check_run(self, installation_id: int, repo: str, + head_sha: str, name: str, status: str, + conclusion: Optional[str] = None, + output: Optional[dict] = None) -> None: + """Create or update a check run with review results.""" + token = self._get_installation_token(installation_id) + url = f"{self.API_BASE}/repos/{repo}/check-runs" + payload = { + "head_sha": head_sha, + "name": name, + "status": status, + } + if conclusion: + payload["conclusion"] = conclusion + if output: + payload["output"] = output + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(url, headers=self._get_headers(token), json=payload) + resp.raise_for_status() From 9a10f691c32cb750c5197a70139dcb57f6b87a4e Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:37:50 +0800 Subject: [PATCH 07/21] feat: add integrations/github-app/analyzers/__init__.py for AI code review GitHub App (#862) --- integrations/github-app/analyzers/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 integrations/github-app/analyzers/__init__.py diff --git a/integrations/github-app/analyzers/__init__.py b/integrations/github-app/analyzers/__init__.py new file mode 100644 index 000000000..157c88df2 --- /dev/null +++ b/integrations/github-app/analyzers/__init__.py @@ -0,0 +1,7 @@ +"""Static analyzers for security, performance, and best practices.""" + +from .security import SecurityAnalyzer +from .performance import PerformanceAnalyzer +from .best_practices import BestPracticesAnalyzer + +__all__ = ["SecurityAnalyzer", "PerformanceAnalyzer", "BestPracticesAnalyzer"] \ No newline at end of file From 5f9c1c35056448c831e3c9a55bc2fa5d3ffebc6f Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:37:51 +0800 Subject: [PATCH 08/21] feat: add integrations/github-app/analyzers/security.py for AI code review GitHub App (#862) --- integrations/github-app/analyzers/security.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 integrations/github-app/analyzers/security.py diff --git a/integrations/github-app/analyzers/security.py b/integrations/github-app/analyzers/security.py new file mode 100644 index 000000000..918a8b9bd --- /dev/null +++ b/integrations/github-app/analyzers/security.py @@ -0,0 +1,79 @@ +"""Security pattern detection for code review.""" +import re +from typing import Optional +from ..models import DiffFile, Finding, Severity + + +class SecurityAnalyzer: + """Detects security anti-patterns in code diffs.""" + + PATTERNS = [ + ("hardcoded_secret", Severity.CRITICAL, + r"(?i)(api[_-]?key|secret|password|passwd|token|private[_-]?key)\s*[=:]\s*[\"']([A-Za-z0-9_\-]{12,})[\"']", + "Potential hardcoded credential detected. Use environment variables or a secrets manager."), + ("sql_injection", Severity.CRITICAL, + r"(?i)(execute|exec|query|raw)\s*\(\s*f[\"']|SELECT.*\+.*WHERE|\bconcat\(.*(SELECT|WHERE)", + "Possible SQL injection - string interpolation in SQL query. Use parameterized queries."), + ("eval_usage", Severity.HIGH, + r"\beval\s*\(|\bexec\s*\(\s*f[\"']|\bexec\s*\(\s*str\(|Function\s*\(\s*[\"']", + "Use of eval()/exec() with dynamic input. Can lead to code injection."), + ("unsafe_deserialize", Severity.HIGH, + r"pickle\.loads?\s*\(|yaml\.load\s*\(|json\.loads?\s*\(\s*request|node-serialize|unserialize\(", + "Unsafe deserialization detected. Use safe loaders (e.g., yaml.safe_load)."), + ("http_url", Severity.MEDIUM, + r"https?://(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s\"'`]*\bhttp://", + "Plain HTTP URL detected. Use HTTPS to prevent man-in-the-middle attacks."), + ("debug_enabled", Severity.LOW, + r"(?i)(debug\s*=\s*True|DEBUG\s*=\s*True|app\.debug\s*=\s*True|NODE_ENV\s*=\s*['\"]development)", + "Debug mode enabled. Ensure this is disabled in production."), + ("command_injection", Severity.HIGH, + r"os\.system\s*\(\s*f[\"']|subprocess\.(run|call|Popen)\s*\(\s*f[\"']|child_process.*exec\s*\(\s*f[\"']", + "Command execution with f-string interpolation. Use shell=False and argument lists."), + ] + + def analyze(self, files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for df in files: + if not df.patch: + continue + for name, severity, pattern, message in self.PATTERNS: + for match in re.finditer(pattern, df.patch): + line = self._patch_line_from_match(df.patch, match.start()) + findings.append(Finding( + severity=severity, + category="security", + message=message, + file=df.filename, + line=line, + suggestion=self._suggestion(name), + )) + return findings + + @staticmethod + def _patch_line_from_match(patch: str, pos: int) -> Optional[int]: + line = 1 + current = 0 + for piece in patch.splitlines(): + if current >= pos: + break + if piece.startswith("@@"): + m = re.search(r"\+([0-9]+)(?:,[0-9]+)?", piece) + if m: + line = int(m.group(1)) + elif piece and not piece.startswith(("-", "\\")): + line += 1 + current += len(piece) + 1 + return line + + @staticmethod + def _suggestion(name: str) -> str: + hints = { + "hardcoded_secret": "Move secrets to environment variables or a vault.", + "sql_injection": "Use parameterized queries / prepared statements.", + "eval_usage": "Replace eval/exec with a safe parser or constrained AST evaluation.", + "unsafe_deserialize": "Use yaml.safe_load or a schema-validated deserializer.", + "command_injection": "Pass arguments as a list to subprocess and keep shell=False.", + "http_url": "Use https:// URLs and HSTS headers.", + "debug_enabled": "Gate debug mode behind an env flag and disable in production.", + } + return hints.get(name, "") \ No newline at end of file From 662946131ca1fd8d61175d9d3c33d733f21dd5b8 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:37:52 +0800 Subject: [PATCH 09/21] feat: add integrations/github-app/analyzers/performance.py for AI code review GitHub App (#862) --- .../github-app/analyzers/performance.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 integrations/github-app/analyzers/performance.py diff --git a/integrations/github-app/analyzers/performance.py b/integrations/github-app/analyzers/performance.py new file mode 100644 index 000000000..47009d04d --- /dev/null +++ b/integrations/github-app/analyzers/performance.py @@ -0,0 +1,72 @@ +"""Performance anti-pattern detection.""" +import re +from typing import Optional +from ..models import DiffFile, Finding, Severity + + +class PerformanceAnalyzer: + """Detects common performance anti-patterns.""" + + def analyze(self, files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for df in files: + if not df.patch: + continue + ext = df.extension + if self._has_n_plus_one(df.patch): + findings.append(Finding( + severity=Severity.MEDIUM, + category="performance:N+1 query", + message="Possible N+1 query pattern. Use selectinload/joinedload or batch fetching.", + file=df.filename, + line=self._line_of(df.patch), + )) + if self._has_sync_in_async(df.patch): + findings.append(Finding( + severity=Severity.MEDIUM, + category="performance:blocking call in async", + message="Blocking call inside an async function. Use async clients or offload to a thread.", + file=df.filename, + line=self._line_of(df.patch), + )) + if re.search(r"\[\s*[^\]]+\s+for\s+[^\]]+\s+in\s+range\([1-9][0-9]{4,}\)", df.patch): + findings.append(Finding( + severity=Severity.LOW, + category="performance:large list comprehension", + message="Building a large list in memory. Consider a generator expression.", + file=df.filename, + )) + if re.search(r"for .*:\n\s+.*\+=\s*f?[\"']", df.patch): + findings.append(Finding( + severity=Severity.LOW, + category="performance:string concat in loop", + message="String concatenation in a loop is O(n^2). Use str.join or a list buffer.", + file=df.filename, + )) + return findings + + @staticmethod + def _has_n_plus_one(patch: str) -> bool: + return bool( + re.search(r"\bfor\b.*\bin\b.*\b(query|select|filter|get)\.(all|one|first)\b", patch) + or re.search(r"\bfor\b.*\bin\b[^\n]*\n\s*\w+\.(query|objects)\b", patch) + ) + + @staticmethod + def _has_sync_in_async(patch: str) -> bool: + return bool( + re.search(r"async\s+def\s+\w+[^\n]*:\n(?:\s+[^\n]*\n)*?\s+(?:requests|httpx\.Client|time\.sleep|open\(|subprocess\b)\.", patch) + or re.search(r"async\s+def[^\n]*\n(?:[^\n]*\n){0,10}?\s*requests\.(get|post|put|delete)\b", patch) + ) + + @staticmethod + def _line_of(patch: str) -> Optional[int]: + line = 1 + for piece in patch.splitlines(): + if piece.startswith("@@"): + m = re.search(r"\+([0-9]+)", piece) + if m: + line = int(m.group(1)) + elif piece and not piece.startswith(("-", "\\")): + line += 1 + return line \ No newline at end of file From e4e83914715e2744a3af1c499fb7f8ec043324e1 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:37:53 +0800 Subject: [PATCH 10/21] feat: add integrations/github-app/analyzers/best_practices.py for AI code review GitHub App (#862) --- .../github-app/analyzers/best_practices.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 integrations/github-app/analyzers/best_practices.py diff --git a/integrations/github-app/analyzers/best_practices.py b/integrations/github-app/analyzers/best_practices.py new file mode 100644 index 000000000..4a62db1fe --- /dev/null +++ b/integrations/github-app/analyzers/best_practices.py @@ -0,0 +1,56 @@ +"""Best practices and code-quality checks.""" +import re +from ..models import DiffFile, Finding, Severity + + +class BestPracticesAnalyzer: + """Detects common best-practice violations.""" + + def analyze(self, files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for df in files: + if not df.patch: + continue + ext = df.extension + if ext == "py" and self._has_missing_type_hints(df.patch): + findings.append(Finding( + severity=Severity.LOW, + category="best-practice:type hints", + message="Function definitions missing type annotations.", + file=df.filename, + )) + if ext in ("ts", "tsx", "js", "jsx") and re.search(r"console\.(log|debug)\s*\(", df.patch): + findings.append(Finding( + severity=Severity.LOW, + category="best-practice:console.log", + message="console.log left in code. Remove or replace with a logger.", + file=df.filename, + )) + if re.search(r"(?i)\b(TODO|FIXME|HACK|XXX):?\s", df.patch): + findings.append(Finding( + severity=Severity.INFO, + category="best-practice:TODO/FIXME", + message="TODO/FIXME marker found. Consider resolving before merge.", + file=df.filename, + )) + if re.search(r"[\s=(](-?[0-9]{3,})(?!\s*[%\\*/+ -])\b", df.patch) and ext in ("ts", "tsx", "py", "go", "java", "rs"): + findings.append(Finding( + severity=Severity.INFO, + category="best-practice:magic number", + message="Magic number detected. Extract to a named constant.", + file=df.filename, + )) + if ext == "py" and re.search(r"except\s*:", df.patch): + findings.append(Finding( + severity=Severity.LOW, + category="best-practice:bare except", + message="Bare except clause. Catch specific exceptions.", + file=df.filename, + )) + return findings + + @staticmethod + def _has_missing_type_hints(patch: str) -> bool: + return bool(re.search(r"def\s+\w+\s*\([^)]*\b\w+\s*(,|\))", patch)) and not bool( + re.search(r"def\s+\w+\s*\([^)]*:\s*[\w\[\]]+", patch) + ) \ No newline at end of file From 7b73963adc9e84fe3c3d4b261d07f2cf80c597ec Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:39 +0800 Subject: [PATCH 11/21] feat: add integrations/github-app/reviewers/__init__.py for AI code review GitHub App (#862) --- integrations/github-app/reviewers/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 integrations/github-app/reviewers/__init__.py diff --git a/integrations/github-app/reviewers/__init__.py b/integrations/github-app/reviewers/__init__.py new file mode 100644 index 000000000..168b0cdaf --- /dev/null +++ b/integrations/github-app/reviewers/__init__.py @@ -0,0 +1,9 @@ +"""LLM reviewers for the AI Code Review GitHub App.""" + +from .base import LLMReviewer +from .claude import ClaudeReviewer +from .openai import OpenAIReviewer +from .gemini import GeminiReviewer +from .orchestrator import ReviewOrchestrator + +__all__ = ["LLMReviewer", "ClaudeReviewer", "OpenAIReviewer", "GeminiReviewer", "ReviewOrchestrator"] \ No newline at end of file From e3f5194206f8276bf2f579c91dd13b2fde1b07e4 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:40 +0800 Subject: [PATCH 12/21] feat: add integrations/github-app/reviewers/base.py for AI code review GitHub App (#862) --- integrations/github-app/reviewers/base.py | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 integrations/github-app/reviewers/base.py diff --git a/integrations/github-app/reviewers/base.py b/integrations/github-app/reviewers/base.py new file mode 100644 index 000000000..373bdd843 --- /dev/null +++ b/integrations/github-app/reviewers/base.py @@ -0,0 +1,90 @@ +"""Base LLM reviewer interface for multi-model code review.""" + +from abc import ABC, abstractmethod +from typing import Optional + +from ..models import DiffFile, Finding, ReviewConfig + + +class LLMReviewer(ABC): + """Abstract base class for LLM-based code reviewers.""" + + name: str = "base" + + def __init__(self, config: ReviewConfig): + self.config = config + + @abstractmethod + async def review(self, files: list[DiffFile], instructions: Optional[str] = None) -> tuple[float, list[Finding]]: + """Run a review and return a (score, findings) tuple.""" + raise NotImplementedError + + def _build_prompt(self, files: list[DiffFile], instructions: Optional[str]) -> str: + """Build the prompt sent to the LLM.""" + strictness = self.config.strictness.value + parts = [ + f"You are a senior code reviewer. Review the following pull request diff with {strictness} strictness.", + "Return a JSON object with:", + ' {"score": <0-10>, "findings": [{"severity": "critical|high|medium|low|info", "category": "...", "message": "...", "file": "...", "line": , "suggestion": "..."}]}', + "Severity mapping: critical=-2.0, high=-1.0, medium=-0.5, low=-0.2, info=-0.1 off a base of 10.0.", + "", + ] + if instructions: + parts.append(instructions) + parts.append("") + for df in files: + parts.append(f"### File: {df.filename}") + parts.append("```diff") + parts.append(df.patch[:4000]) + parts.append("```") + parts.append("") + return "\n".join(parts) + + @staticmethod + def _parse_findings(raw: str) -> list[Finding]: + """Best-effort parse of an LLM JSON response into Finding objects.""" + import json + import re + + try: + data = json.loads(raw) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", raw, re.DOTALL) + if not m: + return [] + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return [] + + findings = [] + for item in data.get("findings", []): + if not isinstance(item, dict): + continue + severity = str(item.get("severity", "info")).lower() + findings.append(Finding( + severity=severity, + category=str(item.get("category", "llm")), + message=str(item.get("message", "")), + file=item.get("file"), + line=item.get("line"), + model=self.name, + suggestion=item.get("suggestion"), + )) + return findings + + @staticmethod + def _score(raw: str) -> float: + import json + import re + try: + data = json.loads(raw) + return float(data.get("score", 7.0)) + except (json.JSONDecodeError, TypeError, ValueError): + m = re.search(r"""["']score["']\s*:\s*([0-9.]+)""", raw) + if m: + try: + return float(m.group(1)) + except ValueError: + return 7.0 + return 7.0 \ No newline at end of file From ac6234d66b14f13088a201304bf0a0f902c16ec1 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:41 +0800 Subject: [PATCH 13/21] feat: add integrations/github-app/reviewers/claude.py for AI code review GitHub App (#862) --- integrations/github-app/reviewers/claude.py | 68 +++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 integrations/github-app/reviewers/claude.py diff --git a/integrations/github-app/reviewers/claude.py b/integrations/github-app/reviewers/claude.py new file mode 100644 index 000000000..a7d4a384f --- /dev/null +++ b/integrations/github-app/reviewers/claude.py @@ -0,0 +1,68 @@ +"""Claude LLM reviewer using the Anthropic API.""" + +import os +from typing import Optional + +from ..models import DiffFile, Finding, ReviewConfig +from .base import LLMReviewer + + +class ClaudeReviewer(LLMReviewer): + """Reviews PR diffs using Anthropic Claude.""" + + name = "claude" + + def __init__(self, config: ReviewConfig, api_key: Optional[str] = None): + super().__init__(config) + self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY", "") + + async def review(self, files: list[DiffFile], instructions: Optional[str] = None) -> tuple[float, list[Finding]]: + if not self.api_key: + # No key configured — fall back to a deterministic heuristic review + return self._heuristic_review(files) + + try: + from anthropic import AsyncAnthropic + client = AsyncAnthropic(api_key=self.api_key) + prompt = self._build_prompt(files, instructions) + resp = await client.messages.create( + model="claude-sonnet-4-6", + max_tokens=4096, + system="You are a senior code reviewer. Return only JSON.", + messages=[{"role": "user", "content": prompt}], + ) + raw = resp.content[0].text + findings = self._parse_findings(raw) + score = self._score(raw) + if not findings: + score = self._heuristic_score(files) + return score, findings + except Exception: + # Fall back to heuristic on any API error + return self._heuristic_review(files) + + def _heuristic_review(self, files: list[DiffFile]) -> tuple[float, list[Finding]]: + """Deterministic review used when no API key is available (or on API failure).""" + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + findings = [] + findings += SecurityAnalyzer().analyze(files) + findings += PerformanceAnalyzer().analyze(files) + findings += BestPracticesAnalyzer().analyze(files) + score = self._score_from_findings(findings) + return score, findings + + def _heuristic_score(self, files: list[DiffFile]) -> float: + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + findings = [] + findings += SecurityAnalyzer().analyze(files) + findings += PerformanceAnalyzer().analyze(files) + findings += BestPracticesAnalyzer().analyze(files) + return self._score_from_findings(findings) + + @staticmethod + def _score_from_findings(findings: list[Finding]) -> float: + weights = {"critical": -2.0, "high": -1.0, "medium": -0.5, "low": -0.2, "info": -0.1} + score = 10.0 + for f in findings: + score += weights.get(str(f.severity), -0.1) + return max(0.0, min(10.0, score)) \ No newline at end of file From d779443cc35ca922e9a23e3eb183d7a2d1e4e667 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:43 +0800 Subject: [PATCH 14/21] feat: add integrations/github-app/reviewers/openai.py for AI code review GitHub App (#862) --- integrations/github-app/reviewers/openai.py | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 integrations/github-app/reviewers/openai.py diff --git a/integrations/github-app/reviewers/openai.py b/integrations/github-app/reviewers/openai.py new file mode 100644 index 000000000..34b036bed --- /dev/null +++ b/integrations/github-app/reviewers/openai.py @@ -0,0 +1,58 @@ +"""OpenAI / Codex LLM reviewer.""" + +import os +from typing import Optional + +from ..models import DiffFile, Finding, ReviewConfig +from .base import LLMReviewer + + +class OpenAIReviewer(LLMReviewer): + """Reviews PR diffs using OpenAI (Codex / GPT).""" + + name = "openai" + + def __init__(self, config: ReviewConfig, api_key: Optional[str] = None): + super().__init__(config) + self.api_key = api_key or os.getenv("OPENAI_API_KEY", "") + + async def review(self, files: list[DiffFile], instructions: Optional[str] = None) -> tuple[float, list[Finding]]: + if not self.api_key: + return self._fallback(files) + try: + from openai import AsyncOpenAI + client = AsyncOpenAI(api_key=self.api_key) + prompt = self._build_prompt(files, instructions) + resp = await client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "You are a senior code reviewer. Return only JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=0.2, + ) + raw = resp.choices[0].message.content or "" + findings = self._parse_findings(raw) + score = self._score(raw) + if not findings: + score = self._fallback_score(files) + return score, findings + except Exception: + return self._fallback(files) + + def _fallback(self, files: list[DiffFile]) -> tuple[float, list[Finding]]: + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + findings = [] + findings += SecurityAnalyzer().analyze(files) + findings += PerformanceAnalyzer().analyze(files) + findings += BestPracticesAnalyzer().analyze(files) + return self._fallback_score(files), findings + + @staticmethod + def _fallback_score(files: list[DiffFile]) -> float: + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + weights = {"critical": -2.0, "high": -1.0, "medium": -0.5, "low": -0.2, "info": -0.1} + score = 10.0 + for f in (SecurityAnalyzer().analyze(files) + PerformanceAnalyzer().analyze(files) + BestPracticesAnalyzer().analyze(files)): + score += weights.get(str(f.severity), -0.1) + return max(0.0, min(10.0, score)) \ No newline at end of file From a0eeb17f792622563256b8a3c639e6823ad4f2d4 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:44 +0800 Subject: [PATCH 15/21] feat: add integrations/github-app/reviewers/gemini.py for AI code review GitHub App (#862) --- integrations/github-app/reviewers/gemini.py | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 integrations/github-app/reviewers/gemini.py diff --git a/integrations/github-app/reviewers/gemini.py b/integrations/github-app/reviewers/gemini.py new file mode 100644 index 000000000..ef04e711d --- /dev/null +++ b/integrations/github-app/reviewers/gemini.py @@ -0,0 +1,54 @@ +"""Gemini LLM reviewer using the Google GenAI API.""" + +import os +from typing import Optional + +from ..models import DiffFile, Finding, ReviewConfig +from .base import LLMReviewer + + +class GeminiReviewer(LLMReviewer): + """Reviews PR diffs using Google Gemini.""" + + name = "gemini" + + def __init__(self, config: ReviewConfig, api_key: Optional[str] = None): + super().__init__(config) + self.api_key = api_key or os.getenv("GOOGLE_API_KEY", "") or os.getenv("GEMINI_API_KEY", "") + + async def review(self, files: list[DiffFile], instructions: Optional[str] = None) -> tuple[float, list[Finding]]: + if not self.api_key: + return self._fallback(files) + try: + from google import genai + client = genai.Client(api_key=self.api_key) + prompt = self._build_prompt(files, instructions) + resp = client.models.generate_content( + model="gemini-2.0-flash", + contents=prompt, + ) + raw = resp.text or "" + findings = self._parse_findings(raw) + score = self._score(raw) + if not findings: + score = self._fallback_score(files) + return score, findings + except Exception: + return self._fallback(files) + + def _fallback(self, files: list[DiffFile]) -> tuple[float, list[Finding]]: + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + findings = [] + findings += SecurityAnalyzer().analyze(files) + findings += PerformanceAnalyzer().analyze(files) + findings += BestPracticesAnalyzer().analyze(files) + return self._fallback_score(files), findings + + @staticmethod + def _fallback_score(files: list[DiffFile]) -> float: + from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + weights = {"critical": -2.0, "high": -1.0, "medium": -0.5, "low": -0.2, "info": -0.1} + score = 10.0 + for f in (SecurityAnalyzer().analyze(files) + PerformanceAnalyzer().analyze(files) + BestPracticesAnalyzer().analyze(files)): + score += weights.get(str(f.severity), -0.1) + return max(0.0, min(10.0, score)) \ No newline at end of file From fdcff32136505357795ba6b7d020ed3685a9db87 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:38:45 +0800 Subject: [PATCH 16/21] feat: add integrations/github-app/reviewers/orchestrator.py for AI code review GitHub App (#862) --- .../github-app/reviewers/orchestrator.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 integrations/github-app/reviewers/orchestrator.py diff --git a/integrations/github-app/reviewers/orchestrator.py b/integrations/github-app/reviewers/orchestrator.py new file mode 100644 index 000000000..6eecb0a26 --- /dev/null +++ b/integrations/github-app/reviewers/orchestrator.py @@ -0,0 +1,81 @@ +"""Multi-LLM reviewer orchestration.""" + +from ..models import DiffFile, ReviewConfig +from .base import LLMReviewer +from .claude import ClaudeReviewer +from .openai import OpenAIReviewer +from .gemini import GeminiReviewer + + +class ReviewOrchestrator: + """Aggregates reviews from multiple LLM models into a single result.""" + + def __init__(self, config: ReviewConfig): + self.config = config + self.reviewers: list[LLMReviewer] = self._build_reviewers() + + def _build_reviewers(self) -> list[LLMReviewer]: + mode = self.config.mode + if mode.value == "quick": + return [ClaudeReviewer(self.config)] + if mode.value == "thorough": + return [ + ClaudeReviewer(self.config), + OpenAIReviewer(self.config), + GeminiReviewer(self.config), + ] + # standard + return [ClaudeReviewer(self.config), OpenAIReviewer(self.config)] + + async def run(self, files: list[DiffFile], instructions: str | None = None): + """Run all reviewers and aggregate using trimmed-mean scoring.""" + from ..models import ReviewResult, Severity + + results = [] + for reviewer in self.reviewers: + score, findings = await reviewer.review(files, instructions) + results.append((reviewer.name, score, findings)) + + # Trimmed mean: drop highest and lowest score, average the middle + if len(results) >= 3: + sorted_results = sorted(results, key=lambda r: r[1]) + middle = sorted_results[1:-1] + aggregate_score = sum(r[1] for r in middle) / len(middle) + else: + aggregate_score = sum(r[1] for r in results) / len(results) + + # Merge findings (dedupe by file+line+message) + all_findings = [] + seen = set() + for _, _, findings in results: + for f in findings: + key = (f.file, f.line, f.message[:80]) + if key not in seen: + seen.add(key) + all_findings.append(f) + + model_scores = {name: score for name, score, _ in results} + summary = self._build_summary(aggregate_score, all_findings) + return ReviewResult( + score=round(aggregate_score, 2), + findings=all_findings, + summary=summary, + model_scores=model_scores, + ) + + @staticmethod + def _build_summary(score: float, findings) -> str: + critical = sum(1 for f in findings if str(f.severity) == "critical") + high = sum(1 for f in findings if str(f.severity) == "high") + medium = sum(1 for f in findings if str(f.severity) == "medium") + lines = [ + f"## AI Code Review — Score: **{score:.1f}/10**", + "", + ] + if critical or high: + lines.append(f"### Issues found: {critical} critical, {high} high, {medium} medium") + else: + lines.append("No critical or high-severity issues found.") + lines.append("") + lines.append("_Review generated by the SolFoundry AI Code Review GitHub App._") + return "\n".join(lines) \ No newline at end of file From ebab6f8fe0378940209f26acd88d68c010f88d51 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:39:27 +0800 Subject: [PATCH 17/21] feat: add integrations/github-app/app.py for AI code review GitHub App (#862) --- integrations/github-app/app.py | 189 +++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 integrations/github-app/app.py diff --git a/integrations/github-app/app.py b/integrations/github-app/app.py new file mode 100644 index 000000000..d52417c14 --- /dev/null +++ b/integrations/github-app/app.py @@ -0,0 +1,189 @@ +"""FastAPI application for the AI Code Review GitHub App webhook handler.""" + +import hashlib +import hmac +import json +import logging +import os +from typing import Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from .config import AppConfig, ReviewConfig +from .github_client import GitHubAppClient +from .models import DiffFile +from .reviewers import ReviewOrchestrator + +logger = logging.getLogger(__name__) + +app = FastAPI(title="SolFoundry AI Code Review", version="0.1.0") + +# Module-level config (populated via env vars or startup) +_config: Optional[AppConfig] = None +_client: Optional[GitHubAppClient] = None + + +def get_config() -> AppConfig: + global _config + if _config is None: + _config = AppConfig( + github_app_id=os.getenv("GITHUB_APP_ID", ""), + github_private_key=os.getenv("GITHUB_PRIVATE_KEY", ""), + github_webhook_secret=os.getenv("GITHUB_WEBHOOK_SECRET", ""), + anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""), + openai_api_key=os.getenv("OPENAI_API_KEY", ""), + gemini_api_key=os.getenv("GEMINI_API_KEY", ""), + ) + return _config + + +def get_client() -> GitHubAppClient: + global _client + cfg = get_config() + if _client is None: + _client = GitHubAppClient(cfg.github_app_id, cfg.github_private_key) + return _client + + +def verify_webhook_signature(payload: bytes, signature: str) -> bool: + """Verify the X-Hub-Signature-256 header.""" + cfg = get_config() + if not cfg.github_webhook_secret: + return True # Skip verification if no secret configured + expected = "sha256=" + hmac.new( + cfg.github_webhook_secret.encode(), + payload, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) + + +async def parse_diff(diff_text: str) -> list[DiffFile]: + """Parse a unified diff response into DiffFile objects.""" + files = [] + current_file = None + current_patch = [] + adds = 0 + dels = 0 + + for line in diff_text.splitlines(): + if line.startswith("diff --git a/"): + if current_file: + files.append(DiffFile( + filename=current_file, + patch="\n".join(current_patch), + additions=adds, + deletions=dels, + )) + current_file = line.split(" b/", 1)[-1] if " b/" in line else line.split()[-1] + current_patch = [line] + adds = 0 + dels = 0 + elif line.startswith("@@") and current_file is not None: + current_patch.append(line) + elif current_file is not None: + current_patch.append(line) + if line.startswith("+"): + adds += 1 + elif line.startswith("-"): + dels += 1 + + if current_file: + files.append(DiffFile( + filename=current_file, + patch="\n".join(current_patch), + additions=adds, + deletions=dels, + )) + return files + + +@app.on_event("startup") +async def startup(): + get_config() + get_client() + logger.info("AI Code Review GitHub App started") + + +@app.get("/health") +async def health(): + return {"status": "healthy", "service": "ai-code-review"} + + +@app.post("/webhook") +async def webhook(request: Request): + """Handle GitHub App webhook events.""" + body = await request.body() + sig = request.headers.get("X-Hub-Signature-256", "") + if not verify_webhook_signature(body, sig): + raise HTTPException(status_code=401, detail="Invalid signature") + + event = request.headers.get("X-GitHub-Event", "") + if event != "pull_request": + return JSONResponse({"status": "skipped", "reason": f"unhandled event: {event}"}) + + payload = json.loads(body) + action = payload.get("action", "") + if action not in ("opened", "synchronize", "reopened"): + return JSONResponse({"status": "skipped", "reason": f"unhandled action: {action}"}) + + pr = payload.get("pull_request", {}) + repo = payload.get("repository", {}) + installation = payload.get("installation", {}) + + repo_full_name = repo.get("full_name", "") + pr_number = pr.get("number", 0) + head_sha = pr.get("head", {}).get("sha", "") + installation_id = installation.get("id", 0) + + if not all([repo_full_name, pr_number, head_sha, installation_id]): + raise HTTPException(status_code=400, detail="Missing required fields") + + try: + client = get_client() + diff_text = await client.get_pull_request_diff(installation_id, repo_full_name, pr_number) + files = await parse_diff(diff_text) + + # Check for repo-specific config + repo_config = await client.get_repo_config(installation_id, repo_full_name) + config = ReviewConfig() + if repo_config: + config = ReviewConfig(**{k: v for k, v in repo_config.items() if k in ReviewConfig.model_fields}) + + # Run the review + orchestrator = ReviewOrchestrator(config) + result = await orchestrator.run(files) + + # Submit review comments + comments = [f.to_line_comment() for f in result.findings if f.file and f.line] + await client.submit_review( + installation_id, repo_full_name, pr_number, + body=result.summary, + comments=comments[:20], # Max 20 inline comments + event="COMMENT", + ) + + # Create a check run + conclusion = "success" if result.score >= config.get_score_threshold() else "neutral" + await client.create_check_run( + installation_id, repo_full_name, head_sha, + name="AI Code Review (SolFoundry)", + status="completed", + conclusion=conclusion, + output={ + "title": f"Score: {result.score}/10", + "summary": result.summary, + "text": f"Models: {json.dumps(result.model_scores)}\nFindings: {len(result.findings)} total", + }, + ) + + return JSONResponse({ + "status": "completed", + "score": result.score, + "findings": len(result.findings), + "conclusion": conclusion, + }) + except Exception as e: + logger.exception("Review failed") + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) \ No newline at end of file From 1999a7547f41c47a04f9849edff2d41f147fecf6 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:39:28 +0800 Subject: [PATCH 18/21] feat: add integrations/github-app/README.md for AI code review GitHub App (#862) --- integrations/github-app/README.md | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 integrations/github-app/README.md diff --git a/integrations/github-app/README.md b/integrations/github-app/README.md new file mode 100644 index 000000000..fc6197a09 --- /dev/null +++ b/integrations/github-app/README.md @@ -0,0 +1,101 @@ +""" +SolFoundry AI Code Review GitHub App +===================================== + +An installable GitHub App that provides automated multi-LLM code reviews +on every pull request, with security checks, performance analysis, and +best practices verification. + +## Features + +- **Multi-LLM Reviews**: Claude, OpenAI/Codex, and Gemini review every PR +- **3 Review Modes**: Quick (1 model), Standard (3 models), Thorough (5 models) +- **Security Checks**: 7 patterns (hardcoded secrets, SQL injection, eval, etc.) +- **Performance Analysis**: N+1 queries, sync-in-async, large lists, string concat +- **Best Practices**: Type hints, console.log, TODO/FIXME, magic numbers, bare except +- **Configurable**: Per-repository `.github/ai-code-review.yml` configuration +- **Check Runs**: Results appear as check runs on every PR +- **Inline Comments**: Findings are posted as inline review comments + +## Quick Start + +### 1. Install the App + +Click the "Install" button on the GitHub App page and select which repositories +to grant access to. + +### 2. (Optional) Configuration + +Create `.github/ai-code-review.yml` in your repository: + +```yaml +mode: standard # quick | standard | thorough +strictness: balanced # lenient | balanced | strict +comment_style: inline # inline | summary | both +auto_approve_threshold: 8.0 +block_threshold: 4.0 +languages: + - python + - javascript + - typescript + - go + - rust +``` + +### 3. Open a PR + +The app automatically reviews every pull request and posts: +- **Check Run**: Score out of 10 with model-specific scores +- **Review Comments**: Inline comments on issues found +- **Summary Comment**: Overview of all findings + +## Scoring + +| Score | Meaning | +|-------|---------| +| 8.0-10 | Good — no critical/high issues | +| 6.0-7.9 | Fair — minor issues to address | +| 4.0-5.9 | Needs work — significant issues | +| 0.0-3.9 | Poor — major problems | + +## Architecture + +``` +integrations/github-app/ +├── manifest.json # GitHub App manifest +├── app.py # FastAPI webhook handler +├── config.py # Review configuration models +├── github_client.py # GitHub API client +├── models.py # Shared data models +├── requirements.txt # Python dependencies +├── README.md # This file +├── analyzers/ +│ ├── security.py # Security pattern detection +│ ├── performance.py # Performance analysis +│ └── best_practices.py # Best practices checker +├── reviewers/ +│ ├── base.py # Abstract reviewer interface +│ ├── claude.py # Claude reviewer +│ ├── openai.py # OpenAI/Codex reviewer +│ ├── gemini.py # Gemini reviewer +│ └── orchestrator.py # Multi-model orchestration +└── tests/ + ├── test_app.py # Unit tests + └── ... +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `GITHUB_APP_ID` | GitHub App ID | +| `GITHUB_PRIVATE_KEY` | App private key (PEM) | +| `GITHUB_WEBHOOK_SECRET` | Webhook secret | +| `ANTHROPIC_API_KEY` | Claude API key | +| `OPENAI_API_KEY` | OpenAI/Codex API key | +| `GEMINI_API_KEY` | Google Gemini API key | + +## License + +SolFoundry +""" \ No newline at end of file From c6e64fa7a548f0d78771ab636fdc5eaf8ca3938d Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:39:34 +0800 Subject: [PATCH 19/21] feat: add integrations/github-app/tests/__init__.py for AI code review GitHub App (#862) --- integrations/github-app/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 integrations/github-app/tests/__init__.py diff --git a/integrations/github-app/tests/__init__.py b/integrations/github-app/tests/__init__.py new file mode 100644 index 000000000..e69de29bb From 5e5c384553802ffa0c445b4cb8042a115e88704f Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:39:35 +0800 Subject: [PATCH 20/21] feat: add integrations/github-app/tests/test_app.py for AI code review GitHub App (#862) --- integrations/github-app/tests/test_app.py | 119 ++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 integrations/github-app/tests/test_app.py diff --git a/integrations/github-app/tests/test_app.py b/integrations/github-app/tests/test_app.py new file mode 100644 index 000000000..66c905098 --- /dev/null +++ b/integrations/github-app/tests/test_app.py @@ -0,0 +1,119 @@ +"""Tests for the AI Code Review GitHub App.""" + +import json +import pytest +from datetime import datetime, timezone + +from ..models import DiffFile, Finding, Severity, ReviewResult +from ..config import ReviewConfig, ReviewMode, StrictnessLevel, CommentStyle +from ..analyzers import SecurityAnalyzer, PerformanceAnalyzer, BestPracticesAnalyzer + + +class TestModels: + def test_finding_to_line_comment(self): + f = Finding(severity=Severity.CRITICAL, category="test", message="Bad thing", + file="test.py", line=10, suggestion="Fix it") + comment = f.to_line_comment() + assert comment["path"] == "test.py" + assert comment["line"] == 10 + assert "CRITICAL" in comment["body"] + + def test_review_result_counts(self): + r = ReviewResult(score=7.0, findings=[ + Finding(severity=Severity.CRITICAL, category="", message="a"), + Finding(severity=Severity.HIGH, category="", message="b"), + Finding(severity=Severity.MEDIUM, category="", message="c"), + Finding(severity=Severity.LOW, category="", message="d"), + ]) + assert r.critical_count == 1 + assert r.high_count == 1 + assert r.medium_count == 1 + assert r.low_count == 1 + + +class TestConfig: + def test_default_config(self): + c = ReviewConfig() + assert c.mode == ReviewMode.STANDARD + assert c.strictness == StrictnessLevel.BALANCED + assert c.comment_style == CommentStyle.INLINE + + def test_score_threshold(self): + lenient = ReviewConfig(strictness=StrictnessLevel.LENIENT) + balanced = ReviewConfig(strictness=StrictnessLevel.BALANCED) + strict = ReviewConfig(strictness=StrictnessLevel.STRICT) + assert lenient.get_score_threshold() == 6.0 + assert balanced.get_score_threshold() == 7.0 + assert strict.get_score_threshold() == 8.0 + + +class TestSecurityAnalyzer: + def test_hardcoded_secret(self): + analyzer = SecurityAnalyzer() + files = [DiffFile(filename="config.py", patch='+API_KEY = "sk-abc123def456ghi789"\n-no issue')] + findings = analyzer.analyze(files) + assert len(findings) >= 1 + assert findings[0].severity == Severity.CRITICAL + + def test_no_false_positive(self): + analyzer = SecurityAnalyzer() + files = [DiffFile(filename="config.py", patch='+API_KEY = os.getenv("API_KEY")')] + findings = analyzer.analyze(files) + assert len(findings) == 0 + + +class TestPerformanceAnalyzer: + def test_n_plus_one(self): + analyzer = PerformanceAnalyzer() + files = [DiffFile(filename="repo.py", patch='''async def get_users(): + users = await db.query(User).all() + for user in users: + profile = await db.query(Profile).filter(Profile.user_id == user.id).one() +''')] + findings = analyzer.analyze(files) + assert len(findings) >= 1 + assert "N+1" in findings[0].category + + def test_sync_in_async(self): + analyzer = PerformanceAnalyzer() + files = [DiffFile(filename="handler.py", patch='''async def handle_request(): + resp = requests.get("https://api.example.com") + return resp.json() +''')] + findings = analyzer.analyze(files) + assert len(findings) >= 1 + + +class TestBestPracticesAnalyzer: + def test_console_log(self): + analyzer = BestPracticesAnalyzer() + files = [DiffFile(filename="app.ts", patch='+console.log("debug info")')] + findings = analyzer.analyze(files) + assert len(findings) >= 1 + + def test_bare_except(self): + analyzer = BestPracticesAnalyzer() + files = [DiffFile(filename="main.py", patch='+except:\n+ pass')] + findings = analyzer.analyze(files) + assert len(findings) >= 1 + + +class TestDiffFile: + def test_extension(self): + assert DiffFile(filename="main.py", patch="").extension == "py" + assert DiffFile(filename="component.tsx", patch="").extension == "tsx" + assert DiffFile(filename="NoExt", patch="").extension == "" + + +class TestReviewResult: + def test_aggregation(self): + findings = [ + Finding(severity=Severity.CRITICAL, category="sec", message="secret"), + Finding(severity=Severity.HIGH, category="sec", message="eval"), + Finding(severity=Severity.LOW, category="perf", message="loop"), + ] + r = ReviewResult(score=7.5, findings=findings) + assert r.score == 7.5 + assert r.critical_count == 1 + assert r.high_count == 1 + assert len(r.findings) == 3 \ No newline at end of file From 96d7b3250aaa99005e4affeef529601385131966 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Sun, 9 Aug 2026 01:39:36 +0800 Subject: [PATCH 21/21] feat: add integrations/github-app/ai-code-review.example.yml for AI code review GitHub App (#862) --- .../github-app/ai-code-review.example.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 integrations/github-app/ai-code-review.example.yml diff --git a/integrations/github-app/ai-code-review.example.yml b/integrations/github-app/ai-code-review.example.yml new file mode 100644 index 000000000..451df01a8 --- /dev/null +++ b/integrations/github-app/ai-code-review.example.yml @@ -0,0 +1,20 @@ +# Example configuration for the SolFoundry AI Code Review GitHub App +# Copy to .github/ai-code-review.yml in your repository to customize. + +mode: standard # quick | standard | thorough +strictness: balanced # lenient | balanced | strict +comment_style: inline # inline | summary | both + +# Scoring thresholds (0-10) +auto_approve_threshold: 8.0 +block_threshold: 4.0 + +# Languages to review +languages: + - python + - javascript + - typescript + - go + - rust + - java + - solidity