feat(mcp): implement Git Operations MCP server with Gitea provider
Implements the Git Operations MCP server (Issue #58) providing: Core features: - GitPython wrapper for local repository operations (clone, commit, push, pull, diff, log) - Branch management (create, delete, list, checkout) - Workspace isolation per project with file-based locking - Gitea provider for remote PR operations MCP Tools (17 registered): - clone_repository, git_status, create_branch, list_branches - checkout, commit, push, pull, diff, log - create_pull_request, get_pull_request, list_pull_requests - merge_pull_request, get_workspace, lock_workspace, unlock_workspace Technical details: - FastMCP + FastAPI with JSON-RPC 2.0 protocol - pydantic-settings for configuration (env prefix: GIT_OPS_) - Comprehensive error hierarchy with structured codes - 131 tests passing with 67% coverage - Async operations via ThreadPoolExecutor Closes: #105, #106, #107, #108, #109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
10
mcp-servers/git-ops/providers/__init__.py
Normal file
10
mcp-servers/git-ops/providers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Git provider implementations.
|
||||
|
||||
Provides adapters for different git hosting platforms (Gitea, GitHub, GitLab).
|
||||
"""
|
||||
|
||||
from .base import BaseProvider
|
||||
from .gitea import GiteaProvider
|
||||
|
||||
__all__ = ["BaseProvider", "GiteaProvider"]
|
||||
388
mcp-servers/git-ops/providers/base.py
Normal file
388
mcp-servers/git-ops/providers/base.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Base provider interface for git hosting platforms.
|
||||
|
||||
Defines the abstract interface that all git providers must implement.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from models import (
|
||||
CreatePRResult,
|
||||
GetPRResult,
|
||||
ListPRsResult,
|
||||
MergePRResult,
|
||||
MergeStrategy,
|
||||
PRState,
|
||||
UpdatePRResult,
|
||||
)
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""
|
||||
Abstract base class for git hosting providers.
|
||||
|
||||
All providers (Gitea, GitHub, GitLab) must implement this interface.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Return the provider name (e.g., 'gitea', 'github')."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def is_connected(self) -> bool:
|
||||
"""Check if the provider is connected and authenticated."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_authenticated_user(self) -> str | None:
|
||||
"""Get the username of the authenticated user."""
|
||||
...
|
||||
|
||||
# Repository operations
|
||||
|
||||
@abstractmethod
|
||||
async def get_repo_info(
|
||||
self, owner: str, repo: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get repository information.
|
||||
|
||||
Args:
|
||||
owner: Repository owner/organization
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Repository info dict
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_default_branch(
|
||||
self, owner: str, repo: str
|
||||
) -> str:
|
||||
"""
|
||||
Get the default branch for a repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner/organization
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Default branch name
|
||||
"""
|
||||
...
|
||||
|
||||
# Pull Request operations
|
||||
|
||||
@abstractmethod
|
||||
async def create_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
draft: bool = False,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
reviewers: list[str] | None = None,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Create a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
title: PR title
|
||||
body: PR description
|
||||
source_branch: Source branch name
|
||||
target_branch: Target branch name
|
||||
draft: Whether to create as draft
|
||||
labels: Labels to add
|
||||
assignees: Users to assign
|
||||
reviewers: Users to request review from
|
||||
|
||||
Returns:
|
||||
CreatePRResult with PR number and URL
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_pr(
|
||||
self, owner: str, repo: str, pr_number: int
|
||||
) -> GetPRResult:
|
||||
"""
|
||||
Get a pull request by number.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
GetPRResult with PR details
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_prs(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
state: PRState | None = None,
|
||||
author: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> ListPRsResult:
|
||||
"""
|
||||
List pull requests.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
state: Filter by state (open, closed, merged)
|
||||
author: Filter by author
|
||||
limit: Maximum PRs to return
|
||||
|
||||
Returns:
|
||||
ListPRsResult with list of PRs
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def merge_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
merge_strategy: MergeStrategy = MergeStrategy.MERGE,
|
||||
commit_message: str | None = None,
|
||||
delete_branch: bool = True,
|
||||
) -> MergePRResult:
|
||||
"""
|
||||
Merge a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
merge_strategy: Merge strategy to use
|
||||
commit_message: Custom merge commit message
|
||||
delete_branch: Whether to delete source branch
|
||||
|
||||
Returns:
|
||||
MergePRResult with merge status
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def update_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
state: PRState | None = None,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
) -> UpdatePRResult:
|
||||
"""
|
||||
Update a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
title: New title
|
||||
body: New description
|
||||
state: New state (open, closed)
|
||||
labels: Replace labels
|
||||
assignees: Replace assignees
|
||||
|
||||
Returns:
|
||||
UpdatePRResult with updated PR info
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close_pr(
|
||||
self, owner: str, repo: str, pr_number: int
|
||||
) -> UpdatePRResult:
|
||||
"""
|
||||
Close a pull request without merging.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
UpdatePRResult with updated PR info
|
||||
"""
|
||||
...
|
||||
|
||||
# Branch operations via API (for operations that need to bypass local git)
|
||||
|
||||
@abstractmethod
|
||||
async def delete_remote_branch(
|
||||
self, owner: str, repo: str, branch: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a remote branch via API.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
branch: Branch name to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_branch(
|
||||
self, owner: str, repo: str, branch: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get branch information via API.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
branch: Branch name
|
||||
|
||||
Returns:
|
||||
Branch info dict or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
# Comment operations
|
||||
|
||||
@abstractmethod
|
||||
async def add_pr_comment(
|
||||
self, owner: str, repo: str, pr_number: int, body: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add a comment to a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
body: Comment body
|
||||
|
||||
Returns:
|
||||
Created comment info
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_pr_comments(
|
||||
self, owner: str, repo: str, pr_number: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List comments on a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
List of comments
|
||||
"""
|
||||
...
|
||||
|
||||
# Label operations
|
||||
|
||||
@abstractmethod
|
||||
async def add_labels(
|
||||
self, owner: str, repo: str, pr_number: int, labels: list[str]
|
||||
) -> list[str]:
|
||||
"""
|
||||
Add labels to a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
labels: Labels to add
|
||||
|
||||
Returns:
|
||||
Updated list of labels
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def remove_label(
|
||||
self, owner: str, repo: str, pr_number: int, label: str
|
||||
) -> list[str]:
|
||||
"""
|
||||
Remove a label from a pull request.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
label: Label to remove
|
||||
|
||||
Returns:
|
||||
Updated list of labels
|
||||
"""
|
||||
...
|
||||
|
||||
# Reviewer operations
|
||||
|
||||
@abstractmethod
|
||||
async def request_review(
|
||||
self, owner: str, repo: str, pr_number: int, reviewers: list[str]
|
||||
) -> list[str]:
|
||||
"""
|
||||
Request review from users.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
reviewers: Usernames to request review from
|
||||
|
||||
Returns:
|
||||
List of reviewers requested
|
||||
"""
|
||||
...
|
||||
|
||||
# Utility methods
|
||||
|
||||
def parse_repo_url(self, repo_url: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse repository URL to extract owner and repo name.
|
||||
|
||||
Args:
|
||||
repo_url: Repository URL (HTTPS or SSH)
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo)
|
||||
|
||||
Raises:
|
||||
ValueError: If URL cannot be parsed
|
||||
"""
|
||||
import re
|
||||
|
||||
# Handle SSH URLs: git@host:owner/repo.git
|
||||
ssh_match = re.match(r"git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$", repo_url)
|
||||
if ssh_match:
|
||||
return ssh_match.group(1), ssh_match.group(2)
|
||||
|
||||
# Handle HTTPS URLs: https://host/owner/repo.git
|
||||
https_match = re.match(
|
||||
r"https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?$", repo_url
|
||||
)
|
||||
if https_match:
|
||||
return https_match.group(1), https_match.group(2)
|
||||
|
||||
raise ValueError(f"Unable to parse repository URL: {repo_url}")
|
||||
723
mcp-servers/git-ops/providers/gitea.py
Normal file
723
mcp-servers/git-ops/providers/gitea.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""
|
||||
Gitea provider implementation.
|
||||
|
||||
Implements the BaseProvider interface for Gitea API operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from config import Settings, get_settings
|
||||
from exceptions import (
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
PRNotFoundError,
|
||||
)
|
||||
from models import (
|
||||
CreatePRResult,
|
||||
GetPRResult,
|
||||
ListPRsResult,
|
||||
MergePRResult,
|
||||
MergeStrategy,
|
||||
PRInfo,
|
||||
PRState,
|
||||
UpdatePRResult,
|
||||
)
|
||||
|
||||
from .base import BaseProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaProvider(BaseProvider):
|
||||
"""
|
||||
Gitea API provider implementation.
|
||||
|
||||
Supports all PR operations, branch operations, and repository queries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
token: str | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize Gitea provider.
|
||||
|
||||
Args:
|
||||
base_url: Gitea server URL (e.g., https://gitea.example.com)
|
||||
token: API token
|
||||
settings: Optional settings override
|
||||
"""
|
||||
self.settings = settings or get_settings()
|
||||
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
|
||||
self.token = token or self.settings.gitea_token
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._user: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the provider name."""
|
||||
return "gitea"
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._client is None:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{self.base_url}/api/v1",
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Make an API request.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: API path
|
||||
**kwargs: Additional request arguments
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
APIError: On API errors
|
||||
AuthenticationError: On auth failures
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
try:
|
||||
response = await client.request(method, path, **kwargs)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("gitea", "Invalid or expired token")
|
||||
|
||||
if response.status_code == 403:
|
||||
raise AuthenticationError(
|
||||
"gitea", "Insufficient permissions for this operation"
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = response.text
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("message", error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
raise APIError("gitea", response.status_code, error_msg)
|
||||
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise APIError("gitea", 0, f"Request failed: {e}")
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
"""Check if connected to Gitea."""
|
||||
if not self.base_url or not self.token:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = await self._request("GET", "/user")
|
||||
return result is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_authenticated_user(self) -> str | None:
|
||||
"""Get the authenticated user's username."""
|
||||
if self._user:
|
||||
return self._user
|
||||
|
||||
try:
|
||||
result = await self._request("GET", "/user")
|
||||
if result:
|
||||
self._user = result.get("login") or result.get("username")
|
||||
return self._user
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Repository operations
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict[str, Any]:
|
||||
"""Get repository information."""
|
||||
result = await self._request("GET", f"/repos/{owner}/{repo}")
|
||||
if result is None:
|
||||
raise APIError("gitea", 404, f"Repository not found: {owner}/{repo}")
|
||||
return result
|
||||
|
||||
async def get_default_branch(self, owner: str, repo: str) -> str:
|
||||
"""Get the default branch for a repository."""
|
||||
repo_info = await self.get_repo_info(owner, repo)
|
||||
return repo_info.get("default_branch", "main")
|
||||
|
||||
# Pull Request operations
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
draft: bool = False,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
reviewers: list[str] | None = None,
|
||||
) -> CreatePRResult:
|
||||
"""Create a pull request."""
|
||||
try:
|
||||
data: dict[str, Any] = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": source_branch,
|
||||
"base": target_branch,
|
||||
}
|
||||
|
||||
# Note: Gitea doesn't have draft PR support in all versions
|
||||
# Draft support was added in Gitea 1.14+
|
||||
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/pulls",
|
||||
json=data,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return CreatePRResult(
|
||||
success=False,
|
||||
error="Failed to create pull request",
|
||||
)
|
||||
|
||||
pr_number = result["number"]
|
||||
|
||||
# Add labels if specified
|
||||
if labels:
|
||||
await self.add_labels(owner, repo, pr_number, labels)
|
||||
|
||||
# Add assignees if specified (via issue update)
|
||||
if assignees:
|
||||
await self._request(
|
||||
"PATCH",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}",
|
||||
json={"assignees": assignees},
|
||||
)
|
||||
|
||||
# Request reviewers if specified
|
||||
if reviewers:
|
||||
await self.request_review(owner, repo, pr_number, reviewers)
|
||||
|
||||
return CreatePRResult(
|
||||
success=True,
|
||||
pr_number=pr_number,
|
||||
pr_url=result.get("html_url"),
|
||||
)
|
||||
|
||||
except APIError as e:
|
||||
return CreatePRResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def get_pr(self, owner: str, repo: str, pr_number: int) -> GetPRResult:
|
||||
"""Get a pull request by number."""
|
||||
try:
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise PRNotFoundError(pr_number, f"{owner}/{repo}")
|
||||
|
||||
pr_info = self._parse_pr(result)
|
||||
|
||||
return GetPRResult(
|
||||
success=True,
|
||||
pr=pr_info.to_dict(),
|
||||
)
|
||||
|
||||
except PRNotFoundError:
|
||||
return GetPRResult(
|
||||
success=False,
|
||||
error=f"Pull request #{pr_number} not found",
|
||||
)
|
||||
except APIError as e:
|
||||
return GetPRResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def list_prs(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
state: PRState | None = None,
|
||||
author: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> ListPRsResult:
|
||||
"""List pull requests."""
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
if state:
|
||||
# Gitea uses different state names
|
||||
if state == PRState.OPEN:
|
||||
params["state"] = "open"
|
||||
elif state == PRState.CLOSED or state == PRState.MERGED:
|
||||
params["state"] = "closed"
|
||||
else:
|
||||
params["state"] = "all"
|
||||
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/pulls",
|
||||
params=params,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return ListPRsResult(
|
||||
success=True,
|
||||
pull_requests=[],
|
||||
total_count=0,
|
||||
)
|
||||
|
||||
prs = []
|
||||
for pr_data in result:
|
||||
# Filter by author if specified
|
||||
if author:
|
||||
pr_author = pr_data.get("user", {}).get("login", "")
|
||||
if pr_author.lower() != author.lower():
|
||||
continue
|
||||
|
||||
# Filter merged PRs if looking specifically for merged
|
||||
if state == PRState.MERGED:
|
||||
if not pr_data.get("merged"):
|
||||
continue
|
||||
|
||||
pr_info = self._parse_pr(pr_data)
|
||||
prs.append(pr_info.to_dict())
|
||||
|
||||
return ListPRsResult(
|
||||
success=True,
|
||||
pull_requests=prs,
|
||||
total_count=len(prs),
|
||||
)
|
||||
|
||||
except APIError as e:
|
||||
return ListPRsResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def merge_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
merge_strategy: MergeStrategy = MergeStrategy.MERGE,
|
||||
commit_message: str | None = None,
|
||||
delete_branch: bool = True,
|
||||
) -> MergePRResult:
|
||||
"""Merge a pull request."""
|
||||
try:
|
||||
# Map merge strategy to Gitea's "Do" values
|
||||
do_map = {
|
||||
MergeStrategy.MERGE: "merge",
|
||||
MergeStrategy.SQUASH: "squash",
|
||||
MergeStrategy.REBASE: "rebase",
|
||||
}
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"Do": do_map[merge_strategy],
|
||||
"delete_branch_after_merge": delete_branch,
|
||||
}
|
||||
|
||||
if commit_message:
|
||||
data["MergeTitleField"] = commit_message.split("\n")[0]
|
||||
if "\n" in commit_message:
|
||||
data["MergeMessageField"] = "\n".join(
|
||||
commit_message.split("\n")[1:]
|
||||
)
|
||||
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}/merge",
|
||||
json=data,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
# Check if PR was actually merged
|
||||
pr_result = await self.get_pr(owner, repo, pr_number)
|
||||
if pr_result.success and pr_result.pr:
|
||||
if pr_result.pr.get("state") == "merged":
|
||||
return MergePRResult(
|
||||
success=True,
|
||||
branch_deleted=delete_branch,
|
||||
)
|
||||
|
||||
return MergePRResult(
|
||||
success=False,
|
||||
error="Failed to merge pull request",
|
||||
)
|
||||
|
||||
return MergePRResult(
|
||||
success=True,
|
||||
merge_commit_sha=result.get("sha"),
|
||||
branch_deleted=delete_branch,
|
||||
)
|
||||
|
||||
except APIError as e:
|
||||
return MergePRResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def update_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
state: PRState | None = None,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
) -> UpdatePRResult:
|
||||
"""Update a pull request."""
|
||||
try:
|
||||
data: dict[str, Any] = {}
|
||||
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
if body is not None:
|
||||
data["body"] = body
|
||||
if state is not None:
|
||||
if state == PRState.OPEN:
|
||||
data["state"] = "open"
|
||||
elif state == PRState.CLOSED:
|
||||
data["state"] = "closed"
|
||||
|
||||
# Update PR if there's data
|
||||
if data:
|
||||
await self._request(
|
||||
"PATCH",
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
||||
json=data,
|
||||
)
|
||||
|
||||
# Update labels via issue endpoint
|
||||
if labels is not None:
|
||||
# First clear existing labels
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}/labels",
|
||||
)
|
||||
# Then add new labels
|
||||
if labels:
|
||||
await self.add_labels(owner, repo, pr_number, labels)
|
||||
|
||||
# Update assignees via issue endpoint
|
||||
if assignees is not None:
|
||||
await self._request(
|
||||
"PATCH",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}",
|
||||
json={"assignees": assignees},
|
||||
)
|
||||
|
||||
# Fetch updated PR
|
||||
result = await self.get_pr(owner, repo, pr_number)
|
||||
return UpdatePRResult(
|
||||
success=result.success,
|
||||
pr=result.pr,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
except APIError as e:
|
||||
return UpdatePRResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def close_pr(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
) -> UpdatePRResult:
|
||||
"""Close a pull request without merging."""
|
||||
return await self.update_pr(
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
state=PRState.CLOSED,
|
||||
)
|
||||
|
||||
# Branch operations
|
||||
|
||||
async def delete_remote_branch(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
) -> bool:
|
||||
"""Delete a remote branch."""
|
||||
try:
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/repos/{owner}/{repo}/branches/{branch}",
|
||||
)
|
||||
return True
|
||||
except APIError:
|
||||
return False
|
||||
|
||||
async def get_branch(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get branch information."""
|
||||
return await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/branches/{branch}",
|
||||
)
|
||||
|
||||
# Comment operations
|
||||
|
||||
async def add_pr_comment(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a comment to a pull request."""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}/comments",
|
||||
json={"body": body},
|
||||
)
|
||||
return result or {}
|
||||
|
||||
async def list_pr_comments(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List comments on a pull request."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}/comments",
|
||||
)
|
||||
return result or []
|
||||
|
||||
# Label operations
|
||||
|
||||
async def add_labels(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
labels: list[str],
|
||||
) -> list[str]:
|
||||
"""Add labels to a pull request."""
|
||||
# First, get or create label IDs
|
||||
label_ids = []
|
||||
for label_name in labels:
|
||||
label_id = await self._get_or_create_label(owner, repo, label_name)
|
||||
if label_id:
|
||||
label_ids.append(label_id)
|
||||
|
||||
if label_ids:
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}/labels",
|
||||
json={"labels": label_ids},
|
||||
)
|
||||
|
||||
# Return current labels
|
||||
issue = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}",
|
||||
)
|
||||
if issue:
|
||||
return [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
return labels
|
||||
|
||||
async def remove_label(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
label: str,
|
||||
) -> list[str]:
|
||||
"""Remove a label from a pull request."""
|
||||
# Get label ID
|
||||
label_info = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/labels?name={label}",
|
||||
)
|
||||
|
||||
if label_info and len(label_info) > 0:
|
||||
label_id = label_info[0]["id"]
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}/labels/{label_id}",
|
||||
)
|
||||
|
||||
# Return remaining labels
|
||||
issue = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/issues/{pr_number}",
|
||||
)
|
||||
if issue:
|
||||
return [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
return []
|
||||
|
||||
async def _get_or_create_label(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
label_name: str,
|
||||
) -> int | None:
|
||||
"""Get or create a label and return its ID."""
|
||||
# Try to find existing label
|
||||
labels = await self._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/labels",
|
||||
)
|
||||
|
||||
if labels:
|
||||
for label in labels:
|
||||
if label["name"].lower() == label_name.lower():
|
||||
return label["id"]
|
||||
|
||||
# Create new label with default color
|
||||
try:
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/labels",
|
||||
json={
|
||||
"name": label_name,
|
||||
"color": "#3B82F6", # Default blue
|
||||
},
|
||||
)
|
||||
if result:
|
||||
return result["id"]
|
||||
except APIError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# Reviewer operations
|
||||
|
||||
async def request_review(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
reviewers: list[str],
|
||||
) -> list[str]:
|
||||
"""Request review from users."""
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers",
|
||||
json={"reviewers": reviewers},
|
||||
)
|
||||
return reviewers
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _parse_pr(self, data: dict[str, Any]) -> PRInfo:
|
||||
"""Parse PR API response into PRInfo."""
|
||||
# Parse dates
|
||||
created_at = self._parse_datetime(data.get("created_at"))
|
||||
updated_at = self._parse_datetime(data.get("updated_at"))
|
||||
merged_at = self._parse_datetime(data.get("merged_at"))
|
||||
closed_at = self._parse_datetime(data.get("closed_at"))
|
||||
|
||||
# Determine state
|
||||
if data.get("merged"):
|
||||
state = PRState.MERGED
|
||||
elif data.get("state") == "closed":
|
||||
state = PRState.CLOSED
|
||||
else:
|
||||
state = PRState.OPEN
|
||||
|
||||
# Extract labels
|
||||
labels = [lbl["name"] for lbl in data.get("labels", [])]
|
||||
|
||||
# Extract assignees
|
||||
assignees = [a["login"] for a in data.get("assignees", [])]
|
||||
|
||||
# Extract reviewers
|
||||
reviewers = []
|
||||
if "requested_reviewers" in data:
|
||||
reviewers = [r["login"] for r in data["requested_reviewers"]]
|
||||
|
||||
return PRInfo(
|
||||
number=data["number"],
|
||||
title=data["title"],
|
||||
body=data.get("body", ""),
|
||||
state=state,
|
||||
source_branch=data.get("head", {}).get("ref", ""),
|
||||
target_branch=data.get("base", {}).get("ref", ""),
|
||||
author=data.get("user", {}).get("login", ""),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
merged_at=merged_at,
|
||||
closed_at=closed_at,
|
||||
url=data.get("html_url"),
|
||||
labels=labels,
|
||||
assignees=assignees,
|
||||
reviewers=reviewers,
|
||||
mergeable=data.get("mergeable"),
|
||||
draft=data.get("draft", False),
|
||||
)
|
||||
|
||||
def _parse_datetime(self, value: str | None) -> datetime:
|
||||
"""Parse datetime string from API."""
|
||||
if not value:
|
||||
return datetime.now(UTC)
|
||||
|
||||
try:
|
||||
# Handle Gitea's datetime format
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return datetime.now(UTC)
|
||||
Reference in New Issue
Block a user