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
- Add GiteaConfig.from_git_repository() method for auto-detection - Support HTTP(S) and SSH git remote URL formats - Parse gitea_url, repo_owner, repo_name from git remote origin - Only requires GITEA_API_TOKEN environment variable - Update GiteaClient to use auto-detection as primary method - Maintain backward compatibility with environment variables - Fix issue creation API to use label IDs instead of names - Add comprehensive error handling and validation - Successfully tested with issues #33 and #34 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
241 lines
9.1 KiB
Python
241 lines
9.1 KiB
Python
"""
|
|
High-level API client that converts between API responses and domain models.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
from .http_client import GiteaHttpClient
|
|
from .models import Issue, Milestone, Label, User, IssueCreateData, IssueUpdateData, MilestoneCreateData, LabelCreateData
|
|
from .config import GiteaConfig
|
|
from .exceptions import GiteaNotFoundError, GiteaError
|
|
|
|
|
|
class GiteaApiClient:
|
|
"""High-level API client with domain model conversion."""
|
|
|
|
def __init__(self, config: GiteaConfig):
|
|
self.config = config
|
|
self.http = GiteaHttpClient(config)
|
|
|
|
# Issue operations
|
|
def get_issue(self, issue_number: int) -> Issue:
|
|
"""Get a specific issue by number."""
|
|
try:
|
|
url = f"{self.config.issues_api_url}/{issue_number}"
|
|
data = self.http.get(url)
|
|
return self._parse_issue(data)
|
|
except GiteaError as e:
|
|
if "not found" in str(e).lower():
|
|
raise GiteaNotFoundError(f"Issue #{issue_number} not found")
|
|
raise
|
|
|
|
def list_issues(self, state: str = "all", page: int = 1, per_page: int = 50) -> List[Issue]:
|
|
"""List issues with optional filtering."""
|
|
params = {"page": str(page), "limit": str(per_page)}
|
|
if state != "all":
|
|
params["state"] = state
|
|
|
|
data = self.http.get(self.config.issues_api_url, params)
|
|
|
|
if not isinstance(data, list):
|
|
raise GiteaError("Invalid response format: expected list of issues")
|
|
|
|
return [self._parse_issue(issue_data) for issue_data in data]
|
|
|
|
def create_issue(self, issue_data: IssueCreateData) -> Issue:
|
|
"""Create a new issue."""
|
|
payload = {
|
|
"title": issue_data.title,
|
|
"body": issue_data.body,
|
|
}
|
|
|
|
if issue_data.assignees:
|
|
payload["assignees"] = issue_data.assignees
|
|
if issue_data.milestone:
|
|
payload["milestone"] = issue_data.milestone
|
|
if issue_data.labels:
|
|
# Convert label names to label IDs
|
|
payload["labels"] = self._resolve_label_ids(issue_data.labels)
|
|
|
|
data = self.http.post(self.config.issues_api_url, payload)
|
|
return self._parse_issue(data)
|
|
|
|
def update_issue(self, issue_number: int, update_data: IssueUpdateData) -> Issue:
|
|
"""Update an existing issue."""
|
|
payload = {}
|
|
|
|
if update_data.title is not None:
|
|
payload["title"] = update_data.title
|
|
if update_data.body is not None:
|
|
payload["body"] = update_data.body
|
|
if update_data.state is not None:
|
|
payload["state"] = update_data.state
|
|
if update_data.assignees is not None:
|
|
payload["assignees"] = update_data.assignees
|
|
if update_data.milestone is not None:
|
|
payload["milestone"] = update_data.milestone
|
|
if update_data.labels is not None:
|
|
payload["labels"] = update_data.labels
|
|
|
|
url = f"{self.config.issues_api_url}/{issue_number}"
|
|
data = self.http.patch(url, payload)
|
|
return self._parse_issue(data)
|
|
|
|
# Milestone operations
|
|
def list_milestones(self, state: str = "all") -> List[Milestone]:
|
|
"""List repository milestones."""
|
|
params = {}
|
|
if state != "all":
|
|
params["state"] = state
|
|
|
|
data = self.http.get(self.config.milestones_api_url, params)
|
|
|
|
if not isinstance(data, list):
|
|
raise GiteaError("Invalid response format: expected list of milestones")
|
|
|
|
return [self._parse_milestone(milestone_data) for milestone_data in data]
|
|
|
|
def create_milestone(self, milestone_data: MilestoneCreateData) -> Milestone:
|
|
"""Create a new milestone."""
|
|
payload = {
|
|
"title": milestone_data.title,
|
|
"description": milestone_data.description,
|
|
}
|
|
|
|
if milestone_data.due_on:
|
|
payload["due_on"] = milestone_data.due_on
|
|
|
|
data = self.http.post(self.config.milestones_api_url, payload)
|
|
return self._parse_milestone(data)
|
|
|
|
# Label operations
|
|
def list_labels(self) -> List[Label]:
|
|
"""List repository labels."""
|
|
data = self.http.get(self.config.labels_api_url)
|
|
|
|
if not isinstance(data, list):
|
|
raise GiteaError("Invalid response format: expected list of labels")
|
|
|
|
return [self._parse_label(label_data) for label_data in data]
|
|
|
|
def create_label(self, label_data: LabelCreateData) -> Label:
|
|
"""Create a new label."""
|
|
payload = {
|
|
"name": label_data.name,
|
|
"color": label_data.color,
|
|
"description": label_data.description,
|
|
}
|
|
|
|
data = self.http.post(self.config.labels_api_url, payload)
|
|
return self._parse_label(data)
|
|
|
|
# Parsing methods
|
|
def _parse_issue(self, data: Dict[str, Any]) -> Issue:
|
|
"""Parse issue data from API response."""
|
|
try:
|
|
# Parse labels
|
|
labels = []
|
|
if data.get('labels'):
|
|
labels = [self._parse_label(label_data) for label_data in data['labels']]
|
|
|
|
# Parse assignee
|
|
assignee = None
|
|
if data.get('assignee'):
|
|
assignee = self._parse_user(data['assignee'])
|
|
|
|
# Parse milestone
|
|
milestone = None
|
|
if data.get('milestone'):
|
|
milestone = self._parse_milestone(data['milestone'])
|
|
|
|
# Check if this is an error response
|
|
if 'message' in data and 'url' in data and 'number' not in data and 'id' not in data:
|
|
raise GiteaError(f"API Error: {data.get('message', 'Unknown error')} (URL: {data.get('url', 'N/A')})")
|
|
|
|
# Handle both 'number' and 'id' fields (Gitea API might use either)
|
|
issue_number = data.get('number') or data.get('id')
|
|
if issue_number is None:
|
|
raise GiteaError(f"Issue response missing both 'number' and 'id' fields. Available fields: {list(data.keys())}")
|
|
|
|
return Issue(
|
|
number=issue_number,
|
|
title=data['title'],
|
|
body=data.get('body', ''),
|
|
state=data['state'],
|
|
created_at=self._parse_datetime(data['created_at']),
|
|
updated_at=self._parse_datetime(data['updated_at']),
|
|
html_url=data['html_url'],
|
|
assignee=assignee,
|
|
labels=labels,
|
|
milestone=milestone
|
|
)
|
|
except (KeyError, ValueError) as e:
|
|
raise GiteaError(f"Failed to parse issue data: {e}")
|
|
|
|
def _parse_milestone(self, data: Dict[str, Any]) -> Milestone:
|
|
"""Parse milestone data from API response."""
|
|
return Milestone(
|
|
id=data['id'],
|
|
title=data['title'],
|
|
description=data.get('description', ''),
|
|
state=data['state'],
|
|
open_issues=data.get('open_issues', 0),
|
|
closed_issues=data.get('closed_issues', 0),
|
|
due_on=data.get('due_on'),
|
|
created_at=self._parse_datetime(data.get('created_at')) if data.get('created_at') else None,
|
|
updated_at=self._parse_datetime(data.get('updated_at')) if data.get('updated_at') else None
|
|
)
|
|
|
|
def _parse_label(self, data: Dict[str, Any]) -> Label:
|
|
"""Parse label data from API response."""
|
|
return Label(
|
|
id=data['id'],
|
|
name=data['name'],
|
|
color=data['color'],
|
|
description=data.get('description', '')
|
|
)
|
|
|
|
def _parse_user(self, data: Dict[str, Any]) -> User:
|
|
"""Parse user data from API response."""
|
|
return User(
|
|
id=data['id'],
|
|
login=data['login'],
|
|
full_name=data.get('full_name', ''),
|
|
email=data.get('email', ''),
|
|
avatar_url=data.get('avatar_url', '')
|
|
)
|
|
|
|
def _parse_datetime(self, date_str: str) -> datetime:
|
|
"""Parse datetime from API response."""
|
|
# Remove Z and microseconds for consistent parsing
|
|
date_str = date_str.replace('Z', '').split('.')[0]
|
|
return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
|
|
|
|
def _resolve_label_ids(self, label_names: List[str]) -> List[int]:
|
|
"""Convert label names to label IDs for API calls."""
|
|
try:
|
|
# Get all labels for the repository
|
|
labels_data = self.http.get(self.config.labels_api_url)
|
|
if not isinstance(labels_data, list):
|
|
raise GiteaError("Invalid labels response format")
|
|
|
|
# Create name-to-ID mapping
|
|
label_map = {label_data['name']: label_data['id'] for label_data in labels_data}
|
|
|
|
# Resolve names to IDs
|
|
label_ids = []
|
|
for name in label_names:
|
|
if name in label_map:
|
|
label_ids.append(label_map[name])
|
|
else:
|
|
# If label doesn't exist, we could create it or skip it
|
|
# For now, let's skip non-existent labels
|
|
print(f"Warning: Label '{name}' not found, skipping")
|
|
|
|
return label_ids
|
|
|
|
except Exception as e:
|
|
# If label resolution fails, proceed without labels rather than failing entirely
|
|
print(f"Warning: Could not resolve labels: {e}")
|
|
return [] |