Files
markitect-main/tddai/project_manager.py
tegwick fd8f792f08 refactor: Factor out Gitea interfacing into clean facade pattern
- Create new gitea/ package with clean API facade
- Establish proper separation of concerns: tddai uses gitea, not vice versa
- Replace duplicate curl+subprocess patterns with unified HTTP client
- Add rich domain models with properties (issue.priority, issue.status)
- Maintain full backwards compatibility in tddai modules
- Reduce code complexity: -373 lines, +151 lines (net -222 lines)
- Improve testability and maintainability through clean interfaces

Architecture:
- gitea.client.GiteaClient - main facade with sub-clients
- gitea.api_client - high-level API with model conversion
- gitea.http_client - low-level HTTP operations
- gitea.models - rich domain objects (Issue, Milestone, Label)
- gitea.config - gitea-specific configuration
- gitea.exceptions - clean exception hierarchy

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 14:25:40 +02:00

226 lines
8.8 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 .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)."""
# 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 Exception as e:
raise IssueError(f"API call failed: {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: 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
}