Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) across all domain models, services, infrastructure, and test files - Add missing timezone imports to all affected files - Fix pytest.ini configuration format from [tool:pytest] to [pytest] - Remove warning suppressions to expose actual issues - Ensure proper pytest marker registration for smoke tests Results: - 305 passed, 2 skipped, 0 warnings (down from 111 warnings) - All functionality preserved with modern datetime API usage - Improved code quality by addressing root causes vs suppression 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
618 lines
23 KiB
Python
618 lines
23 KiB
Python
"""
|
|
Gitea repository implementation with async HTTP client.
|
|
|
|
Provides high-performance, reliable access to Gitea API with connection pooling,
|
|
retry mechanisms, and proper error handling.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from infrastructure.logging import get_logger
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime, timezone
|
|
|
|
import aiohttp
|
|
|
|
from domain.issues.models import Issue, Label, IssueState
|
|
from domain.projects.models import Project, Milestone, ProjectState
|
|
from infrastructure.repositories.interfaces import IssueRepository, ProjectRepository
|
|
from infrastructure.connection_manager import ConnectionManager, retry_with_backoff, RetryConfig
|
|
from infrastructure.exceptions import (
|
|
ErrorContext, OperationType, GiteaApiError, NetworkError,
|
|
ResourceNotFoundError, ValidationError, ConcurrencyError
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class GiteaIssueRepository(IssueRepository):
|
|
"""
|
|
Gitea implementation of IssueRepository using async HTTP client.
|
|
|
|
Provides efficient access to Gitea issues API with connection pooling,
|
|
automatic retries, and proper error handling.
|
|
"""
|
|
|
|
def __init__(self, connection_manager: ConnectionManager, retry_config: Optional[RetryConfig] = None):
|
|
self.connection_manager = connection_manager
|
|
self.retry_config = retry_config or RetryConfig()
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def get_issue(self, issue_number: int, context: Optional[ErrorContext] = None) -> Issue:
|
|
"""Retrieve an issue by its number from Gitea API."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_issue_{issue_number}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="Issue",
|
|
resource_id=str(issue_number)
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
async with session.get(f"/api/v1/repos/issues/{issue_number}") as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
return self._map_api_issue_to_domain(data)
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting issue {issue_number}: {e}")
|
|
raise NetworkError(f"get issue {issue_number}", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def get_issues(
|
|
self,
|
|
project_id: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
labels: Optional[List[str]] = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
context: Optional[ErrorContext] = None
|
|
) -> List[Issue]:
|
|
"""Retrieve multiple issues with filtering and pagination."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_issues_{project_id or 'all'}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="Issue",
|
|
metadata={
|
|
"project_id": project_id,
|
|
"state": state,
|
|
"labels": labels,
|
|
"limit": limit,
|
|
"offset": offset
|
|
}
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
# Build query parameters
|
|
params = {
|
|
"limit": limit,
|
|
"page": (offset // limit) + 1 # Gitea uses 1-based pagination
|
|
}
|
|
|
|
if state:
|
|
params["state"] = state
|
|
|
|
if labels:
|
|
params["labels"] = ",".join(labels)
|
|
|
|
async with session.get("/api/v1/repos/issues", params=params) as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
return [self._map_api_issue_to_domain(issue_data) for issue_data in data]
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting issues: {e}")
|
|
raise NetworkError("get issues", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def create_issue(
|
|
self,
|
|
title: str,
|
|
body: str,
|
|
labels: Optional[List[str]] = None,
|
|
assignees: Optional[List[str]] = None,
|
|
context: Optional[ErrorContext] = None
|
|
) -> Issue:
|
|
"""Create a new issue via Gitea API."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"create_issue_{title[:50]}",
|
|
operation_type=OperationType.WRITE,
|
|
resource_type="Issue",
|
|
request_data={
|
|
"title": title,
|
|
"body": body,
|
|
"labels": labels,
|
|
"assignees": assignees
|
|
}
|
|
)
|
|
|
|
# Validate input
|
|
if not title or not title.strip():
|
|
raise ValidationError("title", title, "Title cannot be empty", context)
|
|
|
|
if len(title) > 255:
|
|
raise ValidationError("title", title, "Title cannot exceed 255 characters", context)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
# Prepare request payload
|
|
payload = {
|
|
"title": title.strip(),
|
|
"body": body or ""
|
|
}
|
|
|
|
if labels:
|
|
payload["labels"] = labels
|
|
|
|
if assignees:
|
|
payload["assignees"] = assignees
|
|
|
|
async with session.post("/api/v1/repos/issues", json=payload) as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
created_issue = self._map_api_issue_to_domain(data)
|
|
|
|
logger.info(f"Created issue #{created_issue.number}: {title}")
|
|
return created_issue
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error creating issue '{title}': {e}")
|
|
raise NetworkError(f"create issue '{title}'", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def update_issue(
|
|
self,
|
|
issue_number: int,
|
|
title: Optional[str] = None,
|
|
body: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
labels: Optional[List[str]] = None,
|
|
context: Optional[ErrorContext] = None
|
|
) -> Issue:
|
|
"""Update an existing issue via Gitea API."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"update_issue_{issue_number}",
|
|
operation_type=OperationType.UPDATE,
|
|
resource_type="Issue",
|
|
resource_id=str(issue_number),
|
|
request_data={
|
|
"title": title,
|
|
"body": body,
|
|
"state": state,
|
|
"labels": labels
|
|
}
|
|
)
|
|
|
|
# Validate input
|
|
if title is not None:
|
|
if not title.strip():
|
|
raise ValidationError("title", title, "Title cannot be empty", context)
|
|
if len(title) > 255:
|
|
raise ValidationError("title", title, "Title cannot exceed 255 characters", context)
|
|
|
|
if state is not None and state not in ["open", "closed"]:
|
|
raise ValidationError("state", state, "State must be 'open' or 'closed'", context)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
# First, get current issue to check for concurrent modifications
|
|
current_issue = await self.get_issue(issue_number, context)
|
|
|
|
# Prepare update payload
|
|
payload = {}
|
|
|
|
if title is not None:
|
|
payload["title"] = title.strip()
|
|
|
|
if body is not None:
|
|
payload["body"] = body
|
|
|
|
if state is not None:
|
|
payload["state"] = state
|
|
|
|
if labels is not None:
|
|
payload["labels"] = labels
|
|
|
|
# Only update if there are changes
|
|
if not payload:
|
|
return current_issue
|
|
|
|
async with session.patch(f"/api/v1/repos/issues/{issue_number}", json=payload) as response:
|
|
# Handle potential concurrent modification
|
|
if response.status == 409:
|
|
raise ConcurrencyError("Issue", str(issue_number), context)
|
|
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
updated_issue = self._map_api_issue_to_domain(data)
|
|
|
|
logger.info(f"Updated issue #{issue_number}")
|
|
return updated_issue
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error updating issue {issue_number}: {e}")
|
|
raise NetworkError(f"update issue {issue_number}", e, context)
|
|
|
|
async def get_issue_project_info(
|
|
self,
|
|
issue_number: int,
|
|
context: Optional[ErrorContext] = None
|
|
) -> Dict[str, Any]:
|
|
"""Get project-related information for an issue."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_issue_project_info_{issue_number}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="ProjectInfo",
|
|
resource_id=str(issue_number)
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
# Get issue details first
|
|
issue = await self.get_issue(issue_number, context)
|
|
|
|
# Get repository information
|
|
async with session.get("/api/v1/repos") as response:
|
|
await self._handle_response_errors(response, context)
|
|
repo_data = await response.json()
|
|
|
|
# Get project boards if available
|
|
project_info = {
|
|
"repository": repo_data,
|
|
"kanban_columns": ["Todo", "In Progress", "Review", "Done"], # Default columns
|
|
"issue": {
|
|
"number": issue.number,
|
|
"title": issue.title,
|
|
"state": issue.state.value,
|
|
"labels": [label.name for label in issue.labels]
|
|
}
|
|
}
|
|
|
|
# Try to get actual project boards
|
|
try:
|
|
async with session.get("/api/v1/repos/projects") as projects_response:
|
|
if projects_response.status == 200:
|
|
projects_data = await projects_response.json()
|
|
if projects_data:
|
|
# Use first project's columns if available
|
|
project_info["projects"] = projects_data
|
|
except Exception:
|
|
# Projects API might not be available, use defaults
|
|
pass
|
|
|
|
return project_info
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting project info for issue {issue_number}: {e}")
|
|
raise NetworkError(f"get project info for issue {issue_number}", e, context)
|
|
|
|
def _map_api_issue_to_domain(self, api_data: Dict[str, Any]) -> Issue:
|
|
"""Map Gitea API issue data to domain Issue object."""
|
|
# Map labels
|
|
labels = []
|
|
if "labels" in api_data:
|
|
for label_data in api_data["labels"]:
|
|
label = Label(
|
|
name=label_data["name"],
|
|
color=label_data.get("color", ""),
|
|
description=label_data.get("description", "")
|
|
)
|
|
labels.append(label)
|
|
|
|
# Map state
|
|
state_value = api_data.get("state", "open")
|
|
issue_state = IssueState.OPEN if state_value == "open" else IssueState.CLOSED
|
|
|
|
# Parse dates
|
|
created_at = datetime.fromisoformat(api_data["created_at"].replace("Z", "+00:00"))
|
|
updated_at = datetime.fromisoformat(api_data["updated_at"].replace("Z", "+00:00"))
|
|
|
|
closed_at = None
|
|
if api_data.get("closed_at"):
|
|
closed_at = datetime.fromisoformat(api_data["closed_at"].replace("Z", "+00:00"))
|
|
|
|
return Issue(
|
|
number=api_data["number"],
|
|
title=api_data["title"],
|
|
body=api_data.get("body", ""),
|
|
state=issue_state,
|
|
labels=labels,
|
|
assignees=api_data.get("assignees", []),
|
|
author=api_data.get("user", {}).get("login", "unknown"),
|
|
created_at=created_at,
|
|
updated_at=updated_at,
|
|
closed_at=closed_at,
|
|
url=api_data.get("html_url", "")
|
|
)
|
|
|
|
async def _handle_response_errors(self, response: aiohttp.ClientResponse, context: ErrorContext):
|
|
"""Handle HTTP response errors and convert to appropriate exceptions."""
|
|
if response.status == 200 or response.status == 201:
|
|
return
|
|
|
|
response_text = await response.text()
|
|
|
|
if response.status == 404:
|
|
resource_id = context.resource_id or "unknown"
|
|
raise ResourceNotFoundError(context.resource_type, resource_id, context)
|
|
|
|
elif response.status == 401:
|
|
raise GiteaApiError(
|
|
response.status,
|
|
"Authentication failed - check API token",
|
|
str(response.url),
|
|
context
|
|
)
|
|
|
|
elif response.status == 403:
|
|
raise GiteaApiError(
|
|
response.status,
|
|
"Access forbidden - check API permissions",
|
|
str(response.url),
|
|
context
|
|
)
|
|
|
|
elif response.status == 409:
|
|
# Conflict - usually concurrent modification
|
|
raise ConcurrencyError(context.resource_type, context.resource_id or "unknown", context)
|
|
|
|
elif response.status == 422:
|
|
# Validation error
|
|
try:
|
|
error_data = await response.json()
|
|
error_message = error_data.get("message", response_text)
|
|
except:
|
|
error_message = response_text
|
|
|
|
raise ValidationError("request", None, error_message, context)
|
|
|
|
elif response.status >= 500:
|
|
raise GiteaApiError(
|
|
response.status,
|
|
f"Server error: {response_text}",
|
|
str(response.url),
|
|
context
|
|
)
|
|
|
|
else:
|
|
raise GiteaApiError(
|
|
response.status,
|
|
response_text,
|
|
str(response.url),
|
|
context
|
|
)
|
|
|
|
|
|
class GiteaProjectRepository(ProjectRepository):
|
|
"""
|
|
Gitea implementation of ProjectRepository.
|
|
|
|
Provides access to project and milestone information via Gitea API.
|
|
"""
|
|
|
|
def __init__(self, connection_manager: ConnectionManager, retry_config: Optional[RetryConfig] = None):
|
|
self.connection_manager = connection_manager
|
|
self.retry_config = retry_config or RetryConfig()
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def get_project(self, project_id: str, context: Optional[ErrorContext] = None) -> Project:
|
|
"""Retrieve a project by its ID from Gitea API."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_project_{project_id}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="Project",
|
|
resource_id=project_id
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
async with session.get(f"/api/v1/repos/projects/{project_id}") as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
return self._map_api_project_to_domain(data)
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting project {project_id}: {e}")
|
|
raise NetworkError(f"get project {project_id}", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def get_projects(
|
|
self,
|
|
organization: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
context: Optional[ErrorContext] = None
|
|
) -> List[Project]:
|
|
"""Retrieve multiple projects with pagination."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_projects_{organization or 'all'}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="Project",
|
|
metadata={
|
|
"organization": organization,
|
|
"limit": limit,
|
|
"offset": offset
|
|
}
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
params = {
|
|
"limit": limit,
|
|
"page": (offset // limit) + 1
|
|
}
|
|
|
|
endpoint = "/api/v1/repos/projects"
|
|
if organization:
|
|
endpoint = f"/api/v1/orgs/{organization}/projects"
|
|
|
|
async with session.get(endpoint, params=params) as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
return [self._map_api_project_to_domain(project_data) for project_data in data]
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting projects: {e}")
|
|
raise NetworkError("get projects", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def get_milestones(
|
|
self,
|
|
project_id: str,
|
|
state: Optional[str] = None,
|
|
context: Optional[ErrorContext] = None
|
|
) -> List[Milestone]:
|
|
"""Retrieve milestones for a project."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"get_milestones_{project_id}",
|
|
operation_type=OperationType.READ,
|
|
resource_type="Milestone",
|
|
metadata={"project_id": project_id, "state": state}
|
|
)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
params = {}
|
|
if state:
|
|
params["state"] = state
|
|
|
|
async with session.get(f"/api/v1/repos/milestones", params=params) as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
return [self._map_api_milestone_to_domain(milestone_data) for milestone_data in data]
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error getting milestones for project {project_id}: {e}")
|
|
raise NetworkError(f"get milestones for project {project_id}", e, context)
|
|
|
|
@retry_with_backoff(RetryConfig())
|
|
async def create_milestone(
|
|
self,
|
|
project_id: str,
|
|
title: str,
|
|
description: str,
|
|
due_date: Optional[str] = None,
|
|
context: Optional[ErrorContext] = None
|
|
) -> Milestone:
|
|
"""Create a new milestone for a project."""
|
|
if context is None:
|
|
context = ErrorContext(
|
|
operation_id=f"create_milestone_{title[:50]}",
|
|
operation_type=OperationType.WRITE,
|
|
resource_type="Milestone",
|
|
request_data={
|
|
"project_id": project_id,
|
|
"title": title,
|
|
"description": description,
|
|
"due_date": due_date
|
|
}
|
|
)
|
|
|
|
# Validate input
|
|
if not title or not title.strip():
|
|
raise ValidationError("title", title, "Milestone title cannot be empty", context)
|
|
|
|
try:
|
|
session = await self.connection_manager.get_http_session()
|
|
|
|
payload = {
|
|
"title": title.strip(),
|
|
"description": description or ""
|
|
}
|
|
|
|
if due_date:
|
|
payload["due_on"] = due_date
|
|
|
|
async with session.post("/api/v1/repos/milestones", json=payload) as response:
|
|
await self._handle_response_errors(response, context)
|
|
|
|
data = await response.json()
|
|
created_milestone = self._map_api_milestone_to_domain(data)
|
|
|
|
logger.info(f"Created milestone: {title}")
|
|
return created_milestone
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Network error creating milestone '{title}': {e}")
|
|
raise NetworkError(f"create milestone '{title}'", e, context)
|
|
|
|
def _map_api_project_to_domain(self, api_data: Dict[str, Any]) -> Project:
|
|
"""Map Gitea API project data to domain Project object."""
|
|
# For now, create a basic project since Gitea projects API might be limited
|
|
created_at = datetime.fromisoformat(api_data.get("created_at", datetime.now(timezone.utc).isoformat()).replace("Z", "+00:00"))
|
|
updated_at = datetime.fromisoformat(api_data.get("updated_at", datetime.now(timezone.utc).isoformat()).replace("Z", "+00:00"))
|
|
|
|
return Project(
|
|
id=str(api_data.get("id", 0)),
|
|
name=api_data.get("title", api_data.get("name", "Unknown Project")),
|
|
description=api_data.get("body", api_data.get("description", "")),
|
|
state=ProjectState.ACTIVE, # Default to active
|
|
milestones=[], # Will be populated separately
|
|
created_at=created_at,
|
|
updated_at=updated_at
|
|
)
|
|
|
|
def _map_api_milestone_to_domain(self, api_data: Dict[str, Any]) -> Milestone:
|
|
"""Map Gitea API milestone data to domain Milestone object."""
|
|
created_at = datetime.fromisoformat(api_data["created_at"].replace("Z", "+00:00"))
|
|
updated_at = datetime.fromisoformat(api_data["updated_at"].replace("Z", "+00:00"))
|
|
|
|
due_date = None
|
|
if api_data.get("due_on"):
|
|
due_date = datetime.fromisoformat(api_data["due_on"].replace("Z", "+00:00"))
|
|
|
|
return Milestone(
|
|
id=api_data["id"],
|
|
title=api_data["title"],
|
|
description=api_data.get("description", ""),
|
|
state=api_data.get("state", "open"),
|
|
open_issues=api_data.get("open_issues", 0),
|
|
closed_issues=api_data.get("closed_issues", 0),
|
|
due_date=due_date,
|
|
created_at=created_at,
|
|
updated_at=updated_at
|
|
)
|
|
|
|
async def _handle_response_errors(self, response: aiohttp.ClientResponse, context: ErrorContext):
|
|
"""Handle HTTP response errors and convert to appropriate exceptions."""
|
|
# Reuse the same error handling logic from GiteaIssueRepository
|
|
if response.status == 200 or response.status == 201:
|
|
return
|
|
|
|
response_text = await response.text()
|
|
|
|
if response.status == 404:
|
|
resource_id = context.resource_id or "unknown"
|
|
raise ResourceNotFoundError(context.resource_type, resource_id, context)
|
|
|
|
elif response.status >= 400:
|
|
raise GiteaApiError(
|
|
response.status,
|
|
response_text,
|
|
str(response.url),
|
|
context
|
|
) |