diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 8570cfe7e..e37543730 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -2,6 +2,9 @@ name: Auto Label on: pull_request: types: [opened, reopened, synchronized] +permissions: + issues: write + pull-requests: read jobs: label: runs-on: ubuntu-latest diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index c8649bfbe..c2bba3c4b 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -2,6 +2,8 @@ name: Issue Triage on: issues: types: [opened] +permissions: + issues: write jobs: triage: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 9abb9b837..2d241eb2b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -4,6 +4,7 @@ on: types: [opened, reopened, synchronize, edited] permissions: issues: write + pull-requests: write jobs: validate: runs-on: ubuntu-latest @@ -17,7 +18,7 @@ jobs: if (pr.title.length < 10) { issues.push('❌ PR title too short (minimum 10 characters)'); } - if (!/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:/.test(pr.title)) { + if (!/^([\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]\s*)?(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:|^(⚡\s*)?Performance improvement/u.test(pr.title)) { issues.push('⚠️ PR title should follow conventional commits format'); } diff --git a/src/youtube_extension/backend/deployment_manager.py b/src/youtube_extension/backend/deployment_manager.py index 27c35f3df..b1b3834d7 100644 --- a/src/youtube_extension/backend/deployment_manager.py +++ b/src/youtube_extension/backend/deployment_manager.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any, Optional -import requests +import httpx from youtube_extension.backend.deploy import deploy_project as _adapter_deploy @@ -493,43 +493,45 @@ async def _create_github_repository(self, repo_name: str, project_config: dict[s "Accept": "application/vnd.github.v3+json" } - # Get user info - user_response = requests.get("https://api.github.com/user", headers=headers) - if user_response.status_code != 200: - raise Exception(f"Failed to get GitHub user info: {user_response.text}") - - user_data = user_response.json() - username = user_data["login"] - - # Create repository - repo_data = { - "name": repo_name, - "description": f"Generated by UVAI from YouTube tutorial - {project_config.get('title', 'Unknown')}", - "private": False, - "auto_init": True, - "has_issues": True, - "has_projects": True, - "has_wiki": False - } + # Use httpx.AsyncClient for non-blocking HTTP requests + async with httpx.AsyncClient() as client: + # Get user info + user_response = await client.get("https://api.github.com/user", headers=headers) + if user_response.status_code != 200: + raise Exception(f"Failed to get GitHub user info: {user_response.text}") + + user_data = user_response.json() + username = user_data["login"] + + # Create repository + repo_data = { + "name": repo_name, + "description": f"Generated by UVAI from YouTube tutorial - {project_config.get('title', 'Unknown')}", + "private": False, + "auto_init": True, + "has_issues": True, + "has_projects": True, + "has_wiki": False + } - response = requests.post( - "https://api.github.com/user/repos", - headers=headers, - json=repo_data - ) + response = await client.post( + "https://api.github.com/user/repos", + headers=headers, + json=repo_data + ) - if response.status_code not in [201, 422]: # 422 if repo already exists - raise Exception(f"Failed to create GitHub repository: {response.text}") + if response.status_code not in [201, 422]: # 422 if repo already exists + raise Exception(f"Failed to create GitHub repository: {response.text}") - if response.status_code == 422: - # Repository already exists, get its info - repo_response = requests.get(f"https://api.github.com/repos/{username}/{repo_name}", headers=headers) - if repo_response.status_code == 200: - repo_info = repo_response.json() + if response.status_code == 422: + # Repository already exists, get its info + repo_response = await client.get(f"https://api.github.com/repos/{username}/{repo_name}", headers=headers) + if repo_response.status_code == 200: + repo_info = repo_response.json() + else: + raise Exception(f"Repository exists but can't access it: {repo_response.text}") else: - raise Exception(f"Repository exists but can't access it: {repo_response.text}") - else: - repo_info = response.json() + repo_info = response.json() return { "repo_name": repo_name, @@ -549,30 +551,27 @@ async def _upload_to_github(self, project_path: str, repo_name: str) -> dict[str "Accept": "application/vnd.github.v3+json" } - # Get user info - user_response = requests.get("https://api.github.com/user", headers=headers) - user_data = user_response.json() - username = user_data["login"] + async with httpx.AsyncClient() as client: + # Get user info + user_response = await client.get("https://api.github.com/user", headers=headers) + user_data = user_response.json() + username = user_data["login"] - uploaded_files = [] - project_path_obj = Path(project_path) + uploaded_files = [] + project_path_obj = Path(project_path) - # Directories to exclude from GitHub upload (standard .gitignore patterns) - EXCLUDED_DIRS = {'node_modules', '.next', '.git', '__pycache__', '.vercel', 'dist', '.turbo'} + # Directories to exclude from GitHub upload (standard .gitignore patterns) + EXCLUDED_DIRS = {'node_modules', '.next', '.git', '__pycache__', '.vercel', 'dist', '.turbo'} - def should_skip_path(path: Path) -> bool: - """Check if any parent directory is in the exclusion list""" - return any(part in EXCLUDED_DIRS for part in path.parts) + def should_skip_path(path: Path) -> bool: + """Check if any parent directory is in the exclusion list""" + return any(part in EXCLUDED_DIRS for part in path.parts) - # Upload each file - for file_path in project_path_obj.rglob("*"): - # Skip excluded directories and dotfiles - if should_skip_path(file_path.relative_to(project_path_obj)): - continue - if file_path.is_file() and not file_path.name.startswith('.'): - try: - relative_path = file_path.relative_to(project_path_obj) + # Read all files to upload concurrently to improve performance further + upload_tasks = [] + async def upload_file(client, file_path, relative_path): + try: # Read file content with open(file_path, 'rb') as f: content = f.read() @@ -587,15 +586,36 @@ def should_skip_path(path: Path) -> bool: } upload_url = f"https://api.github.com/repos/{username}/{repo_name}/contents/{relative_path}" - response = requests.put(upload_url, headers=headers, json=file_data) + response = await client.put(upload_url, headers=headers, json=file_data) if response.status_code in [201, 200]: - uploaded_files.append(str(relative_path)) + return str(relative_path) else: logger.warning(f"Failed to upload {relative_path}: {response.text}") - + return None except Exception as e: logger.warning(f"Error uploading {file_path}: {e}") + return None + + # Collect tasks + for file_path in project_path_obj.rglob("*"): + # Skip excluded directories and dotfiles + if should_skip_path(file_path.relative_to(project_path_obj)): + continue + if file_path.is_file() and not file_path.name.startswith('.'): + relative_path = file_path.relative_to(project_path_obj) + upload_tasks.append(upload_file(client, file_path, relative_path)) + + # Run uploads concurrently with a semaphore to avoid overwhelming the GitHub API + # Secondary rate limit for GitHub is generally not strictly documented for concurrent writes but 10-20 concurrent requests is a safe maximum. + semaphore = asyncio.Semaphore(10) + + async def run_with_semaphore(coro): + async with semaphore: + return await coro + + results = await asyncio.gather(*(run_with_semaphore(task) for task in upload_tasks)) + uploaded_files = [res for res in results if res is not None] return { "files_uploaded": len(uploaded_files),