init
This commit is contained in:
6
backend/app/agents/__init__.py
Normal file
6
backend/app/agents/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""LangGraph agents for code review"""
|
||||
|
||||
from app.agents.reviewer import ReviewerAgent
|
||||
|
||||
__all__ = ["ReviewerAgent"]
|
||||
|
||||
139
backend/app/agents/prompts.py
Normal file
139
backend/app/agents/prompts.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Prompts for AI code reviewer"""
|
||||
|
||||
SYSTEM_PROMPT = """Ты строгий и внимательный code reviewer с многолетним опытом. Твоя задача - тщательно анализировать код и находить ВСЕ проблемы.
|
||||
|
||||
ОБЯЗАТЕЛЬНО проверяй:
|
||||
1. **Синтаксические ошибки** - опечатки, незакрытые скобки, некорректный синтаксис языка
|
||||
2. **Потенциальные баги** - логические ошибки, неправильная обработка исключений, проблемы с null/undefined
|
||||
3. **Проблемы безопасности** - SQL injection, XSS, небезопасное использование eval, утечки данных
|
||||
4. **Нарушения best practices** - неправильное использование React (key prop, hooks), плохие названия переменных
|
||||
5. **Проблемы производительности** - неэффективные алгоритмы, лишние ререндеры, утечки памяти
|
||||
6. **Читаемость кода** - сложная логика, отсутствие обработки ошибок
|
||||
|
||||
Особое внимание:
|
||||
- В React: правильность использования key, hooks rules, JSX syntax
|
||||
- Опечатки в строковых константах (API paths, Content-Type headers)
|
||||
- Незакрытые/лишние скобки в JSX и JavaScript
|
||||
- Несоответствие кода описанию в PR
|
||||
|
||||
Для каждой проблемы укажи:
|
||||
- Номер строки
|
||||
- Уровень серьезности: ERROR (критично), WARNING (важно), INFO (рекомендация)
|
||||
- Что не так
|
||||
- Как исправить
|
||||
|
||||
Будь требовательным! Даже мелкие опечатки могут сломать продакшн."""
|
||||
|
||||
|
||||
CODE_REVIEW_PROMPT = """Проанализируй следующий код из файла `{file_path}`:
|
||||
|
||||
```{language}
|
||||
{code}
|
||||
```
|
||||
|
||||
Контекст: это изменения в Pull Request.
|
||||
{patch_info}
|
||||
|
||||
Найди проблемы и предложи улучшения. Для каждой проблемы укажи:
|
||||
1. Номер строки
|
||||
2. Уровень: INFO, WARNING или ERROR
|
||||
3. Описание проблемы
|
||||
4. Рекомендация
|
||||
|
||||
Ответ дай в формате JSON:
|
||||
{{
|
||||
"comments": [
|
||||
{{
|
||||
"line": <номер_строки>,
|
||||
"severity": "INFO|WARNING|ERROR",
|
||||
"message": "описание проблемы и рекомендация"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Если проблем нет, верни пустой массив comments."""
|
||||
|
||||
|
||||
DIFF_REVIEW_PROMPT = """Ты СТРОГИЙ code reviewer. Твоя задача - найти ВСЕ ошибки в коде.
|
||||
{pr_context}
|
||||
Анализируй изменения в файле `{file_path}`:
|
||||
|
||||
```diff
|
||||
{diff}
|
||||
```
|
||||
|
||||
ПОШАГОВЫЙ АНАЛИЗ каждой строки с +:
|
||||
|
||||
Шаг 1: ЧИТАЙ КАЖДУЮ СТРОКУ с + внимательно
|
||||
Шаг 2: ПРОВЕРЬ каждую строку на:
|
||||
a) ОПЕЧАТКИ - неправильные слова, typos
|
||||
b) СИНТАКСИС - скобки, кавычки, запятые
|
||||
c) ЛОГИКА - правильность кода
|
||||
d) REACT ПРАВИЛА - key, hooks, JSX
|
||||
|
||||
Шаг 3: НАЙДИ ошибки (даже мелкие!)
|
||||
|
||||
КОНКРЕТНЫЕ ПРИМЕРЫ ОШИБОК (ОБЯЗАТЕЛЬНО ИЩИ ТАКИЕ):
|
||||
|
||||
❌ ОПЕЧАТКИ В СТРОКАХ:
|
||||
'Content-Type': 'shmapplication/json' // ОШИБКА! должно быть 'application/json'
|
||||
const url = 'htps://example.com' // ОШИБКА! должно быть 'https'
|
||||
|
||||
❌ НЕЗАКРЫТЫЕ СКОБКИ:
|
||||
{{condition && (<div>text</div>}} // ОШИБКА! пропущена )
|
||||
<span>{{text</span> // ОШИБКА! пропущена }}
|
||||
|
||||
❌ НЕПРАВИЛЬНЫЙ KEY В REACT:
|
||||
<div>
|
||||
<Item> // ОШИБКА! key должен быть ЗДЕСЬ
|
||||
<img key={{id}} /> // а не здесь
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
❌ УДАЛЕНИЕ KEY:
|
||||
-<Item key={{id}}> // ОШИБКА! удалили key
|
||||
+<Item>
|
||||
|
||||
❌ НЕСООТВЕТСТВИЕ ОПИСАНИЮ PR:
|
||||
Описание PR: "Добавление функционала редактирования аватара"
|
||||
Код: меняет Content-Type на 'shmapplication/json' // ОШИБКА! не связано с аватарами
|
||||
|
||||
ОБЯЗАТЕЛЬНО ПРОВЕРЬ:
|
||||
1. СООТВЕТСТВИЕ ОПИСАНИЮ PR - делает ли код то что написано в описании?
|
||||
2. Все строки в кавычках - нет ли опечаток?
|
||||
3. Все скобки - все ли закрыты?
|
||||
4. Все JSX элементы - правильно ли?
|
||||
5. React key - на правильном элементе?
|
||||
|
||||
{format_instructions}
|
||||
|
||||
ВАЖНО:
|
||||
1. ТОЛЬКО JSON в ответе!
|
||||
2. НЕ ПИШИ "Thank you" или другой текст
|
||||
3. Даже мелкая опечатка - это ERROR!
|
||||
4. Если проблем НЕТ: {{"comments": []}}
|
||||
|
||||
Структура ответа:
|
||||
{{
|
||||
"comments": [
|
||||
{{
|
||||
"line": 58,
|
||||
"severity": "ERROR",
|
||||
"message": "Опечатка в строке: 'shmapplication/json' должно быть 'application/json'"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
SUMMARY_PROMPT = """На основе всех найденных проблем в PR создай краткое резюме ревью.
|
||||
|
||||
Найденные проблемы:
|
||||
{issues_summary}
|
||||
|
||||
Создай краткое резюме (2-3 предложения), которое:
|
||||
- Указывает общее количество найденных проблем по уровням серьезности
|
||||
- Выделяет наиболее критичные моменты
|
||||
- Дает общую оценку качества кода
|
||||
|
||||
Ответ верни в виде текста без форматирования."""
|
||||
|
||||
488
backend/app/agents/reviewer.py
Normal file
488
backend/app/agents/reviewer.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Main reviewer agent using LangGraph"""
|
||||
|
||||
from typing import TypedDict, List, Dict, Any, Optional
|
||||
from langgraph.graph import StateGraph, END
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.agents.tools import CodeAnalyzer, detect_language, should_review_file
|
||||
from app.agents.prompts import SYSTEM_PROMPT, SUMMARY_PROMPT
|
||||
from app.models import Review, Comment, PullRequest, Repository
|
||||
from app.models.review import ReviewStatusEnum
|
||||
from app.models.comment import SeverityEnum
|
||||
from app.services import GiteaService, GitHubService, BitbucketService
|
||||
from app.services.base import BaseGitService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ReviewState(TypedDict):
|
||||
"""State for the review workflow"""
|
||||
review_id: int
|
||||
pr_number: int
|
||||
repository_id: int
|
||||
status: str
|
||||
files: List[Dict[str, Any]]
|
||||
analyzed_files: List[str]
|
||||
comments: List[Dict[str, Any]]
|
||||
error: Optional[str]
|
||||
git_service: Optional[BaseGitService]
|
||||
|
||||
|
||||
class ReviewerAgent:
|
||||
"""Agent for reviewing code using LangGraph"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.analyzer = CodeAnalyzer(
|
||||
ollama_base_url=settings.ollama_base_url,
|
||||
model=settings.ollama_model
|
||||
)
|
||||
self.graph = self._build_graph()
|
||||
|
||||
def _build_graph(self) -> StateGraph:
|
||||
"""Build the LangGraph workflow"""
|
||||
workflow = StateGraph(ReviewState)
|
||||
|
||||
# Add nodes
|
||||
workflow.add_node("fetch_pr_info", self.fetch_pr_info)
|
||||
workflow.add_node("fetch_files", self.fetch_files)
|
||||
workflow.add_node("analyze_files", self.analyze_files)
|
||||
workflow.add_node("post_comments", self.post_comments)
|
||||
workflow.add_node("complete_review", self.complete_review)
|
||||
|
||||
# Set entry point
|
||||
workflow.set_entry_point("fetch_pr_info")
|
||||
|
||||
# Add edges
|
||||
workflow.add_edge("fetch_pr_info", "fetch_files")
|
||||
workflow.add_edge("fetch_files", "analyze_files")
|
||||
workflow.add_edge("analyze_files", "post_comments")
|
||||
workflow.add_edge("post_comments", "complete_review")
|
||||
workflow.add_edge("complete_review", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
def _remove_think_blocks(self, text: str) -> str:
|
||||
"""Remove <think>...</think> blocks from text"""
|
||||
import re
|
||||
# Remove <think> blocks
|
||||
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Remove extra whitespace
|
||||
text = re.sub(r'\n\n+', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
def _escape_html_in_text(self, text: str) -> str:
|
||||
"""Escape HTML tags in text to prevent Markdown from hiding them
|
||||
|
||||
Wraps code-like content (anything with < >) in backticks.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Pattern to find HTML-like tags (e.g., <CharacterItem>, <img>)
|
||||
# We want to wrap them in backticks so they display correctly
|
||||
def replace_tag(match):
|
||||
tag = match.group(0)
|
||||
# If it's already in backticks or code block, skip
|
||||
return f"`{tag}`"
|
||||
|
||||
# Find all <...> patterns and wrap them
|
||||
text = re.sub(r'<[^>]+>', replace_tag, text)
|
||||
|
||||
return text
|
||||
|
||||
def _get_git_service(self, repository: Repository) -> BaseGitService:
|
||||
"""Get appropriate Git service for repository"""
|
||||
from app.utils import decrypt_token
|
||||
from app.config import settings
|
||||
|
||||
# Parse repository URL to get owner and name
|
||||
# Assuming URL format: https://git.example.com/owner/repo
|
||||
parts = repository.url.rstrip('/').split('/')
|
||||
repo_name = parts[-1].replace('.git', '')
|
||||
repo_owner = parts[-2]
|
||||
|
||||
base_url = '/'.join(parts[:-2])
|
||||
|
||||
# Определяем токен: проектный или мастер
|
||||
if repository.api_token:
|
||||
# Используем проектный токен
|
||||
try:
|
||||
decrypted_token = decrypt_token(repository.api_token)
|
||||
print(f" 🔑 Используется проектный токен")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Не удалось расшифровать API токен для репозитория {repository.name}: {str(e)}")
|
||||
else:
|
||||
# Используем мастер токен
|
||||
platform = repository.platform.value.lower()
|
||||
if platform == "gitea":
|
||||
decrypted_token = settings.master_gitea_token
|
||||
elif platform == "github":
|
||||
decrypted_token = settings.master_github_token
|
||||
elif platform == "bitbucket":
|
||||
decrypted_token = settings.master_bitbucket_token
|
||||
else:
|
||||
raise ValueError(f"Unsupported platform: {repository.platform}")
|
||||
|
||||
if not decrypted_token:
|
||||
raise ValueError(
|
||||
f"API токен не указан для репозитория {repository.name} "
|
||||
f"и мастер токен для {platform} не настроен в .env (MASTER_{platform.upper()}_TOKEN)"
|
||||
)
|
||||
|
||||
print(f" 🔑 Используется мастер {platform} токен")
|
||||
|
||||
if repository.platform.value == "gitea":
|
||||
return GiteaService(base_url, decrypted_token, repo_owner, repo_name)
|
||||
elif repository.platform.value == "github":
|
||||
return GitHubService(base_url, decrypted_token, repo_owner, repo_name)
|
||||
elif repository.platform.value == "bitbucket":
|
||||
return BitbucketService(base_url, decrypted_token, repo_owner, repo_name)
|
||||
else:
|
||||
raise ValueError(f"Unsupported platform: {repository.platform}")
|
||||
|
||||
async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
|
||||
"""Fetch PR information"""
|
||||
try:
|
||||
# Update review status
|
||||
result = await self.db.execute(
|
||||
select(Review).where(Review.id == state["review_id"])
|
||||
)
|
||||
review = result.scalar_one()
|
||||
review.status = ReviewStatusEnum.FETCHING
|
||||
await self.db.commit()
|
||||
|
||||
# Get repository
|
||||
result = await self.db.execute(
|
||||
select(Repository).where(Repository.id == state["repository_id"])
|
||||
)
|
||||
repository = result.scalar_one()
|
||||
|
||||
# Initialize Git service
|
||||
git_service = self._get_git_service(repository)
|
||||
state["git_service"] = git_service
|
||||
|
||||
# Fetch PR info
|
||||
pr_info = await git_service.get_pull_request(state["pr_number"])
|
||||
|
||||
print("\n" + "📋"*40)
|
||||
print("ИНФОРМАЦИЯ О PR")
|
||||
print("📋"*40)
|
||||
print(f"\n📝 Название: {pr_info.title}")
|
||||
print(f"👤 Автор: {pr_info.author}")
|
||||
print(f"🔀 Ветки: {pr_info.source_branch} → {pr_info.target_branch}")
|
||||
print(f"📄 Описание:")
|
||||
print("-" * 80)
|
||||
print(pr_info.description if pr_info.description else "(без описания)")
|
||||
print("-" * 80)
|
||||
print("📋"*40 + "\n")
|
||||
|
||||
# Store PR info in state
|
||||
state["pr_info"] = {
|
||||
"title": pr_info.title,
|
||||
"description": pr_info.description,
|
||||
"author": pr_info.author,
|
||||
"source_branch": pr_info.source_branch,
|
||||
"target_branch": pr_info.target_branch
|
||||
}
|
||||
|
||||
state["status"] = "pr_info_fetched"
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ОШИБКА в fetch_pr_info: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
state["error"] = str(e)
|
||||
state["status"] = "failed"
|
||||
return state
|
||||
|
||||
async def fetch_files(self, state: ReviewState) -> ReviewState:
|
||||
"""Fetch changed files in PR"""
|
||||
try:
|
||||
git_service = state["git_service"]
|
||||
|
||||
print("\n" + "📥"*40)
|
||||
print("ПОЛУЧЕНИЕ ФАЙЛОВ ИЗ PR")
|
||||
print("📥"*40)
|
||||
|
||||
# Get changed files
|
||||
files = await git_service.get_pr_files(state["pr_number"])
|
||||
|
||||
print(f"\n📊 Получено файлов из API: {len(files)}")
|
||||
for i, f in enumerate(files, 1):
|
||||
print(f"\n {i}. {f.filename}")
|
||||
print(f" Status: {f.status}")
|
||||
print(f" +{f.additions} -{f.deletions}")
|
||||
print(f" Patch: {'ДА' if f.patch else 'НЕТ'} ({len(f.patch) if f.patch else 0} символов)")
|
||||
if f.patch:
|
||||
print(f" Первые 200 символов patch:")
|
||||
print(f" {f.patch[:200]}...")
|
||||
|
||||
# Filter files that should be reviewed
|
||||
reviewable_files = []
|
||||
skipped_files = []
|
||||
|
||||
for f in files:
|
||||
if should_review_file(f.filename):
|
||||
reviewable_files.append({
|
||||
"path": f.filename,
|
||||
"status": f.status,
|
||||
"additions": f.additions,
|
||||
"deletions": f.deletions,
|
||||
"patch": f.patch,
|
||||
"language": detect_language(f.filename)
|
||||
})
|
||||
else:
|
||||
skipped_files.append(f.filename)
|
||||
|
||||
print(f"\n✅ Файлов для ревью: {len(reviewable_files)}")
|
||||
for rf in reviewable_files:
|
||||
print(f" - {rf['path']} ({rf['language']})")
|
||||
|
||||
if skipped_files:
|
||||
print(f"\n⏭️ Пропущено файлов: {len(skipped_files)}")
|
||||
for sf in skipped_files:
|
||||
print(f" - {sf}")
|
||||
|
||||
print("📥"*40 + "\n")
|
||||
|
||||
state["files"] = reviewable_files
|
||||
state["status"] = "files_fetched"
|
||||
|
||||
# Update review
|
||||
result = await self.db.execute(
|
||||
select(Review).where(Review.id == state["review_id"])
|
||||
)
|
||||
review = result.scalar_one()
|
||||
review.status = ReviewStatusEnum.ANALYZING
|
||||
await self.db.commit()
|
||||
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ОШИБКА в fetch_files: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
state["error"] = str(e)
|
||||
state["status"] = "failed"
|
||||
return state
|
||||
|
||||
async def analyze_files(self, state: ReviewState) -> ReviewState:
|
||||
"""Analyze files and generate comments"""
|
||||
try:
|
||||
all_comments = []
|
||||
|
||||
print("\n" + "🔬"*40)
|
||||
print("НАЧАЛО АНАЛИЗА ФАЙЛОВ")
|
||||
print("🔬"*40)
|
||||
print(f"Файлов для анализа: {len(state['files'])}")
|
||||
|
||||
for i, file_info in enumerate(state["files"], 1):
|
||||
file_path = file_info["path"]
|
||||
patch = file_info.get("patch")
|
||||
language = file_info.get("language", "text")
|
||||
|
||||
print(f"\n📂 Файл {i}/{len(state['files'])}: {file_path}")
|
||||
print(f" Язык: {language}")
|
||||
print(f" Размер patch: {len(patch) if patch else 0} символов")
|
||||
print(f" Additions: {file_info.get('additions')}, Deletions: {file_info.get('deletions')}")
|
||||
|
||||
if not patch or len(patch) < 10:
|
||||
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
|
||||
continue
|
||||
|
||||
# Analyze diff with PR context
|
||||
pr_info = state.get("pr_info", {})
|
||||
comments = await self.analyzer.analyze_diff(
|
||||
file_path=file_path,
|
||||
diff=patch,
|
||||
language=language,
|
||||
pr_title=pr_info.get("title", ""),
|
||||
pr_description=pr_info.get("description", "")
|
||||
)
|
||||
|
||||
print(f" 💬 Получено комментариев: {len(comments)}")
|
||||
|
||||
# Add file path to each comment
|
||||
for comment in comments:
|
||||
comment["file_path"] = file_path
|
||||
all_comments.append(comment)
|
||||
|
||||
print(f"\n✅ ИТОГО комментариев: {len(all_comments)}")
|
||||
print("🔬"*40 + "\n")
|
||||
|
||||
state["comments"] = all_comments
|
||||
state["status"] = "analyzed"
|
||||
|
||||
# Update review
|
||||
result = await self.db.execute(
|
||||
select(Review).where(Review.id == state["review_id"])
|
||||
)
|
||||
review = result.scalar_one()
|
||||
review.files_analyzed = len(state["files"])
|
||||
review.status = ReviewStatusEnum.COMMENTING
|
||||
await self.db.commit()
|
||||
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ОШИБКА в analyze_files: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
state["error"] = str(e)
|
||||
state["status"] = "failed"
|
||||
return state
|
||||
|
||||
async def post_comments(self, state: ReviewState) -> ReviewState:
|
||||
"""Post comments to PR"""
|
||||
try:
|
||||
# Save comments to database
|
||||
result = await self.db.execute(
|
||||
select(Review).where(Review.id == state["review_id"])
|
||||
)
|
||||
review = result.scalar_one()
|
||||
|
||||
db_comments = []
|
||||
for comment_data in state["comments"]:
|
||||
# Фильтруем <think> блоки из сообщения
|
||||
message = comment_data.get("message", "")
|
||||
message = self._remove_think_blocks(message)
|
||||
# Экранируем HTML теги (чтобы они не исчезали в Markdown)
|
||||
message = self._escape_html_in_text(message)
|
||||
|
||||
comment = Comment(
|
||||
review_id=review.id,
|
||||
file_path=comment_data["file_path"],
|
||||
line_number=comment_data.get("line", 1),
|
||||
content=message,
|
||||
severity=SeverityEnum(comment_data.get("severity", "INFO").lower()),
|
||||
posted=False
|
||||
)
|
||||
self.db.add(comment)
|
||||
db_comments.append({**comment_data, "message": message})
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# Post to Git platform
|
||||
git_service = state["git_service"]
|
||||
pr_info = state.get("pr_info", {})
|
||||
|
||||
# Generate summary
|
||||
summary = await self.analyzer.generate_summary(
|
||||
all_comments=db_comments,
|
||||
pr_title=pr_info.get("title", ""),
|
||||
pr_description=pr_info.get("description", "")
|
||||
)
|
||||
|
||||
# Фильтруем <think> блоки из summary
|
||||
summary = self._remove_think_blocks(summary)
|
||||
# Экранируем HTML теги в summary
|
||||
summary = self._escape_html_in_text(summary)
|
||||
|
||||
if db_comments:
|
||||
# Format comments for API
|
||||
formatted_comments = [
|
||||
{
|
||||
"file_path": c["file_path"],
|
||||
"line_number": c.get("line", 1),
|
||||
"content": f"**{c.get('severity', 'INFO').upper()}**: {c.get('message', '')}"
|
||||
}
|
||||
for c in db_comments
|
||||
]
|
||||
|
||||
try:
|
||||
# Determine review status based on severity
|
||||
has_errors = any(c.get('severity', '').upper() == 'ERROR' for c in db_comments)
|
||||
event = "REQUEST_CHANGES" if has_errors else "COMMENT"
|
||||
|
||||
await git_service.create_review(
|
||||
pr_number=state["pr_number"],
|
||||
comments=formatted_comments,
|
||||
body=summary,
|
||||
event=event
|
||||
)
|
||||
|
||||
# Mark comments as posted
|
||||
result = await self.db.execute(
|
||||
select(Comment).where(Comment.review_id == review.id)
|
||||
)
|
||||
comments = result.scalars().all()
|
||||
for comment in comments:
|
||||
comment.posted = True
|
||||
await self.db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error posting comments to Git platform: {e}")
|
||||
# Continue even if posting fails
|
||||
else:
|
||||
# No issues found - approve PR
|
||||
try:
|
||||
await git_service.create_review(
|
||||
pr_number=state["pr_number"],
|
||||
comments=[],
|
||||
body=summary,
|
||||
event="APPROVE" # Approve if no issues
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error posting approval: {e}")
|
||||
|
||||
review.comments_generated = len(db_comments)
|
||||
await self.db.commit()
|
||||
|
||||
state["status"] = "commented"
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
state["error"] = str(e)
|
||||
state["status"] = "failed"
|
||||
return state
|
||||
|
||||
async def complete_review(self, state: ReviewState) -> ReviewState:
|
||||
"""Complete the review"""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Review).where(Review.id == state["review_id"])
|
||||
)
|
||||
review = result.scalar_one()
|
||||
|
||||
if state.get("error"):
|
||||
review.status = ReviewStatusEnum.FAILED
|
||||
review.error_message = state["error"]
|
||||
else:
|
||||
review.status = ReviewStatusEnum.COMPLETED
|
||||
|
||||
from datetime import datetime
|
||||
review.completed_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
|
||||
state["status"] = "completed"
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
state["error"] = str(e)
|
||||
state["status"] = "failed"
|
||||
return state
|
||||
|
||||
async def run_review(
|
||||
self,
|
||||
review_id: int,
|
||||
pr_number: int,
|
||||
repository_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the review workflow"""
|
||||
initial_state: ReviewState = {
|
||||
"review_id": review_id,
|
||||
"pr_number": pr_number,
|
||||
"repository_id": repository_id,
|
||||
"status": "pending",
|
||||
"files": [],
|
||||
"analyzed_files": [],
|
||||
"comments": [],
|
||||
"error": None,
|
||||
"git_service": None
|
||||
}
|
||||
|
||||
final_state = await self.graph.ainvoke(initial_state)
|
||||
return final_state
|
||||
|
||||
299
backend/app/agents/tools.py
Normal file
299
backend/app/agents/tools.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Tools for the reviewer agent"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from langchain_ollama import OllamaLLM
|
||||
from langchain_core.output_parsers import JsonOutputParser
|
||||
from langchain_core.prompts import PromptTemplate
|
||||
from app.agents.prompts import DIFF_REVIEW_PROMPT, CODE_REVIEW_PROMPT
|
||||
|
||||
|
||||
class CodeAnalyzer:
|
||||
"""Tool for analyzing code with Ollama"""
|
||||
|
||||
def __init__(self, ollama_base_url: str, model: str):
|
||||
self.llm = OllamaLLM(
|
||||
base_url=ollama_base_url,
|
||||
model=model,
|
||||
temperature=0.3, # Увеличили для более внимательного анализа
|
||||
format="json" # Форсируем JSON формат
|
||||
)
|
||||
# Используем JsonOutputParser для гарантированного JSON
|
||||
self.json_parser = JsonOutputParser()
|
||||
|
||||
def _extract_json_from_response(self, response: str) -> Dict[str, Any]:
|
||||
"""Extract JSON from LLM response"""
|
||||
# Remove markdown code blocks if present
|
||||
response = response.strip()
|
||||
if response.startswith('```'):
|
||||
response = re.sub(r'^```(?:json)?\s*', '', response)
|
||||
response = re.sub(r'\s*```$', '', response)
|
||||
|
||||
# Try to find JSON in the response
|
||||
json_match = re.search(r'\{[\s\S]*\}', response)
|
||||
if json_match:
|
||||
try:
|
||||
json_str = json_match.group()
|
||||
print(f" 🔍 Найден JSON: {json_str[:200]}...")
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ❌ Ошибка парсинга JSON: {e}")
|
||||
print(f" 📄 JSON строка: {json_str[:500]}")
|
||||
else:
|
||||
print(f" ❌ JSON не найден в ответе!")
|
||||
print(f" 📄 Ответ: {response[:500]}")
|
||||
|
||||
# If no valid JSON found, return empty comments
|
||||
return {"comments": []}
|
||||
|
||||
async def generate_summary(
|
||||
self,
|
||||
all_comments: List[Dict[str, Any]],
|
||||
pr_title: str = "",
|
||||
pr_description: str = ""
|
||||
) -> str:
|
||||
"""Generate overall review summary in markdown"""
|
||||
if not all_comments:
|
||||
return """## 🤖 AI Code Review
|
||||
|
||||
✅ **Отличная работа!** Серьезных проблем не обнаружено.
|
||||
|
||||
Код выглядит хорошо и соответствует стандартам."""
|
||||
|
||||
# Группируем по severity
|
||||
errors = [c for c in all_comments if c.get('severity', '').upper() == 'ERROR']
|
||||
warnings = [c for c in all_comments if c.get('severity', '').upper() == 'WARNING']
|
||||
infos = [c for c in all_comments if c.get('severity', '').upper() == 'INFO']
|
||||
|
||||
summary = f"""## 🤖 AI Code Review
|
||||
|
||||
### 📊 Статистика
|
||||
|
||||
- **Всего проблем:** {len(all_comments)}
|
||||
"""
|
||||
|
||||
if errors:
|
||||
summary += f"- ❌ **Критичных:** {len(errors)}\n"
|
||||
if warnings:
|
||||
summary += f"- ⚠️ **Важных:** {len(warnings)}\n"
|
||||
if infos:
|
||||
summary += f"- ℹ️ **Рекомендаций:** {len(infos)}\n"
|
||||
|
||||
summary += "\n### 💡 Рекомендации\n\n"
|
||||
|
||||
if errors:
|
||||
summary += "⚠️ **Найдены критичные проблемы!** Пожалуйста, исправьте их перед мержем в main.\n\n"
|
||||
elif warnings:
|
||||
summary += "Найдены важные замечания. Рекомендуется исправить перед мержем.\n\n"
|
||||
else:
|
||||
summary += "Проблемы не критичны, но рекомендуется учесть.\n\n"
|
||||
|
||||
summary += "📝 **Детальные комментарии для каждой проблемы опубликованы ниже.**\n"
|
||||
|
||||
return summary
|
||||
|
||||
async def analyze_diff(
|
||||
self,
|
||||
file_path: str,
|
||||
diff: str,
|
||||
language: Optional[str] = None,
|
||||
pr_title: str = "",
|
||||
pr_description: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Analyze code diff and return comments"""
|
||||
|
||||
if not diff or not diff.strip():
|
||||
print(f"⚠️ Пустой diff для {file_path}")
|
||||
return []
|
||||
|
||||
# Add PR context if available
|
||||
pr_context = ""
|
||||
if pr_title or pr_description:
|
||||
pr_context = f"\n\n**КОНТЕКСТ PR:**\n"
|
||||
if pr_title:
|
||||
pr_context += f"Название: {pr_title}\n"
|
||||
if pr_description:
|
||||
pr_context += f"Описание: {pr_description}\n"
|
||||
pr_context += "\nОБЯЗАТЕЛЬНО проверь: соответствует ли код описанию PR!\n"
|
||||
|
||||
# Получаем инструкции по формату JSON от парсера
|
||||
format_instructions = self.json_parser.get_format_instructions()
|
||||
|
||||
prompt = DIFF_REVIEW_PROMPT.format(
|
||||
file_path=file_path,
|
||||
diff=diff,
|
||||
pr_context=pr_context,
|
||||
format_instructions=format_instructions
|
||||
)
|
||||
|
||||
print("\n" + "="*80)
|
||||
print(f"🔍 АНАЛИЗ ФАЙЛА: {file_path}")
|
||||
print("="*80)
|
||||
|
||||
if pr_title or pr_description:
|
||||
print(f"\n📋 КОНТЕКСТ PR:")
|
||||
print("-" * 80)
|
||||
if pr_title:
|
||||
print(f"Название: {pr_title}")
|
||||
if pr_description:
|
||||
desc_short = pr_description[:200] + ("..." if len(pr_description) > 200 else "")
|
||||
print(f"Описание: {desc_short}")
|
||||
print("-" * 80)
|
||||
|
||||
print(f"\n📝 DIFF ({len(diff)} символов):")
|
||||
print("-" * 80)
|
||||
# Показываем первые 800 символов diff
|
||||
print(diff[:800] + ("...\n[обрезано]" if len(diff) > 800 else ""))
|
||||
print("-" * 80)
|
||||
print(f"\n💭 ПРОМПТ ({len(prompt)} символов):")
|
||||
print("-" * 80)
|
||||
print(prompt[:500] + "...")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
|
||||
|
||||
# Создаем chain с LLM и JSON парсером
|
||||
chain = self.llm | self.json_parser
|
||||
|
||||
# Получаем результат
|
||||
result = await chain.ainvoke(prompt)
|
||||
|
||||
print(f"\n🤖 ОТВЕТ AI (распарсен через JsonOutputParser):")
|
||||
print("-" * 80)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
|
||||
print("-" * 80)
|
||||
|
||||
comments = result.get("comments", [])
|
||||
|
||||
if comments:
|
||||
print(f"\n✅ Найдено комментариев: {len(comments)}")
|
||||
for i, comment in enumerate(comments, 1):
|
||||
print(f"\n {i}. Строка {comment.get('line', '?')}:")
|
||||
print(f" Severity: {comment.get('severity', '?')}")
|
||||
print(f" Message: {comment.get('message', '?')[:100]}...")
|
||||
else:
|
||||
print("\n⚠️ Комментариев не найдено! AI не нашел проблем.")
|
||||
|
||||
print("="*80 + "\n")
|
||||
|
||||
return comments
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ ОШИБКА при анализе {file_path}: {e}")
|
||||
print(f" Тип ошибки: {type(e).__name__}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Fallback: попытка извлечь JSON вручную
|
||||
print("\n🔄 Попытка fallback парсинга...")
|
||||
try:
|
||||
if hasattr(e, 'args') and len(e.args) > 0:
|
||||
response_text = str(e.args[0])
|
||||
result = self._extract_json_from_response(response_text)
|
||||
return result.get("comments", [])
|
||||
except:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
async def analyze_code(
|
||||
self,
|
||||
file_path: str,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
patch_info: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Analyze full code content and return comments"""
|
||||
|
||||
if not code or not code.strip():
|
||||
return []
|
||||
|
||||
prompt = CODE_REVIEW_PROMPT.format(
|
||||
file_path=file_path,
|
||||
code=code,
|
||||
language=language,
|
||||
patch_info=patch_info
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.llm.ainvoke(prompt)
|
||||
result = self._extract_json_from_response(response)
|
||||
return result.get("comments", [])
|
||||
except Exception as e:
|
||||
print(f"Error analyzing code for {file_path}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def detect_language(file_path: str) -> str:
|
||||
"""Detect programming language from file extension"""
|
||||
extension_map = {
|
||||
'.py': 'python',
|
||||
'.js': 'javascript',
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.jsx': 'javascript',
|
||||
'.java': 'java',
|
||||
'.go': 'go',
|
||||
'.rs': 'rust',
|
||||
'.cpp': 'cpp',
|
||||
'.c': 'c',
|
||||
'.cs': 'csharp',
|
||||
'.php': 'php',
|
||||
'.rb': 'ruby',
|
||||
'.swift': 'swift',
|
||||
'.kt': 'kotlin',
|
||||
'.scala': 'scala',
|
||||
'.sh': 'bash',
|
||||
'.sql': 'sql',
|
||||
'.html': 'html',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.json': 'json',
|
||||
'.xml': 'xml',
|
||||
'.md': 'markdown',
|
||||
}
|
||||
|
||||
ext = '.' + file_path.split('.')[-1] if '.' in file_path else ''
|
||||
return extension_map.get(ext.lower(), 'text')
|
||||
|
||||
|
||||
def should_review_file(file_path: str) -> bool:
|
||||
"""Determine if file should be reviewed"""
|
||||
# Skip binary, generated, and config files
|
||||
skip_extensions = {
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
|
||||
'.pdf', '.zip', '.tar', '.gz',
|
||||
'.lock', '.min.js', '.min.css',
|
||||
'.pyc', '.pyo', '.class', '.o',
|
||||
}
|
||||
|
||||
skip_patterns = [
|
||||
'node_modules/',
|
||||
'venv/',
|
||||
'.git/',
|
||||
'dist/',
|
||||
'build/',
|
||||
'__pycache__/',
|
||||
'.next/',
|
||||
'.nuxt/',
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'poetry.lock',
|
||||
]
|
||||
|
||||
# Check extension
|
||||
ext = '.' + file_path.split('.')[-1] if '.' in file_path else ''
|
||||
if ext.lower() in skip_extensions:
|
||||
return False
|
||||
|
||||
# Check patterns
|
||||
for pattern in skip_patterns:
|
||||
if pattern in file_path:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user