feat: Add review events persistence, version display, and auto-versioning system
This commit is contained in:
		
							parent
							
								
									cfba28f913
								
							
						
					
					
						commit
						2db1225618
					
				
							
								
								
									
										66
									
								
								.git-hooks/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								.git-hooks/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | |||||||
|  | # Git Hooks | ||||||
|  | 
 | ||||||
|  | Эта папка содержит пользовательские git hooks для автоматизации задач. | ||||||
|  | 
 | ||||||
|  | ## Установка | ||||||
|  | 
 | ||||||
|  | Чтобы использовать эти hooks, выполните: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | # Из корня проекта | ||||||
|  | git config core.hooksPath .git-hooks | ||||||
|  | 
 | ||||||
|  | # Сделать hooks исполняемыми | ||||||
|  | chmod +x .git-hooks/pre-commit | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Hooks | ||||||
|  | 
 | ||||||
|  | ### pre-commit | ||||||
|  | 
 | ||||||
|  | Автоматически повышает версию backend при изменениях в `backend/` директории. | ||||||
|  | 
 | ||||||
|  | **Правила повышения версии:** | ||||||
|  | 
 | ||||||
|  | - `feat:` или `feature:` - повышает MINOR версию (0.1.0 → 0.2.0) | ||||||
|  | - `fix:` или `bugfix:` - повышает PATCH версию (0.1.0 → 0.1.1) | ||||||
|  | - `BREAKING:` или `major:` - повышает MAJOR версию (0.1.0 → 1.0.0) | ||||||
|  | - Остальные - повышают PATCH версию | ||||||
|  | 
 | ||||||
|  | **Примеры коммитов:** | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | git commit -m "feat: Add new feature"        # 0.1.0 → 0.2.0 | ||||||
|  | git commit -m "fix: Fix bug"                # 0.1.0 → 0.1.1 | ||||||
|  | git commit -m "BREAKING: Major changes"      # 0.1.0 → 1.0.0 | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Ручное повышение версии | ||||||
|  | 
 | ||||||
|  | Вы можете вручную повысить версию: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | # Patch version (0.1.0 → 0.1.1) | ||||||
|  | bash bump_version.sh patch | ||||||
|  | 
 | ||||||
|  | # Minor version (0.1.0 → 0.2.0) | ||||||
|  | bash bump_version.sh minor | ||||||
|  | 
 | ||||||
|  | # Major version (0.1.0 → 1.0.0) | ||||||
|  | bash bump_version.sh major | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Отключение hooks | ||||||
|  | 
 | ||||||
|  | Если вы хотите временно отключить hooks: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | git commit --no-verify -m "Your message" | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Или полностью отключить: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | git config core.hooksPath .git/hooks | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
							
								
								
									
										24
									
								
								.git-hooks/pre-commit
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.git-hooks/pre-commit
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | #!/bin/bash | ||||||
|  | 
 | ||||||
|  | # Pre-commit hook для автоповышения версии | ||||||
|  | 
 | ||||||
|  | echo "🔄 Проверка версии backend..." | ||||||
|  | 
 | ||||||
|  | # Проверка, есть ли изменения в backend | ||||||
|  | if git diff --cached --name-only | grep -q '^backend/'; then | ||||||
|  |     echo "📝 Обнаружены изменения в backend, обновление версии..." | ||||||
|  |      | ||||||
|  |     # Запуск скрипта повышения версии | ||||||
|  |     bash bump_version.sh | ||||||
|  |      | ||||||
|  |     # Проверка, был ли изменен файл версии | ||||||
|  |     if git diff --name-only | grep -q '^backend/VERSION'; then | ||||||
|  |         echo "✅ Версия обновлена, добавляем в коммит" | ||||||
|  |         git add backend/VERSION | ||||||
|  |     fi | ||||||
|  | else | ||||||
|  |     echo "ℹ️ Изменений в backend нет, версия не обновляется" | ||||||
|  | fi | ||||||
|  | 
 | ||||||
|  | exit 0 | ||||||
|  | 
 | ||||||
