From 2b681b31c65941b064a215280e3793006cd6b82e Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 24 Sep 2025 23:51:29 +0200 Subject: [PATCH] feat: Implement comprehensive project management system with issue lifecycle support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProjectManager with milestone and label-based project organization - Support project states (Todo, Active, Review, Done, Blocked) via labels - Add priority management (Low, Medium, High, Critical) with label integration - Implement milestone creation and management for project tracking - Enhance IssueWriter with project management methods (assign_to_milestone, add/remove_labels) - Add 8 new CLI commands for complete project management workflow - Support automatic project management setup with ensure_project_labels() - Enable issue state transitions with automatic closing for completed issues - Integrate with existing Gitea API authentication and error handling patterns šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tddai/__init__.py | 4 + tddai/issue_writer.py | 58 ++++++- tddai/project_manager.py | 322 +++++++++++++++++++++++++++++++++++++++ tddai_cli.py | 203 ++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 tddai/project_manager.py diff --git a/tddai/__init__.py b/tddai/__init__.py index 46576bf6..3dab4e1e 100644 --- a/tddai/__init__.py +++ b/tddai/__init__.py @@ -8,6 +8,7 @@ Provides workspace management, test generation, and issue integration. from .workspace import WorkspaceManager, Workspace, WorkspaceStatus from .issue_fetcher import IssueFetcher, Issue from .issue_creator import IssueCreator +from .project_manager import ProjectManager, ProjectState, Priority from .test_generator import TestGenerator from .coverage_analyzer import CoverageAnalyzer, CoverageAssessment, TestRequirement, CoverageGap from .exceptions import TddaiError, WorkspaceError, IssueError, ConfigurationError, TestGenerationError @@ -20,6 +21,9 @@ __all__ = [ "IssueFetcher", "Issue", "IssueCreator", + "ProjectManager", + "ProjectState", + "Priority", "TestGenerator", "CoverageAnalyzer", "CoverageAssessment", diff --git a/tddai/issue_writer.py b/tddai/issue_writer.py index bf84925e..b0c8feb6 100644 --- a/tddai/issue_writer.py +++ b/tddai/issue_writer.py @@ -79,4 +79,60 @@ class IssueWriter: def reopen_issue(self, issue_number: int) -> Dict[str, Any]: """Reopen a closed issue.""" - return self.update_issue_state(issue_number, 'open') \ No newline at end of file + return self.update_issue_state(issue_number, 'open') + + def assign_to_milestone(self, issue_number: int, milestone_id: int) -> Dict[str, Any]: + """Assign issue to a milestone (project).""" + return self.update_issue(issue_number, {'milestone': milestone_id}) + + def remove_from_milestone(self, issue_number: int) -> Dict[str, Any]: + """Remove issue from its current milestone.""" + return self.update_issue(issue_number, {'milestone': None}) + + def update_labels(self, issue_number: int, labels: list) -> Dict[str, Any]: + """Update issue labels completely.""" + return self.update_issue(issue_number, {'labels': labels}) + + def add_labels(self, issue_number: int, new_labels: list) -> Dict[str, Any]: + """Add labels to issue (preserving existing labels).""" + # First get current labels + url = f"{self.config.issues_api_url}/{issue_number}" + curl_cmd = [ + 'curl', '-s', '-X', 'GET', + '-H', f'Authorization: token {self.auth_token}', + url + ] + + try: + result = subprocess.run(curl_cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, check=True) + issue_data = json.loads(result.stdout) + current_labels = [label['name'] for label in issue_data.get('labels', [])] + + # Add new labels (avoid duplicates) + updated_labels = list(set(current_labels + new_labels)) + return self.update_labels(issue_number, updated_labels) + + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + raise IssueError(f"Failed to add labels to issue #{issue_number}: {e}") + + def remove_labels(self, issue_number: int, labels_to_remove: list) -> Dict[str, Any]: + """Remove specific labels from issue.""" + # First get current labels + url = f"{self.config.issues_api_url}/{issue_number}" + curl_cmd = [ + 'curl', '-s', '-X', 'GET', + '-H', f'Authorization: token {self.auth_token}', + url + ] + + try: + result = subprocess.run(curl_cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, check=True) + issue_data = json.loads(result.stdout) + current_labels = [label['name'] for label in issue_data.get('labels', [])] + + # Remove specified labels + updated_labels = [label for label in current_labels if label not in labels_to_remove] + return self.update_labels(issue_number, updated_labels) + + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + raise IssueError(f"Failed to remove labels from issue #{issue_number}: {e}") \ No newline at end of file diff --git a/tddai/project_manager.py b/tddai/project_manager.py new file mode 100644 index 00000000..370a38c5 --- /dev/null +++ b/tddai/project_manager.py @@ -0,0 +1,322 @@ +""" +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.""" + # First remove any existing state 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) + + # Update issue with new labels + data = {'labels': current_labels} + return self._make_api_call('PATCH', issue_url, data) + + def set_issue_priority(self, issue_number: int, priority: Priority) -> Dict[str, Any]: + """Set issue priority using 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) + + # Update issue with new labels + data = {'labels': current_labels} + return self._make_api_call('PATCH', issue_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 + } \ No newline at end of file diff --git a/tddai_cli.py b/tddai_cli.py index 07cbeda2..4948f5ef 100644 --- a/tddai_cli.py +++ b/tddai_cli.py @@ -15,6 +15,7 @@ from tddai import ( WorkspaceStatus, TddaiError ) from tddai.issue_creator import IssueCreator +from tddai.project_manager import ProjectManager, ProjectState, Priority def workspace_status(): @@ -460,6 +461,172 @@ def create_from_template(template_file: str, **kwargs): sys.exit(1) +def setup_project_management(): + """Setup project management labels and milestones.""" + try: + project_mgr = ProjectManager() + print("šŸš€ Setting up project management system...") + + # Ensure all required labels exist + project_mgr.ensure_project_labels() + + print("āœ… Project management setup complete!") + print("šŸ“‹ Available states: todo, active, review, done, blocked") + print("šŸ“Š Available priorities: low, medium, high, critical") + + except TddaiError as e: + print(f"āŒ Error setting up project management: {e}") + sys.exit(1) + + +def move_issue_to_state(issue_number: int, state: str): + """Move issue to a specific project state.""" + try: + project_mgr = ProjectManager() + + # Convert string to ProjectState enum + state_map = { + 'todo': ProjectState.TODO, + 'active': ProjectState.ACTIVE, + 'review': ProjectState.REVIEW, + 'done': ProjectState.DONE, + 'blocked': ProjectState.BLOCKED + } + + if state not in state_map: + print(f"āŒ Invalid state '{state}'. Valid states: {list(state_map.keys())}") + sys.exit(1) + + project_state = state_map[state] + print(f"šŸ“‹ Moving issue #{issue_number} to {state} state...") + + result = project_mgr.set_issue_state(issue_number, project_state) + + # If moving to done, also close the issue + if state == 'done': + project_mgr.move_issue_to_done(issue_number) + print(f"āœ… Issue #{issue_number} moved to {state} and closed") + else: + print(f"āœ… Issue #{issue_number} moved to {state}") + + except TddaiError as e: + print(f"āŒ Error moving issue to {state}: {e}") + sys.exit(1) + + +def set_issue_priority(issue_number: int, priority: str): + """Set issue priority.""" + try: + project_mgr = ProjectManager() + + # Convert string to Priority enum + priority_map = { + 'low': Priority.LOW, + 'medium': Priority.MEDIUM, + 'high': Priority.HIGH, + 'critical': Priority.CRITICAL + } + + if priority not in priority_map: + print(f"āŒ Invalid priority '{priority}'. Valid priorities: {list(priority_map.keys())}") + sys.exit(1) + + priority_level = priority_map[priority] + print(f"šŸ“Š Setting issue #{issue_number} priority to {priority}...") + + result = project_mgr.set_issue_priority(issue_number, priority_level) + print(f"āœ… Issue #{issue_number} priority set to {priority}") + + except TddaiError as e: + print(f"āŒ Error setting issue priority: {e}") + sys.exit(1) + + +def create_milestone(title: str, description: str = ""): + """Create a new milestone (project).""" + try: + project_mgr = ProjectManager() + print(f"šŸš€ Creating milestone: {title}") + + milestone = project_mgr.create_milestone(title, description) + + print(f"āœ… Milestone created successfully!") + print(f" ID: {milestone.id}") + print(f" Title: {milestone.title}") + print(f" Description: {milestone.description}") + print(f" State: {milestone.state}") + + except TddaiError as e: + print(f"āŒ Error creating milestone: {e}") + sys.exit(1) + + +def list_milestones(): + """List all milestones.""" + try: + project_mgr = ProjectManager() + print("šŸ“‹ Project Milestones") + print("====================") + print() + + milestones = project_mgr.list_milestones("all") + if not milestones: + print("No milestones found") + return + + for milestone in milestones: + status_icon = "🟢" if milestone.state == "open" else "šŸ”“" + print(f"{status_icon} Milestone #{milestone.id}: {milestone.title}") + print(f" State: {milestone.state.upper()}") + print(f" Issues: {milestone.open_issues} open, {milestone.closed_issues} closed") + if milestone.description: + print(f" Description: {milestone.description}") + if milestone.due_on: + print(f" Due: {milestone.due_on}") + print() + + except TddaiError as e: + print(f"āŒ Error listing milestones: {e}") + sys.exit(1) + + +def assign_issue_to_milestone(issue_number: int, milestone_id: int): + """Assign issue to a milestone.""" + try: + from tddai.issue_writer import IssueWriter + writer = IssueWriter() + + print(f"šŸ“‹ Assigning issue #{issue_number} to milestone #{milestone_id}...") + + result = writer.assign_to_milestone(issue_number, milestone_id) + print(f"āœ… Issue #{issue_number} assigned to milestone #{milestone_id}") + + except TddaiError as e: + print(f"āŒ Error assigning issue to milestone: {e}") + sys.exit(1) + + +def project_overview(): + """Show project management overview.""" + try: + project_mgr = ProjectManager() + print("šŸ“Š Project Management Overview") + print("==============================") + print() + + overview = project_mgr.get_project_overview() + + print(f"šŸ“‹ Milestones: {overview['milestones']} total") + print(f" Active Projects: {overview['active_projects']}") + print(f" Completed Projects: {overview['completed_projects']}") + print(f"šŸ·ļø Total Labels: {overview['total_labels']}") + print(f"šŸŽÆ Project Management Ready: {'āœ… Yes' if overview['project_management_ready'] else 'āŒ No - run setup-project-mgmt'}") + + except TddaiError as e: + print(f"āŒ Error getting project overview: {e}") + sys.exit(1) + + def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser(description="tddai CLI tool") @@ -502,6 +669,28 @@ def main(): template_parser.add_argument('template_file', help='Template file path') template_parser.add_argument('--vars', help='Template variables in key=value format', nargs='*', default=[]) + # Project management commands + subparsers.add_parser('setup-project-mgmt', help='Setup project management labels and milestones') + subparsers.add_parser('project-overview', help='Show project management overview') + + state_parser = subparsers.add_parser('set-issue-state', help='Set issue project state') + state_parser.add_argument('issue_number', type=int, help='Issue number') + state_parser.add_argument('state', choices=['todo', 'active', 'review', 'done', 'blocked'], help='Project state') + + priority_parser = subparsers.add_parser('set-issue-priority', help='Set issue priority') + priority_parser.add_argument('issue_number', type=int, help='Issue number') + priority_parser.add_argument('priority', choices=['low', 'medium', 'high', 'critical'], help='Priority level') + + milestone_parser = subparsers.add_parser('create-milestone', help='Create a new milestone (project)') + milestone_parser.add_argument('title', help='Milestone title') + milestone_parser.add_argument('--description', help='Milestone description', default='') + + subparsers.add_parser('list-milestones', help='List all milestones') + + assign_parser = subparsers.add_parser('assign-to-milestone', help='Assign issue to milestone') + assign_parser.add_argument('issue_number', type=int, help='Issue number') + assign_parser.add_argument('milestone_id', type=int, help='Milestone ID') + args = parser.parse_args() if not args.command: @@ -540,6 +729,20 @@ def main(): key, value = var.split('=', 1) template_vars[key] = value create_from_template(args.template_file, **template_vars) + elif args.command == 'setup-project-mgmt': + setup_project_management() + elif args.command == 'project-overview': + project_overview() + elif args.command == 'set-issue-state': + move_issue_to_state(args.issue_number, args.state) + elif args.command == 'set-issue-priority': + set_issue_priority(args.issue_number, args.priority) + elif args.command == 'create-milestone': + create_milestone(args.title, args.description) + elif args.command == 'list-milestones': + list_milestones() + elif args.command == 'assign-to-milestone': + assign_issue_to_milestone(args.issue_number, args.milestone_id) except KeyboardInterrupt: print("\nāš ļø Operation cancelled") sys.exit(1)