diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 31f6a34a0..a8d1e8ff1 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -5,6 +5,9 @@ on: types: - opened +permissions: + issues: write + jobs: auto-assign: runs-on: ubuntu-latest diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 8570cfe7e..438845873 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: + pull-requests: write + issues: write 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..c28fafdee 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 @@ -18,7 +19,7 @@ jobs: issues.push('❌ PR title too short (minimum 10 characters)'); } if (!/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:/.test(pr.title)) { - issues.push('⚠️ PR title should follow conventional commits format'); + issues.push('❌ PR title should follow conventional commits format'); } if (!pr.body || pr.body.length < 20) { @@ -26,14 +27,20 @@ jobs: } const totalChanges = (pr.additions || 0) + (pr.deletions || 0); + + // For warnings (like large PR), we don't fail the PR. We only fail for errors (like title format or description missing). + const errors = issues.filter(issue => issue.startsWith('❌')); + const warnings = issues.filter(issue => issue.startsWith('⚠️')); + if (totalChanges > 500) { - issues.push(`⚠️ Large PR detected (${totalChanges} lines changed)`); + warnings.push(`⚠️ Large PR detected (${totalChanges} lines changed)`); } - if (issues.length > 0) { + if (errors.length > 0 || warnings.length > 0) { + const allIssues = [...errors, ...warnings]; // Fork PRs get a read-only GITHUB_TOKEN; skip commenting to avoid errors if (pr.head.repo.full_name === pr.base.repo.full_name) { - const comment = `## 🔍 PR Validation\n\n${issues.join('\n')}`; + const comment = `## 🔍 PR Validation\n\n${allIssues.join('\n')}`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -42,7 +49,9 @@ jobs: }); } else { core.warning('Skipping PR comment for fork PR (read-only token)'); - issues.forEach(issue => core.warning(issue)); + allIssues.forEach(issue => core.warning(issue)); + } + if (errors.length > 0) { + core.setFailed('PR validation failed due to errors'); } - core.setFailed('PR validation failed'); } diff --git a/src/youtube_extension/backend/deployment_manager.py b/src/youtube_extension/backend/deployment_manager.py index 27c35f3df..5fd7635c2 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 aiohttp 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 - } - - response = requests.post( - "https://api.github.com/user/repos", - headers=headers, - json=repo_data - ) + async with aiohttp.ClientSession() as session: + # Get user info + async with session.get("https://api.github.com/user", headers=headers) as user_response: + if user_response.status != 200: + error_text = await user_response.text() + raise Exception(f"Failed to get GitHub user info: {error_text}") + user_data = await user_response.json() + username = user_data["login"] - if response.status_code not in [201, 422]: # 422 if repo already exists - raise Exception(f"Failed to create GitHub repository: {response.text}") + # 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 + } - 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() - else: - raise Exception(f"Repository exists but can't access it: {repo_response.text}") - else: - repo_info = response.json() + async with session.post( + "https://api.github.com/user/repos", + headers=headers, + json=repo_data + ) as response: + if response.status not in [201, 422]: # 422 if repo already exists + error_text = await response.text() + raise Exception(f"Failed to create GitHub repository: {error_text}") + + if response.status == 422: + # Repository already exists, get its info + async with session.get(f"https://api.github.com/repos/{username}/{repo_name}", headers=headers) as repo_response: + if repo_response.status == 200: + repo_info = await repo_response.json() + else: + error_text = await repo_response.text() + raise Exception(f"Repository exists but can't access it: {error_text}") + else: + repo_info = await response.json() return { "repo_name": repo_name, @@ -549,53 +551,65 @@ 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"] - - uploaded_files = [] - project_path_obj = Path(project_path) + async with aiohttp.ClientSession() as session: + # Get user info + async with session.get("https://api.github.com/user", headers=headers) as user_response: + user_data = await user_response.json() + username = user_data["login"] - # Directories to exclude from GitHub upload (standard .gitignore patterns) - EXCLUDED_DIRS = {'node_modules', '.next', '.git', '__pycache__', '.vercel', 'dist', '.turbo'} + uploaded_files = [] + project_path_obj = Path(project_path) - 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) + # Directories to exclude from GitHub upload (standard .gitignore patterns) + EXCLUDED_DIRS = {'node_modules', '.next', '.git', '__pycache__', '.vercel', 'dist', '.turbo'} - # 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 file content - with open(file_path, 'rb') as f: - content = f.read() - - # Encode content - encoded_content = base64.b64encode(content).decode('utf-8') + 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 file - file_data = { - "message": f"Add {relative_path}", - "content": encoded_content - } + # Prepare files for concurrent upload + upload_tasks = [] + semaphore = asyncio.Semaphore(10) # Limit concurrent uploads to avoid rate limits - upload_url = f"https://api.github.com/repos/{username}/{repo_name}/contents/{relative_path}" - response = requests.put(upload_url, headers=headers, json=file_data) + async def upload_single_file(file_path: Path, relative_path: Path): + async with semaphore: + try: + # Read file content + with open(file_path, 'rb') as f: + content = f.read() + + # Encode content + encoded_content = base64.b64encode(content).decode('utf-8') + + # Upload file + file_data = { + "message": f"Add {relative_path}", + "content": encoded_content + } + + upload_url = f"https://api.github.com/repos/{username}/{repo_name}/contents/{relative_path}" + async with session.put(upload_url, headers=headers, json=file_data) as response: + if response.status in [201, 200]: + uploaded_files.append(str(relative_path)) + else: + error_text = await response.text() + logger.warning(f"Failed to upload {relative_path}: {error_text}") - if response.status_code in [201, 200]: - uploaded_files.append(str(relative_path)) - else: - logger.warning(f"Failed to upload {relative_path}: {response.text}") + except Exception as e: + logger.warning(f"Error uploading {file_path}: {e}") + + # 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_single_file(file_path, relative_path)) - except Exception as e: - logger.warning(f"Error uploading {file_path}: {e}") + # Execute all uploads concurrently (limited by semaphore) + if upload_tasks: + await asyncio.gather(*upload_tasks) return { "files_uploaded": len(uploaded_files),