| @ -1,73 +0,0 @@ | |||||||
| ./ARCHITECTURE.md |  | ||||||
| ./backend/app/__init__.py |  | ||||||
| ./backend/app/agents/__init__.py |  | ||||||
| ./backend/app/agents/prompts.py |  | ||||||
| ./backend/app/agents/reviewer.py |  | ||||||
| ./backend/app/agents/tools.py |  | ||||||
| ./backend/app/api/__init__.py |  | ||||||
| ./backend/app/api/repositories.py |  | ||||||
| ./backend/app/api/reviews.py |  | ||||||
| ./backend/app/api/webhooks.py |  | ||||||
| ./backend/app/config.py |  | ||||||
| ./backend/app/database.py |  | ||||||
| ./backend/app/main.py |  | ||||||
| ./backend/app/models/__init__.py |  | ||||||
| ./backend/app/models/comment.py |  | ||||||
| ./backend/app/models/pull_request.py |  | ||||||
| ./backend/app/models/repository.py |  | ||||||
| ./backend/app/models/review.py |  | ||||||
| ./backend/app/schemas/__init__.py |  | ||||||
| ./backend/app/schemas/repository.py |  | ||||||
| ./backend/app/schemas/review.py |  | ||||||
| ./backend/app/schemas/webhook.py |  | ||||||
| ./backend/app/services/__init__.py |  | ||||||
| ./backend/app/services/base.py |  | ||||||
| ./backend/app/services/bitbucket.py |  | ||||||
| ./backend/app/services/gitea.py |  | ||||||
| ./backend/app/services/github.py |  | ||||||
| ./backend/app/utils.py |  | ||||||
| ./backend/app/webhooks/__init__.py |  | ||||||
| ./backend/app/webhooks/bitbucket.py |  | ||||||
| ./backend/app/webhooks/gitea.py |  | ||||||
| ./backend/app/webhooks/github.py |  | ||||||
| ./backend/README.md |  | ||||||
| ./backend/requirements.txt |  | ||||||
| ./backend/start.bat |  | ||||||
| ./backend/start.sh |  | ||||||
| ./cloud.md |  | ||||||
| ./COMMANDS.md |  | ||||||
| ./CONTRIBUTING.md |  | ||||||
| ./FILES_LIST.txt |  | ||||||
| ./frontend/index.html |  | ||||||
| ./frontend/package.json |  | ||||||
| ./frontend/postcss.config.js |  | ||||||
| ./frontend/README.md |  | ||||||
| ./frontend/src/api/client.ts |  | ||||||
| ./frontend/src/api/websocket.ts |  | ||||||
| ./frontend/src/App.tsx |  | ||||||
| ./frontend/src/components/CommentsList.tsx |  | ||||||
| ./frontend/src/components/RepositoryForm.tsx |  | ||||||
| ./frontend/src/components/RepositoryList.tsx |  | ||||||
| ./frontend/src/components/ReviewList.tsx |  | ||||||
| ./frontend/src/components/ReviewProgress.tsx |  | ||||||
| ./frontend/src/components/WebSocketStatus.tsx |  | ||||||
| ./frontend/src/index.css |  | ||||||
| ./frontend/src/main.tsx |  | ||||||
| ./frontend/src/pages/Dashboard.tsx |  | ||||||
| ./frontend/src/pages/Repositories.tsx |  | ||||||
| ./frontend/src/pages/ReviewDetail.tsx |  | ||||||
| ./frontend/src/pages/Reviews.tsx |  | ||||||
| ./frontend/src/types/index.ts |  | ||||||
| ./frontend/src/vite-env.d.ts |  | ||||||
| ./frontend/start.bat |  | ||||||
| ./frontend/start.sh |  | ||||||
| ./frontend/tailwind.config.js |  | ||||||
| ./frontend/tsconfig.json |  | ||||||
| ./frontend/tsconfig.node.json |  | ||||||
| ./frontend/vite.config.ts |  | ||||||
| ./LICENSE |  | ||||||
| ./PROJECT_STATUS.md |  | ||||||
| ./PROJECT_STRUCTURE.txt |  | ||||||
| ./QUICKSTART.md |  | ||||||
| ./README.md |  | ||||||
| ./SUMMARY.md |  | ||||||
| @ -1,33 +0,0 @@ | |||||||
| ./.gitignore |  | ||||||
| ./ARCHITECTURE.md |  | ||||||
| ./backend/app/config.py |  | ||||||
| ./backend/app/database.py |  | ||||||
| ./backend/app/main.py |  | ||||||
| ./backend/app/utils.py |  | ||||||
| ./backend/app/__init__.py |  | ||||||
| ./backend/README.md |  | ||||||
| ./backend/requirements.txt |  | ||||||
| ./backend/start.bat |  | ||||||
| ./backend/start.sh |  | ||||||
| ./cloud.md |  | ||||||
| ./COMMANDS.md |  | ||||||
| ./CONTRIBUTING.md |  | ||||||
| ./frontend/.eslintrc.cjs |  | ||||||
| ./frontend/index.html |  | ||||||
| ./frontend/package.json |  | ||||||
| ./frontend/postcss.config.js |  | ||||||
| ./frontend/README.md |  | ||||||
| ./frontend/src/App.tsx |  | ||||||
| ./frontend/src/index.css |  | ||||||
| ./frontend/src/main.tsx |  | ||||||
| ./frontend/src/vite-env.d.ts |  | ||||||
| ./frontend/start.bat |  | ||||||
| ./frontend/start.sh |  | ||||||
| ./frontend/tailwind.config.js |  | ||||||
| ./frontend/tsconfig.json |  | ||||||
| ./frontend/tsconfig.node.json |  | ||||||
| ./frontend/vite.config.ts |  | ||||||
| ./LICENSE |  | ||||||
| ./PROJECT_STRUCTURE.txt |  | ||||||
| ./QUICKSTART.md |  | ||||||
| ./README.md |  | ||||||
							
								
								
									
										1
									
								
								backend/VERSION
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								backend/VERSION
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | 0.1.0 | ||||||
| @ -552,7 +552,7 @@ class ReviewerAgent: | |||||||
|         try: |         try: | ||||||
|             async for event in self.graph.astream( |             async for event in self.graph.astream( | ||||||
|                 initial_state, |                 initial_state, | ||||||
|                 stream_mode=["updates"] |                 stream_mode=["updates", "messages"] | ||||||
|             ): |             ): | ||||||
|                 event_count += 1 |                 event_count += 1 | ||||||
|                 print(f"📨 Event #{event_count} received from graph") |                 print(f"📨 Event #{event_count} received from graph") | ||||||
| @ -581,6 +581,28 @@ class ReviewerAgent: | |||||||
|                             if isinstance(node_data, dict): |                             if isinstance(node_data, dict): | ||||||
|                                 final_state = node_data |                                 final_state = node_data | ||||||
|                      |                      | ||||||
|  |                     # Handle 'messages' events (LLM streaming) | ||||||
|  |                     elif event_type == 'messages': | ||||||
|  |                         print(f"   💬 LLM messages received") | ||||||
|  |                         # event_data is a list of messages | ||||||
|  |                         if isinstance(event_data, (list, tuple)): | ||||||
|  |                             for msg in event_data: | ||||||
|  |                                 # Check if it's an AIMessage or similar | ||||||
|  |                                 msg_content = None | ||||||
|  |                                 if hasattr(msg, 'content'): | ||||||
|  |                                     msg_content = msg.content | ||||||
|  |                                 elif isinstance(msg, dict) and 'content' in msg: | ||||||
|  |                                     msg_content = msg['content'] | ||||||
|  |                                 else: | ||||||
|  |                                     msg_content = str(msg) | ||||||
|  |                                  | ||||||
|  |                                 if msg_content and on_event: | ||||||
|  |                                     print(f"   💬 Sending LLM message: {msg_content[:100]}...") | ||||||
|  |                                     await on_event({ | ||||||
|  |                                         "type": "llm_message", | ||||||
|  |                                         "message": msg_content | ||||||
|  |                                     }) | ||||||
|  |                      | ||||||
|                     # Handle 'values' events (state snapshots) |                     # Handle 'values' events (state snapshots) | ||||||
|                     elif event_type == 'values': |                     elif event_type == 'values': | ||||||
|                         print(f"   📊 State snapshot received") |                         print(f"   📊 State snapshot received") | ||||||
|  | |||||||
| @ -6,9 +6,11 @@ from sqlalchemy import select, func | |||||||
| from sqlalchemy.orm import joinedload | from sqlalchemy.orm import joinedload | ||||||
| 
 | 
 | ||||||
| from app.database import get_db | from app.database import get_db | ||||||
| from app.models import Review, Comment, PullRequest | from app.models import Review, Comment, PullRequest, ReviewEvent | ||||||
| from app.schemas.review import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse | from app.schemas.review import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse | ||||||
|  | from app.schemas.review_event import ReviewEvent as ReviewEventSchema | ||||||
| from app.agents import ReviewerAgent | from app.agents import ReviewerAgent | ||||||
|  | from typing import List | ||||||
| 
 | 
 | ||||||
| router = APIRouter() | router = APIRouter() | ||||||
| 
 | 
 | ||||||
| @ -216,3 +218,27 @@ async def get_review_stats(db: AsyncSession = Depends(get_db)): | |||||||
|         avg_comments_per_review=round(avg_comments, 2) |         avg_comments_per_review=round(avg_comments, 2) | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | @router.get("/{review_id}/events", response_model=List[ReviewEventSchema]) | ||||||
|  | async def get_review_events( | ||||||
|  |     review_id: int, | ||||||
|  |     db: AsyncSession = Depends(get_db) | ||||||
|  | ): | ||||||
|  |     """Get all events for a specific review""" | ||||||
|  |     # Check if review exists | ||||||
|  |     result = await db.execute(select(Review).where(Review.id == review_id)) | ||||||
|  |     review = result.scalar_one_or_none() | ||||||
|  |      | ||||||
|  |     if not review: | ||||||
|  |         raise HTTPException(status_code=404, detail="Review not found") | ||||||
|  |      | ||||||
|  |     # Get events | ||||||
|  |     events_result = await db.execute( | ||||||
|  |         select(ReviewEvent) | ||||||
|  |         .where(ReviewEvent.review_id == review_id) | ||||||
|  |         .order_by(ReviewEvent.created_at) | ||||||
|  |     ) | ||||||
|  |     events = events_result.scalars().all() | ||||||
|  |      | ||||||
|  |     return events | ||||||
|  | 
 | ||||||
|  | |||||||
| @ -118,6 +118,20 @@ async def health_check(): | |||||||
|     return {"status": "healthy"} |     return {"status": "healthy"} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @app.get("/version") | ||||||
|  | async def get_version(): | ||||||
|  |     """Get backend version""" | ||||||
|  |     try: | ||||||
|  |         version_file = Path(__file__).parent.parent / "VERSION" | ||||||
|  |         if version_file.exists(): | ||||||
|  |             version = version_file.read_text().strip() | ||||||
|  |         else: | ||||||
|  |             version = "unknown" | ||||||
|  |         return {"version": version} | ||||||
|  |     except Exception: | ||||||
|  |         return {"version": "unknown"} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @app.websocket("/ws/reviews") | @app.websocket("/ws/reviews") | ||||||
| async def websocket_endpoint(websocket: WebSocket): | async def websocket_endpoint(websocket: WebSocket): | ||||||
|     """WebSocket endpoint for real-time review updates""" |     """WebSocket endpoint for real-time review updates""" | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from app.models.review import Review | |||||||
| from app.models.comment import Comment | from app.models.comment import Comment | ||||||
| from app.models.organization import Organization | from app.models.organization import Organization | ||||||
| from app.models.review_task import ReviewTask | from app.models.review_task import ReviewTask | ||||||
|  | from app.models.review_event import ReviewEvent | ||||||
| 
 | 
 | ||||||
