Implement comprehensive type annotations and mypy configuration as part of code quality initiative. Achieve 100% type annotation coverage for main CLI entry points and resolve Optional type inconsistencies. ## Key Improvements ### CLI Layer (100% Type Coverage) - tddai_cli.py: Complete type annotations for all 21 functions - cli/core.py: Full type coverage for CLI framework (20 functions) - cli/commands/issues.py: Fixed Optional[List[str]] parameter types - cli/commands/workspace.py: Improved type checker logic for Optional handling ### Service Layer Type Safety - services/issue_service.py: Fixed Optional parameter type signatures - services/project_service.py: Updated Optional type annotations - tddai/issue_creator.py: Proper Optional[List[str]] usage - tddai/project_manager.py: Fixed Optional parameter handling ### Mypy Configuration - pyproject.toml: Added comprehensive mypy configuration - Gradual adoption strategy with module-specific strictness - Python 3.12 compatibility for proper type checking - Incremental typing approach for legacy modules ## Technical Details - Proper Optional vs Union type usage throughout - Generic type annotations for collections - Return type annotations for all public functions - Fixed implicit Optional violations (PEP 484) - Type checker logic improvements for better safety ## Benefits - Improved IDE autocomplete and error detection - Compile-time type checking for CLI commands - Better maintainability and debugging capabilities - Foundation for expanding type safety to remaining modules Resolves #27 - Type safety improvements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
9.6 KiB
Python
244 lines
9.6 KiB
Python
"""
|
|
Project management functionality using the Gitea facade.
|
|
|
|
This module now acts as an adapter to the new gitea package,
|
|
maintaining backwards compatibility while using the cleaner API.
|
|
"""
|
|
|
|
import os
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
from gitea import GiteaClient, GiteaConfig
|
|
from gitea.models import ProjectState, Priority, Milestone as GiteaMilestone, Label as GiteaLabel
|
|
from gitea.exceptions import GiteaError, GiteaNotFoundError, GiteaAuthError, GiteaApiError
|
|
from .config import get_config
|
|
from .exceptions import IssueError
|
|
|
|
# Re-export for backwards compatibility
|
|
Milestone = GiteaMilestone
|
|
Label = GiteaLabel
|
|
|
|
|
|
class ProjectManager:
|
|
"""Manages project organization using the Gitea facade."""
|
|
|
|
def __init__(self, config=None, auth_token=None):
|
|
self.config = config or get_config()
|
|
self.auth_token = auth_token or os.getenv('GITEA_API_TOKEN')
|
|
|
|
# Create Gitea client from tddai config
|
|
gitea_config = GiteaConfig.from_tddai_config(self.config)
|
|
if self.auth_token:
|
|
gitea_config.auth_token = self.auth_token
|
|
self.gitea_client = GiteaClient(gitea_config)
|
|
|
|
def _make_api_call(self, method: str, url: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
"""Make authenticated API call to Gitea (kept for backwards compatibility).
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, etc.)
|
|
url: API endpoint URL
|
|
data: Optional request data
|
|
|
|
Raises:
|
|
IssueError: When API call fails (with specific context)
|
|
"""
|
|
# This method is kept for backwards compatibility but now delegates to the gitea client
|
|
# For new code, use the gitea_client directly
|
|
try:
|
|
if method == 'GET' and 'issues' in url and url.endswith('/issues'):
|
|
issues = self.gitea_client.issues.list()
|
|
return [self._issue_to_dict(issue) for issue in issues]
|
|
elif method == 'GET' and '/issues/' in url and not url.endswith('/labels'):
|
|
issue_number = int(url.split('/issues/')[-1])
|
|
issue = self.gitea_client.issues.get(issue_number)
|
|
return self._issue_to_dict(issue)
|
|
else:
|
|
raise IssueError(f"Legacy API call not supported: {method} {url}")
|
|
except GiteaNotFoundError as e:
|
|
raise IssueError(f"Resource not found for {method} {url}") from e
|
|
except GiteaAuthError as e:
|
|
raise IssueError(f"Authentication failed for {method} {url}") from e
|
|
except GiteaApiError as e:
|
|
raise IssueError(f"API error for {method} {url}: {e}") from e
|
|
except GiteaError as e:
|
|
raise IssueError(f"Gitea error for {method} {url}: {e}") from e
|
|
except (ValueError, IndexError) as e:
|
|
raise IssueError(f"Invalid URL format for {method} {url}") from e
|
|
|
|
def _issue_to_dict(self, issue) -> Dict[str, Any]:
|
|
"""Convert Issue object to dict for backwards compatibility."""
|
|
return {
|
|
'number': issue.number,
|
|
'title': issue.title,
|
|
'body': issue.body,
|
|
'state': issue.state,
|
|
'html_url': issue.html_url,
|
|
'created_at': issue.created_at.isoformat(),
|
|
'updated_at': issue.updated_at.isoformat(),
|
|
'assignee': {'login': issue.assignee.login} if issue.assignee else None,
|
|
'labels': [{'name': label.name, 'color': label.color} for label in issue.labels]
|
|
}
|
|
|
|
# Milestone Management (Projects)
|
|
|
|
def create_milestone(self, title: str, description: str = "", due_date: Optional[str] = None) -> Milestone:
|
|
"""Create a new milestone (project)."""
|
|
try:
|
|
return self.gitea_client.milestones.create(title, description, due_date)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to create milestone: {e}")
|
|
|
|
def list_milestones(self, state: str = "open") -> List[Milestone]:
|
|
"""List all milestones (projects)."""
|
|
try:
|
|
if state == "all":
|
|
return self.gitea_client.milestones.list()
|
|
elif state == "open":
|
|
return self.gitea_client.milestones.list_open()
|
|
elif state == "closed":
|
|
return self.gitea_client.milestones.list_closed()
|
|
else:
|
|
return self.gitea_client.milestones.list(state)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to list milestones: {e}")
|
|
|
|
def update_milestone(self, milestone_id: int, **kwargs) -> Milestone:
|
|
"""Update milestone details."""
|
|
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/milestones/{milestone_id}"
|
|
|
|
# Only include fields that can be updated
|
|
valid_fields = ['title', 'description', 'state', 'due_on']
|
|
data = {k: v for k, v in kwargs.items() if k in valid_fields}
|
|
|
|
response = self._make_api_call('PATCH', url, data)
|
|
|
|
return Milestone(
|
|
id=response['id'],
|
|
title=response['title'],
|
|
description=response.get('description', ''),
|
|
state=response['state'],
|
|
open_issues=response['open_issues'],
|
|
closed_issues=response['closed_issues'],
|
|
due_on=response.get('due_on')
|
|
)
|
|
|
|
def close_milestone(self, milestone_id: int) -> Milestone:
|
|
"""Close a milestone (complete project)."""
|
|
return self.update_milestone(milestone_id, state='closed')
|
|
|
|
# Label Management (States & Priority)
|
|
|
|
def create_label(self, name: str, color: str, description: str = "") -> Label:
|
|
"""Create a new label."""
|
|
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/labels"
|
|
|
|
data = {
|
|
'name': name,
|
|
'color': color,
|
|
'description': description
|
|
}
|
|
|
|
response = self._make_api_call('POST', url, data)
|
|
|
|
return Label(
|
|
id=response['id'],
|
|
name=response['name'],
|
|
color=response['color'],
|
|
description=response.get('description', '')
|
|
)
|
|
|
|
def list_labels(self) -> List[Label]:
|
|
"""List all repository labels."""
|
|
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/labels"
|
|
|
|
response = self._make_api_call('GET', url)
|
|
|
|
return [
|
|
Label(
|
|
id=l['id'],
|
|
name=l['name'],
|
|
color=l['color'],
|
|
description=l.get('description', '')
|
|
)
|
|
for l in response
|
|
]
|
|
|
|
def ensure_project_labels(self) -> None:
|
|
"""Ensure all required project management labels exist."""
|
|
try:
|
|
self.gitea_client.labels.ensure_project_labels()
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to ensure project labels: {e}")
|
|
|
|
def list_labels(self) -> List[Label]:
|
|
"""List all repository labels."""
|
|
try:
|
|
return self.gitea_client.labels.list()
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to list labels: {e}")
|
|
|
|
def create_label(self, name: str, color: str, description: str = "") -> Label:
|
|
"""Create a new label."""
|
|
try:
|
|
return self.gitea_client.labels.create(name, color, description)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to create label: {e}")
|
|
|
|
# Project Management Operations
|
|
|
|
def assign_issue_to_milestone(self, issue_number: int, milestone_id: int) -> Dict[str, Any]:
|
|
"""Assign issue to a milestone (project)."""
|
|
url = f"{self.config.issues_api_url}/{issue_number}"
|
|
|
|
data = {'milestone': milestone_id}
|
|
return self._make_api_call('PATCH', url, data)
|
|
|
|
def set_issue_state(self, issue_number: int, state: ProjectState) -> Dict[str, Any]:
|
|
"""Set issue project state using labels."""
|
|
try:
|
|
issue = self.gitea_client.issues.set_status(issue_number, state)
|
|
return self._issue_to_dict(issue)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to set issue state: {e}")
|
|
|
|
def set_issue_priority(self, issue_number: int, priority: Priority) -> Dict[str, Any]:
|
|
"""Set issue priority using labels."""
|
|
try:
|
|
issue = self.gitea_client.issues.set_priority(issue_number, priority)
|
|
return self._issue_to_dict(issue)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to set issue priority: {e}")
|
|
|
|
def move_issue_to_done(self, issue_number: int) -> Dict[str, Any]:
|
|
"""Move issue to done state and close it."""
|
|
try:
|
|
# Set state to done
|
|
self.set_issue_state(issue_number, ProjectState.DONE)
|
|
|
|
# Close the issue
|
|
issue = self.gitea_client.issues.close(issue_number)
|
|
return self._issue_to_dict(issue)
|
|
except Exception as e:
|
|
raise IssueError(f"Failed to move issue to done: {e}")
|
|
|
|
def get_project_overview(self) -> Dict[str, Any]:
|
|
"""Get overview of project status."""
|
|
milestones = self.list_milestones("all")
|
|
labels = self.list_labels()
|
|
|
|
# Count issues by state
|
|
state_counts = {}
|
|
for state in ProjectState:
|
|
state_counts[state.value] = 0
|
|
|
|
# This would require fetching all issues to count by labels
|
|
# For now, return milestone overview
|
|
|
|
return {
|
|
'milestones': len(milestones),
|
|
'active_projects': len([m for m in milestones if m.state == 'open']),
|
|
'completed_projects': len([m for m in milestones if m.state == 'closed']),
|
|
'total_labels': len(labels),
|
|
'project_management_ready': len([l for l in labels if l.name.startswith('status:')]) > 0
|
|
} |