300 lines
10 KiB
Python
300 lines
10 KiB
Python
|
|
"""
|
||
|
|
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
|