| __all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask"] | __all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask", "ReviewEvent"] | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ class Review(Base): | |||||||
|     # Relationships |     # Relationships | ||||||
|     pull_request = relationship("PullRequest", back_populates="reviews") |     pull_request = relationship("PullRequest", back_populates="reviews") | ||||||
|     comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan") |     comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan") | ||||||
|  |     events = relationship("ReviewEvent", back_populates="review", cascade="all, delete-orphan", order_by="ReviewEvent.created_at") | ||||||
|      |      | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>" |         return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>" | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								backend/app/models/review_event.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/app/models/review_event.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | """Review Event model - хранение событий процесса review""" | ||||||
|  | 
 | ||||||
|  | from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON | ||||||
|  | from sqlalchemy.orm import relationship | ||||||
|  | from datetime import datetime | ||||||
|  | 
 | ||||||
|  | from app.database import Base | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReviewEvent(Base): | ||||||
|  |     """Событие процесса review""" | ||||||
|  |     __tablename__ = "review_events" | ||||||
|  |      | ||||||
|  |     id = Column(Integer, primary_key=True, index=True) | ||||||
|  |     review_id = Column(Integer, ForeignKey("reviews.id", ondelete="CASCADE"), nullable=False, index=True) | ||||||
|  |     event_type = Column(String(50), nullable=False)  # agent_step, llm_message, review_started, etc. | ||||||
|  |     step = Column(String(100), nullable=True)  # fetch_pr_info, analyze_files, etc. | ||||||
|  |     message = Column(Text, nullable=True) | ||||||
|  |     data = Column(JSON, nullable=True)  # Дополнительные данные события | ||||||
|  |     created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) | ||||||
|  |      | ||||||
|  |     # Relationships | ||||||
|  |     review = relationship("Review", back_populates="events") | ||||||
|  |      | ||||||
|  |     def __repr__(self): | ||||||
|  |         return f"<ReviewEvent(id={self.id}, review_id={self.review_id}, type={self.event_type})>" | ||||||
|  | 
 | ||||||
