feat(git-ops): add GitHub provider with auto-detection

Implements GitHub API provider following the same pattern as Gitea:
- Full PR operations (create, get, list, merge, update, close)
- Branch operations via API
- Comment and label management
- Reviewer request support
- Rate limit error handling

Server enhancements:
- Auto-detect provider from repository URL (github.com vs custom Gitea)
- Initialize GitHub provider when token is configured
- Health check includes both provider statuses
- Token selection based on repo URL for clone/push operations

Refs: #110

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 20:55:22 +01:00
parent 9dfa76aa41
commit 1779239c07
4 changed files with 1328 additions and 14 deletions

View File

@@ -22,7 +22,7 @@ from config import Settings, get_settings
from exceptions import ErrorCode, GitOpsError
from git_wrapper import GitWrapper
from models import MergeStrategy, PRState
from providers import GiteaProvider
from providers import BaseProvider, GiteaProvider, GitHubProvider
from workspace import WorkspaceManager
# Input validation patterns
@@ -77,25 +77,57 @@ logger = logging.getLogger(__name__)
_settings: Settings | None = None
_workspace_manager: WorkspaceManager | None = None
_gitea_provider: GiteaProvider | None = None
_github_provider: GitHubProvider | None = None
def _get_provider_for_url(repo_url: str) -> GiteaProvider | None:
def _get_provider_for_url(repo_url: str) -> BaseProvider | None:
"""Get the appropriate provider for a repository URL."""
if not _settings:
return None
# Normalize URL for matching
url_lower = repo_url.lower()
# Check for GitHub URLs
if "github.com" in url_lower or (
_settings.github_api_url
and _settings.github_api_url != "https://api.github.com"
and _settings.github_api_url.replace("https://", "").replace("/api/v3", "") in url_lower
):
return _github_provider
# Check if it's a Gitea URL
if _settings.gitea_base_url and _settings.gitea_base_url in repo_url:
if _settings.gitea_base_url and _settings.gitea_base_url.lower() in url_lower:
return _gitea_provider
# Default to Gitea for now
# Default: try to detect from URL pattern
# If URL contains 'github' anywhere, use GitHub
if "github" in url_lower:
return _github_provider
# Default to Gitea for self-hosted instances
return _gitea_provider
def _get_auth_token_for_url(repo_url: str) -> str | None:
"""Get the appropriate auth token for a repository URL."""
if not _settings:
return None
url_lower = repo_url.lower()
# GitHub token
if "github.com" in url_lower or "github" in url_lower:
return _settings.github_token if _settings.github_token else None
# Gitea token (default)
return _settings.gitea_token if _settings.gitea_token else None
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Application lifespan handler."""
global _settings, _workspace_manager, _gitea_provider
global _settings, _workspace_manager, _gitea_provider, _github_provider
logger.info("Starting Git Operations MCP Server...")
@@ -114,6 +146,13 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
)
logger.info(f"Gitea provider initialized: {_settings.gitea_base_url}")
if _settings.github_token:
_github_provider = GitHubProvider(
token=_settings.github_token,
settings=_settings,
)
logger.info("GitHub provider initialized")
logger.info("Git Operations MCP Server started successfully")
yield
@@ -124,6 +163,9 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
if _gitea_provider:
await _gitea_provider.close()
if _github_provider:
await _github_provider.close()
logger.info("Git Operations MCP Server shut down")
@@ -167,6 +209,21 @@ async def health_check() -> dict[str, Any]:
else:
status["dependencies"]["gitea"] = "not configured"
# Check GitHub connectivity
if _github_provider:
try:
if await _github_provider.is_connected():
user = await _github_provider.get_authenticated_user()
status["dependencies"]["github"] = f"connected as {user}"
else:
status["dependencies"]["github"] = "not connected"
is_degraded = True
except Exception as e:
status["dependencies"]["github"] = f"error: {e}"
is_degraded = True
else:
status["dependencies"]["github"] = "not configured"
# Check workspace directory
if _workspace_manager:
try:
@@ -442,10 +499,8 @@ async def clone_repository(
# Create workspace
workspace = await _workspace_manager.create_workspace(project_id, repo_url) # type: ignore[union-attr]
# Get auth token from provider
auth_token = None
if _settings and _settings.gitea_token:
auth_token = _settings.gitea_token
# Get auth token appropriate for the repository URL
auth_token = _get_auth_token_for_url(repo_url)
# Clone repository
git = GitWrapper(workspace.path, _settings)
@@ -715,10 +770,8 @@ async def push(
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
# Get auth token
auth_token = None
if _settings and _settings.gitea_token:
auth_token = _settings.gitea_token
# Get auth token appropriate for the repository URL
auth_token = _get_auth_token_for_url(workspace.repo_url or "")
git = GitWrapper(workspace.path, _settings)
result = await git.push(