Spaces:
Sleeping
Sleeping
| import httpx | |
| import logging | |
| from typing import List, Dict, Optional | |
| from fastapi import HTTPException | |
| logger = logging.getLogger(__name__) | |
| class GitHubService: | |
| """Service for interacting with GitHub API.""" | |
| def __init__(self): | |
| self.base_url = "https://api.github.com" | |
| async def fetch_maintainer_repos( | |
| self, | |
| github_access_token: str, | |
| existing_repos: List[str] | |
| ) -> List[Dict[str, str]]: | |
| """ | |
| Fetch all repositories where the user has admin, maintain, or push permissions, | |
| excluding repos already added to the website. | |
| Args: | |
| github_access_token: GitHub OAuth token | |
| existing_repos: List of repo full names already added | |
| Returns: | |
| List of dicts with 'owner' and 'repo' keys | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| response = await client.get( | |
| f"{self.base_url}/user/repos", | |
| params={"per_page": 100, "type": "owner,collaborator"}, | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if response.status_code != 200: | |
| raise HTTPException( | |
| status_code=response.status_code, | |
| detail=f"GitHub API error: {response.status_code} {response.text}" | |
| ) | |
| repos = response.json() | |
| # Filter repos with required permissions and not in existing_repos | |
| filtered_repos = [] | |
| for repo in repos: | |
| permissions = repo.get('permissions', {}) | |
| has_permission = ( | |
| permissions.get('admin') or | |
| permissions.get('maintain') or | |
| permissions.get('push') | |
| ) | |
| if has_permission and repo['full_name'] not in existing_repos: | |
| filtered_repos.append({ | |
| 'owner': repo['owner']['login'], | |
| 'repo': repo['name'] | |
| }) | |
| return filtered_repos | |
| except httpx.TimeoutException: | |
| raise HTTPException(status_code=504, detail="GitHub API request timed out") | |
| except Exception as e: | |
| logger.error(f"Failed to fetch maintainer repos: {e}") | |
| raise HTTPException(status_code=500, detail=f"Failed to fetch maintainer repos: {str(e)}") | |
| async def fetch_unmerged_pull_requests( | |
| self, | |
| github_access_token: str, | |
| owner: str, | |
| repo: str | |
| ) -> List[Dict[str, any]]: | |
| """ | |
| Fetch only pull requests that are open and not merged. | |
| Args: | |
| github_access_token: GitHub OAuth token | |
| owner: Repository owner | |
| repo: Repository name | |
| Returns: | |
| List of dicts with 'number' and 'title' keys | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| response = await client.get( | |
| f"{self.base_url}/repos/{owner}/{repo}/pulls", | |
| params={"state": "open"}, | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if response.status_code != 200: | |
| raise HTTPException( | |
| status_code=response.status_code, | |
| detail=f"GitHub API error: {response.status_code} {response.text}" | |
| ) | |
| pulls = response.json() | |
| # Filter only unmerged PRs (merged_at is null) | |
| unmerged_prs = [ | |
| { | |
| 'number': pr['number'], | |
| 'title': pr['title'] | |
| } | |
| for pr in pulls | |
| if pr.get('merged_at') is None | |
| ] | |
| return unmerged_prs | |
| except httpx.TimeoutException: | |
| raise HTTPException(status_code=504, detail="GitHub API request timed out") | |
| except Exception as e: | |
| logger.error(f"Failed to fetch unmerged PRs for {owner}/{repo}: {e}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to fetch unmerged PRs for {owner}/{repo}: {str(e)}" | |
| ) | |
| async def comment_on_pull_request( | |
| self, | |
| github_access_token: str, | |
| owner: str, | |
| repo: str, | |
| pr_number: int, | |
| comment_text: str | |
| ) -> Dict: | |
| """ | |
| Post a comment on a pull request after verifying repo access. | |
| Args: | |
| github_access_token: GitHub OAuth token | |
| owner: Repository owner | |
| repo: Repository name | |
| pr_number: Pull request number | |
| comment_text: Comment body text | |
| Returns: | |
| GitHub API response for the created comment | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| # First, verify repo access | |
| repo_response = await client.get( | |
| f"{self.base_url}/repos/{owner}/{repo}", | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if repo_response.status_code != 200: | |
| raise HTTPException( | |
| status_code=repo_response.status_code, | |
| detail=f"Cannot access repository: {repo_response.status_code} {repo_response.text}" | |
| ) | |
| repo_data = repo_response.json() | |
| permissions = repo_data.get('permissions', {}) | |
| # Check if user has required permissions | |
| if not (permissions.get('admin') or permissions.get('maintain') or permissions.get('push')): | |
| raise HTTPException( | |
| status_code=403, | |
| detail="Insufficient permissions to comment on this repository" | |
| ) | |
| # Post the comment | |
| comment_response = await client.post( | |
| f"{self.base_url}/repos/{owner}/{repo}/issues/{pr_number}/comments", | |
| json={"body": comment_text}, | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json", | |
| "Content-Type": "application/json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if comment_response.status_code not in [200, 201]: | |
| raise HTTPException( | |
| status_code=comment_response.status_code, | |
| detail=f"Failed to post comment: {comment_response.status_code} {comment_response.text}" | |
| ) | |
| return comment_response.json() | |
| except HTTPException: | |
| raise | |
| except httpx.TimeoutException: | |
| raise HTTPException(status_code=504, detail="GitHub API request timed out") | |
| except Exception as e: | |
| logger.error(f"Failed to comment on PR #{pr_number} in {owner}/{repo}: {e}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to comment on PR #{pr_number} in {owner}/{repo}: {str(e)}" | |
| ) | |
| async def comment_on_issue( | |
| self, | |
| github_access_token: str, | |
| owner: str, | |
| repo: str, | |
| issue_number: int, | |
| comment_text: str | |
| ) -> Dict: | |
| """ | |
| Post a comment on a GitHub issue after verifying repo access. | |
| Args: | |
| github_access_token: GitHub OAuth token | |
| owner: Repository owner | |
| repo: Repository name | |
| issue_number: Issue number | |
| comment_text: Comment body text | |
| Returns: | |
| GitHub API response for the created comment | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| # First, verify repo access | |
| repo_response = await client.get( | |
| f"{self.base_url}/repos/{owner}/{repo}", | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if repo_response.status_code != 200: | |
| raise HTTPException( | |
| status_code=repo_response.status_code, | |
| detail=f"Cannot access repository: {repo_response.status_code} {repo_response.text}" | |
| ) | |
| repo_data = repo_response.json() | |
| permissions = repo_data.get('permissions', {}) | |
| # Check if user has required permissions | |
| if not (permissions.get('admin') or permissions.get('maintain') or permissions.get('push')): | |
| raise HTTPException( | |
| status_code=403, | |
| detail="Insufficient permissions to comment on this repository" | |
| ) | |
| # Post the comment | |
| comment_response = await client.post( | |
| f"{self.base_url}/repos/{owner}/{repo}/issues/{issue_number}/comments", | |
| json={"body": comment_text}, | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json", | |
| "Content-Type": "application/json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if comment_response.status_code not in [200, 201]: | |
| raise HTTPException( | |
| status_code=comment_response.status_code, | |
| detail=f"Failed to post comment: {comment_response.status_code} {comment_response.text}" | |
| ) | |
| return comment_response.json() | |
| except HTTPException: | |
| raise | |
| except httpx.TimeoutException: | |
| raise HTTPException(status_code=504, detail="GitHub API request timed out") | |
| except Exception as e: | |
| logger.error(f"Failed to comment on issue #{issue_number} in {owner}/{repo}: {e}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to comment on issue #{issue_number} in {owner}/{repo}: {str(e)}" | |
| ) | |
| async def fetch_issue_comments( | |
| self, | |
| github_access_token: str, | |
| owner: str, | |
| repo: str, | |
| issue_number: int | |
| ) -> List[Dict]: | |
| """ | |
| Fetch all comments for a GitHub issue. | |
| Args: | |
| github_access_token: GitHub OAuth token | |
| owner: Repository owner | |
| repo: Repository name | |
| issue_number: Issue number | |
| Returns: | |
| List of comment objects from GitHub API | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| response = await client.get( | |
| f"{self.base_url}/repos/{owner}/{repo}/issues/{issue_number}/comments", | |
| headers={ | |
| "Authorization": f"Bearer {github_access_token}", | |
| "Accept": "application/vnd.github+json" | |
| }, | |
| timeout=30.0 | |
| ) | |
| if response.status_code != 200: | |
| raise HTTPException( | |
| status_code=response.status_code, | |
| detail=f"Failed to fetch comments: {response.status_code} {response.text}" | |
| ) | |
| return response.json() | |
| except HTTPException: | |
| raise | |
| except httpx.TimeoutException: | |
| raise HTTPException(status_code=504, detail="GitHub API request timed out") | |
| except Exception as e: | |
| logger.error(f"Failed to fetch comments for issue #{issue_number} in {owner}/{repo}: {e}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to fetch comments for issue #{issue_number} in {owner}/{repo}: {str(e)}" | |
| ) | |
| async def fetch_repo_issues(self, repo_full_name: str, github_access_token: Optional[str] = None, include_prs: bool = True) -> Dict: | |
| """Fetch issues and PRs from a GitHub repository.""" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| issues_url = f"{self.base_url}/repos/{repo_full_name}/issues" | |
| params = {"state": "all", "per_page": 100} | |
| headers = {"Accept": "application/vnd.github+json"} | |
| if github_access_token: | |
| headers["Authorization"] = f"Bearer {github_access_token}" | |
| response = await client.get(issues_url, params=params, headers=headers, timeout=30.0) | |
| if response.status_code != 200: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Failed to fetch from GitHub: {response.text}" | |
| ) | |
| items = response.json() | |
| issues = [] | |
| prs = [] | |
| for item in items: | |
| is_pr = 'pull_request' in item | |
| if is_pr: | |
| prs.append(item) | |
| else: | |
| issues.append(item) | |
| return {"issues": issues, "prs": prs if include_prs else []} | |
| except Exception as e: | |
| logger.error(f"GitHub fetch error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def fetch_user_activity(self, username: str, github_access_token: Optional[str] = None) -> Dict: | |
| """Fetch a user's GitHub activity (issues and PRs).""" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| search_url = f"{self.base_url}/search/issues" | |
| params = { | |
| "q": f"author:{username}", | |
| "per_page": 100, | |
| "sort": "created", | |
| "order": "desc" | |
| } | |
| headers = {"Accept": "application/vnd.github+json"} | |
| if github_access_token: | |
| headers["Authorization"] = f"Bearer {github_access_token}" | |
| response = await client.get(search_url, params=params, headers=headers, timeout=30.0) | |
| if response.status_code != 200: | |
| logger.error(f"GitHub search error: {response.text}") | |
| return {"issues": [], "prs": []} | |
| items = response.json().get('items', []) | |
| issues = [] | |
| prs = [] | |
| for item in items: | |
| is_pr = 'pull_request' in item | |
| if is_pr: | |
| prs.append(item) | |
| else: | |
| issues.append(item) | |
| return {"issues": issues, "prs": prs} | |
| except Exception as e: | |
| logger.error(f"GitHub user activity fetch error: {e}") | |
| return {"issues": [], "prs": []} | |
| async def fetch_repository_readme(self, repo_full_name: str, github_access_token: Optional[str] = None) -> str: | |
| """ | |
| Fetch the README content for a repository. | |
| """ | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| url = f"{self.base_url}/repos/{repo_full_name}/readme" | |
| headers = {"Accept": "application/vnd.github.raw+json"} | |
| if github_access_token: | |
| headers["Authorization"] = f"Bearer {github_access_token}" | |
| response = await client.get(url, headers=headers, timeout=30.0) | |
| if response.status_code == 404: | |
| return "" | |
| if response.status_code != 200: | |
| logger.error(f"Failed to fetch README for {repo_full_name}: {response.text}") | |
| return "" | |
| return response.text | |
| except Exception as e: | |
| logger.error(f"README fetch error for {repo_full_name}: {e}") | |
| return "" | |
| async def fetch_contributing_file(self, repo_full_name: str, github_access_token: Optional[str] = None) -> str: | |
| """ | |
| Fetch the CONTRIBUTING.md content for a repository. | |
| Tries multiple common paths: CONTRIBUTING.md, .github/CONTRIBUTING.md, docs/CONTRIBUTING.md | |
| """ | |
| paths_to_try = [ | |
| "CONTRIBUTING.md", | |
| ".github/CONTRIBUTING.md", | |
| "docs/CONTRIBUTING.md", | |
| "contributing.md", | |
| ] | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| headers = {"Accept": "application/vnd.github.raw+json"} | |
| if github_access_token: | |
| headers["Authorization"] = f"Bearer {github_access_token}" | |
| for path in paths_to_try: | |
| url = f"{self.base_url}/repos/{repo_full_name}/contents/{path}" | |
| response = await client.get(url, headers=headers, timeout=30.0) | |
| if response.status_code == 200: | |
| return response.text | |
| return "" | |
| except Exception as e: | |
| logger.error(f"CONTRIBUTING fetch error for {repo_full_name}: {e}") | |
| return "" | |
| async def fetch_repository_docs( | |
| self, | |
| repo_full_name: str, | |
| github_access_token: Optional[str] = None | |
| ) -> Dict[str, str]: | |
| """ | |
| Fetch README and CONTRIBUTING files for RAG indexing. | |
| Returns dict with 'readme' and 'contributing' keys. | |
| """ | |
| readme = await self.fetch_repository_readme(repo_full_name, github_access_token) | |
| contributing = await self.fetch_contributing_file(repo_full_name, github_access_token) | |
| return { | |
| "readme": readme, | |
| "contributing": contributing | |
| } | |
| # Singleton instance | |
| github_service = GitHubService() | |