""" 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 }