| @ -23,6 +23,10 @@ from app.schemas.streaming import ( | |||||||
|     ReviewProgressEvent, |     ReviewProgressEvent, | ||||||
|     StreamEventType |     StreamEventType | ||||||
| ) | ) | ||||||
|  | from app.schemas.review_event import ( | ||||||
|  |     ReviewEvent as ReviewEventSchema, | ||||||
|  |     ReviewEventCreate | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| __all__ = [ | __all__ = [ | ||||||
|     "RepositoryCreate", |     "RepositoryCreate", | ||||||
| @ -40,5 +44,7 @@ __all__ = [ | |||||||
|     "LLMStreamEvent", |     "LLMStreamEvent", | ||||||
|     "ReviewProgressEvent", |     "ReviewProgressEvent", | ||||||
|     "StreamEventType", |     "StreamEventType", | ||||||
|  |     "ReviewEventSchema", | ||||||
|  |     "ReviewEventCreate", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										29
									
								
								backend/app/schemas/review_event.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/app/schemas/review_event.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | |||||||
|  | """Review Event schemas""" | ||||||
|  | 
 | ||||||
|  | from pydantic import BaseModel, Field | ||||||
|  | from datetime import datetime | ||||||
|  | from typing import Optional, Dict, Any | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReviewEventBase(BaseModel): | ||||||
|  |     """Base review event schema""" | ||||||
|  |     event_type: str = Field(..., description="Тип события") | ||||||
|  |     step: Optional[str] = Field(None, description="Шаг процесса") | ||||||
|  |     message: Optional[str] = Field(None, description="Сообщение") | ||||||
|  |     data: Optional[Dict[str, Any]] = Field(None, description="Дополнительные данные") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReviewEventCreate(ReviewEventBase): | ||||||
|  |     """Schema for creating review event""" | ||||||
|  |     review_id: int | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReviewEvent(ReviewEventBase): | ||||||
|  |     """Review event response schema""" | ||||||
|  |     id: int | ||||||
|  |     review_id: int | ||||||
|  |     created_at: datetime | ||||||
|  |      | ||||||
|  |     class Config: | ||||||
|  |         from_attributes = True | ||||||
|  | 
 | ||||||
| @ -200,6 +200,19 @@ class ReviewTaskWorker: | |||||||
|                  |                  | ||||||
|                 logger.info(f"      🔔 Broadcasting event: type={event.get('type')}, connections={len(manager.active_connections)}") |                 logger.info(f"      🔔 Broadcasting event: type={event.get('type')}, connections={len(manager.active_connections)}") | ||||||
|                  |                  | ||||||
|  |                 # Save event to database | ||||||
|  |                 from app.models.review_event import ReviewEvent | ||||||
|  |                 db_event = ReviewEvent( | ||||||
|  |                     review_id=review.id, | ||||||
|  |                     event_type=event.get("type", "agent_update"), | ||||||
|  |                     step=event.get("step"), | ||||||
|  |                     message=event.get("message"), | ||||||
|  |                     data=event | ||||||
|  |                 ) | ||||||
|  |                 db.add(db_event) | ||||||
|  |                 await db.commit() | ||||||
|  |                 logger.debug(f"      💾 Event saved to DB: {db_event.id}") | ||||||
|  |                  | ||||||
|                 # Broadcast to all connected clients |                 # Broadcast to all connected clients | ||||||
|                 await manager.broadcast(event_data) |                 await manager.broadcast(event_data) | ||||||
|                  |                  | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
| 
 | 
 | ||||||
| import asyncio | import asyncio | ||||||
| from app.database import engine, Base | from app.database import engine, Base | ||||||
| from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment | from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment, ReviewEvent | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def create_tables(): | async def create_tables(): | ||||||
| @ -20,6 +20,7 @@ async def create_tables(): | |||||||
|     print("   - pull_requests") |     print("   - pull_requests") | ||||||
|     print("   - reviews") |     print("   - reviews") | ||||||
|     print("   - comments") |     print("   - comments") | ||||||
|  |     print("   - review_events") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|  | |||||||
							
								
								
									
										17
									
								
								backend/migrations/add_review_events.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/migrations/add_review_events.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | |||||||
|  | -- Migration: Add review_events table | ||||||
|  | 
 | ||||||
|  | CREATE TABLE IF NOT EXISTS review_events ( | ||||||
|  |     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  |     review_id INTEGER NOT NULL, | ||||||
|  |     event_type VARCHAR(50) NOT NULL, | ||||||
|  |     step VARCHAR(100), | ||||||
|  |     message TEXT, | ||||||
|  |     data JSON, | ||||||
|  |     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | -- Create indexes for better performance | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_review_events_review_id ON review_events(review_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_review_events_created_at ON review_events(created_at); | ||||||
|  | 
 | ||||||
							
								
								
									
										0
									
								
								bump_version.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								bump_version.sh
									
									
									
									
									
										Normal file
									
								
							| @ -1,34 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "AI Review Service - Status Check" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "1. Service Status:" |  | ||||||
| systemctl status ai-review.service --no-pager |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "2. Last 100 lines of logs:" |  | ||||||
| echo "==========================================" |  | ||||||
| journalctl -u ai-review.service -n 100 --no-pager |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "3. Checking files:" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Backend exists: $([ -d /home/user/code-review-agent/backend ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo "Frontend exists: $([ -d /home/user/code-review-agent/frontend ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo "Public dir exists: $([ -d /home/user/code-review-agent/backend/public ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo "venv exists: $([ -d /home/user/code-review-agent/backend/venv ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo ".env exists: $([ -f /home/user/code-review-agent/backend/.env ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo "DB exists: $([ -f /home/user/code-review-agent/backend/review.db ] && echo 'YES' || echo 'NO')" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "4. Manual start test:" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Run this command to see actual error:" |  | ||||||
| echo "cd /home/user/code-review-agent/backend && source venv/bin/activate && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000" |  | ||||||
| 
 |  | ||||||
| @ -1,108 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "AI Review - Диагностика и исправление" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Определить директорию установки (где находится скрипт) |  | ||||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |  | ||||||
| INSTALL_DIR="$SCRIPT_DIR" |  | ||||||
| 
 |  | ||||||
| echo "Working directory: $INSTALL_DIR" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "1. Проверка файлов:" |  | ||||||
| echo "   - Backend: $([ -d $INSTALL_DIR/backend ] && echo '✓' || echo '✗')" |  | ||||||
| echo "   - Frontend: $([ -d $INSTALL_DIR/frontend ] && echo '✓' || echo '✗')" |  | ||||||
| echo "   - venv: $([ -d $INSTALL_DIR/backend/venv ] && echo '✓' || echo '✗ MISSING')" |  | ||||||
| echo "   - venv/bin/python: $([ -f $INSTALL_DIR/backend/venv/bin/python ] && echo '✓' || echo '✗ MISSING')" |  | ||||||
| echo "   - venv/bin/python3: $([ -f $INSTALL_DIR/backend/venv/bin/python3 ] && echo '✓' || echo '✗ MISSING')" |  | ||||||
| echo "   - public: $([ -d $INSTALL_DIR/backend/public ] && echo '✓' || echo '✗ MISSING')" |  | ||||||
| echo "   - DB: $([ -f $INSTALL_DIR/backend/review.db ] && echo '✓' || echo '⚠️  будет создана')" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Проверить что именно в venv |  | ||||||
| if [ -d "$INSTALL_DIR/backend/venv" ]; then |  | ||||||
|     echo "2. Содержимое venv/bin/:" |  | ||||||
|     ls -la "$INSTALL_DIR/backend/venv/bin/" | head -20 |  | ||||||
|     echo "" |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Исправление" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| cd "$INSTALL_DIR/backend" |  | ||||||
| 
 |  | ||||||
| # Удалить старый venv если есть |  | ||||||
| if [ -d "venv" ]; then |  | ||||||
|     echo "Удаление старого venv..." |  | ||||||
|     rm -rf venv |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Создать новый venv |  | ||||||
| echo "Создание нового venv..." |  | ||||||
| python3 -m venv venv |  | ||||||
| 
 |  | ||||||
| # Проверить создание |  | ||||||
| if [ ! -f "venv/bin/python" ] && [ ! -f "venv/bin/python3" ]; then |  | ||||||
|     echo "✗ ОШИБКА: venv не создан правильно!" |  | ||||||
|     echo "" |  | ||||||
|     echo "Попробуйте:" |  | ||||||
|     echo "  sudo apt-get install python3-venv" |  | ||||||
|     echo "  python3 -m venv venv" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo "✓ venv создан" |  | ||||||
| 
 |  | ||||||
| # Активировать и установить зависимости |  | ||||||
| echo "Установка зависимостей..." |  | ||||||
| source venv/bin/activate |  | ||||||
| pip install --upgrade pip > /dev/null |  | ||||||
| pip install -r requirements.txt |  | ||||||
| 
 |  | ||||||
| echo "✓ Зависимости установлены" |  | ||||||
| 
 |  | ||||||
| # Применить миграции |  | ||||||
| if [ -f "migrate.py" ]; then |  | ||||||
|     echo "Применение миграций..." |  | ||||||
|     python migrate.py |  | ||||||
|     echo "✓ Миграции применены" |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo "" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Проверка" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| echo "Попытка запуска (5 секунд)..." |  | ||||||
| timeout 5 python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 2>&1 | head -20 & |  | ||||||
| UVICORN_PID=$! |  | ||||||
| 
 |  | ||||||
| sleep 6 |  | ||||||
| 
 |  | ||||||
| if ps -p $UVICORN_PID > /dev/null 2>&1; then |  | ||||||
|     echo "✓ Uvicorn запустился успешно" |  | ||||||
|     kill $UVICORN_PID 2>/dev/null |  | ||||||
| else |  | ||||||
|     echo "⚠️  Uvicorn остановился (это нормально для теста)" |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo "" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Готово!" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| echo "Теперь перезапустите сервис:" |  | ||||||
| echo "  sudo systemctl restart ai-review" |  | ||||||
| echo "  sudo systemctl status ai-review" |  | ||||||
| echo "" |  | ||||||
| echo "Или запустите вручную для теста:" |  | ||||||
| echo "  cd $INSTALL_DIR/backend" |  | ||||||
| echo "  source venv/bin/activate" |  | ||||||
| echo "  python -m uvicorn app.main:app --host 0.0.0.0 --port 8000" |  | ||||||
| 
 |  | ||||||
							
								
								
									
										53
									
								
								docs/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | |||||||
|  | # Документация AI Code Review Agent | ||||||
|  | 
 | ||||||
|  | Здесь собрана вся документация проекта. | ||||||
|  | 
 | ||||||
|  | ## Быстрый старт | ||||||
|  | 
 | ||||||
|  | - [QUICKSTART.md](QUICKSTART.md) - Быстрый старт проекта | ||||||
|  | - [START_PROJECT.md](START_PROJECT.md) - Детальная инструкция по запуску | ||||||
|  | 
 | ||||||
|  | ## Развертывание | ||||||
|  | 
 | ||||||
|  | - [DEPLOYMENT.md](DEPLOYMENT.md) - Общая информация о развертывании | ||||||
|  | - [UBUNTU_DEPLOYMENT.md](UBUNTU_DEPLOYMENT.md) - Развертывание на Ubuntu/Debian | ||||||
|  | - [REDEPLOY_GUIDE.md](REDEPLOY_GUIDE.md) - Руководство по обновлению | ||||||
|  | - [REDEPLOY_UBUNTU_QUICK.md](REDEPLOY_UBUNTU_QUICK.md) - Быстрое обновление на Ubuntu | ||||||
|  | - [cloud.md](cloud.md) - Развертывание в облаке | ||||||
|  | 
 | ||||||
|  | ## Функционал | ||||||
|  | 
 | ||||||
|  | - [FEATURES_UPDATE.md](FEATURES_UPDATE.md) - Обновления функционала | ||||||
|  | - [REVIEW_FEATURES.md](REVIEW_FEATURES.md) - Возможности review | ||||||
|  | - [ORGANIZATION_FEATURE.md](ORGANIZATION_FEATURE.md) - Работа с организациями | ||||||
|  | - [ORGANIZATION_QUICKSTART.md](ORGANIZATION_QUICKSTART.md) - Быстрый старт с организациями | ||||||
|  | - [MASTER_TOKEN_FEATURE.md](MASTER_TOKEN_FEATURE.md) - Мастер токены | ||||||
|  | - [PR_CONTEXT_FEATURE.md](PR_CONTEXT_FEATURE.md) - Контекст Pull Request | ||||||
|  | - [HTML_ESCAPE_FIX.md](HTML_ESCAPE_FIX.md) - Исправление экранирования HTML | ||||||
|  | 
 | ||||||
|  | ## Архитектура и разработка | ||||||
|  | 
 | ||||||
|  | - [ARCHITECTURE.md](ARCHITECTURE.md) - Архитектура проекта | ||||||
|  | - [PROJECT_STATUS.md](PROJECT_STATUS.md) - Статус проекта | ||||||
|  | - [CONTRIBUTING.md](CONTRIBUTING.md) - Как внести вклад | ||||||
|  | 
 | ||||||
|  | ## Changelog | ||||||
|  | 
 | ||||||
|  | - [CHANGELOG.md](CHANGELOG.md) - История изменений | ||||||
|  | - [CHANGELOG_ORGANIZATIONS.md](CHANGELOG_ORGANIZATIONS.md) - История изменений организаций | ||||||
|  | 
 | ||||||
|  | ## Команды и отладка | ||||||
|  | 
 | ||||||
|  | - [COMMANDS.md](COMMANDS.md) - Полезные команды | ||||||
|  | - [DEBUG_GUIDE.md](DEBUG_GUIDE.md) - Руководство по отладке | ||||||
|  | 
 | ||||||
|  | ## Настройки и рекомендации | ||||||
|  | 
 | ||||||
|  | - [MODEL_RECOMMENDATION.md](MODEL_RECOMMENDATION.md) - Рекомендации по выбору модели | ||||||
|  | - [PRODUCTION_URLS.md](PRODUCTION_URLS.md) - Настройка production URL | ||||||
|  | - [SUMMARY.md](SUMMARY.md) - Краткое резюме проекта | ||||||
|  | 
 | ||||||
|  | ## Дополнительно | ||||||
|  | 
 | ||||||
|  | - [TEST_STREAMING.md](TEST_STREAMING.md) - Тестирование WebSocket стриминга | ||||||
|  | 
 | ||||||
| @ -1,92 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
| 
 |  | ||||||
| ############################################################################### |  | ||||||
| # Скрипт быстрого исправления установки |  | ||||||
| ############################################################################### |  | ||||||
| 
 |  | ||||||
| set -e |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Fixing AI Review Installation" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Определить директорию установки (где находится скрипт) |  | ||||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |  | ||||||
| INSTALL_DIR="$SCRIPT_DIR" |  | ||||||
| 
 |  | ||||||
| echo "Working directory: $INSTALL_DIR" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| cd "$INSTALL_DIR" |  | ||||||
| 
 |  | ||||||
| # 1. Создать Python virtual environment |  | ||||||
| echo "[1/5] Создание Python virtual environment..." |  | ||||||
| cd backend |  | ||||||
| python3 -m venv venv |  | ||||||
| source venv/bin/activate |  | ||||||
| echo "✓ venv создан" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # 2. Установить Python зависимости |  | ||||||
| echo "[2/5] Установка Python зависимостей..." |  | ||||||
| pip install --upgrade pip > /dev/null |  | ||||||
| pip install -r requirements.txt > /dev/null |  | ||||||
| echo "✓ Python зависимости установлены" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # 3. Создать базу данных |  | ||||||
| echo "[3/5] Создание базы данных..." |  | ||||||
| python migrate.py |  | ||||||
| echo "✓ База данных создана" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # 4. Установить Node.js зависимости и собрать frontend |  | ||||||
| echo "[4/5] Сборка frontend..." |  | ||||||
| cd ../frontend |  | ||||||
| 
 |  | ||||||
| # Проверить наличие Node.js |  | ||||||
| if ! command -v node &> /dev/null; then |  | ||||||
|     echo "ERROR: Node.js не установлен!" |  | ||||||
|     echo "Установите: curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - && sudo apt-get install -y nodejs" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Создать .env.production |  | ||||||
| cat > .env.production << 'EOF' |  | ||||||
| VITE_API_URL=/api |  | ||||||
| VITE_WS_URL= |  | ||||||
| EOF |  | ||||||
| 
 |  | ||||||
| npm install > /dev/null 2>&1 |  | ||||||
| npm run build |  | ||||||
| echo "✓ Frontend собран" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # 5. Проверить результат |  | ||||||
| echo "[5/5] Проверка..." |  | ||||||
| cd .. |  | ||||||
| 
 |  | ||||||
| echo "Backend venv: $([ -d backend/venv ] && echo '✓ OK' || echo '✗ MISSING')" |  | ||||||
| echo "Backend DB: $([ -f backend/review.db ] && echo '✓ OK' || echo '✗ MISSING')" |  | ||||||
| echo "Frontend build: $([ -d backend/public ] && echo '✓ OK' || echo '✗ MISSING')" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| if [ -d backend/venv ] && [ -f backend/review.db ] && [ -d backend/public ]; then |  | ||||||
|     echo "==========================================" |  | ||||||
|     echo "✓ Installation fixed successfully!" |  | ||||||
|     echo "==========================================" |  | ||||||
|     echo "" |  | ||||||
|     echo "Now run:" |  | ||||||
|     echo "  sudo systemctl restart ai-review" |  | ||||||
|     echo "  sudo systemctl status ai-review" |  | ||||||
|     echo "" |  | ||||||
|     echo "Or start manually:" |  | ||||||
|     echo "  cd $INSTALL_DIR/backend" |  | ||||||
|     echo "  source venv/bin/activate" |  | ||||||
|     echo "  uvicorn app.main:app --host 0.0.0.0 --port 8000" |  | ||||||
| else |  | ||||||
|     echo "ERROR: Something is still missing!" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| @ -1,91 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
| 
 |  | ||||||
| echo "==========================================" |  | ||||||
| echo "Creating simple systemd service" |  | ||||||
| echo "==========================================" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| INSTALL_DIR="$HOME/code-review-agent" |  | ||||||
| 
 |  | ||||||
| echo "Install directory: $INSTALL_DIR" |  | ||||||
| echo "User: $USER" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Создать простой systemd service БЕЗ жестких ограничений |  | ||||||
| sudo tee /etc/systemd/system/ai-review.service > /dev/null << EOF |  | ||||||
| [Unit] |  | ||||||
| Description=AI Code Review Platform |  | ||||||
| After=network.target |  | ||||||
| 
 |  | ||||||
| [Service] |  | ||||||
| Type=simple |  | ||||||
| User=$USER |  | ||||||
| WorkingDirectory=$INSTALL_DIR/backend |  | ||||||
| Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin" |  | ||||||
| ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 |  | ||||||
| Restart=always |  | ||||||
| RestartSec=10 |  | ||||||
| StandardOutput=append:/var/log/ai-review/access.log |  | ||||||
| StandardError=append:/var/log/ai-review/error.log |  | ||||||
| 
 |  | ||||||
| [Install] |  | ||||||
| WantedBy=multi-user.target |  | ||||||
| EOF |  | ||||||
| 
 |  | ||||||
| echo "✓ Service файл создан" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Создать директорию логов |  | ||||||
| sudo mkdir -p /var/log/ai-review |  | ||||||
| sudo chown $USER:$USER /var/log/ai-review |  | ||||||
| echo "✓ Директория логов создана" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Перезагрузить systemd |  | ||||||
| echo "Перезагрузка systemd..." |  | ||||||
| sudo systemctl daemon-reload |  | ||||||
| sudo systemctl enable ai-review |  | ||||||
| echo "✓ Systemd обновлен" |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| # Запустить |  | ||||||
| echo "Запуск сервиса..." |  | ||||||
| sudo systemctl restart ai-review |  | ||||||
| sleep 3 |  | ||||||
| 
 |  | ||||||
| # Проверить статус |  | ||||||
| echo "" |  | ||||||
| echo "==========================================" |  | ||||||
| if sudo systemctl is-active --quiet ai-review; then |  | ||||||
|     echo "✅ Сервис запущен успешно!" |  | ||||||
|     echo "==========================================" |  | ||||||
|     echo "" |  | ||||||
|     sudo systemctl status ai-review --no-pager | head -20 |  | ||||||
|     echo "" |  | ||||||
|     echo "Приложение доступно: http://localhost:8000" |  | ||||||
|     echo "" |  | ||||||
|     echo "Полезные команды:" |  | ||||||
|     echo "  sudo systemctl status ai-review" |  | ||||||
|     echo "  sudo journalctl -u ai-review -f" |  | ||||||
|     echo "  tail -f /var/log/ai-review/error.log" |  | ||||||
| else |  | ||||||
|     echo "❌ Сервис не запустился" |  | ||||||
|     echo "==========================================" |  | ||||||
|     echo "" |  | ||||||
|     echo "Статус:" |  | ||||||
|     sudo systemctl status ai-review --no-pager |  | ||||||
|     echo "" |  | ||||||
|     echo "Последние 30 строк логов:" |  | ||||||
|     sudo journalctl -u ai-review -n 30 --no-pager |  | ||||||
|     echo "" |  | ||||||
|     echo "Проверьте:" |  | ||||||
|     echo "  1. tail -50 /var/log/ai-review/error.log" |  | ||||||
|     echo "  2. Попробуйте запустить вручную:" |  | ||||||
|     echo "     cd $INSTALL_DIR/backend" |  | ||||||
|     echo "     source venv/bin/activate" |  | ||||||
|     echo "     python -m uvicorn app.main:app" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo "" |  | ||||||
| 
 |  | ||||||
| @ -7,6 +7,7 @@ import ReviewDetail from './pages/ReviewDetail'; | |||||||
| import Organizations from './pages/Organizations'; | import Organizations from './pages/Organizations'; | ||||||
| import Tasks from './pages/Tasks'; | import Tasks from './pages/Tasks'; | ||||||
| import WebSocketStatus from './components/WebSocketStatus'; | import WebSocketStatus from './components/WebSocketStatus'; | ||||||
|  | import Footer from './components/Footer'; | ||||||
| 
 | 
 | ||||||
| const queryClient = new QueryClient({ | const queryClient = new QueryClient({ | ||||||
|   defaultOptions: { |   defaultOptions: { | ||||||
| @ -65,7 +66,7 @@ function Navigation() { | |||||||
| 
 | 
 | ||||||
| function AppContent() { | function AppContent() { | ||||||
|   return ( |   return ( | ||||||
|     <div className="min-h-screen bg-dark-bg"> |     <div className="min-h-screen bg-dark-bg pb-12"> | ||||||
|       <Navigation /> |       <Navigation /> | ||||||
|        |        | ||||||
|       <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> |       <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||||||
| @ -78,6 +79,8 @@ function AppContent() { | |||||||
|           <Route path="/reviews/:id" element={<ReviewDetail />} /> |           <Route path="/reviews/:id" element={<ReviewDetail />} /> | ||||||
|         </Routes> |         </Routes> | ||||||
|       </main> |       </main> | ||||||
|  |        | ||||||
|  |       <Footer /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  | |||||||
| @ -68,5 +68,25 @@ export const getReviewStats = async () => { | |||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | export interface ReviewEvent { | ||||||
|  |   id: number; | ||||||
|  |   review_id: number; | ||||||
|  |   event_type: string; | ||||||
|  |   step?: string; | ||||||
|  |   message?: string; | ||||||
|  |   data?: any; | ||||||
|  |   created_at: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const getReviewEvents = async (reviewId: number) => { | ||||||
|  |   const response = await api.get<ReviewEvent[]>(`/reviews/${reviewId}/events`); | ||||||
|  |   return response.data; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const getBackendVersion = async () => { | ||||||
|  |   const response = await api.get<{ version: string }>('/version'); | ||||||
|  |   return response.data; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default api; | export default api; | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										39
									
								
								frontend/src/components/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								frontend/src/components/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | |||||||
|  | /** | ||||||
|  |  * Footer component with version info | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getBackendVersion } from '../api/client'; | ||||||
|  | 
 | ||||||
|  | export default function Footer() { | ||||||
|  |   const { data: versionData } = useQuery({ | ||||||
|  |     queryKey: ['backendVersion'], | ||||||
|  |     queryFn: getBackendVersion, | ||||||
|  |     staleTime: 60000, // Cache for 1 minute
 | ||||||
|  |     refetchInterval: 300000, // Refetch every 5 minutes
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <footer className="fixed bottom-0 left-0 right-0 bg-dark-card border-t border-dark-border py-2 px-4 z-10"> | ||||||
|  |       <div className="container mx-auto flex items-center justify-between text-xs text-dark-text-muted"> | ||||||
|  |         <div> | ||||||
|  |           AI Code Review Agent | ||||||
|  |         </div> | ||||||
|  |         <div className="flex items-center gap-4"> | ||||||
|  |           <span> | ||||||
|  |             Backend v{versionData?.version || '...'} | ||||||
|  |           </span> | ||||||
|  |           <a | ||||||
|  |             href="https://github.com/yourusername/ai-review-agent" | ||||||
|  |             target="_blank" | ||||||
|  |             rel="noopener noreferrer" | ||||||
|  |             className="hover:text-dark-text-secondary transition-colors" | ||||||
|  |           > | ||||||
|  |             GitHub | ||||||
|  |           </a> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </footer> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @ -1,5 +1,6 @@ | |||||||
| import React, { useEffect, useState } from 'react'; | import React, { useEffect, useState } from 'react'; | ||||||
| import { WS_URL } from '../api/websocket'; | import { WS_URL } from '../api/websocket'; | ||||||
|  | import { getReviewEvents, ReviewEvent } from '../api/client'; | ||||||
| 
 | 
 | ||||||
| interface StreamEvent { | interface StreamEvent { | ||||||
|   type: string; |   type: string; | ||||||
| @ -36,6 +37,41 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => { | |||||||
|     console.log('🔌 Connecting to WebSocket:', WS_URL); |     console.log('🔌 Connecting to WebSocket:', WS_URL); | ||||||
|     console.log('👀 Watching for review ID:', reviewId); |     console.log('👀 Watching for review ID:', reviewId); | ||||||
|      |      | ||||||
|  |     // Load historical events from database
 | ||||||
|  |     const loadHistoricalEvents = async () => { | ||||||
|  |       try { | ||||||
|  |         console.log('📥 Loading historical events from DB...'); | ||||||
|  |         const historicalEvents = await getReviewEvents(reviewId); | ||||||
|  |         console.log(`✅ Loaded ${historicalEvents.length} historical events`); | ||||||
|  |          | ||||||
|  |         // Convert DB events to stream events format
 | ||||||
|  |         const streamEvents: StreamEvent[] = historicalEvents.map((dbEvent: ReviewEvent) => ({ | ||||||
|  |           type: dbEvent.event_type, | ||||||
|  |           review_id: dbEvent.review_id, | ||||||
|  |           pr_number: 0, // Not stored in DB
 | ||||||
|  |           timestamp: dbEvent.created_at, | ||||||
|  |           data: { | ||||||
|  |             type: dbEvent.event_type, | ||||||
|  |             step: dbEvent.step, | ||||||
|  |             message: dbEvent.message, | ||||||
|  |             data: dbEvent.data | ||||||
|  |           } | ||||||
|  |         })); | ||||||
|  |          | ||||||
|  |         setEvents(streamEvents); | ||||||
|  |          | ||||||
|  |         // Set current step from last event
 | ||||||
|  |         const lastAgentStep = streamEvents.reverse().find(e => e.type === 'agent_step'); | ||||||
|  |         if (lastAgentStep && lastAgentStep.data.step) { | ||||||
|  |           setCurrentStep(lastAgentStep.data.step); | ||||||
|  |         } | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('❌ Error loading historical events:', error); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     loadHistoricalEvents(); | ||||||
|  |      | ||||||
|     const ws = new WebSocket(WS_URL); |     const ws = new WebSocket(WS_URL); | ||||||
|     let pingInterval: number; |     let pingInterval: number; | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										137
									
								
								test_llm_streaming.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								test_llm_streaming.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,137 @@ | |||||||
|  | """ | ||||||
|  | Тест стриминга LLM messages от LangGraph | ||||||
|  | """ | ||||||
|  | 
 | ||||||
|  | import asyncio | ||||||
|  | from langgraph.graph import StateGraph, END | ||||||
|  | from typing import TypedDict, Annotated | ||||||
|  | import operator | ||||||
|  | from langchain_ollama import OllamaLLM | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestState(TypedDict): | ||||||
|  |     messages: Annotated[list, operator.add] | ||||||
|  |     result: str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def llm_node(state: TestState) -> TestState: | ||||||
|  |     """Нода с LLM вызовом""" | ||||||
|  |     print("  [LLM NODE] Вызов LLM...") | ||||||
|  |      | ||||||
|  |     llm = OllamaLLM( | ||||||
|  |         model="qwen2.5-coder:3b", | ||||||
|  |         base_url="http://localhost:11434", | ||||||
|  |         temperature=0.7 | ||||||
|  |     ) | ||||||
|  |      | ||||||
|  |     # Простой промпт для быстрого ответа | ||||||
|  |     prompt = "Напиши короткую проверку кода на Python (не более 100 символов)" | ||||||
|  |      | ||||||
|  |     response = await llm.ainvoke(prompt) | ||||||
|  |      | ||||||
|  |     print(f"  [LLM NODE] Ответ получен: {response[:50]}...") | ||||||
|  |      | ||||||
|  |     return { | ||||||
|  |         "messages": [{"role": "ai", "content": response}], | ||||||
|  |         "result": response | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def create_test_graph(): | ||||||
|  |     """Создает тестовый граф с LLM""" | ||||||
|  |     workflow = StateGraph(TestState) | ||||||
|  |      | ||||||
|  |     workflow.add_node("llm_call", llm_node) | ||||||
|  |      | ||||||
|  |     workflow.set_entry_point("llm_call") | ||||||
|  |     workflow.add_edge("llm_call", END) | ||||||
|  |      | ||||||
|  |     return workflow.compile() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def test_with_llm(): | ||||||
|  |     """Тест стриминга с LLM""" | ||||||
|  |     print("\n" + "="*80) | ||||||
|  |     print("ТЕСТ СТРИМИНГА LLM MESSAGES") | ||||||
|  |     print("="*80) | ||||||
|  |      | ||||||
|  |     graph = create_test_graph() | ||||||
|  |      | ||||||
|  |     initial_state: TestState = { | ||||||
|  |         "messages": [], | ||||||
|  |         "result": "" | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     # Тест: updates + messages | ||||||
|  |     print(f"\n🔍 Тест: stream_mode=['updates', 'messages']") | ||||||
|  |     print("-" * 80) | ||||||
|  |      | ||||||
|  |     event_count = 0 | ||||||
|  |     messages_count = 0 | ||||||
|  |      | ||||||
|  |     async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]): | ||||||
|  |         event_count += 1 | ||||||
|  |          | ||||||
|  |         if isinstance(event, tuple) and len(event) >= 2: | ||||||
|  |             event_type, event_data = event[0], event[1] | ||||||
|  |              | ||||||
|  |             print(f"\n📨 Event #{event_count}") | ||||||
|  |             print(f"   Type: {event_type}") | ||||||
|  |             print(f"   Data type: {type(event_data)}") | ||||||
|  |              | ||||||
|  |             if event_type == 'updates': | ||||||
|  |                 print(f"   ✅ Node update") | ||||||
|  |                 if isinstance(event_data, dict): | ||||||
|  |                     for node_name in event_data.keys(): | ||||||
|  |                         print(f"      Node: {node_name}") | ||||||
|  |              | ||||||
|  |             elif event_type == 'messages': | ||||||
|  |                 messages_count += 1 | ||||||
|  |                 print(f"   💬 LLM Messages (#{messages_count})") | ||||||
|  |                  | ||||||
|  |                 if isinstance(event_data, (list, tuple)): | ||||||
|  |                     for i, msg in enumerate(event_data): | ||||||
|  |                         print(f"      Message {i+1}:") | ||||||
|  |                          | ||||||
|  |                         # Извлекаем контент | ||||||
|  |                         if hasattr(msg, 'content'): | ||||||
|  |                             content = msg.content | ||||||
|  |                             print(f"         Content: {content[:100]}...") | ||||||
|  |                         elif isinstance(msg, dict): | ||||||
|  |                             print(f"         Dict: {msg}") | ||||||
|  |                         else: | ||||||
|  |                             print(f"         Type: {type(msg)}") | ||||||
|  |                             print(f"         Str: {str(msg)[:100]}...") | ||||||
|  |      | ||||||
|  |     print(f"\n" + "="*80) | ||||||
|  |     print(f"✅ Всего событий: {event_count}") | ||||||
|  |     print(f"✅ Messages событий: {messages_count}") | ||||||
|  |     print("="*80) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(): | ||||||
|  |     print("\n" + "="*80) | ||||||
|  |     print("ТЕСТИРОВАНИЕ LLM STREAMING В LANGGRAPH") | ||||||
|  |     print("="*80) | ||||||
|  |     print("\nПроверка Ollama...") | ||||||
|  |      | ||||||
|  |     try: | ||||||
|  |         # Проверяем что Ollama доступен | ||||||
|  |         from langchain_ollama import OllamaLLM | ||||||
|  |         test_llm = OllamaLLM(model="qwen2.5-coder:3b", base_url="http://localhost:11434") | ||||||
|  |         result = await test_llm.ainvoke("test") | ||||||
|  |         print("✅ Ollama работает!") | ||||||
|  |     except Exception as e: | ||||||
|  |         print(f"❌ Ошибка подключения к Ollama: {e}") | ||||||
|  |         print("\n⚠️  Убедитесь что Ollama запущен: ollama serve") | ||||||
|  |         print("⚠️  И модель загружена: ollama pull qwen2.5-coder:3b\n") | ||||||
|  |         return | ||||||
|  |      | ||||||
|  |     await test_with_llm() | ||||||
|  |      | ||||||
|  |     print("\n✅ Тестирование завершено\n") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     asyncio.run(main()) | ||||||
|  | 
 | ||||||
							
								
								
									
										72
									
								
								tests/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								tests/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | |||||||
|  | # Тесты | ||||||
|  | 
 | ||||||
|  | Эта папка содержит тестовые скрипты для проверки различных компонентов системы. | ||||||
|  | 
 | ||||||
|  | ## Тесты стриминга | ||||||
|  | 
 | ||||||
|  | ### test_simple_graph.py | ||||||
|  | 
 | ||||||
|  | Простой тест стриминга LangGraph без реальных данных и БД. | ||||||
|  | 
 | ||||||
|  | **Запуск:** | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | cd backend | ||||||
|  | $env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_simple_graph.py  # Windows PowerShell | ||||||
|  | # или | ||||||
|  | python ../tests/test_simple_graph.py  # Linux/Mac | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | **Что тестирует:** | ||||||
|  | - Различные режимы стриминга (`updates`, `messages`, `values`, `debug`) | ||||||
|  | - Обработку событий через callback | ||||||
|  | - Формат событий от LangGraph | ||||||
|  | 
 | ||||||
|  | ### test_langgraph_events.py | ||||||
|  | 
 | ||||||
|  | Полный тест с реальным ReviewerAgent и БД. | ||||||
|  | 
 | ||||||
|  | **Требования:** | ||||||
|  | - Работающая БД с данными | ||||||
|  | - Существующий Review ID, PR Number, Repository ID | ||||||
|  | - Настроенный `.env` файл | ||||||
|  | 
 | ||||||
|  | **Запуск:** | ||||||
|  | 
 | ||||||
|  | 1. Отредактируйте параметры в файле: | ||||||
|  |    ```python | ||||||
|  |    TEST_REVIEW_ID = 1 | ||||||
|  |    TEST_PR_NUMBER = 5 | ||||||
|  |    TEST_REPOSITORY_ID = 1 | ||||||
|  |    ``` | ||||||
|  | 
 | ||||||
|  | 2. Запустите: | ||||||
|  |    ```bash | ||||||
|  |    cd backend | ||||||
|  |    python ../tests/test_langgraph_events.py | ||||||
|  |    ``` | ||||||
|  | 
 | ||||||
|  | ### test_llm_streaming.py | ||||||
|  | 
 | ||||||
|  | Тест стриминга LLM messages с реальным Ollama. | ||||||
|  | 
 | ||||||
|  | **Требования:** | ||||||
|  | - Ollama запущен (`ollama serve`) | ||||||
|  | - Модель загружена (`ollama pull qwen2.5-coder:3b`) | ||||||
|  | 
 | ||||||
|  | **Запуск:** | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | cd backend | ||||||
|  | $env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_llm_streaming.py  # Windows | ||||||
|  | python ../tests/test_llm_streaming.py  # Linux/Mac | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Добавление новых тестов | ||||||
|  | 
 | ||||||
|  | Добавляйте новые тесты в эту папку с префиксом `test_`. | ||||||
|  | 
 | ||||||
|  | ## Полезные ссылки | ||||||
|  | 
 | ||||||
|  | - [TEST_STREAMING.md](../docs/TEST_STREAMING.md) - Детальная документация по тестированию стриминга | ||||||
|  | 
 | ||||||
							
								
								
									
										137
									
								
								tests/test_llm_streaming.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								tests/test_llm_streaming.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,137 @@ | |||||||
|  | """ | ||||||
|  | Тест стриминга LLM messages от LangGraph | ||||||
|  | """ | ||||||
|  | 
 | ||||||
|  | import asyncio | ||||||
|  | from langgraph.graph import StateGraph, END | ||||||
|  | from typing import TypedDict, Annotated | ||||||
|  | import operator | ||||||
|  | from langchain_ollama import OllamaLLM | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestState(TypedDict): | ||||||
|  |     messages: Annotated[list, operator.add] | ||||||
|  |     result: str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def llm_node(state: TestState) -> TestState: | ||||||
|  |     """Нода с LLM вызовом""" | ||||||
|  |     print("  [LLM NODE] Вызов LLM...") | ||||||
|  |      | ||||||
|  |     llm = OllamaLLM( | ||||||
|  |         model="qwen2.5-coder:3b", | ||||||
|  |         base_url="http://localhost:11434", | ||||||
|  |         temperature=0.7 | ||||||
|  |     ) | ||||||
|  |      | ||||||
|  |     # Простой промпт для быстрого ответа | ||||||
|  |     prompt = "Напиши короткую проверку кода на Python (не более 100 символов)" | ||||||
|  |      | ||||||
|  |     response = await llm.ainvoke(prompt) | ||||||
|  |      | ||||||
|  |     print(f"  [LLM NODE] Ответ получен: {response[:50]}...") | ||||||
|  |      | ||||||
|  |     return { | ||||||
|  |         "messages": [{"role": "ai", "content": response}], | ||||||
|  |         "result": response | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def create_test_graph(): | ||||||
|  |     """Создает тестовый граф с LLM""" | ||||||
|  |     workflow = StateGraph(TestState) | ||||||
|  |      | ||||||
|  |     workflow.add_node("llm_call", llm_node) | ||||||
|  |      | ||||||
|  |     workflow.set_entry_point("llm_call") | ||||||
|  |     workflow.add_edge("llm_call", END) | ||||||
|  |      | ||||||
|  |     return workflow.compile() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def test_with_llm(): | ||||||
|  |     """Тест стриминга с LLM""" | ||||||
|  |     print("\n" + "="*80) | ||||||
|  |     print("ТЕСТ СТРИМИНГА LLM MESSAGES") | ||||||
|  |     print("="*80) | ||||||
|  |      | ||||||
|  |     graph = create_test_graph() | ||||||
|  |      | ||||||
|  |     initial_state: TestState = { | ||||||
|  |         "messages": [], | ||||||
|  |         "result": "" | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     # Тест: updates + messages | ||||||
|  |     print(f"\n🔍 Тест: stream_mode=['updates', 'messages']") | ||||||
|  |     print("-" * 80) | ||||||
|  |      | ||||||
|  |     event_count = 0 | ||||||
|  |     messages_count = 0 | ||||||
|  |      | ||||||
|  |     async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]): | ||||||
|  |         event_count += 1 | ||||||
|  |          | ||||||
|  |         if isinstance(event, tuple) and len(event) >= 2: | ||||||
|  |             event_type, event_data = event[0], event[1] | ||||||
|  |              | ||||||
|  |             print(f"\n📨 Event #{event_count}") | ||||||
|  |             print(f"   Type: {event_type}") | ||||||
|  |             print(f"   Data type: {type(event_data)}") | ||||||
|  |              | ||||||
|  |             if event_type == 'updates': | ||||||
|  |                 print(f"   ✅ Node update") | ||||||
|  |                 if isinstance(event_data, dict): | ||||||
|  |                     for node_name in event_data.keys(): | ||||||
|  |                         print(f"      Node: {node_name}") | ||||||
|  |              | ||||||
|  |             elif event_type == 'messages': | ||||||
|  |                 messages_count += 1 | ||||||
|  |                 print(f"   💬 LLM Messages (#{messages_count})") | ||||||
|  |                  | ||||||
|  |                 if isinstance(event_data, (list, tuple)): | ||||||
|  |                     for i, msg in enumerate(event_data): | ||||||
|  |                         print(f"      Message {i+1}:") | ||||||
|  |                          | ||||||
|  |                         # Извлекаем контент | ||||||
|  |                         if hasattr(msg, 'content'): | ||||||
|  |                             content = msg.content | ||||||
|  |                             print(f"         Content: {content[:100]}...") | ||||||
|  |                         elif isinstance(msg, dict): | ||||||
|  |                             print(f"         Dict: {msg}") | ||||||
|  |                         else: | ||||||
|  |                             print(f"         Type: {type(msg)}") | ||||||
|  |                             print(f"         Str: {str(msg)[:100]}...") | ||||||
|  |      | ||||||
|  |     print(f"\n" + "="*80) | ||||||
|  |     print(f"✅ Всего событий: {event_count}") | ||||||
|  |     print(f"✅ Messages событий: {messages_count}") | ||||||
|  |     print("="*80) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(): | ||||||
|  |     print("\n" + "="*80) | ||||||
|  |     print("ТЕСТИРОВАНИЕ LLM STREAMING В LANGGRAPH") | ||||||
|  |     print("="*80) | ||||||
|  |     print("\nПроверка Ollama...") | ||||||
|  |      | ||||||
|  |     try: | ||||||
|  |         # Проверяем что Ollama доступен | ||||||
|  |         from langchain_ollama import OllamaLLM | ||||||
|  |         test_llm = OllamaLLM(model="qwen2.5-coder:3b", base_url="http://localhost:11434") | ||||||
|  |         result = await test_llm.ainvoke("test") | ||||||
|  |         print("✅ Ollama работает!") | ||||||
|  |     except Exception as e: | ||||||
|  |         print(f"❌ Ошибка подключения к Ollama: {e}") | ||||||
|  |         print("\n⚠️  Убедитесь что Ollama запущен: ollama serve") | ||||||
|  |         print("⚠️  И модель загружена: ollama pull qwen2.5-coder:3b\n") | ||||||
|  |         return | ||||||
|  |      | ||||||
|  |     await test_with_llm() | ||||||
|  |      | ||||||
|  |     print("\n✅ Тестирование завершено\n") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     asyncio.run(main()) | ||||||
|  | 
 | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user