""" Project management functionality for Gitea using milestones and labels. Since Gitea project boards may not be available in all instances, this module provides project management using milestones (for projects) and labels (for states). """ import json import os import subprocess from subprocess import PIPE from typing import Dict, Any, List, Optional from dataclasses import dataclass from enum import Enum from .config import get_config from .exceptions import IssueError class ProjectState(Enum): """Standard project states using labels.""" TODO = "status:todo" ACTIVE = "status:active" REVIEW = "status:review" DONE = "status:done" BLOCKED = "status:blocked" class Priority(Enum): """Priority levels using labels.""" LOW = "priority:low" MEDIUM = "priority:medium" HIGH = "priority:high" CRITICAL = "priority:critical" @dataclass class Milestone: """Represents a project milestone.""" id: int title: str description: str state: str open_issues: int closed_issues: int due_on: Optional[str] = None @dataclass class Label: """Represents an issue label.""" id: int name: str color: str description: str class ProjectManager: """Manages project organization using milestones and labels.""" 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') def _make_api_call(self, method: str, url: str, data: Dict[str, Any] = None) -> Dict[str, Any]: """Make authenticated API call to Gitea.""" if not self.auth_token: raise IssueError("Authentication token required for project operations") cmd = [ 'curl', '-s', '-X', method, '-H', 'Content-Type: application/json', '-H', f'Authorization: token {self.auth_token}', ] if data: cmd.extend(['-d', json.dumps(data)]) cmd.append(url) try: result = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, check=True) if result.returncode != 0: raise IssueError(f"API call failed: {result.stderr}") if result.stdout.strip(): response_data = json.loads(result.stdout) # Check for API error responses if isinstance(response_data, dict) and 'message' in response_data and 'id' not in response_data: raise IssueError(f"API error: {response_data['message']}") return response_data else: return {} except subprocess.CalledProcessError as e: raise IssueError(f"API call failed: {e}") except json.JSONDecodeError as e: raise IssueError(f"Failed to parse API response: {e}") # Milestone Management (Projects) def create_milestone(self, title: str, description: str = "", due_date: str = None) -> Milestone: """Create a new milestone (project).""" url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/milestones" data = { 'title': title, 'description': description, } if due_date: data['due_on'] = due_date response = self._make_api_call('POST', 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 list_milestones(self, state: str = "open") -> List[Milestone]: """List all milestones (projects).""" url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/milestones" params = f"?state={state}" if state else "" response = self._make_api_call('GET', url + params) return [ Milestone( id=m['id'], title=m['title'], description=m.get('description', ''), state=m['state'], open_issues=m['open_issues'], closed_issues=m['closed_issues'], due_on=m.get('due_on') ) for m in response ] 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.""" existing_labels = {label.name for label in self.list_labels()} # Standard state labels required_labels = [ ('status:todo', 'e6e6e6', 'Issues ready to be worked on'), ('status:active', '0052cc', 'Issues currently being worked on'), ('status:review', 'fbca04', 'Issues under review'), ('status:done', '0e8a16', 'Completed issues'), ('status:blocked', 'd93f0b', 'Issues blocked by dependencies'), # Priority labels ('priority:low', 'c2e0c6', 'Low priority issue'), ('priority:medium', 'fef2c0', 'Medium priority issue'), ('priority:high', 'f9d0c4', 'High priority issue'), ('priority:critical', 'f4c2c2', 'Critical priority issue'), # Type labels ('type:bug', 'fc2929', 'Bug report'), ('type:feature', '84b6eb', 'New feature request'), ('type:enhancement', '7057ff', 'Enhancement to existing feature'), ('type:documentation', '0075ca', 'Documentation update'), ] for name, color, description in required_labels: if name not in existing_labels: try: self.create_label(name, color, description) print(f"✅ Created label: {name}") except IssueError as e: print(f"⚠️ Failed to create label {name}: {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.""" # Use the dedicated labels endpoint which works more reliably labels_url = f"{self.config.issues_api_url}/{issue_number}/labels" # First get current labels issue_url = f"{self.config.issues_api_url}/{issue_number}" issue_data = self._make_api_call('GET', issue_url) current_labels = [label['name'] for label in issue_data.get('labels', [])] state_labels = [label for label in current_labels if label.startswith('status:')] # Remove old state labels for old_state in state_labels: if old_state in current_labels: current_labels.remove(old_state) # Add new state label current_labels.append(state.value) # Use PUT to replace all labels on the dedicated labels endpoint data = {'labels': current_labels} return self._make_api_call('PUT', labels_url, data) def set_issue_priority(self, issue_number: int, priority: Priority) -> Dict[str, Any]: """Set issue priority using labels.""" # Use the dedicated labels endpoint which works more reliably labels_url = f"{self.config.issues_api_url}/{issue_number}/labels" # First get current labels issue_url = f"{self.config.issues_api_url}/{issue_number}" issue_data = self._make_api_call('GET', issue_url) current_labels = [label['name'] for label in issue_data.get('labels', [])] priority_labels = [label for label in current_labels if label.startswith('priority:')] # Remove old priority labels for old_priority in priority_labels: if old_priority in current_labels: current_labels.remove(old_priority) # Add new priority label current_labels.append(priority.value) # Use PUT to replace all labels on the dedicated labels endpoint data = {'labels': current_labels} return self._make_api_call('PUT', labels_url, data) def move_issue_to_done(self, issue_number: int) -> Dict[str, Any]: """Move issue to done state and close it.""" # Set state to done self.set_issue_state(issue_number, ProjectState.DONE) # Close the issue url = f"{self.config.issues_api_url}/{issue_number}" data = {'state': 'closed'} return self._make_api_call('PATCH', url, data) 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 }