""" Local file backend plugin for issue management. This plugin provides offline issue management using markdown files with YAML frontmatter. """ import os import re import yaml import subprocess from datetime import datetime from pathlib import Path from typing import List, Optional, Dict, Any from ..base import IssueBackend from domain.issues.models import Issue, IssueState, Label class LocalPlugin(IssueBackend): """Local file-based backend plugin.""" def __init__(self, config: Dict[str, Any]): """Initialize local plugin with configuration.""" super().__init__(config) self.issues_dir = Path(config.get('directory', '.markitect/issues')) self.auto_git = config.get('auto_git', True) self._setup_directory_structure() self._load_local_config() def _setup_directory_structure(self): """Create necessary directory structure.""" self.issues_dir.mkdir(parents=True, exist_ok=True) (self.issues_dir / 'open').mkdir(exist_ok=True) (self.issues_dir / 'closed').mkdir(exist_ok=True) def _load_local_config(self): """Load or create local configuration.""" config_file = self.issues_dir / 'config.yml' if config_file.exists(): with open(config_file, 'r') as f: self.local_config = yaml.safe_load(f) or {} else: self.local_config = {'next_issue_number': self.config.get('numbering_start', 1)} self._save_local_config() def _save_local_config(self): """Save local configuration.""" config_file = self.issues_dir / 'config.yml' with open(config_file, 'w') as f: yaml.dump(self.local_config, f, default_flow_style=False) def list_issues(self, state: Optional[str] = None) -> List[Issue]: """List issues from local files.""" issues = [] if state == 'open' or state is None or state == 'all': issues.extend(self._read_issues_from_directory(self.issues_dir / 'open')) if state == 'closed' or state is None or state == 'all': issues.extend(self._read_issues_from_directory(self.issues_dir / 'closed')) # Sort by issue number issues.sort(key=lambda x: x.number) return issues def _read_issues_from_directory(self, directory: Path) -> List[Issue]: """Read all issues from a directory.""" issues = [] if not directory.exists(): return issues for file_path in directory.glob('*.md'): try: issue = self._read_issue_file(file_path) issues.append(issue) except Exception: # Skip malformed files continue return issues def _read_issue_file(self, file_path: Path) -> Issue: """Read issue from markdown file with YAML frontmatter.""" with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Split frontmatter and body if content.startswith('---\n'): try: parts = content.split('---\n', 2) if len(parts) >= 3: frontmatter = yaml.safe_load(parts[1]) body = parts[2].strip() else: frontmatter = {} body = content except yaml.YAMLError: raise yaml.YAMLError(f"Invalid YAML in {file_path}") else: frontmatter = {} body = content # Convert string labels to Label objects label_objects = [] for label in frontmatter.get('labels', []): if isinstance(label, str): label_objects.append(Label(name=label)) else: label_objects.append(label) # Map state string to IssueState enum state_str = frontmatter.get('state', 'open') issue_state = IssueState.OPEN if state_str == 'open' else IssueState.CLOSED # Create Issue object issue = Issue( number=frontmatter.get('number', 0), title=frontmatter.get('title', ''), state=issue_state, labels=label_objects, created_at=datetime.fromisoformat(frontmatter.get('created_at', datetime.now().isoformat())), updated_at=datetime.fromisoformat(frontmatter.get('updated_at', datetime.now().isoformat())), assignee=frontmatter.get('assignee'), milestone=frontmatter.get('milestone') ) # Store body separately since domain model doesn't have it issue._body = body return issue def get_issue(self, issue_id: str) -> Issue: """Get specific issue by ID.""" file_path = self._find_issue_file(issue_id) if not file_path: raise FileNotFoundError(f"Issue {issue_id} not found") return self._read_issue_file(file_path) def _find_issue_file(self, issue_id: str) -> Optional[Path]: """Find issue file in open or closed directories.""" # Convert issue_id to 3-digit format to match filename pattern issue_num = f"{int(issue_id):03d}" pattern = f"{issue_num}-*.md" # Search in open directory for file_path in (self.issues_dir / 'open').glob(pattern): return file_path # Search in closed directory for file_path in (self.issues_dir / 'closed').glob(pattern): return file_path return None def create_issue(self, title: str, body: str, **kwargs) -> Issue: """Create new issue as local file.""" issue_number = self.local_config.get('next_issue_number', 1) # Convert string labels to Label objects label_objects = [] for label in kwargs.get('labels', []): if isinstance(label, str): label_objects.append(Label(name=label)) else: label_objects.append(label) # Create Issue object issue = Issue( number=issue_number, title=title, state=IssueState.OPEN, labels=label_objects, created_at=datetime.now(), updated_at=datetime.now(), assignee=kwargs.get('assignee'), milestone=kwargs.get('milestone') ) # Store body separately since domain model doesn't have it issue._body = body # Temporary storage for body content # Write to file self._write_issue_file(issue, self.issues_dir / 'open') # Update counter self.local_config['next_issue_number'] = issue_number + 1 self._save_local_config() # Git integration if self.auto_git: self._git_add_and_commit(f"Create issue #{issue_number}: {title}") return issue def _write_issue_file(self, issue: Issue, directory: Path): """Write issue to markdown file with YAML frontmatter.""" filename = self._generate_filename(issue) file_path = directory / filename # Convert Label objects to strings for YAML label_names = [label.name for label in issue.labels] # Prepare frontmatter frontmatter = { 'number': issue.number, 'title': issue.title, 'state': issue.state.value, 'created_at': issue.created_at.isoformat(), 'updated_at': issue.updated_at.isoformat(), 'labels': label_names, 'assignee': issue.assignee, 'milestone': issue.milestone } # Write file with open(file_path, 'w', encoding='utf-8') as f: f.write('---\n') yaml.dump(frontmatter, f, default_flow_style=False) f.write('---\n\n') f.write(getattr(issue, '_body', '')) def _generate_filename(self, issue: Issue) -> str: """Generate safe filename from issue.""" # Sanitize title for filename safe_title = re.sub(r'[^\w\s-]', '', issue.title.lower()) safe_title = re.sub(r'[\s_-]+', '-', safe_title) safe_title = safe_title.strip('-')[:50] # Limit length return f"{issue.number:03d}-{safe_title}.md" def add_comment(self, issue_id: str, comment: str) -> Dict[str, Any]: """Add comment to local issue file.""" if not comment.strip(): raise ValueError("Comment cannot be empty") if not issue_id.strip(): raise ValueError("Issue ID cannot be empty") # For now, return mock comment data # Full implementation would append to issue file return { 'id': f"comment_{datetime.now().timestamp()}", 'body': comment, 'timestamp': datetime.now().isoformat() } def close_issue(self, issue_id: str) -> Issue: """Close issue by moving to closed directory.""" return self.update_issue(issue_id, state=IssueState.CLOSED) def update_issue(self, issue_id: str, **kwargs) -> Issue: """Update issue properties.""" file_path = self._find_issue_file(issue_id) if not file_path: raise FileNotFoundError(f"Issue {issue_id} not found") # Read current issue issue = self._read_issue_file(file_path) # Update properties for key, value in kwargs.items(): if hasattr(issue, key): setattr(issue, key, value) # Handle state change (move file if needed) old_state = 'open' if 'open' in str(file_path) else 'closed' new_state_obj = kwargs.get('state', issue.state) new_state = new_state_obj.value if hasattr(new_state_obj, 'value') else str(new_state_obj) if new_state != old_state: # Remove old file file_path.unlink() # Write to new directory new_directory = self.issues_dir / new_state self._write_issue_file(issue, new_directory) else: # Update existing file self._write_issue_file(issue, file_path.parent) # Git integration if self.auto_git: self._git_add_and_commit(f"Update issue #{issue.number}") return issue def _git_add_and_commit(self, message: str): """Add and commit changes to git.""" try: subprocess.run(['git', 'add', str(self.issues_dir)], cwd='.', check=True, capture_output=True) subprocess.run(['git', 'commit', '-m', message], cwd='.', check=True, capture_output=True) except (subprocess.CalledProcessError, FileNotFoundError): # Git not available or not a git repo, ignore pass