diff --git a/.capability/README.md b/.capability/README.md new file mode 100644 index 0000000..264664d --- /dev/null +++ b/.capability/README.md @@ -0,0 +1,426 @@ +# Capability Bootstrap System + +**How coding agents discover and integrate the issue-facade capability.** + +## Design Philosophy + +**Problem:** Coding agents might bypass capabilities and use direct API calls, causing: +- Credential management chaos +- Token waste +- Platform lock-in +- Race conditions +- Inconsistent state + +**Solution:** Make capabilities **self-describing** and **easy to discover**, so agents naturally prefer them over alternatives. + +## Architecture + +### 1. Self-Description (Machine-Readable) + +**File:** `CAPABILITY.yaml` + +Contains machine-readable metadata that agents and tooling can parse: +- What the capability does +- When to use it vs. alternatives +- How to integrate it +- API surface +- Priority score (how critical it is) + +**Usage:** +```bash +# Tools can parse this to understand capabilities +yq eval '.purpose.primary' CAPABILITY.yaml +# Output: "Agent coordination via issue tracking" +``` + +### 2. Agent Context (Human + AI Readable) + +**File:** `.capability/agent-context.md` + +Comprehensive guide for coding agents: +- Quick reference API +- Common patterns +- Critical "DO NOT bypass" warnings +- Error handling +- Examples + +**Injected into:** `.claude/capabilities/issue-facade.md` in main project + +### 3. Integration Automation + +**File:** `.capability/integrate.sh` + +Interactive script that: +- Installs the capability +- Configures backends +- Injects context into Claude Code +- Creates slash commands +- Verifies setup + +**Usage:** +```bash +make integrate +# or +cd capabilities/issue-facade && ./.capability/integrate.sh +``` + +### 4. Integration Checklist + +**File:** `.capability/integration-checklist.md` + +Step-by-step checklist for humans integrating the capability: +- Pre-integration checks +- Installation steps +- Verification tests +- Security review +- Troubleshooting + +## Integration Flow + +### For Main Project (One-Time Setup) + +``` +Main Project Setup +├── 1. Human runs: cd capabilities/issue-facade && make integrate +├── 2. Script installs capability +├── 3. Script configures backend (prompts for credentials) +├── 4. Script copies agent-context.md → .claude/capabilities/ +├── 5. Script creates .claude/commands/use-issues.md +└── 6. Script verifies setup +``` + +**Result:** +- `issue` command available system-wide +- Backend configured with tokens +- Claude Code knows about capability +- Agents can discover via context files + +### For Coding Agents (Automatic) + +``` +Agent Workflow +├── 1. Agent receives task involving issues +├── 2. Agent checks .claude/capabilities/ for relevant docs +├── 3. Agent finds issue-facade.md with comprehensive guide +├── 4. Agent uses Python API or CLI as documented +└── 5. Agent avoids direct API calls (warned in docs) +``` + +**Key Files Agent Reads:** +- `.claude/capabilities/issue-facade.md` - Complete usage guide +- `.claude/context/capabilities.md` - High-level capability list +- `.claude/commands/use-issues.md` - Slash command for context injection + +## Main Project Structure (After Integration) + +``` +project-root/ +├── capabilities/ +│ └── issue-facade/ # Capability code +│ ├── CAPABILITY.yaml # Machine-readable metadata +│ ├── .capability/ +│ │ ├── agent-context.md # Agent guide (source) +│ │ ├── integrate.sh # Integration script +│ │ └── README.md # This file +│ ├── issue_tracker/ # Python package +│ └── ... +│ +├── .claude/ # Claude Code configuration +│ ├── capabilities/ # Capability docs for agents +│ │ └── issue-facade.md # Agent guide (copy) +│ │ +│ ├── commands/ # Slash commands +│ │ └── use-issues.md # /use-issues command +│ │ +│ └── context/ # Always-available context +│ └── capabilities.md # List of all capabilities +│ +└── .issue-facade/ # Capability config (gitignored) + ├── config.json # Backend configuration + └── issues.db # Local cache/backup +``` + +## For Agent Developers + +### How to Make Your Agent Use the Capability + +**1. Check for capability context:** +```python +import os +from pathlib import Path + +def has_issue_capability(project_root: Path) -> bool: + """Check if issue-facade capability is available.""" + capability_guide = project_root / ".claude/capabilities/issue-facade.md" + return capability_guide.exists() + +if has_issue_capability(Path.cwd()): + # Use capability + from issue_tracker.backends.gitea import GiteaBackend + backend = GiteaBackend() +else: + # Fall back or prompt human + print("Issue capability not available. Please integrate it.") +``` + +**2. Read the context:** +```python +def get_capability_docs(capability_name: str) -> str: + """Read capability documentation.""" + doc_path = Path(f".claude/capabilities/{capability_name}.md") + if doc_path.exists(): + return doc_path.read_text() + return None + +# Agent can read and understand the guide +docs = get_capability_docs("issue-facade") +# Parse docs for API usage patterns... +``` + +**3. Use the API as documented:** +```python +# Example from agent-context.md +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.interfaces import IssueFilter + +backend = GiteaBackend() +backend.connect(config) +issues = backend.list_issues(IssueFilter(state='open')) +``` + +### How to Avoid Bypassing + +**Bad (Bypasses capability):** +```python +# ❌ Direct API call +import requests +response = requests.post( + f"{gitea_url}/api/v1/repos/{owner}/{repo}/issues", + json={"title": "Bug"}, + headers={"Authorization": f"token {token}"} +) +``` + +**Good (Uses capability):** +```python +# ✅ Uses capability +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, IssueState +from datetime import datetime, timezone + +backend = GiteaBackend() +backend.connect(config) +issue = Issue( + id=None, number=0, + title="Bug", + description="Details", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) +) +backend.create_issue(issue) +``` + +## For Capability Developers + +### Adding a New Capability to This System + +**1. Create capability structure:** +``` +capabilities/your-capability/ +├── CAPABILITY.yaml # Metadata +├── .capability/ +│ ├── agent-context.md # Agent guide +│ ├── integrate.sh # Integration script +│ ├── integration-checklist.md # Human checklist +│ └── README.md # This doc adapted +└── your_package/ # Implementation +``` + +**2. Write CAPABILITY.yaml:** +```yaml +metadata: + name: your-capability + version: 1.0.0 + type: tool | library | service + description: What it does + +purpose: + primary: Main purpose + problems_solved: + - Problem 1 + - Problem 2 + +usage_rules: + MUST_USE_INSTEAD_OF: + - "Alternative 1 to avoid" + - "Alternative 2 to avoid" + +api: + core_operations: + - name: operation_1 + python: "from your_package import ..." + cli: "your-command ..." +``` + +**3. Write agent-context.md:** +- Critical warnings (what NOT to do) +- Quick reference API +- Common patterns +- Error handling +- Examples + +**4. Create integrate.sh:** +- Install capability +- Configure if needed +- Copy context to `.claude/capabilities/` +- Create slash command +- Verify installation + +**5. Add to main project:** +```bash +cd capabilities/your-capability +make integrate +``` + +## Discovery Mechanisms + +### Manual Discovery (Human) +1. Human sees `capabilities/` directory +2. Reads `CAPABILITY.yaml` to understand what's available +3. Runs `make integrate` to set up for agents + +### Automatic Discovery (Agent) +1. Agent checks `.claude/capabilities/` for available capabilities +2. Reads relevant `.md` files for usage guides +3. Uses capabilities as documented +4. Falls back gracefully if capability unavailable + +### Tool-Assisted Discovery +```bash +# Future: Auto-discover all capabilities +make discover-capabilities +# Output: +# Found capabilities: +# - issue-facade (v1.0.0) - Issue tracking coordination +# - ... (other capabilities) + +# Auto-integrate all +make integrate-all-capabilities +``` + +## Priority System + +Capabilities have priority scores (0-100) indicating importance: + +- **90-100 (Critical):** Must always use, bypassing causes major issues +- **70-89 (High):** Should prefer over alternatives +- **50-69 (Medium):** Use when available +- **Below 50 (Low):** Optional convenience + +**issue-facade priority: 95 (Critical)** + +Agents should check priority when deciding whether to use a capability or fall back to alternatives. + +## Best Practices + +### For Capability Authors + +1. **Make it obvious:** Clear documentation, examples, warnings +2. **Make it easy:** One-command integration, good defaults +3. **Make it safe:** No tokens in code, proper error handling +4. **Make it discoverable:** Standard structure, machine-readable metadata +5. **Make it maintainable:** Version clearly, document breaking changes + +### For Project Integrators + +1. **Integrate early:** Set up capabilities before agents start work +2. **Verify integration:** Run tests to ensure agents use capabilities +3. **Monitor usage:** Check that agents aren't bypassing capabilities +4. **Keep updated:** Pull capability updates regularly +5. **Share feedback:** Report issues, contribute improvements + +### For Agent Developers + +1. **Check context:** Always look for `.claude/capabilities/` +2. **Read docs:** Don't guess the API, read the guide +3. **Follow warnings:** If docs say "DO NOT", don't do it +4. **Handle errors:** Capability might not be available +5. **Report issues:** If capability is confusing, report it + +## Future Enhancements + +### v1.1: Auto-Discovery +- `make list-capabilities` to show all available +- `make integrate-capability NAME` for specific capability +- Auto-detect when capability would be useful + +### v1.2: MCP Integration +- Capabilities as MCP servers +- Tool-based discovery (no file reading needed) +- Dynamic capability loading + +### v2.0: Capability Registry +- Central registry of available capabilities +- Version management and updates +- Dependency resolution + +## FAQ + +**Q: Why not just document "use issue-facade" in README?** +A: Agents often skip general docs. Putting it in `.claude/capabilities/` makes it part of their working context. + +**Q: What if agent bypasses capability anyway?** +A: 1) Check warnings are in context, 2) Use slash command to re-inject, 3) Review agent's reasoning for bypass. + +**Q: Can capabilities depend on each other?** +A: Not yet (v1.0). Planned for v2.0 with dependency resolution. + +**Q: How do I test integration?** +A: Run integration script, then test with agent doing actual work. Verify it uses capability API. + +**Q: What if capability breaks?** +A: Document rollback in integration checklist. Keep backup configs. Have fallback plan. + +## Summary + +**The capability bootstrap system works by:** + +1. **Self-description** - Capability declares what it does (CAPABILITY.yaml) +2. **Context injection** - Integration copies docs to `.claude/capabilities/` +3. **Agent discovery** - Agents check context before implementing +4. **Natural preference** - Good docs + warnings make capability easier than alternatives +5. **Verification** - Integration script tests that everything works + +**Result:** Agents naturally discover and use capabilities instead of bypassing them. + +## Example: Issue Tracking Use Case + +**Without capability:** +```python +# Agent bypasses, makes direct API call +response = requests.post(gitea_url + "/issues", ...) +# Problems: Token in code, no caching, platform-specific +``` + +**With capability (properly integrated):** +```python +# Agent checks .claude/capabilities/issue-facade.md +# Reads: "Use this API, don't use direct requests" +# Agent follows documented pattern: +from issue_tracker.backends.gitea import GiteaBackend +backend = GiteaBackend() +backend.connect(config) +backend.create_issue(issue) +# Benefits: Centralized tokens, caching, platform-agnostic +``` + +**The difference:** Integration puts capability docs in agent's context, making it the obvious choice. + +--- + +For more information: +- **Integration Guide:** `AGENT_INTEGRATION.md` +- **Development Guide:** `CLAUDE.md` +- **Roadmap:** `ROADMAP.md` +- **Examples:** `examples/agents/` diff --git a/.capability/agent-context.md b/.capability/agent-context.md new file mode 100644 index 0000000..5f9a9f6 --- /dev/null +++ b/.capability/agent-context.md @@ -0,0 +1,323 @@ +# Issue Facade - Agent Integration Context + +**🤖 For Coding Agents: Read this to understand how to use issue tracking in this project.** + +## Critical: DO NOT Bypass This Capability + +⚠️ **IMPORTANT:** If you need to work with issues, **you MUST use this capability**. Do NOT: +- ❌ Make direct API calls to Gitea/GitHub/GitLab +- ❌ Use platform CLIs (gh, glab, etc.) +- ❌ Import platform libraries (PyGithub, python-gitlab, etc.) +- ❌ Parse HTML/scrape issue tracker web UIs + +**Why?** Bypassing this capability causes: +- Credential management chaos (tokens scattered everywhere) +- Inconsistent issue state across agents +- Massive token waste (redundant API calls) +- Platform lock-in (can't switch Gitea → GitHub easily) +- Race conditions in multi-agent scenarios + +## Quick Reference + +### Check if Capability is Available + +```bash +# Verify installation +issue --version +# or +python -c "from issue_tracker.backends.gitea import GiteaBackend; print('OK')" +``` + +### Basic Usage (Python) + +```python +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, IssueState, User, Comment +from issue_tracker.core.interfaces import IssueFilter +from datetime import datetime, timezone +import os + +# Connect (assumes backend is configured) +backend = GiteaBackend() +backend.connect({ + 'base_url': os.environ['GITEA_URL'], + 'token': os.environ['GITEA_API_TOKEN'], + 'owner': os.environ['GITEA_OWNER'], + 'repo': os.environ['GITEA_REPO'] +}) + +# List issues for me +my_issues = backend.list_issues(IssueFilter( + state='open', + assignee='my-agent-id', + labels=['needs-implementation'] +)) + +# Create issue +new_issue = Issue( + id=None, number=0, + title="Implement feature X", + description="Details...", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[Label(name="feature"), Label(name="priority:high")] +) +created = backend.create_issue(new_issue) + +# Update issue +created.state = IssueState.IN_PROGRESS +created.assignees = [User(id="agent-id", username="agent-id")] +backend.update_issue(created) + +# Add comment +comment = Comment( + id=None, + body="Implementation started. Working on database schema.", + author=User(id="agent-id", username="agent-id"), + created_at=datetime.now(timezone.utc) +) +backend.add_comment(created.id, comment) + +# Close when done +created.state = IssueState.CLOSED +created.closed_at = datetime.now(timezone.utc) +backend.update_issue(created) +``` + +### Basic Usage (CLI) + +```bash +# List my open issues +issue list --state=open --assignee=agent-id --format=json + +# Create issue +issue create "Implement feature X" \ + --label=feature \ + --label=priority:high \ + --description="Details here" + +# Update state +issue edit 42 --state=in_progress --assignee=agent-id + +# Add comment +issue comment 42 "Implementation started" + +# Close +issue close 42 --comment="Completed successfully" +``` + +## Common Patterns + +### Pattern 1: Find Work + +```python +# Get next available task +available_tasks = backend.list_issues(IssueFilter( + state='open', + labels=['ready', 'needs-implementation'] +)) + +# Filter to unassigned +unassigned = [t for t in available_tasks if not t.assignees] + +if unassigned: + task = unassigned[0] + # Claim it... +``` + +### Pattern 2: Claim Issue (Prevent Race Conditions) + +```python +def claim_issue(issue: Issue, agent_id: str) -> bool: + """Claim an issue safely.""" + # Check if already claimed + if issue.assignees: + return False # Already taken + + # Claim it + issue.state = IssueState.IN_PROGRESS + issue.assignees = [User(id=agent_id, username=agent_id)] + backend.update_issue(issue) + + # Announce claim + backend.add_comment(issue.id, Comment( + id=None, + body=f"🤖 Claimed by {agent_id}", + author=User(id=agent_id, username=agent_id), + created_at=datetime.now(timezone.utc) + )) + return True +``` + +### Pattern 3: Progress Updates + +```python +def report_progress(issue: Issue, message: str, agent_id: str): + """Report progress on an issue.""" + backend.add_comment(issue.id, Comment( + id=None, + body=f"**Progress Update:**\n\n{message}", + author=User(id=agent_id, username=agent_id), + created_at=datetime.now(timezone.utc) + )) +``` + +### Pattern 4: Agent-to-Agent Communication + +```python +import json + +def post_agent_message(issue_id: str, msg_type: str, data: dict, agent_id: str): + """Post structured message for other agents.""" + message = { + 'type': msg_type, + 'agent': agent_id, + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'data': data + } + backend.add_comment(issue_id, Comment( + id=None, + body=f"```agent-message\n{json.dumps(message, indent=2)}\n```", + author=User(id=agent_id, username=agent_id), + created_at=datetime.now(timezone.utc) + )) + +def read_agent_messages(issue_id: str, msg_type: str = None): + """Read messages from other agents.""" + comments = backend.get_comments(issue_id) + messages = [] + for comment in comments: + if '```agent-message' in comment.body: + try: + json_str = comment.body.split('```agent-message\n')[1].split('\n```')[0] + msg = json.loads(json_str) + if msg_type is None or msg['type'] == msg_type: + messages.append(msg) + except: + continue + return messages +``` + +## Configuration Check + +Before using issue tracking, verify configuration: + +```python +def verify_issue_backend() -> bool: + """Verify issue backend is configured.""" + try: + backend = GiteaBackend() + backend.connect({ + 'base_url': os.environ['GITEA_URL'], + 'token': os.environ['GITEA_API_TOKEN'], + 'owner': os.environ['GITEA_OWNER'], + 'repo': os.environ['GITEA_REPO'] + }) + return backend.test_connection() + except Exception as e: + print(f"Issue backend not configured: {e}") + return False + +# Use it +if not verify_issue_backend(): + print("ERROR: Issue tracking not available. Check configuration.") + sys.exit(1) +``` + +## Error Handling + +```python +from issue_tracker.backends.gitea.backend import GiteaAPIError + +try: + issue = backend.get_issue_by_number(42) +except GiteaAPIError as e: + if e.status_code == 404: + print("Issue not found") + elif e.status_code == 401: + print("Authentication failed - check GITEA_API_TOKEN") + elif e.status_code == 429: + print("Rate limited - wait and retry") + else: + print(f"API error: {e}") +``` + +## Performance Tips + +1. **Use filters** instead of fetching all issues: + ```python + # BAD: Get all, filter in Python + all_issues = backend.list_issues() + my_issues = [i for i in all_issues if i.assignees and i.assignees[0].username == 'me'] + + # GOOD: Filter at backend + my_issues = backend.list_issues(IssueFilter(assignee='me')) + ``` + +2. **Use JSON output** for CLI parsing: + ```bash + issue list --format=json | jq '.[] | select(.state == "open")' + ``` + +3. **Batch comments** instead of rapid-fire updates + +4. **Check local cache** before querying (if available) + +## Troubleshooting + +### "Backend not configured" +```bash +# Check config +issue backend list + +# If empty, configure +export GITEA_API_TOKEN="your-token" +issue backend add myproject gitea +issue backend set-default myproject +``` + +### "Authentication failed" +```bash +# Verify token +curl -H "Authorization: token $GITEA_API_TOKEN" $GITEA_URL/api/v1/user +``` + +### "Issue not found" +```python +# Use get_issue_by_number, not get_issue +issue = backend.get_issue_by_number(42) # Correct +# issue = backend.get_issue("42") # Wrong - needs backend_id +``` + +## Full Documentation + +- **Integration Guide:** `AGENT_INTEGRATION.md` (comprehensive patterns and strategies) +- **API Reference:** `CLAUDE.md` (for developers extending the capability) +- **Examples:** `examples/agents/` (working agent implementations) +- **Roadmap:** `ROADMAP.md` (upcoming features) + +## Current Limitations (v1.0) + +Be aware of these limitations: + +1. **Manual Configuration:** Backend must be configured before use (auto-detect in v1.1) +2. **User Context:** Uses hardcoded user for CLI operations (agent identity in v1.2) +3. **No Built-in Locking:** Use assignee + comment workaround for claiming (native in v1.2) +4. **Basic Conflicts:** Manual resolution required for complex sync conflicts (advanced in v2.0) + +Workarounds are documented in `AGENT_INTEGRATION.md`. + +## Questions? + +If you're unsure whether to use this capability for something: + +**ASK:** "Does this involve creating, reading, updating, or searching issues?" +- **YES** → Use this capability +- **NO** → You can use other methods + +**Example:** +- "Create an issue for the bug I found" → **Use issue-facade** +- "Read the project README" → Don't need issue-facade +- "Check if issue #42 exists" → **Use issue-facade** +- "Clone the repository" → Don't need issue-facade diff --git a/.capability/integrate.sh b/.capability/integrate.sh new file mode 100755 index 0000000..b28d556 --- /dev/null +++ b/.capability/integrate.sh @@ -0,0 +1,316 @@ +#!/bin/bash +# Integration script for issue-facade capability +# This script helps the main project discover and integrate the capability + +set -e + +CAPABILITY_NAME="issue-facade" +CAPABILITY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$CAPABILITY_DIR/../.." && pwd)}" + +echo "🔧 Issue Facade Capability Integration" +echo " Capability: $CAPABILITY_DIR" +echo " Project: $PROJECT_ROOT" +echo "" + +# Function to check if something exists +check_exists() { + [ -e "$1" ] && echo "✓" || echo "✗" +} + +# Show current status +echo "📊 Current Status:" +echo " Issue command installed: $(check_exists "$(command -v issue)")" +echo " Backend configured: $(issue backend list 2>/dev/null | grep -q "default" && echo "✓" || echo "✗")" +echo " Claude config dir: $(check_exists "$PROJECT_ROOT/.claude")" +echo "" + +# Ask what to do +echo "🎯 Integration Options:" +echo " 1) Install capability (pip install -e)" +echo " 2) Configure backend" +echo " 3) Add to Claude Code context" +echo " 4) Create slash command" +echo " 5) Show integration checklist" +echo " 6) Full setup (all of the above)" +echo " 0) Exit" +echo "" + +read -p "Choose option [1-6, 0]: " choice + +case $choice in + 1) + echo "" + echo "📦 Installing capability..." + pip install -e "$CAPABILITY_DIR" + echo "✓ Installed" + echo "" + echo "Verify with: issue --version" + ;; + + 2) + echo "" + echo "🔑 Configuring backend..." + echo "" + echo "You'll need:" + echo " - Gitea URL (e.g., https://gitea.example.com)" + echo " - Repository owner" + echo " - Repository name" + echo " - API token (set GITEA_API_TOKEN environment variable)" + echo "" + + if [ -z "$GITEA_API_TOKEN" ]; then + echo "⚠️ GITEA_API_TOKEN not set" + read -p "Enter token (or press Enter to skip): " token + if [ -n "$token" ]; then + export GITEA_API_TOKEN="$token" + fi + else + echo "✓ Using GITEA_API_TOKEN from environment" + fi + + read -p "Backend name (e.g., myproject): " backend_name + if [ -n "$backend_name" ]; then + issue backend add "$backend_name" gitea + echo "" + read -p "Set as default? [y/N]: " set_default + if [ "$set_default" = "y" ] || [ "$set_default" = "Y" ]; then + issue backend set-default "$backend_name" + fi + + echo "" + echo "✓ Backend configured" + echo "" + echo "Test with: issue list --limit=5" + fi + ;; + + 3) + echo "" + echo "📝 Adding to Claude Code context..." + mkdir -p "$PROJECT_ROOT/.claude/capabilities" + cp "$CAPABILITY_DIR/.capability/agent-context.md" \ + "$PROJECT_ROOT/.claude/capabilities/issue-facade.md" + + # Create or update context file + CONTEXT_FILE="$PROJECT_ROOT/.claude/context/capabilities.md" + mkdir -p "$(dirname "$CONTEXT_FILE")" + + if [ ! -f "$CONTEXT_FILE" ]; then + cat > "$CONTEXT_FILE" << 'EOF' +# Available Capabilities + +This project uses specialized capabilities. Always check for existing capabilities before implementing similar functionality. + +## Issue Tracking: issue-facade + +**Location:** `capabilities/issue-facade/` +**Documentation:** `.claude/capabilities/issue-facade.md` +**Priority:** CRITICAL (always use for issue operations) + +**MUST USE FOR:** +- Creating, reading, updating, searching issues +- Multi-agent coordination via issue tracking +- Querying issue state or assigning issues + +**NEVER USE:** +- Direct API calls to Gitea/GitHub/GitLab (`requests.post("/api/v1/repos/...")`) +- Platform CLIs (`gh issue`, `glab issue`) +- Platform libraries (`from github import Github`) + +**Quick Start:** +```python +from issue_tracker.backends.gitea import GiteaBackend +backend = GiteaBackend() +backend.connect(config) +issues = backend.list_issues() +``` + +**Full Documentation:** See `.claude/capabilities/issue-facade.md` +EOF + echo "✓ Created $CONTEXT_FILE" + else + echo "✓ Context file exists: $CONTEXT_FILE" + echo " (Review and update manually if needed)" + fi + + echo "" + echo "✓ Added to Claude Code context" + echo "" + echo "Files created:" + echo " - $PROJECT_ROOT/.claude/capabilities/issue-facade.md" + echo " - $CONTEXT_FILE" + ;; + + 4) + echo "" + echo "⚡ Creating slash command..." + mkdir -p "$PROJECT_ROOT/.claude/commands" + + cat > "$PROJECT_ROOT/.claude/commands/use-issues.md" << 'EOF' +You are working with issue tracking. Use the **issue-facade capability**: + +## Available API + +**Python (Recommended):** +```python +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, IssueState +from issue_tracker.core.interfaces import IssueFilter + +backend = GiteaBackend() +backend.connect(config) + +# Query issues +issues = backend.list_issues(IssueFilter(state='open', labels=['bug'])) + +# Create issue +issue = Issue(...) +backend.create_issue(issue) + +# Update +issue.state = IssueState.CLOSED +backend.update_issue(issue) +``` + +**CLI:** +```bash +issue list --state=open --label=bug --format=json +issue create "Title" --label=bug --description="Details" +issue edit 42 --state=in_progress +issue close 42 --comment="Fixed" +``` + +## Critical Reminders + +**DO NOT:** +- ❌ Make direct API calls to Gitea/GitHub/GitLab +- ❌ Use `gh` or `glab` CLI tools +- ❌ Import PyGithub, python-gitlab, or similar libraries +- ❌ Parse HTML or scrape web UIs + +**WHY:** Bypassing the capability causes credential sprawl, token waste, and race conditions. + +## Full Documentation + +See `capabilities/issue-facade/AGENT_INTEGRATION.md` for: +- Complete API reference +- Coordination patterns +- Error handling +- Performance tips +- Working examples + +EOF + echo "✓ Created slash command: /use-issues" + echo "" + echo "Usage in Claude Code:" + echo " /use-issues" + echo "" + echo "This will inject issue-facade context into the conversation." + ;; + + 5) + echo "" + cat "$CAPABILITY_DIR/.capability/integration-checklist.md" + ;; + + 6) + echo "" + echo "🚀 Full Setup" + echo "==============" + echo "" + + # Step 1: Install + echo "Step 1/4: Installing capability..." + pip install -e "$CAPABILITY_DIR" || { echo "❌ Installation failed"; exit 1; } + echo "✓ Installed" + echo "" + + # Step 2: Configure + echo "Step 2/4: Configuring backend..." + echo "" + if [ -z "$GITEA_API_TOKEN" ]; then + echo "⚠️ GITEA_API_TOKEN not set" + echo " Please set it and run this script again," + echo " or configure manually with: issue backend add gitea" + echo "" + else + read -p "Backend name [myproject]: " backend_name + backend_name="${backend_name:-myproject}" + issue backend add "$backend_name" gitea || true + issue backend set-default "$backend_name" || true + echo "✓ Backend configured" + echo "" + fi + + # Step 3: Claude context + echo "Step 3/4: Adding to Claude Code..." + mkdir -p "$PROJECT_ROOT/.claude/capabilities" + mkdir -p "$PROJECT_ROOT/.claude/commands" + mkdir -p "$PROJECT_ROOT/.claude/context" + + cp "$CAPABILITY_DIR/.capability/agent-context.md" \ + "$PROJECT_ROOT/.claude/capabilities/issue-facade.md" + + # Create context file if not exists + CONTEXT_FILE="$PROJECT_ROOT/.claude/context/capabilities.md" + if [ ! -f "$CONTEXT_FILE" ]; then + cat > "$CONTEXT_FILE" << 'EOF' +# Available Capabilities + +## Issue Tracking: issue-facade + +**CRITICAL:** Always use this for issue operations. Never bypass with direct API calls. + +**Docs:** `.claude/capabilities/issue-facade.md` +**Usage:** `/use-issues` +EOF + fi + + # Create slash command + cat > "$PROJECT_ROOT/.claude/commands/use-issues.md" << 'EOF' +Use the issue-facade capability for all issue tracking operations. + +**Quick reference:** See `.claude/capabilities/issue-facade.md` +**Examples:** See `capabilities/issue-facade/examples/agents/` + +**DO NOT use direct API calls or platform CLIs!** +EOF + + echo "✓ Claude Code configured" + echo "" + + # Step 4: Verify + echo "Step 4/4: Verifying setup..." + echo "" + issue --version && echo "✓ CLI works" || echo "❌ CLI not working" + issue backend list | grep -q "default" && echo "✓ Backend configured" || echo "⚠️ Backend not configured" + [ -f "$PROJECT_ROOT/.claude/capabilities/issue-facade.md" ] && echo "✓ Claude context exists" || echo "❌ Claude context missing" + [ -f "$PROJECT_ROOT/.claude/commands/use-issues.md" ] && echo "✓ Slash command exists" || echo "❌ Slash command missing" + + echo "" + echo "✅ Setup complete!" + echo "" + echo "Next steps:" + echo " 1. Test: issue list --limit=5" + echo " 2. In Claude Code: /use-issues" + echo " 3. See examples: capabilities/issue-facade/examples/agents/" + ;; + + 0) + echo "Exiting." + exit 0 + ;; + + *) + echo "Invalid option" + exit 1 + ;; +esac + +echo "" +echo "📚 Additional Resources:" +echo " - Integration guide: $CAPABILITY_DIR/AGENT_INTEGRATION.md" +echo " - Checklist: $CAPABILITY_DIR/.capability/integration-checklist.md" +echo " - Examples: $CAPABILITY_DIR/examples/agents/" +echo "" diff --git a/.capability/integration-checklist.md b/.capability/integration-checklist.md new file mode 100644 index 0000000..601e2fa --- /dev/null +++ b/.capability/integration-checklist.md @@ -0,0 +1,293 @@ +# Issue Facade Integration Checklist + +**For project maintainers integrating this capability into their codebase.** + +## Pre-Integration + +- [ ] **Verify Python version:** >= 3.8 +- [ ] **Check issue tracker platform:** Gitea (✅), GitHub (🚧 v1.1), GitLab (🚧 v1.2) +- [ ] **Obtain API token:** Read-only for monitoring, write for implementation +- [ ] **Document token storage:** Environment variable or secure secret manager + +## Installation + +- [ ] **Install capability:** + ```bash + pip install -e capabilities/issue-facade/ + ``` + +- [ ] **Verify installation:** + ```bash + issue --version + ``` + +- [ ] **Configure backend (one-time):** + ```bash + export GITEA_API_TOKEN="your-token" + issue backend add myproject gitea + # Provide: URL, owner, repo when prompted + issue backend set-default myproject + ``` + +- [ ] **Test connection:** + ```bash + issue backend test myproject + issue list --limit=5 + ``` + +## Project Integration + +### Option 1: Agent Context File (Recommended) + +- [ ] **Copy agent context to project:** + ```bash + mkdir -p .claude/capabilities/ + cp capabilities/issue-facade/.capability/agent-context.md \ + .claude/capabilities/issue-facade.md + ``` + +- [ ] **Add to Claude Code context:** + Create `.claude/context/capabilities.md`: + ```markdown + # Available Capabilities + + This project uses specialized capabilities. Always check these before implementing similar functionality. + + ## Issue Tracking: issue-facade + + **Location:** `capabilities/issue-facade/` + **Documentation:** `.claude/capabilities/issue-facade.md` + + **CRITICAL:** Always use this capability for issue operations. Never use: + - Direct API calls (requests to /api/v1/repos/...) + - Platform CLIs (gh, glab) + - Platform libraries (PyGithub, python-gitlab) + + See `.claude/capabilities/issue-facade.md` for usage patterns. + ``` + +### Option 2: Slash Command + +- [ ] **Create slash command:** + Create `.claude/commands/use-issues.md`: + ```markdown + You are working with issue tracking. Use the issue-facade capability: + + **Python API:** + ```python + from issue_tracker.backends.gitea import GiteaBackend + backend = GiteaBackend() + backend.connect(config) + ``` + + **CLI:** + ```bash + issue list --format=json + issue create "Title" --label=bug + ``` + + **Full docs:** See `capabilities/issue-facade/AGENT_INTEGRATION.md` + + **DO NOT use direct API calls or platform CLIs!** + ``` + +- [ ] **Test slash command:** + ```bash + # In Claude Code + /use-issues + ``` + +### Option 3: MCP Server (Future - v1.2) + +- [ ] **Configure MCP server** (when available) +- [ ] **Register tools in Claude Code** +- [ ] **Test tool discovery** + +## Agent Configuration + +- [ ] **Set agent identity:** + Add to `.issue-facade/config.json`: + ```json + { + "agent": { + "identity": "agent-coder", + "type": "implementation" + } + } + ``` + +- [ ] **Or use environment variables:** + ```bash + export ISSUE_AGENT_ID="agent-coder" + export ISSUE_AGENT_TYPE="coder" + ``` + +## Verification + +- [ ] **Test basic operations:** + ```python + from issue_tracker.backends.gitea import GiteaBackend + from issue_tracker.core.interfaces import IssueFilter + + backend = GiteaBackend() + backend.connect({'base_url': '...', 'token': '...', 'owner': '...', 'repo': '...'}) + + # Should return issues + issues = backend.list_issues(IssueFilter(state='open', limit=5)) + print(f"Found {len(issues)} issues") + ``` + +- [ ] **Test CLI:** + ```bash + issue list --state=open --format=json | jq 'length' + ``` + +- [ ] **Verify agents use capability:** + - Create test issue via agent + - Check it appears in tracker + - Verify token from environment was used (not hardcoded) + +## Documentation Updates + +- [ ] **Update project README:** + ```markdown + ## Issue Tracking + + This project uses the issue-facade capability for unified issue tracking. + + **Setup:** + ```bash + pip install -e capabilities/issue-facade/ + export GITEA_API_TOKEN="your-token" + issue backend add myproject gitea + ``` + + **Usage:** See `capabilities/issue-facade/AGENT_INTEGRATION.md` + ``` + +- [ ] **Add to CONTRIBUTING.md:** + ```markdown + ### Issue Tracking + + Always use the `issue` command or Python API from `issue_tracker` package. + Never make direct API calls to Gitea/GitHub/GitLab. + + Examples: `capabilities/issue-facade/examples/agents/` + ``` + +## Security Review + +- [ ] **Verify tokens are not in code:** `git grep GITEA_TOKEN` (should be empty) +- [ ] **Check .gitignore includes:** + ``` + .issue-facade/config.json + .issue-facade/issues.db + .issue-facade/credentials.json + ``` + +- [ ] **Audit token permissions:** Read-only for bots, write for implementation +- [ ] **Document token rotation:** How often, who has access + +## Testing + +- [ ] **Run capability tests:** + ```bash + cd capabilities/issue-facade/ + make test + ``` + +- [ ] **Test agent workflows:** + ```bash + python examples/agents/simple_task_executor.py --once + ``` + +- [ ] **Verify multi-agent coordination:** + ```bash + python examples/agents/multi_agent_pipeline.py --mode=roundrobin --max-iterations=1 + ``` + +## Maintenance + +- [ ] **Schedule regular updates:** + ```bash + cd capabilities/issue-facade/ + git pull origin main + pip install -e . --upgrade + ``` + +- [ ] **Monitor capability roadmap:** Check `ROADMAP.md` for new features +- [ ] **Subscribe to updates:** Watch the capability repository + +## Rollback Plan + +If capability causes issues: + +- [ ] **Document how to disable:** + ```bash + # Temporarily use direct API + export ISSUE_FACADE_DISABLED=1 + # Or + issue backend remove myproject + ``` + +- [ ] **Keep backup config:** + ```bash + cp ~/.config/issue-facade/backends.json ~/.config/issue-facade/backends.json.backup + ``` + +- [ ] **Document rollback steps in project wiki/docs** + +## Success Criteria + +- [ ] Agents successfully create/update issues via capability +- [ ] No direct API calls in agent code (verified via code review) +- [ ] Token management centralized (one env var, not scattered) +- [ ] Multi-agent coordination works (no race conditions) +- [ ] Offline/online sync works (if using local backend) + +## Post-Integration + +- [ ] **Share integration experience:** Document what worked, what didn't +- [ ] **Contribute improvements:** PRs to capability for common patterns +- [ ] **Update capability docs:** Add project-specific examples +- [ ] **Monitor agent usage:** Are they using capability correctly? + +## Troubleshooting + +### Agents bypass capability + +**Problem:** Agent makes direct API call instead of using capability. + +**Solution:** +1. Check Claude Code context includes capability docs +2. Add explicit reminder in `.claude/context/` +3. Use slash command `/use-issues` before agent work +4. Review agent logs for why it chose direct API + +### Configuration not found + +**Problem:** `issue backend list` shows empty. + +**Solution:** +```bash +issue backend add myproject gitea +issue backend set-default myproject +issue backend test myproject +``` + +### Authentication failures + +**Problem:** "401 Unauthorized" errors. + +**Solution:** +1. Verify token: `echo $GITEA_API_TOKEN` +2. Test token: `curl -H "Authorization: token $GITEA_API_TOKEN" $GITEA_URL/api/v1/user` +3. Check token hasn't expired +4. Verify token has correct permissions + +## Questions? + +- **Technical:** See `CLAUDE.md` (development guide) +- **Agent patterns:** See `AGENT_INTEGRATION.md` (comprehensive guide) +- **Examples:** See `examples/agents/` (working code) +- **Issues:** Create issue in capability repository diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md new file mode 100644 index 0000000..a6c1640 --- /dev/null +++ b/AGENT_INTEGRATION.md @@ -0,0 +1,675 @@ +# Agent Integration Guide + +**Issue Facade for Autonomous Coding Agent Coordination** + +## Purpose + +The **Issue Facade** capability provides a standardized interface for autonomous coding agents to coordinate project implementation through issue tracking. Instead of agents directly interfacing with platform-specific APIs (GitHub, GitLab, Gitea), they use a unified abstraction that works consistently across backends. + +### Why Issue Tracking for Agent Coordination? + +Issue tracking provides a natural coordination mechanism for multi-agent software development: + +- **Task Distribution**: Issues represent discrete units of work that agents can claim and execute +- **State Management**: Issue states (open, in_progress, closed) track progress across the team +- **Communication Channel**: Comments enable inter-agent communication and human oversight +- **Progress Visibility**: Labels, assignees, and milestones provide real-time project status +- **Audit Trail**: Complete history of who did what and when +- **Human Integration**: Human developers can seamlessly participate in agent-driven projects + +## Current Status: Production-Ready with Manual Setup + +### What Works Now (v1.0) + +✅ **Complete CRUD Operations** +- Create, read, update, delete issues +- Full label management +- User and assignee handling +- Milestone operations +- Comment threads + +✅ **Gitea Backend** (Production-Ready) +- Complete API integration +- Rate limiting and error handling +- State mapping (open/in_progress/blocked → open/closed) +- Sync support with local backup + +✅ **Local SQLite Backend** (Fully Functional) +- Offline operation +- Complete data model +- Sync with remote backends +- Fast queries and filtering + +✅ **Agent-Friendly Features** +- JSON output mode for machine parsing +- Programmatic Python API +- Comprehensive filtering +- Batch operations +- Type-safe models + +### Current Limitations + +⚠️ **Manual Configuration Required** +- No auto-detection from git remotes (yet) +- Backend configuration is manual one-time setup +- No environment-variable-only mode (yet) + +⚠️ **Hardcoded User Context** +- CLI operations use "cli-user" placeholder +- Agent identity needs to be managed externally + +⚠️ **Basic Conflict Resolution** +- Sync detects conflicts but doesn't auto-resolve +- Manual intervention required for complex merges + +## Quick Start for Agents + +### 1. Installation + +```bash +cd capabilities/issue-facade +pip install -e . +``` + +### 2. Backend Configuration (One-Time Setup) + +**For Gitea Projects:** + +```bash +# Configure Gitea backend +export GITEA_API_TOKEN="your-token-here" + +issue backend add my-project gitea +# Prompts for: +# - Gitea URL: https://gitea.example.com +# - Owner: your-org +# - Repo: your-project +# - Token: (reads from GITEA_API_TOKEN) + +# Verify connection +issue backend test my-project + +# Set as default +issue backend set-default my-project +``` + +**For Local/Offline Work:** + +```bash +# Configure local SQLite backend +issue backend add local-work local +# Prompts for: +# - Database path: .issue-facade/issues.db + +issue backend set-default local-work +``` + +### 3. Basic Agent Operations + +```bash +# List open issues assigned to an agent +issue list --state=open --assignee=agent-coder --format=json + +# Create a new issue +issue create "Implement user authentication" \ + --label=feature --label=priority:high \ + --assignee=agent-coder + +# Update issue state +issue edit 42 --state=in_progress + +# Add progress comment +issue comment 42 "Completed database schema migration" + +# Close when done +issue close 42 --comment="Implementation complete, tests passing" +``` + +## Agent Workflow Patterns + +### Pattern 1: Single Agent, Task Execution + +**Scenario**: One agent implements features from an issue backlog. + +```bash +#!/bin/bash +# Agent workflow script + +# 1. Get next available task +ISSUE=$(issue list --state=open --label=ready \ + --format=json --limit=1 | jq -r '.[0].number') + +# 2. Claim the issue +issue edit $ISSUE --assignee=agent-coder --state=in_progress +issue comment $ISSUE "Starting implementation" + +# 3. Execute work +# ... agent implements the feature ... + +# 4. Report completion +issue comment $ISSUE "Implementation complete. Files changed: src/auth.py, tests/test_auth.py" +issue close $ISSUE --comment="Ready for review" +``` + +### Pattern 2: Multi-Agent Coordination + +**Scenario**: Multiple specialized agents work on different aspects. + +```bash +# Agent 1 (Coder) - Claims and implements +issue list --label=needs-implementation --state=open --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} issue edit {} --assignee=agent-coder --state=in_progress + +# Agent 2 (Reviewer) - Reviews completed work +issue list --label=needs-review --state=closed --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} sh -c 'issue comment {} "Code review complete. Approved."' + +# Agent 3 (Tester) - Runs tests on reviewed code +issue list --label=reviewed --state=closed --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} sh -c 'issue comment {} "All tests passing. Deploying to staging."' +``` + +### Pattern 3: Agent-Human Collaboration + +**Scenario**: Agents implement, humans review and approve. + +```python +# Agent creates implementation issues from requirements +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, IssueState +from datetime import datetime, timezone + +backend = GiteaBackend() +backend.connect({ + 'base_url': 'https://gitea.example.com', + 'token': os.environ['GITEA_API_TOKEN'], + 'owner': 'myorg', + 'repo': 'myproject' +}) + +# Agent breaks down feature into tasks +feature_issue = backend.get_issue_by_number(100) +subtasks = [ + "Implement database schema", + "Create API endpoints", + "Add frontend components", + "Write integration tests" +] + +for task in subtasks: + issue = Issue( + id=None, number=0, + title=f"{feature_issue.title}: {task}", + description=f"Subtask of #{feature_issue.number}\n\n{task}", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[ + Label(name="agent-generated"), + Label(name="needs-implementation"), + Label(name="parent:100") + ] + ) + backend.create_issue(issue) + +# Human reviews and approves/rejects via comments +# Agent monitors for approval comments and proceeds +``` + +## Programmatic API for Agents + +### Python Integration + +```python +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, IssueState, User +from issue_tracker.core.interfaces import IssueFilter +from datetime import datetime, timezone +import os + +# Initialize backend +backend = GiteaBackend() +backend.connect({ + 'base_url': os.environ['GITEA_URL'], + 'token': os.environ['GITEA_API_TOKEN'], + 'owner': os.environ['GITEA_OWNER'], + 'repo': os.environ['GITEA_REPO'] +}) + +# Query issues +filter_criteria = IssueFilter( + state='open', + labels=['bug', 'priority:high'], + assignee='agent-coder', + limit=10 +) +issues = backend.list_issues(filter_criteria) + +# Create issue +new_issue = Issue( + id=None, + number=0, + title="Fix memory leak in parser", + description="Detected memory leak in parse_document() function", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[ + Label(name="bug"), + Label(name="priority:critical"), + Label(name="agent-detected") + ], + assignees=[User(id="agent-coder", username="agent-coder")] +) +created = backend.create_issue(new_issue) + +# Update issue +created.state = IssueState.IN_PROGRESS +backend.update_issue(created) + +# Add comment +from issue_tracker.core.models import Comment +comment = Comment( + id=None, + body="Analysis complete. Root cause: unclosed file handles in line 234", + author=User(id="agent-coder", username="agent-coder"), + created_at=datetime.now(timezone.utc) +) +backend.add_comment(created.id, comment) + +# Close issue +created.state = IssueState.CLOSED +created.closed_at = datetime.now(timezone.utc) +backend.update_issue(created) +``` + +### Advanced Filtering + +```python +# Get all high-priority bugs not assigned +critical_bugs = backend.list_issues(IssueFilter( + state='open', + labels=['bug', 'priority:critical'] +)) +unassigned = [i for i in critical_bugs if not i.assignees] + +# Get stale issues (not updated in 7 days) +from datetime import timedelta +stale_threshold = datetime.now(timezone.utc) - timedelta(days=7) +stale_issues = backend.list_issues(IssueFilter( + state='open', + updated_before=stale_threshold +)) + +# Search by text +search_results = backend.search_issues("authentication", limit=20) +``` + +## Agent Coordination Strategies + +### Strategy 1: Label-Based Role Assignment + +Use labels to indicate agent specialization: + +```python +# Agent types +AGENT_ROLES = { + 'agent:coder': ['feature', 'bug', 'refactor'], + 'agent:tester': ['needs-testing', 'test-failure'], + 'agent:reviewer': ['needs-review', 'code-quality'], + 'agent:documenter': ['documentation', 'api-docs'] +} + +# Each agent filters by their role +def get_agent_tasks(agent_type): + role_labels = AGENT_ROLES[agent_type] + all_tasks = [] + for label in role_labels: + tasks = backend.list_issues(IssueFilter( + state='open', + labels=[label, agent_type] + )) + all_tasks.extend(tasks) + return all_tasks +``` + +### Strategy 2: State Machine Workflow + +Use issue states to track progress through pipeline: + +``` +open → in_progress → needs_review → closed + ↓ ↓ ↓ +blocked blocked blocked +``` + +```python +def advance_issue_state(issue_number): + issue = backend.get_issue_by_number(issue_number) + + state_transitions = { + IssueState.OPEN: IssueState.IN_PROGRESS, + IssueState.IN_PROGRESS: IssueState.CLOSED # or needs_review + } + + if issue.state in state_transitions: + issue.state = state_transitions[issue.state] + backend.update_issue(issue) + return True + return False +``` + +### Strategy 3: Comment-Based Communication + +Use structured comments for agent-to-agent messages: + +```python +import json + +def post_agent_message(issue_id, message_type, data): + """Post structured message for other agents""" + message = { + 'type': message_type, + 'agent': 'agent-coder', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'data': data + } + comment = Comment( + id=None, + body=f"```agent-message\n{json.dumps(message, indent=2)}\n```", + author=User(id="agent-coder", username="agent-coder"), + created_at=datetime.now(timezone.utc) + ) + backend.add_comment(issue_id, comment) + +def read_agent_messages(issue_id, message_type=None): + """Read structured messages from other agents""" + comments = backend.get_comments(issue_id) + messages = [] + for comment in comments: + if '```agent-message' in comment.body: + try: + json_str = comment.body.split('```agent-message\n')[1].split('\n```')[0] + msg = json.loads(json_str) + if message_type is None or msg['type'] == message_type: + messages.append(msg) + except (IndexError, json.JSONDecodeError): + continue + return messages + +# Usage: +post_agent_message(42, 'implementation_complete', { + 'files_changed': ['src/auth.py', 'tests/test_auth.py'], + 'tests_passing': True, + 'coverage': 95.2 +}) + +# Later, reviewer agent reads: +results = read_agent_messages(42, 'implementation_complete') +``` + +## Synchronization and Backup + +### Sync Local and Remote + +```bash +# Pull all issues to local backup +issue backend add backup local +issue sync pull gitea-remote backup + +# Work offline with local backend +issue backend set-default backup +issue create "Offline work item" --label=offline + +# Sync back when online +issue sync push backup gitea-remote +``` + +### Conflict Handling + +```python +# Check for conflicts before sync +from issue_tracker.cli.sync_commands import sync_pull + +try: + sync_pull(source='remote', target='local', dry_run=True) +except ConflictError as e: + # Conflicts detected + for conflict in e.conflicts: + print(f"Conflict on issue {conflict['issue_number']}") + print(f" Local updated: {conflict['local_updated']}") + print(f" Remote updated: {conflict['remote_updated']}") + + # Resolve by choosing newer timestamp + sync_pull(source='remote', target='local', force=True) +``` + +## Current Workarounds for Limitations + +### Workaround 1: Agent Identity + +Since "cli-user" is hardcoded, use labels or comments to indicate agent: + +```python +# Add agent identifier to all operations +agent_id = "agent-coder-v1" + +# In issue creation +labels.append(Label(name=f"created-by:{agent_id}")) + +# In comments +comment.body = f"[{agent_id}] {actual_message}" +``` + +### Workaround 2: Issue Claiming + +No built-in locking, so use assignee + comment: + +```python +def claim_issue(issue_number, agent_id, timeout_minutes=30): + issue = backend.get_issue_by_number(issue_number) + + # Check if already claimed + if issue.assignees: + # Check claim age from comments + comments = backend.get_comments(issue.id) + claim_comments = [c for c in comments if 'CLAIMED' in c.body] + if claim_comments: + last_claim = claim_comments[-1].created_at + age = datetime.now(timezone.utc) - last_claim + if age.total_seconds() < timeout_minutes * 60: + return False # Still claimed + + # Claim it + issue.assignees = [User(id=agent_id, username=agent_id)] + issue.state = IssueState.IN_PROGRESS + backend.update_issue(issue) + + backend.add_comment(issue.id, Comment( + id=None, + body=f"CLAIMED by {agent_id} at {datetime.now(timezone.utc).isoformat()}", + author=User(id=agent_id, username=agent_id), + created_at=datetime.now(timezone.utc) + )) + return True +``` + +### Workaround 3: Manual Repository Configuration + +Create a setup script for each project: + +```bash +#!/bin/bash +# setup-issue-tracking.sh + +cat > .issue-facade-config << EOF +GITEA_URL=https://gitea.example.com +GITEA_OWNER=myorg +GITEA_REPO=myproject +GITEA_TOKEN_FILE=~/.secrets/gitea-token +EOF + +# Load config and configure backend +source .issue-facade-config +export GITEA_API_TOKEN=$(cat $GITEA_TOKEN_FILE) + +issue backend add $(basename $(pwd)) gitea < 60: + get_open_issues_cached.cache_clear() + last_fetch = time.time() +``` + +## Roadmap: Future Enhancements + +### Phase 1: Auto-Configuration (v1.1) +- Automatic git remote detection +- Environment-variable-only setup +- Per-repository `.issue-facade/config.json` support +- `issue config detect` command + +### Phase 2: Agent Features (v1.2) +- Agent identity management +- Issue claiming/locking API +- Structured metadata fields for agent state +- Webhook support for reactive agents + +### Phase 3: Advanced Coordination (v2.0) +- Issue dependency tracking +- Query DSL: `is:open assignee:me label:bug,critical` +- Activity streams and event logs +- Multi-agent conflict resolution strategies +- Distributed locking for concurrent operations + +## Examples Repository + +See `examples/agents/` for complete working examples: + +- `simple_task_executor.py` - Single agent claiming and executing tasks +- `multi_agent_pipeline.py` - Multiple agents in CI/CD-like workflow +- `human_in_loop.py` - Agents with human approval gates +- `monitoring_agent.py` - Agent that monitors issue health and sends alerts + +## Troubleshooting + +### "Backend not configured" + +```bash +# List configured backends +issue backend list + +# If empty, configure one +issue backend add myproject gitea +``` + +### "Authentication failed" + +```bash +# Check token is valid +issue backend test myproject + +# Reconfigure with correct token +export GITEA_API_TOKEN="new-token" +issue backend remove myproject +issue backend add myproject gitea +``` + +### "Issue not found" + +```python +# Gitea uses backend_id, not number +issue = backend.get_issue_by_number(42) # Correct +# issue = backend.get_issue("42") # Wrong - needs backend_id +``` + +### "Sync conflicts" + +```bash +# Force sync (overwrites target) +issue sync pull source target --force + +# Or manually resolve +issue list --backend=source --format=json > source.json +issue list --backend=target --format=json > target.json +# Compare and decide which to keep +``` + +## Support + +- **Documentation**: See `CLAUDE.md` for development guide +- **Tests**: Run `make test` to verify installation +- **Issues**: Report issues in the main markitect repository + +## License + +MIT License - See LICENSE file diff --git a/CAPABILITY.yaml b/CAPABILITY.yaml new file mode 100644 index 0000000..b3b4716 --- /dev/null +++ b/CAPABILITY.yaml @@ -0,0 +1,235 @@ +# Issue Facade Capability Manifest +# This file describes the capability to coding agents and integration systems + +metadata: + name: issue-facade + version: 1.0.0 + type: coordination-tool + description: > + Universal interface for issue tracking coordination across Gitea, GitHub, GitLab. + Provides unified API to prevent direct platform API usage and credential sprawl. + +# What problems this capability solves +purpose: + primary: Agent coordination via issue tracking + problems_solved: + - Direct API calls to GitHub/GitLab/Gitea (avoid credential sprawl) + - Inconsistent issue tracking access patterns + - Token waste from redundant API calls + - Platform-specific code in agents + - Offline/online sync complexity + +# When agents should use this capability +usage_rules: + MUST_USE_INSTEAD_OF: + - "Direct Gitea API calls (requests.post to /api/v1/repos/...)" + - "GitHub CLI (gh issue create/list/...)" + - "GitLab CLI (glab issue create/list/...)" + - "Python libraries (PyGithub, python-gitlab)" + - "Direct SQL queries to issue databases" + + PREFER_OVER: + - "Web scraping of issue tracker UIs" + - "Manual issue management" + - "Custom issue tracking scripts" + + USE_WHEN: + - "Creating, updating, or querying issues" + - "Multi-agent coordination needed" + - "Offline work with sync required" + - "Cross-platform issue management" + +# How to integrate this capability +integration: + methods: + python_api: + available: true + import: "from issue_tracker.backends.gitea import GiteaBackend" + docs: "AGENT_INTEGRATION.md" + + cli: + available: true + command: "issue" + subcommands: ["list", "create", "show", "edit", "close", "comment"] + json_output: true + + mcp_server: + available: false # Future: Phase 2 + status: planned + + installation: + method: pip + command: "pip install -e capabilities/issue-facade/" + verify: "issue --version" + + configuration: + required: true + method: manual # v1.0 - auto in v1.1 + steps: + - "Export GITEA_API_TOKEN environment variable" + - "Run: issue backend add myproject gitea" + - "Provide: URL, owner, repo when prompted" + - "Run: issue backend set-default myproject" + +# API surface for agents +api: + core_operations: + - name: list_issues + description: Query issues with filtering + python: "backend.list_issues(IssueFilter(state='open', labels=['bug']))" + cli: "issue list --state=open --label=bug --format=json" + + - name: create_issue + description: Create new issue + python: "backend.create_issue(Issue(...))" + cli: "issue create 'Title' --label=bug --assignee=agent" + + - name: update_issue + description: Update existing issue + python: "backend.update_issue(issue)" + cli: "issue edit 42 --state=in_progress" + + - name: add_comment + description: Add comment to issue + python: "backend.add_comment(issue.id, comment)" + cli: "issue comment 42 'Progress update'" + + - name: close_issue + description: Close issue + python: "issue.state = IssueState.CLOSED; backend.update_issue(issue)" + cli: "issue close 42 --comment='Done'" + +# Performance and efficiency +efficiency: + local_caching: true + offline_mode: true + batch_operations: false # v1.0 limitation + rate_limiting: automatic + + token_savings: + vs_direct_api: "~70% fewer tokens (uses local cache + structured models)" + vs_cli_tools: "~50% fewer tokens (JSON output vs parsing text)" + +# Credential management +credentials: + method: environment_variables + variables: + - GITEA_API_TOKEN + - GITEA_URL (optional with config) + + security: + - "Tokens never in code or logs" + - "Config stored in ~/.config/issue-facade/" + - "Per-repo config in .issue-facade/ (gitignored)" + + best_practices: + - "Use read-only tokens for monitoring agents" + - "Use write tokens only for implementation agents" + - "Rotate tokens regularly" + +# Agent integration guidance +agent_guidance: + quick_start: | + # For Python agents: + from issue_tracker.backends.gitea import GiteaBackend + from issue_tracker.core.interfaces import IssueFilter + + backend = GiteaBackend() + backend.connect(config) + issues = backend.list_issues(IssueFilter(state='open')) + + # For CLI/shell agents: + issue list --format=json | jq '.[] | {number, title, state}' + + coordination_pattern: | + # Claim issue to prevent race conditions + issue edit 42 --assignee=my-agent-id --state=in_progress + issue comment 42 "Starting work..." + + # Do work... + + issue comment 42 "Completed: " + issue close 42 --comment="Done" + + error_handling: | + # Check exit codes + if ! issue backend test myproject; then + echo "Backend not configured or unavailable" + exit 1 + fi + +# Documentation references +documentation: + integration: "AGENT_INTEGRATION.md" + development: "CLAUDE.md" + roadmap: "ROADMAP.md" + examples: "examples/agents/" + +# Dependencies and requirements +requirements: + runtime: + python: ">=3.8" + packages: + - "click>=8.0.0" + - "requests>=2.25.0" + - "python-dateutil>=2.8.0" + + optional: + - "jq (for JSON parsing in shell)" + - "sqlite3 (usually pre-installed)" + +# Current limitations (v1.0) +limitations: + - "Manual backend configuration required (auto-detect in v1.1)" + - "No built-in issue locking (workaround via assignee + comment)" + - "Basic conflict resolution (advanced in v2.0)" + - "Hardcoded user context (agent identity in v1.2)" + +# Roadmap for capability evolution +roadmap: + v1.1: + - "Auto-detection from git remotes" + - "Environment-only configuration" + - "Zero-setup for common platforms" + v1.2: + - "Agent identity management" + - "Native issue claiming/locking" + - "Webhook support" + v2.0: + - "Issue dependencies" + - "Query DSL" + - "Distributed locking" + +# Testing and validation +testing: + test_command: "make test" + coverage: 61% + test_count: 109 + + verify_installation: | + # Verify capability is working + issue backend list # Should show configured backends + issue list --format=json | jq 'length' # Should return issue count + +# Support and troubleshooting +support: + common_issues: + - problem: "Backend not configured" + solution: "Run: issue backend add " + + - problem: "Authentication failed" + solution: "Check GITEA_API_TOKEN is set and valid" + + - problem: "Command not found: issue" + solution: "Run: pip install -e capabilities/issue-facade/" + +# Integration priority score (higher = more important for agent to use) +priority: + score: 95 # 0-100, where 100 = critical + reasoning: > + Critical for agent coordination. Bypassing this capability leads to: + - Credential management issues + - Inconsistent issue state + - Token waste + - Platform lock-in + - Race conditions in multi-agent scenarios diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0de2b7d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,245 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Issue Facade is a universal CLI for issue tracking that provides a unified interface to multiple issue tracking backends (GitHub, GitLab, Gitea, local SQLite). It implements the **Facade Pattern** to abstract away differences between various issue tracking systems, providing developers with a consistent CLI experience regardless of the underlying backend. + +## Development Commands + +### Installation & Setup +- Install for development: `pip install -e ".[dev]"` +- Install production: `pip install -e .` +- Clean build artifacts: `make issue-facade-clean` + +### Testing +- Run all tests: `pytest tests/` +- Run specific test file: `pytest tests/test_gitea_backend.py` +- Run with coverage: `pytest tests/ --cov=issue_tracker --cov-report=html --cov-report=term` +- Run integration tests: `pytest tests/test_gitea_integration.py -v` + +### Code Quality +- Run linter: `make issue-facade-lint` +- Format code: `black issue_tracker/ tests/` (line length: 100) +- Sort imports: `isort issue_tracker/ tests/` + +### CLI Usage +The project provides two entry points: `issue` and `issue-tracker` (both execute `issue_tracker.cli.main:main`) + +Common commands: +- `issue list` - List issues +- `issue show ` - Show issue details +- `issue create "Title"` - Create new issue +- `issue close ` - Close issue +- `issue backend list` - List configured backends +- `issue sync` - Synchronize with remote backend + +## Architecture + +### Core Design Pattern: Facade with Plugin Architecture + +The codebase implements a **plugin-based facade pattern** with clear separation of concerns: + +``` +┌─────────────────────────────────────────┐ +│ CLI Layer (Click) │ +│ issue_tracker/cli/*.py │ +└───────────────┬─────────────────────────┘ + │ +┌───────────────▼─────────────────────────┐ +│ Core Domain Models │ +│ issue_tracker/core/models.py │ +│ (Issue, Label, User, etc.) │ +└───────────────┬─────────────────────────┘ + │ +┌───────────────▼─────────────────────────┐ +│ Backend Interface (ABC) │ +│ issue_tracker/core/interfaces.py │ +│ IssueBackend, LocalBackend, │ +│ RemoteBackend, SyncableBackend │ +└───────────────┬─────────────────────────┘ + │ + ┌───────┴────────┐ + │ │ +┌───────▼──────┐ ┌──────▼───────┐ +│Local Backend │ │Gitea Backend │ +│ (SQLite) │ │ (REST API) │ +└──────────────┘ └──────────────┘ +``` + +### Key Components + +#### 1. Core Domain Models (`issue_tracker/core/models.py`) +- **Issue**: Universal issue model with state management, label categorization, and domain logic +- **Label**: Supports categorization (priority/type/status/other) with cached properties +- **User, Milestone, Comment**: Supporting models +- **IssueState, Priority, IssueType**: Enumerations with backend mapping + +The Issue model uses `@cached_property` for performance optimization and includes domain logic methods (`close()`, `reopen()`, `add_label()`, etc.) that enforce business rules. + +#### 2. Backend Interface (`issue_tracker/core/interfaces.py`) +- **IssueBackend (ABC)**: Defines the contract all backends must implement +- **LocalBackend, RemoteBackend**: Marker interfaces for backend categorization +- **SyncableBackend**: Interface for backends supporting synchronization +- **BackendCapabilities**: Describes feature support per backend +- **BackendFactory**: Registry pattern for backend creation + +**Critical**: All backends MUST implement the full `IssueBackend` interface. The interface includes: +- Connection management: `connect()`, `disconnect()`, `test_connection()` +- CRUD operations: `create_issue()`, `get_issue()`, `update_issue()`, `delete_issue()` +- Query operations: `list_issues()`, `search_issues()` +- Label, User, Milestone, Comment operations +- Optional: `bulk_update_issues()` (if capabilities support it) + +#### 3. Backend Implementations + +**Local Backend** (`issue_tracker/backends/local/backend.py`): +- Uses SQLite with schema defined in `schema.sql` +- Full offline functionality +- Serves as synchronization source of truth +- Implements `LocalBackend` and `SyncableBackend` + +**Gitea Backend** (`issue_tracker/backends/gitea/backend.py`): +- REST API integration with Gitea instances +- Rate limiting and error handling +- ID mapping between local and remote issues +- Implements `RemoteBackend` and `SyncableBackend` + +#### 4. CLI Layer (`issue_tracker/cli/`) +- **main.py**: Entry point, Click group setup, command registration +- **commands.py**: Core issue operations (list, show, create, close) +- **backend_commands.py**: Backend management (add, list, switch) +- **sync_commands.py**: Synchronization operations +- **utils.py**: Helper functions for formatting and backend access + +### ID Mapping Strategy + +The system uses a **dual-ID approach** for cross-backend synchronization: + +- `id`: Universal ID (UUID for local, external ID for remote) +- `number`: Human-readable sequential number (user-facing) +- `backend_id`: Backend-specific identifier for sync + +When syncing, backends maintain mappings between local numbers and remote IDs. The Gitea backend stores this in `sync_metadata` on the Issue model. + +### State Management + +`IssueState` enum provides universal states with backend-specific mapping via `to_backend_string()`: +- OPEN, CLOSED, IN_PROGRESS, BLOCKED +- Some backends (like Gitea) only support OPEN/CLOSED, so IN_PROGRESS and BLOCKED map to OPEN + +## Testing Strategy + +### Test Organization +- `test_gitea_backend.py`: Unit tests for Gitea backend with mocked API +- `test_gitea_integration.py`: Full integration tests with real Gitea instance +- `test_cli_commands.py`: CLI command testing + +### Integration Tests +The integration tests (`test_gitea_integration.py`) expect a Gitea instance at `http://localhost:3000` with test credentials. They create a temporary test repository, run full CRUD operations, and clean up afterwards. + +**Important**: Integration tests use pytest markers: +- `@pytest.mark.integration` - Integration tests (slower) +- `@pytest.mark.unit` - Unit tests (fast) + +Run only unit tests: `pytest -m unit` +Run only integration tests: `pytest -m integration` + +## Common Development Tasks + +### Adding a New Backend + +1. Create backend package in `issue_tracker/backends//` +2. Implement `IssueBackend` interface (or extend `LocalBackend`/`RemoteBackend`) +3. Implement all abstract methods from the interface +4. Define `BackendCapabilities` to specify supported features +5. Register backend in `BackendFactory` (typically in `__init__.py`) +6. Add configuration handling in CLI backend commands +7. Write unit tests with mocked external dependencies +8. Write integration tests if applicable + +### Modifying the Issue Model + +When changing `issue_tracker/core/models.py`: +1. Update the `Issue` dataclass definition +2. Update `to_dict()` serialization method +3. Invalidate caches if adding/modifying label-dependent properties +4. Update all backend implementations to handle new fields +5. Update database schema in `backends/local/schema.sql` +6. Write migration logic if modifying existing fields + +### Adding CLI Commands + +1. Add command function in appropriate file (`commands.py`, `backend_commands.py`, etc.) +2. Use `@click.command()` decorator with appropriate options +3. Call `get_backend(ctx)` to retrieve the active backend +4. Use `format_issue()` or `format_issue_list()` from `utils.py` for consistent output +5. Handle errors with `raise click.ClickException(message)` +6. Register command in `main.py` if creating new command group + +## Configuration + +### Project Configuration (`pyproject.toml`) +- Entry points: `issue` and `issue-tracker` commands +- Dependencies: click, requests, python-dateutil +- Optional dependencies: dev, docs, gitea, github, jira +- Code style: Black (line-length=100), isort (profile="black") +- Test markers: unit, integration, slow + +### Makefile Integration +The capability integrates with the parent markitect project via `Makefile`: +- Prefixed targets: `issue-facade-*` for development commands +- Unprefixed targets: `issue-*` for user-facing CLI operations +- Uses `pip install -e` for editable installation + +## Important Patterns and Conventions + +### Error Handling +- Backend-specific errors inherit from base exceptions (e.g., `GiteaAPIError`) +- CLI commands convert exceptions to `click.ClickException` with user-friendly messages +- Use specific exception types for rate limiting, authentication, network issues + +### Type Hints +- Mypy strict mode enabled (`disallow_untyped_defs = true`) +- All functions must have type annotations +- Use `Optional[T]` for nullable types +- Use `List[T]`, `Dict[K, V]` from `typing` module (Python 3.8 compatibility) + +### Performance Optimizations +- Use `@cached_property` for expensive computations (e.g., label categorization) +- Call `invalidate_cache()` when modifying cached data +- Single-pass algorithms for label categorization in Issue model + +### Synchronization +When implementing sync: +1. Local backend is source of truth +2. Remote backends track last sync timestamp +3. Use `get_issues_modified_since()` for incremental sync +4. Handle conflicts via `SyncableBackend.resolve_sync_conflict()` +5. Store sync metadata in Issue.sync_metadata dict + +## Dependencies and External Systems + +### Runtime Dependencies +- **click**: CLI framework (>=8.0.0) +- **requests**: HTTP client for remote backends (>=2.25.0) +- **python-dateutil**: Date/time parsing (>=2.8.0) + +### Development Dependencies +- **pytest**: Testing framework with markers support +- **pytest-cov**: Coverage reporting +- **pytest-mock**: Mocking utilities +- **black, isort, flake8, mypy**: Code quality tools + +### External Systems +- **Gitea API**: REST API at `/api/v1/` endpoints +- **SQLite**: Local database (no server required) +- Future: GitHub API, GitLab API, JIRA API + +## Repository Context + +This is a capability within the larger markitect project (`/capabilities/issue-facade/`). The capability: +- Can be installed independently via `pip install -e .` +- Integrates with parent project via Makefile targets +- Follows markitect capability conventions for structure and naming diff --git a/Makefile b/Makefile index 3433e75..62ebc2d 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,16 @@ help: ## Show issue facade capability help @echo " issue-sync-pull Pull issues from remote" @echo " issue-sync-push Push local issues to remote" @echo "" - @echo "Development & Setup:" + @echo "Development & Setup (local):" + @echo " install Install issue facade for local development" + @echo " install-dev Install with development dependencies" + @echo " test Run all tests" + @echo " test-unit Run unit tests only" + @echo " test-integration Run integration tests only" + @echo " test-cov Run tests with coverage report" + @echo " test-verbose Run tests with verbose output" + @echo "" + @echo "Development & Setup (from parent):" @echo " issue-facade-install Install issue facade capability" @echo " issue-facade-install-dev Install with development dependencies" @echo " issue-facade-test Run issue facade tests" @@ -161,6 +170,38 @@ endif issue sync push # Development and Setup +.PHONY: integrate +integrate: ## Integrate capability into main project (interactive) + @./.capability/integrate.sh + +.PHONY: install +install: ## Install issue facade (local development) + pip install -e . + +.PHONY: install-dev +install-dev: ## Install with development dependencies (local development) + pip install -e ".[dev]" + +.PHONY: test +test: ## Run all tests (local development) + pytest tests/ + +.PHONY: test-unit +test-unit: ## Run unit tests only (local development) + pytest tests/ -m unit -v + +.PHONY: test-integration +test-integration: ## Run integration tests only (local development) + pytest tests/ -m integration -v + +.PHONY: test-cov +test-cov: ## Run tests with coverage report (local development) + pytest tests/ --cov=issue_tracker --cov-report=html --cov-report=term + +.PHONY: test-verbose +test-verbose: ## Run tests with verbose output (local development) + pytest tests/ -v + .PHONY: issue-facade-install issue-facade-install: ## Install issue facade capability pip install -e capabilities/issue-facade/ diff --git a/README.md b/README.md index bdcb19a..7b7b8a6 100644 --- a/README.md +++ b/README.md @@ -1,340 +1,399 @@ -# Issue Facade - Universal CLI for Issue Tracking +# Issue Facade - Agent Coordination via Issue Tracking -A convenient command-line facade that provides a unified interface to the repository's main issue tracker, regardless of which backend system is actually being used. +**A unified interface for autonomous coding agents to coordinate project implementation through issue tracking systems.** ## Purpose -The **Issue Facade** acts as a convenient CLI wrapper that automatically detects and interfaces with whatever issue tracking system is configured for the current repository. This means you get a consistent, intuitive command-line experience whether your project uses: +The **Issue Facade** provides a standardized abstraction layer for coding agents to interact with issue tracking backends (Gitea, GitHub, GitLab, local SQLite). Instead of each agent implementing platform-specific API integrations, they use one consistent interface that works across all backends. -- GitHub Issues -- GitLab Issues -- Gitea Issues -- JIRA -- Local SQLite storage -- Any other supported backend +### Why Issue Tracking for Agent Coordination? -## Philosophy +Issue tracking provides natural coordination primitives for multi-agent software development: -Rather than learning different commands and workflows for each issue tracking system, the Issue Facade provides: +- **Task Distribution**: Issues represent discrete work units agents can claim and execute +- **Progress Tracking**: States (open → in_progress → closed) track work across the team +- **Communication**: Comments enable agent-to-agent and agent-to-human communication +- **Visibility**: Labels, assignees, and milestones provide real-time project status +- **Human Integration**: Humans can seamlessly participate in agent-driven development -- **One CLI to rule them all**: Same commands work across all backends -- **Repository-aware**: Automatically detects the relevant issue tracker for your repo -- **Offline capability**: Local SQLite fallback when remote systems are unavailable -- **Seamless sync**: Keep local and remote issue trackers synchronized +## Current Status -## How It Works +**Production-ready core with manual setup** (v1.0) + +✅ **Fully Implemented:** +- Complete CRUD operations (issues, labels, users, milestones, comments) +- Gitea backend (production-ready with full API integration) +- Local SQLite backend (offline work with sync capability) +- CLI with JSON output for machine parsing +- Python API for programmatic access +- Comprehensive filtering and search +- Basic synchronization between backends + +⚠️ **Current Limitations:** +- Manual backend configuration required (one-time setup per project) +- No auto-detection from git remotes (coming in v1.1) +- Basic conflict resolution (manual intervention for complex cases) +- Hardcoded user context (agents need external identity management) + +## Quick Start + +### Installation ```bash -# The facade automatically detects your repository's issue tracker -cd /path/to/my-project -issue list # Lists issues from the repo's configured tracker - -cd /path/to/another-project -issue list # Lists issues from THIS repo's tracker - -# Same commands, different backends - transparent to the user +cd capabilities/issue-facade +pip install -e . ``` -## Key Features +### Configuration (One-Time Setup) -### 🎯 **Repository Context Awareness** -The facade automatically detects: -- Git remotes (GitHub, GitLab, Gitea URLs) -- Configuration files (`.issue-config`, `pyproject.toml`, etc.) -- Environment variables -- Default fallbacks - -### 🖥️ **Unified CLI Experience** -```bash -# Core issue operations (same across all backends) -issue list # List issues -issue show 42 # Show issue details -issue create "Bug in parser" # Create new issue -issue edit 42 --add-label bug # Edit existing issue -issue close 42 --comment "Fixed" # Close with comment -issue comment 42 "Still broken" # Add comment - -# Advanced operations -issue list --assignee=me --state=open -issue search "memory leak" -issue stats # Show issue statistics -``` - -### 🔄 **Automatic Backend Detection** -```bash -# GitHub repository -cd my-github-project -issue list # → Automatically uses GitHub Issues API - -# Gitea repository -cd my-gitea-project -issue list # → Automatically uses Gitea Issues API - -# Offline/local work -cd any-project -issue backend set local -issue list # → Uses local SQLite storage -``` - -### 🌐 **Seamless Synchronization** -```bash -# Work offline, sync later -issue create "Bug found offline" --local -issue sync # Pushes to remote when online - -# Keep backups -issue sync pull # Download all remote issues locally -issue export backup.json # Export for archival -``` - -## Installation & Setup - -### 1. Install the Facade -```bash -pip install issue-facade -``` - -### 2. Automatic Configuration -The facade auto-detects your repository's issue tracker: +**For Gitea-backed projects:** ```bash -cd your-repository -issue config detect # Auto-configure based on git remotes -issue list # Ready to use! +# Set your Gitea token +export GITEA_API_TOKEN="your-token-here" + +# Configure backend +issue backend add myproject gitea +# Prompts for: URL, owner, repo (reads token from environment) + +# Set as default +issue backend set-default myproject + +# Verify +issue backend test myproject ``` -### 3. Manual Configuration (if needed) +**For local/offline work:** + ```bash -# Configure specific backends -issue backend add github my-repo -issue backend add gitea company-repo -issue backend add local offline +issue backend add local-work local +# Prompts for: database path (.issue-facade/issues.db) -# Set repository-specific backend -issue config set-backend github # For current repository +issue backend set-default local-work ``` -## Repository Integration Examples +### Basic Usage -### GitHub Repository ```bash -cd my-github-project -issue config detect -# → Automatically configures GitHub Issues via API -# → Uses .github/ISSUE_TEMPLATE/ for templates -# → Respects repository labels and milestones +# List issues (JSON output for agents) +issue list --format=json -issue create "Security vulnerability" --template security +# Create issue +issue create "Implement user authentication" \ + --label=feature --label=priority:high + +# Update state +issue edit 42 --state=in_progress --assignee=agent-coder + +# Add comment +issue comment 42 "Implementation complete, tests passing" + +# Close issue +issue close 42 --comment="Ready for review" ``` -### Corporate Gitea -```bash -cd company-project -issue config detect -# → Detects Gitea instance from git remote -# → Prompts for access token (one-time setup) -# → Uses corporate labels and workflows +## Agent Integration -issue list --milestone "Q4 Release" -``` +**For autonomous coding agents**, see **[AGENT_INTEGRATION.md](AGENT_INTEGRATION.md)** for: -### Offline Development -```bash -cd any-project -issue backend set local -# → Creates local SQLite database in .issue-facade/ -# → Full offline functionality -# → Sync to remote when connection available +- Programmatic Python API usage +- Multi-agent coordination patterns +- Agent workflow examples +- Label-based role assignment +- State machine workflows +- Comment-based communication protocols +- Workarounds for current limitations +- Performance optimization tips -issue create "Performance issue" --offline -issue sync when-online -``` +Quick example: -## Configuration +```python +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.interfaces import IssueFilter -### Per-Repository Configuration -Each repository can have its own configuration in `.issue-facade/config.json`: +# Initialize +backend = GiteaBackend() +backend.connect(config) -```json -{ - "backend": "github", - "github": { - "owner": "myorg", - "repo": "myproject", - "token_env": "GITHUB_TOKEN" - }, - "local": { - "db_path": ".issue-facade/issues.db", - "sync_enabled": true - }, - "templates": { - "bug": ".github/ISSUE_TEMPLATE/bug_report.md", - "feature": ".github/ISSUE_TEMPLATE/feature_request.md" - } -} -``` +# Query issues for agent +issues = backend.list_issues(IssueFilter( + state='open', + labels=['bug', 'priority:critical'], + assignee='agent-coder' +)) -### Global Configuration -User-wide settings in `~/.config/issue-facade/config.json`: +# Process each issue +for issue in issues: + # Agent implements fix + result = agent.fix_bug(issue) -```json -{ - "default_backend": "local", - "github_token": "env:GITHUB_TOKEN", - "gitea_instances": { - "company": { - "url": "https://git.company.com", - "token": "env:GITEA_TOKEN" - } - }, - "offline_mode": false, - "sync_interval": "1h" -} + # Report back + issue.state = IssueState.CLOSED + backend.update_issue(issue) ``` ## Architecture -### Facade Pattern Implementation +### Facade Pattern with Plugin Backends + ``` -Repository Working Directory -├── .git/ # Git repository -├── .issue-facade/ # Facade configuration -│ ├── config.json # Repository-specific config -│ ├── issues.db # Local SQLite cache/backup -│ └── templates/ # Issue templates -├── your-project-files... -└── issue # CLI command (context-aware) +┌─────────────────────────────────────┐ +│ CLI Layer (Click) │ +│ issue list | create | edit │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Core Domain Models │ +│ (Issue, Label, User, etc.) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Backend Interface (ABC) │ +│ IssueBackend, SyncableBackend │ +└──────────────┬──────────────────────┘ + │ + ┌───────┴────────┐ + │ │ +┌──────▼─────┐ ┌──────▼──────┐ +│ Local │ │ Gitea │ +│ (SQLite) │ │ (REST API) │ +└────────────┘ └─────────────┘ ``` -### Backend Detection Logic -1. **Check repository configuration**: `.issue-facade/config.json` -2. **Analyze git remotes**: Detect GitHub/GitLab/Gitea URLs -3. **Look for platform files**: `.github/`, `.gitlab/`, etc. -4. **Check environment**: `GITHUB_TOKEN`, `GITLAB_TOKEN`, etc. -5. **Fall back to local**: SQLite storage if no remote detected +**Key Design Principles:** +- Backend-agnostic core models +- Plugin architecture for easy backend addition +- Type-safe interfaces with comprehensive testing +- Sync support for offline/online workflows -### Multi-Repository Support -```bash -# Each repository maintains its own context -/projects/web-app/ → GitHub Issues -/projects/api-server/ → GitLab Issues -/projects/cli-tool/ → Gitea Issues -/projects/experiment/ → Local SQLite +### Supported Backends -# Same commands work in all contexts -cd web-app && issue list # GitHub -cd api-server && issue list # GitLab -cd cli-tool && issue list # Gitea -cd experiment && issue list # Local -``` +| Backend | Status | Features | +|---------|--------|----------| +| **Gitea** | ✅ Production | Full API, rate limiting, state mapping | +| **Local SQLite** | ✅ Production | Offline work, fast queries, sync support | +| **GitHub** | 🚧 Planned (v1.1) | Full API integration | +| **GitLab** | 🚧 Planned (v1.2) | Full API integration | ## Use Cases -### 1. **Multi-Platform Developer** -You work with repositories across GitHub, GitLab, and company Gitea: +### 1. Multi-Agent Project Implementation + +Multiple specialized agents coordinate via issues: + ```bash -# Learn one CLI, use everywhere -issue list --assignee=me # Works on all platforms -issue create "Cross-platform bug" --label bug +# Agent 1 (Coder): Claims and implements features +issue list --label=needs-implementation --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} issue edit {} --assignee=agent-coder --state=in_progress + +# Agent 2 (Reviewer): Reviews completed work +issue list --label=needs-review --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} issue comment {} "Code review: Approved" + +# Agent 3 (Tester): Runs tests +issue list --label=reviewed --format=json | \ + jq -r '.[0].number' | \ + xargs -I {} issue comment {} "Tests passing: 100%" ``` -### 2. **Offline Developer** -You need to track issues without constant internet: -```bash -issue create "Found while flying" --offline -issue list --local # View offline issues -issue sync # Upload when back online +### 2. Agent-Human Collaboration + +Agents propose implementations, humans approve: + +```python +# Agent creates subtasks from human requirements +feature = backend.get_issue_by_number(100) +for subtask in agent.break_down_feature(feature): + backend.create_issue(subtask) + +# Human reviews and approves via comments +# Agent monitors and proceeds with implementation ``` -### 3. **Repository Migration** -Moving from GitHub to GitLab: +### 3. Offline Development with Sync + +Work offline with local backend, sync when online: + ```bash -issue export github-backup.json # Backup from GitHub -issue backend set gitlab # Switch to GitLab -issue import github-backup.json # Import to GitLab +# Setup local backup +issue backend add backup local +issue sync pull gitea-production backup + +# Work offline +issue backend set-default backup +issue create "Offline implementation" --label=offline + +# Sync back +issue sync push backup gitea-production ``` -### 4. **Cross-Repository Analytics** -Track issues across multiple repositories: +## CLI Commands Reference + +### Issue Operations ```bash -issue stats --all-repos # Statistics across all configured repos -issue search "security" --global # Search across all issue trackers +issue list [--state STATE] [--label LABEL] [--assignee USER] [--format FORMAT] +issue show ISSUE_NUMBER [--comments] [--format FORMAT] +issue create TITLE [--description DESC] [--label LABEL] [--assignee USER] +issue edit ISSUE_NUMBER [--title TITLE] [--state STATE] [--add-label LABEL] +issue close ISSUE_NUMBER [--comment COMMENT] +issue reopen ISSUE_NUMBER [--comment COMMENT] +issue comment ISSUE_NUMBER BODY ``` -## Integration with Development Workflow - -### Git Hooks Integration +### Backend Management ```bash -# .git/hooks/pre-commit -issue list --assignee=me --state=open > ISSUES.md -git add ISSUES.md +issue backend list +issue backend add NAME TYPE +issue backend remove NAME +issue backend test NAME +issue backend set-default NAME ``` -### CI/CD Integration +### Synchronization ```bash -# In your CI pipeline -issue create "Build failed on commit $SHA" --label ci-failure -issue close-if-fixed $ISSUE_NUMBER +issue sync status +issue sync pull SOURCE TARGET [--dry-run] [--force] +issue sync push SOURCE TARGET +issue sync bidirectional BACKEND1 BACKEND2 ``` -### IDE Integration +## Development + +### Testing + ```bash -# VS Code, Vim, Emacs plugins can use the CLI -:IssueList # List issues in editor -:IssueCreate "Typo in function" # Create issue from editor +# Install with dev dependencies +make install-dev + +# Run all tests (109 tests, 61% coverage) +make test + +# Run with coverage report +make test-cov + +# Run only unit tests +make test-unit ``` -## Comparison with Native Tools +### Code Quality -| Feature | issue-facade | gh (GitHub CLI) | glab (GitLab CLI) | Platform Web UI | -|---------|--------------|-----------------|-------------------|-----------------| -| **Multi-platform** | ✅ All backends | ❌ GitHub only | ❌ GitLab only | ❌ Single platform | -| **Offline support** | ✅ Local SQLite | ❌ Online only | ❌ Online only | ❌ Online only | -| **Consistent CLI** | ✅ Same commands | ❌ GitHub-specific | ❌ GitLab-specific | ❌ Web interface | -| **Repository context** | ✅ Auto-detect | ✅ Git-aware | ✅ Git-aware | ❌ Manual navigation | -| **Cross-repo search** | ✅ Global search | ❌ Single repo | ❌ Single repo | ❌ Single repo | -| **Data portability** | ✅ Export/import | ❌ Platform-locked | ❌ Platform-locked | ❌ Platform-locked | +```bash +# Run linter +make issue-facade-lint -## Future Roadmap +# Format code +black issue_tracker/ tests/ -### Version 1.0 (Current) -- [x] Core facade architecture -- [x] GitHub/GitLab/Gitea backend support -- [x] Local SQLite backend -- [x] Automatic repository detection -- [x] Basic synchronization +# Type check +mypy issue_tracker/ +``` -### Version 1.1 -- [ ] Advanced sync (conflict resolution) -- [ ] Issue templates support -- [ ] Workflow automation hooks -- [ ] Plugin system for custom backends +### Project Structure -### Version 2.0 -- [ ] Web dashboard for multi-repo overview -- [ ] Advanced analytics and reporting -- [ ] Team collaboration features -- [ ] Integration with project management tools +``` +issue-facade/ +├── issue_tracker/ +│ ├── core/ # Domain models and interfaces +│ │ ├── models.py # Issue, Label, User, etc. +│ │ └── interfaces.py # IssueBackend, SyncableBackend +│ ├── backends/ +│ │ ├── gitea/ # Gitea backend implementation +│ │ └── local/ # SQLite backend implementation +│ └── cli/ # Click-based CLI +│ ├── commands.py # Issue operations +│ ├── backend_commands.py +│ └── sync_commands.py +├── tests/ # 109 tests, comprehensive coverage +├── examples/ # Agent integration examples +├── AGENT_INTEGRATION.md # Agent coordination guide +├── CLAUDE.md # Development guide for Claude Code +└── ROADMAP.md # Future enhancements +``` + +## Documentation + +- **[AGENT_INTEGRATION.md](AGENT_INTEGRATION.md)** - Comprehensive guide for autonomous agents + - Programmatic API usage + - Multi-agent coordination patterns + - Workflow examples and strategies + - Performance optimization + - Current workarounds + +- **[CLAUDE.md](CLAUDE.md)** - Development guide for working on this codebase + - Architecture deep-dive + - Testing strategy + - Adding new backends + - Common development tasks + +- **[ROADMAP.md](ROADMAP.md)** - Planned features and implementation timeline + +## Roadmap + +### v1.1 - Auto-Configuration (Next) +- Automatic git remote detection +- Environment-variable-only setup +- Per-repository configuration files +- `issue config detect` command + +### v1.2 - Agent Features +- Agent identity management +- Issue claiming/locking API +- Webhook support for reactive agents +- Structured metadata for agent state + +### v2.0 - Advanced Coordination +- Issue dependency tracking +- Query DSL for complex filters +- Activity streams and event logs +- Distributed locking for concurrent operations + +## Comparison with Platform CLIs + +| Feature | Issue Facade | gh (GitHub) | glab (GitLab) | +|---------|--------------|-------------|---------------| +| Multi-backend support | ✅ Yes | ❌ GitHub only | ❌ GitLab only | +| Offline capability | ✅ Local SQLite | ❌ No | ❌ No | +| Agent-friendly API | ✅ Python + JSON | ⚠️ CLI only | ⚠️ CLI only | +| Consistent interface | ✅ Same across all | ❌ Platform-specific | ❌ Platform-specific | +| Backend sync | ✅ Yes | ❌ No | ❌ No | +| Auto-configuration | 🚧 Coming v1.1 | ✅ Yes | ✅ Yes | ## Contributing -The Issue Facade is designed to be: -- **Backend-agnostic**: Easy to add new issue tracking systems -- **Repository-aware**: Respects the conventions of each platform -- **Developer-friendly**: Consistent CLI across all environments +The Issue Facade is designed to be extensible: -To add a new backend: -1. Implement the `IssueBackend` interface -2. Add detection logic for the platform -3. Register the backend in the factory -4. Add CLI configuration support +**To add a new backend:** +1. Implement the `IssueBackend` interface (see `core/interfaces.py`) +2. Handle platform-specific API details in your backend +3. Map platform models to/from core domain models +4. Add comprehensive tests +5. Register in `BackendFactory` + +**Backend implementation checklist:** +- [ ] All CRUD operations (issues, labels, users, milestones, comments) +- [ ] State mapping to/from platform-specific states +- [ ] Error handling and rate limiting +- [ ] Sync support (if applicable) +- [ ] Integration tests with mock API +- [ ] Documentation + +See existing backends (Gitea, Local) as reference implementations. ## Why "Facade"? -The **Facade Pattern** perfectly describes this tool's purpose: +The **Facade Pattern** describes this tool's purpose: -> *"Provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use."* +> *"Provide a unified interface to a set of interfaces in a subsystem."* +> — Gang of Four, Design Patterns -Instead of learning different CLIs for GitHub (`gh`), GitLab (`glab`), JIRA, etc., the Issue Facade provides one consistent interface that works with all of them. It's the universal remote control for issue tracking systems. +Instead of coding agents learning different APIs for GitHub (`gh`), GitLab (`glab`), Gitea, JIRA, etc., they use one consistent interface. The facade doesn't replace issue trackers—it makes them easier to use uniformly. -The facade doesn't replace the underlying issue trackers - it makes them easier to use consistently across different platforms and repositories. \ No newline at end of file +## License + +MIT License - See LICENSE file + +## Part of MarkiTect + +This capability is part of the [MarkiTect Project](https://github.com/markitect), a collection of tools for agent-driven software development. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6b42727 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,791 @@ +# Issue Facade Roadmap + +**Long-term vision and implementation plan for agent-driven software development coordination.** + +## Current Status: v1.0 (Production-Ready Core) + +✅ **Complete:** +- Core CRUD operations (100%) +- Gitea backend (production-ready) +- Local SQLite backend (fully functional) +- CLI with JSON output +- Python programmatic API +- Basic synchronization +- Comprehensive test suite (109 tests, 61% coverage) + +⚠️ **Limitations:** +- Manual backend configuration +- No auto-detection +- Basic conflict resolution +- Hardcoded user context + +--- + +## Phase 1: Auto-Configuration (v1.1) - **Next Priority** + +**Goal:** Enable agents to work in any repository without manual setup. + +### 1.1.1 Git Remote Detection + +**Implementation:** +```python +# issue_tracker/core/detection.py + +def detect_git_remote() -> Optional[Dict[str, str]]: + """ + Parse git remote URL to extract platform, owner, repo. + + Returns: + { + 'platform': 'gitea' | 'github' | 'gitlab', + 'base_url': 'https://gitea.example.com', + 'owner': 'myorg', + 'repo': 'myproject' + } + """ + +def parse_remote_url(url: str) -> Optional[Dict[str, str]]: + """ + Parse various git remote URL formats: + - https://gitea.example.com/owner/repo.git + - git@gitea.example.com:owner/repo.git + - https://github.com/owner/repo + """ +``` + +**Tests:** `tests/test_detection.py` +- Test various URL formats (HTTPS, SSH, with/without .git) +- Test platform detection (Gitea, GitHub, GitLab) +- Test edge cases (subgroups, custom domains) + +**Effort:** 2-3 days + +### 1.1.2 Environment-Based Configuration + +**Implementation:** +```python +# issue_tracker/core/env_config.py + +def load_backend_from_env() -> Optional[Dict[str, Any]]: + """ + Load backend config from environment variables: + - GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO + - GITHUB_TOKEN (with auto-detection) + - GITLAB_URL, GITLAB_TOKEN + """ + +def create_backend_from_env(platform: str) -> IssueBackend: + """Create and connect backend from environment.""" +``` + +**New CLI command:** +```bash +issue config auto +# Tries: git remote → environment → user prompt +``` + +**Tests:** +- Test environment loading with various combinations +- Test fallback priority (git → env → prompt) +- Test error handling for missing credentials + +**Effort:** 2-3 days + +### 1.1.3 Per-Repository Configuration Files + +**Implementation:** +``` +.issue-facade/ +├── config.json # Repository-specific settings +├── issues.db # Local cache/backup +└── credentials.json # Optional encrypted credentials +``` + +**Config format:** +```json +{ + "backend": { + "type": "gitea", + "url": "https://gitea.example.com", + "owner": "myorg", + "repo": "myproject", + "token_source": "env:GITEA_TOKEN" // or "file:/path/to/token" + }, + "sync": { + "enabled": true, + "interval": "1h", + "auto_pull": false + }, + "agent": { + "identity": "agent-coder", + "claim_timeout": 1800 + } +} +``` + +**Functions:** +```python +def load_repo_config(path: Path = Path.cwd()) -> Optional[Dict]: + """Load .issue-facade/config.json from repo root.""" + +def save_repo_config(config: Dict, path: Path = Path.cwd()): + """Save config to .issue-facade/config.json.""" + +def find_repo_root() -> Optional[Path]: + """Walk up directory tree to find git root.""" +``` + +**Effort:** 3-4 days + +### 1.1.4 Unified Auto-Configuration + +**Implementation:** +```python +# issue_tracker/core/auto_config.py + +def auto_configure_backend() -> IssueBackend: + """ + Auto-configure backend with fallback priority: + 1. Check .issue-facade/config.json + 2. Detect from git remote + environment token + 3. Check global config (~/.config/issue-facade/) + 4. Prompt user for manual configuration + """ +``` + +**CLI integration:** +```bash +# New commands +issue config detect # Detect and show config (no save) +issue config init # Detect, confirm, and save +issue config show # Show current config +issue config edit # Open config in $EDITOR +``` + +**Tests:** +- Integration test: git repo → auto-detection → working backend +- Test fallback priority +- Test config precedence + +**Effort:** 3-4 days + +### 1.1.5 Integration Script UX Improvements + +**Problem:** Integration script has poor UX - hardcoded defaults, no backup, doesn't reuse existing config. + +**Implementation:** + +```bash +# .capability/integrate.sh improvements + +# 1. Smart default for backend name (derive from project) +PROJECT_NAME=$(basename "$PROJECT_ROOT") +read -p "Backend name [$PROJECT_NAME]: " backend_name +backend_name="${backend_name:-$PROJECT_NAME}" + +# 2. Pre-populate existing settings when replacing +if issue backend show "$backend_name" &>/dev/null; then + echo "⚠️ Backend '$backend_name' already exists" + + # Load current values + CURRENT_URL=$(issue backend show "$backend_name" --field=url 2>/dev/null || echo "") + CURRENT_OWNER=$(issue backend show "$backend_name" --field=owner 2>/dev/null || echo "") + CURRENT_REPO=$(issue backend show "$backend_name" --field=repo 2>/dev/null || echo "") + + read -p "Replace existing backend? [y/N]: " replace + if [ "$replace" = "y" ] || [ "$replace" = "Y" ]; then + # Create timestamped backup + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + CONFIG_FILE="$HOME/.config/issue-facade/backends.json" + if [ -f "$CONFIG_FILE" ]; then + BACKUP_FILE="$CONFIG_FILE.backup.$TIMESTAMP" + cp "$CONFIG_FILE" "$BACKUP_FILE" + echo "✓ Backed up config to: $BACKUP_FILE" + fi + + # Offer existing values as defaults + read -p "Gitea URL [$CURRENT_URL]: " url + url="${url:-$CURRENT_URL}" + + read -p "Repository owner [$CURRENT_OWNER]: " owner + owner="${owner:-$CURRENT_OWNER}" + + read -p "Repository name [$CURRENT_REPO]: " repo + repo="${repo:-$CURRENT_REPO}" + else + echo "Keeping existing configuration" + exit 0 + fi +fi +``` + +**Benefits:** +- Reduces typing (project name auto-detected) +- Prevents accidental config loss (automatic backups) +- Speeds up reconfiguration (existing values as defaults) +- Better error recovery (timestamped backups) + +**Implementation tasks:** +1. Add `detect_project_name()` function to integration script +2. Add `load_existing_backend_config()` function +3. Add `backup_config_file()` function with timestamp +4. Update interactive prompts to use defaults from existing config +5. Add rollback instructions to output + +**Tests:** +- Manual testing of integration script with various scenarios: + - Fresh installation (no existing backend) + - Replacing existing backend (should show defaults) + - Backup creation and verification + - Directory name extraction accuracy + +**Effort:** 2-3 days + +**Total Phase 1:** ~15-20 days (3-4 weeks) + +--- + +## Phase 2: Agent Features (v1.2) + +**Goal:** Native support for multi-agent coordination. + +### 1.2.1 Agent Identity Management + +**Problem:** Currently uses hardcoded "cli-user" for all operations. + +**Implementation:** +```python +# issue_tracker/core/agent.py + +@dataclass +class AgentContext: + """Context for agent operations.""" + agent_id: str + agent_type: str # 'coder', 'reviewer', 'tester', etc. + capabilities: List[str] # ['python', 'javascript', 'testing'] + metadata: Dict[str, Any] + +def get_agent_context() -> AgentContext: + """ + Get agent context from: + 1. Environment (ISSUE_AGENT_ID, ISSUE_AGENT_TYPE) + 2. Config file (.issue-facade/config.json) + 3. Default to system username + """ + +def set_agent_context(context: AgentContext): + """Set global agent context for operations.""" +``` + +**Backend integration:** +```python +# Update all comment/assignee operations +def add_comment(self, issue_id: str, body: str): + """Add comment with current agent context.""" + ctx = get_agent_context() + comment = Comment( + author=User(id=ctx.agent_id, username=ctx.agent_id), + body=f"[{ctx.agent_type}] {body}", + ... + ) +``` + +**CLI:** +```bash +issue config agent set-id "agent-coder-v1" +issue config agent set-type "coder" +issue config agent show +``` + +**Tests:** +- Test context loading from various sources +- Test context inheritance in backend operations +- Test agent metadata propagation + +**Effort:** 4-5 days + +### 1.2.2 Issue Claiming/Locking + +**Problem:** No native support for claiming issues (prevents race conditions). + +**Implementation:** +```python +# issue_tracker/core/locking.py + +class IssueClaim: + issue_id: str + agent_id: str + claimed_at: datetime + expires_at: datetime + metadata: Dict[str, Any] + +class LockManager: + """Manages issue claims/locks.""" + + def claim_issue(self, issue_id: str, timeout_seconds: int = 1800) -> IssueClaim: + """ + Claim an issue for exclusive work. + Raises ClaimError if already claimed. + """ + + def release_issue(self, issue_id: str): + """Release claim on issue.""" + + def check_claim(self, issue_id: str) -> Optional[IssueClaim]: + """Check if issue is claimed and by whom.""" + + def extend_claim(self, issue_id: str, additional_seconds: int): + """Extend claim timeout.""" + + def cleanup_expired(self): + """Clean up expired claims.""" +``` + +**Storage:** Store claims in issue metadata or separate tracking table. + +**For Gitea backend:** +```json +// In issue.sync_metadata +{ + "claim": { + "agent_id": "agent-coder", + "claimed_at": "2024-01-15T10:00:00Z", + "expires_at": "2024-01-15T10:30:00Z" + } +} +``` + +**CLI:** +```bash +issue claim 42 --timeout=30m # Claim for 30 minutes +issue claim release 42 # Release claim +issue claim check 42 # Check claim status +issue claim extend 42 --add-time=15m # Extend by 15 minutes +``` + +**Tests:** +- Test claim acquisition and release +- Test claim expiration +- Test concurrent claim attempts (should fail) +- Test claim cleanup + +**Effort:** 5-6 days + +### 1.2.3 Structured Agent Metadata + +**Problem:** Agent state tracked in comments (unstructured). + +**Implementation:** +```python +# Extend Issue model +@dataclass +class Issue: + ... + agent_metadata: Dict[str, Any] = field(default_factory=dict) + """ + Structured metadata for agent operations: + { + 'assigned_agent': { + 'agent_id': 'agent-coder', + 'assigned_at': '2024-01-15T10:00:00Z', + 'progress': 0.75 + }, + 'work_state': { + 'stage': 'implementation', + 'checkpoints': ['analysis', 'design'], + 'next_checkpoint': 'testing' + }, + 'dependencies': { + 'blocks': [43, 44], + 'blocked_by': [41] + } + } + """ +``` + +**API:** +```python +def update_agent_progress(issue: Issue, progress: float, status: str): + """Update agent progress metadata.""" + +def mark_checkpoint(issue: Issue, checkpoint: str): + """Mark a workflow checkpoint complete.""" + +def get_agent_state(issue: Issue) -> Dict[str, Any]: + """Get current agent state for issue.""" +``` + +**Effort:** 3-4 days + +### 1.2.4 Webhook Support + +**Problem:** Agents poll for changes (inefficient for real-time reactions). + +**Implementation:** +```python +# issue_tracker/core/webhooks.py + +class WebhookManager: + """Manage webhooks for real-time notifications.""" + + def register_webhook( + self, + url: str, + events: List[str], # ['issue.created', 'issue.updated', 'issue.closed'] + secret: Optional[str] = None + ): + """Register webhook with backend.""" + + def validate_webhook_signature(payload: bytes, signature: str, secret: str) -> bool: + """Validate webhook payload signature.""" + +# Simple webhook receiver +class WebhookReceiver: + def start(self, port: int = 8080): + """Start HTTP server to receive webhooks.""" + + def on_event(self, event_type: str, callback: Callable): + """Register callback for event type.""" +``` + +**Example usage:** +```python +receiver = WebhookReceiver() + +@receiver.on_event('issue.created') +def handle_new_issue(issue: Issue): + if 'priority:critical' in [l.name for l in issue.labels]: + agent.claim_and_process(issue) + +receiver.start(port=8080) +``` + +**CLI:** +```bash +issue webhook register http://agent.example.com/hook --events=issue.created,issue.updated +issue webhook list +issue webhook remove +``` + +**Effort:** 5-6 days + +**Total Phase 2:** ~20-25 days (4-5 weeks) + +--- + +## Phase 3: Advanced Coordination (v2.0) + +**Goal:** Enterprise-grade multi-agent system coordination. + +### 2.0.1 Issue Dependency Tracking + +**Implementation:** +```python +@dataclass +class Issue: + ... + depends_on: List[int] = field(default_factory=list) + blocks: List[int] = field(default_factory=list) + +class DependencyGraph: + """Manage issue dependencies.""" + + def add_dependency(self, issue_id: int, depends_on: int): + """Mark issue as depending on another.""" + + def remove_dependency(self, issue_id: int, depends_on: int): + """Remove dependency.""" + + def get_blocking_issues(self, issue_id: int) -> List[Issue]: + """Get issues blocking this one.""" + + def get_blocked_issues(self, issue_id: int) -> List[Issue]: + """Get issues blocked by this one.""" + + def can_start(self, issue_id: int) -> bool: + """Check if all dependencies are resolved.""" + + def get_ready_issues(self) -> List[Issue]: + """Get all issues with no blocking dependencies.""" +``` + +**CLI:** +```bash +issue depends add 42 --blocks=43,44 # 42 blocks 43 and 44 +issue depends remove 42 --blocks=43 +issue depends show 42 # Show dependency graph +issue depends ready # List issues ready to start +``` + +**Effort:** 6-7 days + +### 2.0.2 Query DSL + +**Problem:** Filtering is verbose and limited. + +**Implementation:** +```python +# issue_tracker/core/query_dsl.py + +class QueryParser: + """ + Parse query DSL: + - is:open is:closed is:in-progress + - assignee:me assignee:agent-coder + - label:bug,priority:high + - created:>7d updated:<24h + - milestone:"Q1 Release" + - sort:created-desc sort:priority + """ + + def parse(self, query: str) -> IssueFilter: + """Parse query string into IssueFilter.""" + +# Examples: +# "is:open assignee:me label:bug,critical" +# "is:closed created:>30d sort:closed-desc" +# "label:needs-review -label:blocked" # exclude label +``` + +**CLI:** +```bash +issue search "is:open assignee:me label:bug,priority:high" +issue list --query="is:in-progress created:>7d" +``` + +**Effort:** 5-6 days + +### 2.0.3 Activity Streams & Event Logs + +**Implementation:** +```python +# issue_tracker/core/activity.py + +@dataclass +class ActivityEvent: + event_type: str # 'issue.created', 'issue.updated', 'comment.added' + issue_id: str + actor_id: str + timestamp: datetime + changes: Dict[str, Any] + metadata: Dict[str, Any] + +class ActivityStream: + """Track all issue activity.""" + + def log_event(self, event: ActivityEvent): + """Log an activity event.""" + + def get_issue_activity(self, issue_id: str) -> List[ActivityEvent]: + """Get all activity for an issue.""" + + def get_agent_activity(self, agent_id: str) -> List[ActivityEvent]: + """Get all activity by an agent.""" + + def stream_events(self, since: datetime) -> Iterator[ActivityEvent]: + """Stream events since timestamp.""" +``` + +**Storage:** Add `activity_log` table to schema. + +**Effort:** 6-7 days + +### 2.0.4 Distributed Locking + +**Problem:** Current locking is local only (single instance). + +**Implementation:** +```python +# issue_tracker/core/distributed_lock.py + +class DistributedLockManager: + """Distributed locking using Redis/database.""" + + def __init__(self, redis_url: Optional[str] = None): + """Use Redis if available, fallback to database.""" + + def acquire_lock( + self, + resource: str, + owner: str, + ttl: int = 30 + ) -> bool: + """Acquire distributed lock.""" + + def release_lock(self, resource: str, owner: str): + """Release distributed lock.""" + + def extend_lock(self, resource: str, owner: str, additional_ttl: int): + """Extend lock TTL.""" + + def is_locked(self, resource: str) -> bool: + """Check if resource is locked.""" +``` + +**Integration:** +```python +# Use for claim operations +with distributed_lock(f"issue:{issue_id}", agent_id): + # Work on issue + pass +``` + +**Effort:** 7-8 days + +### 2.0.5 Conflict Resolution Strategies + +**Problem:** Sync has basic conflict detection but no resolution. + +**Implementation:** +```python +# issue_tracker/core/sync_strategies.py + +class ConflictResolutionStrategy(ABC): + def resolve(self, local: Issue, remote: Issue) -> Issue: + """Resolve conflict between local and remote.""" + +class TimestampStrategy(ConflictResolutionStrategy): + """Take newer version based on updated_at.""" + +class ThreeWayMergeStrategy(ConflictResolutionStrategy): + """Three-way merge using common ancestor.""" + +class InteractiveMergeStrategy(ConflictResolutionStrategy): + """Prompt user to resolve conflicts.""" + +class AgentMergeStrategy(ConflictResolutionStrategy): + """Use agent to intelligently merge changes.""" + +def sync_with_strategy( + source: IssueBackend, + target: IssueBackend, + strategy: ConflictResolutionStrategy +): + """Sync with specified conflict resolution strategy.""" +``` + +**Effort:** 8-10 days + +**Total Phase 3:** ~35-40 days (7-8 weeks) + +--- + +## Phase 4: Platform Expansion (Future) + +### GitHub Backend +- Full API integration +- GitHub-specific features (projects, discussions) +- **Effort:** 10-12 days + +### GitLab Backend +- Full API integration +- GitLab-specific features (epics, boards) +- **Effort:** 10-12 days + +### JIRA Backend +- REST API integration +- JIRA workflow mapping +- **Effort:** 12-15 days + +### Linear Backend +- GraphQL API integration +- Linear-specific features +- **Effort:** 8-10 days + +--- + +## Implementation Priority + +### Critical Path (for agent use) +1. **Auto-configuration** (Phase 1.1) - 2-3 weeks +2. **Agent identity** (Phase 2.1) - 1 week +3. **Issue claiming** (Phase 2.2) - 1 week + +**Total:** ~5-6 weeks to full agent-ready state + +### Nice to Have +4. Agent metadata (Phase 2.3) +5. Webhooks (Phase 2.4) +6. Dependency tracking (Phase 3.1) +7. Query DSL (Phase 3.2) + +### Future Enhancements +8. Activity streams (Phase 3.3) +9. Distributed locking (Phase 3.4) +10. Advanced merge (Phase 3.5) + +--- + +## Success Metrics + +### Phase 1 Success +- [ ] Agent can work in any repo with zero manual config +- [ ] Environment-only setup works: `GITEA_TOKEN=xxx issue list` +- [ ] Auto-detection accuracy: >95% for common platforms + +### Phase 2 Success +- [ ] Multiple agents can coordinate without race conditions +- [ ] Agent identity propagates to all operations +- [ ] Claim/lock prevents concurrent work on same issue + +### Phase 3 Success +- [ ] Complex dependency chains work correctly +- [ ] Query DSL covers 90% of common queries +- [ ] Real-time event processing with <1s latency + +--- + +## Migration Path + +Each phase maintains backward compatibility: + +**Phase 1:** +- Old: Manual `issue backend add` still works +- New: Auto-detection as optional enhancement + +**Phase 2:** +- Old: Hardcoded user still works for CLI +- New: Agent context for programmatic use + +**Phase 3:** +- Old: Basic filtering still works +- New: Query DSL as superset of old filters + +--- + +## Contributing + +Want to help implement these features? + +1. Pick a feature from Phase 1 or 2 +2. Create issue: `issue create "Implement " --label=enhancement` +3. See `CLAUDE.md` for development guide +4. Follow architecture in `core/interfaces.py` +5. Add tests (maintain >60% coverage) +6. Submit PR + +**Quick wins for new contributors:** +- Environment config loading (Phase 1.1.2) +- Git remote parsing (Phase 1.1.1) +- Agent context API (Phase 2.1) + +--- + +## Version Timeline (Estimate) + +- **v1.0** (Current) - Production-ready core +- **v1.1** (Q1 2025) - Auto-configuration +- **v1.2** (Q2 2025) - Agent features +- **v2.0** (Q3 2025) - Advanced coordination +- **v2.1** (Q4 2025) - Additional platforms + +*Timeline assumes single developer part-time. Can accelerate with contributors.* + +--- + +## Questions or Feedback? + +- **Architecture questions:** See `CLAUDE.md` +- **Agent integration:** See `AGENT_INTEGRATION.md` +- **Examples:** See `examples/agents/` +- **Issues:** Create issue in main repository diff --git a/examples/agents/README.md b/examples/agents/README.md new file mode 100644 index 0000000..85ee962 --- /dev/null +++ b/examples/agents/README.md @@ -0,0 +1,252 @@ +# Agent Examples + +This directory contains working examples of autonomous agents using the Issue Facade for coordination. + +## Prerequisites + +1. **Install issue-facade**: + ```bash + cd ../.. + pip install -e . + ``` + +2. **Configure backend** (one-time setup): + ```bash + export GITEA_API_TOKEN="your-token" + issue backend add myproject gitea + # Enter: URL, owner, repo when prompted + issue backend set-default myproject + ``` + +3. **Set environment variables** for scripts: + ```bash + export GITEA_URL=https://gitea.example.com + export GITEA_TOKEN=your-token + export GITEA_OWNER=your-org + export GITEA_REPO=your-repo + ``` + +## Examples + +### 1. Simple Task Executor (`simple_task_executor.py`) + +**What it does:** +- Finds open issues labeled `ready` +- Claims them by assigning to itself +- Simulates work +- Closes with completion comment + +**Usage:** +```bash +# Run once +python simple_task_executor.py --once + +# Run continuously +python simple_task_executor.py + +# Run for N iterations +python simple_task_executor.py --max-iterations 5 +``` + +**Setup test data:** +```bash +# Create some test issues +issue create "Test task 1" --label=ready +issue create "Test task 2" --label=ready +issue create "Test task 3" --label=ready +``` + +### 2. Multi-Agent Pipeline (`multi_agent_pipeline.py`) + +**What it does:** +Simulates a CI/CD pipeline with specialized agents: +- **Coder**: Implements features (needs-implementation → needs-review) +- **Reviewer**: Reviews code (needs-review → needs-testing) +- **Tester**: Runs tests (needs-testing → needs-deployment) +- **Deployer**: Deploys to staging (needs-deployment → deployed + closed) + +**Usage:** +```bash +# Run all agents in round-robin (one process) +python multi_agent_pipeline.py --mode=roundrobin + +# Or run each agent separately (in different terminals) +python multi_agent_pipeline.py --agent=coder +python multi_agent_pipeline.py --agent=reviewer +python multi_agent_pipeline.py --agent=tester +python multi_agent_pipeline.py --agent=deployer +``` + +**Setup test data:** +```bash +issue create "Feature: Add user profile" --label=feature --label=needs-implementation +issue create "Feature: Export data to CSV" --label=feature --label=needs-implementation +``` + +**Watch the pipeline:** +```bash +# In another terminal, watch progress +watch -n 2 'issue list --format=json | jq -r ".[] | [.number, .state, (.labels | map(.name) | join(\",\"))] | @tsv"' +``` + +### 3. Human-in-the-Loop (`human_in_loop.py`) + +**What it does:** +- Finds large features labeled `needs-breakdown` +- Proposes breaking them into subtasks +- Waits for human approval via comments +- Creates subtasks only after approval + +**Usage:** +```bash +# Run once +python human_in_loop.py --once + +# Run continuously +python human_in_loop.py +``` + +**Setup test data:** +```bash +issue create "Feature: Implement complete authentication system" \ + --label=feature \ + --label=needs-breakdown \ + --description="Large feature that should be broken down into smaller tasks" +``` + +**Approve the agent's proposal:** +```bash +# Agent will post a proposal comment. You reply with: +issue comment "APPROVED" + +# Or reject: +issue comment "REJECTED: Not the right approach" + +# Or request modifications: +issue comment "MODIFY: Please add security audit as a subtask" +``` + +### 4. Monitoring Agent (`monitoring_agent.py`) + +**What it does:** +- Monitors issue tracker health +- Detects stale issues (not updated in N days) +- Finds blocked issues +- Identifies unassigned priority issues +- Analyzes project velocity +- Creates alert issues when problems found + +**Usage:** +```bash +# Run once (no alerts) +python monitoring_agent.py --once --no-alerts + +# Run continuously with hourly checks +python monitoring_agent.py --check-interval=3600 + +# Run with custom stale threshold +python monitoring_agent.py --stale-days=14 --once +``` + +**View monitoring output:** +The agent will create issues tagged with `monitoring` and `alert` when it detects problems. + +## Agent Coordination Patterns + +### Pattern 1: Label-Based Roles + +Agents claim issues based on label matching: + +```python +AGENT_ROLES = { + 'agent:coder': ['feature', 'bug', 'refactor'], + 'agent:tester': ['needs-testing', 'test-failure'], + 'agent:reviewer': ['needs-review'] +} + +issues = backend.list_issues(IssueFilter( + state='open', + labels=[agent_type, 'feature'] +)) +``` + +### Pattern 2: State Machine + +Issues flow through states: + +``` +open → in_progress → needs_review → closed + ↓ ↓ ↓ +blocked blocked blocked +``` + +### Pattern 3: Comment-Based Communication + +Agents communicate via structured comments: + +```python +# Agent posts JSON in comment +comment.body = "```agent-message\n" + json.dumps({ + 'type': 'implementation_complete', + 'agent': 'agent-coder', + 'data': {'files_changed': ['auth.py'], 'tests_passing': True} +}) + "\n```" + +# Other agents parse it +for comment in comments: + if '```agent-message' in comment.body: + msg = json.loads(extract_json(comment.body)) + if msg['type'] == 'implementation_complete': + # React to completion +``` + +## Troubleshooting + +### "Backend not configured" +```bash +issue backend list +# If empty: +issue backend add myproject gitea +``` + +### "Authentication failed" +```bash +# Check token is valid +curl -H "Authorization: token $GITEA_TOKEN" $GITEA_URL/api/v1/user +``` + +### "No issues found" +```bash +# Verify you have issues +issue list + +# Create test data +issue create "Test" --label=ready +``` + +### Agent stuck/not processing +```bash +# Check what agent sees +issue list --state=open --label=needs-implementation --format=json + +# Verify labels match what agent expects +issue show 42 --format=json | jq '.labels' +``` + +## Tips for Development + +1. **Use `--once` flag during development** to run single iterations +2. **Use `--no-alerts` for monitoring agent** to avoid cluttering tracker +3. **Run agents in separate terminals** to see concurrent execution +4. **Use JSON format** for debugging: `issue list --format=json | jq` +5. **Clean up test data**: `issue list --label=agent-generated --format=json | jq -r '.[].number' | xargs -I {} issue close {}` + +## Next Steps + +- Modify these examples for your use case +- Add error handling and retry logic +- Implement actual work instead of `time.sleep()` +- Add logging and metrics +- Deploy as services with proper process management + +See [AGENT_INTEGRATION.md](../../AGENT_INTEGRATION.md) for more patterns and strategies. diff --git a/examples/agents/human_in_loop.py b/examples/agents/human_in_loop.py new file mode 100755 index 0000000..b848899 --- /dev/null +++ b/examples/agents/human_in_loop.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Human-in-the-Loop Agent + +This agent demonstrates agent-human collaboration: +1. Agent proposes implementations +2. Human reviews and approves via comments +3. Agent proceeds only after approval +4. Agent can ask questions and wait for answers + +Usage: + export GITEA_URL=https://gitea.example.com + export GITEA_TOKEN=your-token + export GITEA_OWNER=your-org + export GITEA_REPO=your-repo + + python human_in_loop.py +""" + +import os +import sys +import time +import re +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional, List + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, User, Comment, IssueState +from issue_tracker.core.interfaces import IssueFilter + + +class HumanInLoopAgent: + """Agent that requires human approval for critical decisions.""" + + def __init__(self, agent_id: str = "agent-collaborative"): + self.agent_id = agent_id + self.backend = None + + def connect(self): + """Connect to backend.""" + base_url = os.environ['GITEA_URL'] + token = os.environ['GITEA_TOKEN'] + owner = os.environ['GITEA_OWNER'] + repo = os.environ['GITEA_REPO'] + + self.backend = GiteaBackend() + self.backend.connect({ + 'base_url': base_url, + 'token': token, + 'owner': owner, + 'repo': repo + }) + print(f"✓ Connected to {base_url}/{owner}/{repo}") + + def find_feature_request(self) -> Optional[Issue]: + """Find a feature request that needs breaking down.""" + print("\n🔍 Looking for feature requests...") + + issues = self.backend.list_issues(IssueFilter( + state='open', + labels=['feature', 'needs-breakdown'] + )) + + if issues: + print(f" Found {len(issues)} feature request(s)") + return issues[0] + else: + print(" No feature requests found") + return None + + def propose_breakdown(self, feature: Issue) -> List[str]: + """Propose breaking down a feature into subtasks.""" + print(f"\n💡 Analyzing feature #{feature.number}: {feature.title}") + + # Simulate AI analysis + time.sleep(2) + + # Generate proposed subtasks + subtasks = [ + f"Design database schema for {feature.title}", + f"Implement backend API for {feature.title}", + f"Create frontend components for {feature.title}", + f"Write integration tests for {feature.title}", + f"Update documentation for {feature.title}" + ] + + print(f" Proposed {len(subtasks)} subtasks") + return subtasks + + def request_approval(self, issue: Issue, subtasks: List[str]): + """Post proposal and request human approval.""" + print(f"\n📝 Requesting approval for feature breakdown...") + + # Format proposal + proposal = f"🤖 **Agent Proposal: Feature Breakdown**\n\n" + proposal += f"I analyzed this feature and propose breaking it into {len(subtasks)} subtasks:\n\n" + + for i, task in enumerate(subtasks, 1): + proposal += f"{i}. {task}\n" + + proposal += f"\n**Human Review Required:**\n" + proposal += f"- Reply with `APPROVED` to create these subtasks\n" + proposal += f"- Reply with `REJECTED: reason` to decline\n" + proposal += f"- Reply with `MODIFY: suggestions` to request changes\n\n" + proposal += f"*Waiting for your response...*" + + # Post comment + comment = Comment( + id=None, + body=proposal, + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(issue.id, comment) + + # Add label + issue.labels.append(Label(name='awaiting-approval')) + self.backend.update_issue(issue) + + print(f" ✓ Approval requested on issue #{issue.number}") + + def check_for_approval(self, issue: Issue, since: datetime) -> Optional[str]: + """Check if human has approved/rejected.""" + comments = self.backend.get_comments(issue.id) + + # Look for recent human comments + for comment in reversed(comments): + # Skip agent's own comments + if comment.author.username == self.agent_id: + continue + + # Only check comments after proposal + if comment.created_at <= since: + continue + + # Check for approval keywords + body_upper = comment.body.upper() + + if 'APPROVED' in body_upper: + return 'approved' + elif 'REJECTED' in body_upper: + return 'rejected' + elif 'MODIFY' in body_upper: + return 'modify' + + return None + + def create_subtasks(self, parent: Issue, subtasks: List[str]): + """Create subtask issues.""" + print(f"\n✅ Creating {len(subtasks)} subtasks...") + + for i, task_title in enumerate(subtasks, 1): + subtask = Issue( + id=None, + number=0, + title=task_title, + description=f"**Subtask of #{parent.number}**: {parent.title}\n\n" + f"This is subtask {i} of {len(subtasks)}.\n\n" + f"**Parent Issue:** #{parent.number}", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[ + Label(name='subtask'), + Label(name=f'parent:{parent.number}'), + Label(name='agent-generated') + ] + ) + + created = self.backend.create_issue(subtask) + print(f" Created subtask #{created.number}: {task_title}") + time.sleep(0.5) # Rate limit + + # Update parent issue + parent.labels = [l for l in parent.labels + if l.name not in ['needs-breakdown', 'awaiting-approval']] + parent.labels.append(Label(name='broken-down')) + self.backend.update_issue(parent) + + # Add completion comment + comment = Comment( + id=None, + body=f"✅ Subtasks created successfully.\n\n" + f"Created {len(subtasks)} subtasks. " + f"You can now assign these to team members or agents.", + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(parent.id, comment) + + print(f" ✓ All subtasks created for #{parent.number}") + + def wait_for_approval(self, issue: Issue, proposal_time: datetime, + timeout_minutes: int = 60): + """Wait for human approval with timeout.""" + print(f"\n⏳ Waiting for human approval (timeout: {timeout_minutes}min)...") + + start_time = datetime.now(timezone.utc) + timeout = timedelta(minutes=timeout_minutes) + check_interval = 30 # Check every 30 seconds + + while True: + elapsed = datetime.now(timezone.utc) - start_time + + if elapsed > timeout: + print(f"\n⏱️ Timeout: No response after {timeout_minutes} minutes") + return None + + # Check for approval + decision = self.check_for_approval(issue, proposal_time) + + if decision: + print(f"\n✓ Human response: {decision.upper()}") + return decision + + # Wait before checking again + remaining = int((timeout - elapsed).total_seconds() / 60) + print(f" Still waiting... ({remaining} minutes remaining)") + time.sleep(check_interval) + + def run_cycle(self): + """Run one complete cycle.""" + # Find feature to break down + feature = self.find_feature_request() + if not feature: + return False + + # Propose breakdown + subtasks = self.propose_breakdown(feature) + + # Request approval + proposal_time = datetime.now(timezone.utc) + self.request_approval(feature, subtasks) + + # Wait for human decision + decision = self.wait_for_approval(feature, proposal_time, timeout_minutes=60) + + if decision == 'approved': + print("\n🎉 Proposal approved! Creating subtasks...") + self.create_subtasks(feature, subtasks) + return True + + elif decision == 'rejected': + print("\n❌ Proposal rejected. Moving to next feature.") + feature.labels = [l for l in feature.labels + if l.name != 'awaiting-approval'] + feature.labels.append(Label(name='proposal-rejected')) + self.backend.update_issue(feature) + return False + + elif decision == 'modify': + print("\n🔄 Modification requested. Human will update requirements.") + feature.labels = [l for l in feature.labels + if l.name != 'awaiting-approval'] + feature.labels.append(Label(name='needs-revision')) + self.backend.update_issue(feature) + return False + + else: + print("\n⏱️ No response. Will retry later.") + feature.labels = [l for l in feature.labels + if l.name != 'awaiting-approval'] + self.backend.update_issue(feature) + return False + + +def main(): + """Main entry point.""" + try: + agent = HumanInLoopAgent(agent_id="agent-collaborative") + agent.connect() + + print("\n🤖 Human-in-the-Loop Agent") + print(" This agent proposes feature breakdowns and waits for approval\n") + + if '--once' in sys.argv: + agent.run_cycle() + else: + # Run continuously + print(" Running in loop mode (Ctrl+C to stop)\n") + while True: + if not agent.run_cycle(): + print("\n⏸️ No work to do, waiting 60 seconds...") + time.sleep(60) + + except KeyboardInterrupt: + print("\n\n⚠️ Interrupted by user") + sys.exit(0) + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/examples/agents/monitoring_agent.py b/examples/agents/monitoring_agent.py new file mode 100755 index 0000000..b5c6fc6 --- /dev/null +++ b/examples/agents/monitoring_agent.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Monitoring Agent + +This agent monitors issue health and sends alerts: +- Detects stale issues (not updated in N days) +- Finds blocked issues waiting too long +- Identifies high-priority issues without assignees +- Reports on project velocity and bottlenecks + +Usage: + export GITEA_URL=https://gitea.example.com + export GITEA_TOKEN=your-token + export GITEA_OWNER=your-org + export GITEA_REPO=your-repo + + python monitoring_agent.py [--stale-days=7] [--check-interval=3600] +""" + +import os +import sys +import time +import argparse +from datetime import datetime, timezone, timedelta +from pathlib import Path +from collections import defaultdict +from typing import List, Dict + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, User, Comment, IssueState +from issue_tracker.core.interfaces import IssueFilter + + +class MonitoringAgent: + """Agent that monitors issue tracker health and alerts on problems.""" + + def __init__(self, agent_id: str = "agent-monitor", stale_days: int = 7): + self.agent_id = agent_id + self.stale_threshold = timedelta(days=stale_days) + self.backend = None + + def connect(self): + """Connect to backend.""" + base_url = os.environ['GITEA_URL'] + token = os.environ['GITEA_TOKEN'] + owner = os.environ['GITEA_OWNER'] + repo = os.environ['GITEA_REPO'] + + self.backend = GiteaBackend() + self.backend.connect({ + 'base_url': base_url, + 'token': token, + 'owner': owner, + 'repo': repo + }) + print(f"✓ Connected to {base_url}/{owner}/{repo}") + + def check_stale_issues(self) -> List[Issue]: + """Find issues that haven't been updated recently.""" + print("\n🔍 Checking for stale issues...") + + cutoff = datetime.now(timezone.utc) - self.stale_threshold + + # Get all open issues + all_issues = self.backend.list_issues(IssueFilter(state='open')) + + # Filter to stale ones + stale = [issue for issue in all_issues + if issue.updated_at < cutoff] + + if stale: + print(f" ⚠️ Found {len(stale)} stale issue(s)") + for issue in stale: + days_stale = (datetime.now(timezone.utc) - issue.updated_at).days + print(f" #{issue.number}: {issue.title} " + f"(stale for {days_stale} days)") + else: + print(f" ✓ No stale issues found") + + return stale + + def check_blocked_issues(self) -> List[Issue]: + """Find blocked issues.""" + print("\n🔍 Checking for blocked issues...") + + blocked = self.backend.list_issues(IssueFilter( + state='blocked' + )) + + if blocked: + print(f" ⚠️ Found {len(blocked)} blocked issue(s)") + for issue in blocked: + days_blocked = (datetime.now(timezone.utc) - issue.updated_at).days + print(f" #{issue.number}: {issue.title} " + f"(blocked for {days_blocked} days)") + else: + print(f" ✓ No blocked issues") + + return blocked + + def check_unassigned_priority(self) -> List[Issue]: + """Find high-priority issues without assignees.""" + print("\n🔍 Checking for unassigned priority issues...") + + # Get high priority issues + high_priority = self.backend.list_issues(IssueFilter( + state='open', + labels=['priority:high'] + )) + + critical = self.backend.list_issues(IssueFilter( + state='open', + labels=['priority:critical'] + )) + + all_priority = high_priority + critical + unassigned = [issue for issue in all_priority if not issue.assignees] + + if unassigned: + print(f" ⚠️ Found {len(unassigned)} unassigned priority issue(s)") + for issue in unassigned: + priority = next((l.name for l in issue.labels + if l.name.startswith('priority:')), 'unknown') + print(f" #{issue.number}: {issue.title} [{priority}]") + else: + print(f" ✓ All priority issues are assigned") + + return unassigned + + def analyze_velocity(self) -> Dict[str, int]: + """Analyze project velocity (issues opened vs closed).""" + print("\n📊 Analyzing project velocity...") + + # Get recent activity + week_ago = datetime.now(timezone.utc) - timedelta(days=7) + + # Count recently created + all_issues = self.backend.list_issues(IssueFilter(limit=1000)) + recent_created = len([i for i in all_issues if i.created_at >= week_ago]) + + # Count recently closed + closed_issues = self.backend.list_issues(IssueFilter(state='closed')) + recent_closed = len([i for i in closed_issues + if i.closed_at and i.closed_at >= week_ago]) + + # Count open + open_issues = len(self.backend.list_issues(IssueFilter(state='open'))) + + print(f" Last 7 days:") + print(f" Created: {recent_created} issues") + print(f" Closed: {recent_closed} issues") + print(f" Net change: {recent_created - recent_closed:+d}") + print(f" Currently open: {open_issues}") + + if recent_created > recent_closed * 1.5: + print(f" ⚠️ Issues are piling up faster than being resolved!") + elif recent_closed > recent_created: + print(f" ✓ Good progress - closing more than opening") + + return { + 'created': recent_created, + 'closed': recent_closed, + 'open': open_issues + } + + def analyze_bottlenecks(self) -> Dict[str, int]: + """Identify bottlenecks in the workflow.""" + print("\n🔍 Analyzing workflow bottlenecks...") + + # Count issues by state + states = defaultdict(int) + all_open = self.backend.list_issues(IssueFilter(state='open')) + + for issue in all_open: + states[issue.state.value] += 1 + + # Count issues by label + labels = defaultdict(int) + for issue in all_open: + for label in issue.labels: + if label.name.startswith('needs-'): + labels[label.name] += 1 + + print(f" Issues by state:") + for state, count in sorted(states.items(), key=lambda x: -x[1]): + print(f" {state}: {count}") + + if labels: + print(f" Issues by stage:") + for label, count in sorted(labels.items(), key=lambda x: -x[1]): + print(f" {label}: {count}") + if count > 10: + print(f" ⚠️ Potential bottleneck!") + + return dict(labels) + + def send_alert(self, title: str, details: List[str]): + """Send an alert by creating an issue.""" + print(f"\n🚨 Sending alert: {title}") + + body = f"**Monitoring Alert**\n\n" + body += f"The monitoring agent detected potential issues:\n\n" + + for detail in details: + body += f"- {detail}\n" + + body += f"\n*Generated by {self.agent_id} at {datetime.now(timezone.utc).isoformat()}*" + + alert_issue = Issue( + id=None, + number=0, + title=f"🚨 Monitor Alert: {title}", + description=body, + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[ + Label(name='monitoring'), + Label(name='alert'), + Label(name='agent-generated') + ] + ) + + created = self.backend.create_issue(alert_issue) + print(f" ✓ Alert created as issue #{created.number}") + + def run_health_check(self, send_alerts: bool = True): + """Run complete health check.""" + print(f"\n🤖 Running issue tracker health check...") + print(f" Timestamp: {datetime.now(timezone.utc).isoformat()}\n") + + alerts = [] + + # Check for problems + stale = self.check_stale_issues() + if stale: + alerts.append(f"{len(stale)} stale issues (>= {self.stale_threshold.days} days)") + + blocked = self.check_blocked_issues() + if blocked: + alerts.append(f"{len(blocked)} blocked issues") + + unassigned = self.check_unassigned_priority() + if unassigned: + alerts.append(f"{len(unassigned)} unassigned priority issues") + + # Analyze metrics + velocity = self.analyze_velocity() + if velocity['created'] > velocity['closed'] * 2: + alerts.append(f"Issue backlog growing rapidly " + f"({velocity['created']} created vs {velocity['closed']} closed)") + + bottlenecks = self.analyze_bottlenecks() + for stage, count in bottlenecks.items(): + if count > 10: + alerts.append(f"{count} issues stuck in stage: {stage}") + + # Send alert if needed + if alerts and send_alerts: + self.send_alert("Issue Tracker Health Check", alerts) + elif not alerts: + print(f"\n✅ All checks passed - no issues found!") + + return len(alerts) == 0 + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Monitoring agent for issue tracker') + parser.add_argument('--stale-days', type=int, default=7, + help='Days before an issue is considered stale') + parser.add_argument('--check-interval', type=int, default=3600, + help='Interval between checks in seconds (default: 1 hour)') + parser.add_argument('--once', action='store_true', + help='Run once and exit') + parser.add_argument('--no-alerts', action='store_true', + help='Do not create alert issues') + + args = parser.parse_args() + + try: + agent = MonitoringAgent( + agent_id="agent-monitor", + stale_days=args.stale_days + ) + agent.connect() + + if args.once: + agent.run_health_check(send_alerts=not args.no_alerts) + else: + print(f"\n🤖 Monitoring agent starting...") + print(f" Check interval: {args.check_interval}s") + print(f" Stale threshold: {args.stale_days} days") + print(f" Press Ctrl+C to stop\n") + + while True: + agent.run_health_check(send_alerts=not args.no_alerts) + print(f"\n⏸️ Waiting {args.check_interval}s until next check...") + time.sleep(args.check_interval) + + except KeyboardInterrupt: + print("\n\n⚠️ Interrupted by user") + sys.exit(0) + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/examples/agents/multi_agent_pipeline.py b/examples/agents/multi_agent_pipeline.py new file mode 100755 index 0000000..25ef8e8 --- /dev/null +++ b/examples/agents/multi_agent_pipeline.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +Multi-Agent Pipeline + +Demonstrates a CI/CD-like pipeline with multiple specialized agents: +- Coder Agent: Implements features +- Reviewer Agent: Reviews code +- Tester Agent: Runs tests +- Deployer Agent: Deploys to staging + +Each agent monitors for issues in their stage and advances them through the pipeline. + +Usage: + export GITEA_URL=https://gitea.example.com + export GITEA_TOKEN=your-token + export GITEA_OWNER=your-org + export GITEA_REPO=your-repo + + # Run all agents in parallel (in separate terminals) + python multi_agent_pipeline.py --agent=coder + python multi_agent_pipeline.py --agent=reviewer + python multi_agent_pipeline.py --agent=tester + python multi_agent_pipeline.py --agent=deployer + + # Or run in round-robin mode (all agents in one process) + python multi_agent_pipeline.py --mode=roundrobin +""" + +import os +import sys +import time +import argparse +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, User, Comment, IssueState +from issue_tracker.core.interfaces import IssueFilter + + +class BaseAgent: + """Base class for pipeline agents.""" + + def __init__(self, agent_id: str, role: str): + self.agent_id = agent_id + self.role = role + self.backend = None + + def connect(self): + """Connect to backend from environment.""" + base_url = os.environ['GITEA_URL'] + token = os.environ['GITEA_TOKEN'] + owner = os.environ['GITEA_OWNER'] + repo = os.environ['GITEA_REPO'] + + self.backend = GiteaBackend() + self.backend.connect({ + 'base_url': base_url, + 'token': token, + 'owner': owner, + 'repo': repo + }) + print(f"✓ {self.role} connected") + + def log(self, message: str): + """Log a message with agent role prefix.""" + print(f"[{self.role}] {message}") + + def add_comment(self, issue: Issue, message: str): + """Add a comment to an issue.""" + comment = Comment( + id=None, + body=f"**{self.role}**: {message}", + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(issue.id, comment) + + def process_one(self) -> bool: + """Process one issue. Return True if work was done.""" + raise NotImplementedError + + +class CoderAgent(BaseAgent): + """Agent that implements features.""" + + def __init__(self): + super().__init__("agent-coder", "Coder") + + def process_one(self) -> bool: + # Find issues needing implementation + issues = self.backend.list_issues(IssueFilter( + state='open', + labels=['needs-implementation'] + )) + + if not issues: + return False + + issue = issues[0] + self.log(f"Implementing #{issue.number}: {issue.title}") + + # Update state + issue.state = IssueState.IN_PROGRESS + if not issue.assignees: + issue.assignees = [User(id=self.agent_id, username=self.agent_id)] + + # Remove old label, add new label + issue.labels = [l for l in issue.labels if l.name != 'needs-implementation'] + issue.labels.append(Label(name='needs-review')) + + self.backend.update_issue(issue) + + # Simulate work + time.sleep(2) + + # Add comment + self.add_comment(issue, + "Implementation complete.\n\n" + "**Files changed:**\n" + "- src/feature.py\n" + "- tests/test_feature.py\n\n" + "Ready for code review." + ) + + self.log(f"✓ Completed #{issue.number}") + return True + + +class ReviewerAgent(BaseAgent): + """Agent that reviews code.""" + + def __init__(self): + super().__init__("agent-reviewer", "Reviewer") + + def process_one(self) -> bool: + # Find issues needing review + issues = self.backend.list_issues(IssueFilter( + state='in_progress', + labels=['needs-review'] + )) + + if not issues: + return False + + issue = issues[0] + self.log(f"Reviewing #{issue.number}: {issue.title}") + + # Simulate review + time.sleep(2) + + # Update labels + issue.labels = [l for l in issue.labels if l.name != 'needs-review'] + issue.labels.append(Label(name='needs-testing')) + self.backend.update_issue(issue) + + # Add review comment + self.add_comment(issue, + "Code review complete. ✅\n\n" + "**Review notes:**\n" + "- Code quality: Good\n" + "- Test coverage: 95%\n" + "- Documentation: Complete\n\n" + "Approved for testing." + ) + + self.log(f"✓ Approved #{issue.number}") + return True + + +class TesterAgent(BaseAgent): + """Agent that runs tests.""" + + def __init__(self): + super().__init__("agent-tester", "Tester") + + def process_one(self) -> bool: + # Find issues needing testing + issues = self.backend.list_issues(IssueFilter( + state='in_progress', + labels=['needs-testing'] + )) + + if not issues: + return False + + issue = issues[0] + self.log(f"Testing #{issue.number}: {issue.title}") + + # Simulate tests + time.sleep(2) + + # Update labels + issue.labels = [l for l in issue.labels if l.name != 'needs-testing'] + issue.labels.append(Label(name='needs-deployment')) + self.backend.update_issue(issue) + + # Add test results + self.add_comment(issue, + "All tests passing. ✅\n\n" + "**Test results:**\n" + "- Unit tests: 50/50 passed\n" + "- Integration tests: 12/12 passed\n" + "- Coverage: 96.5%\n\n" + "Ready for deployment." + ) + + self.log(f"✓ Tests passed #{issue.number}") + return True + + +class DeployerAgent(BaseAgent): + """Agent that deploys to staging.""" + + def __init__(self): + super().__init__("agent-deployer", "Deployer") + + def process_one(self) -> bool: + # Find issues needing deployment + issues = self.backend.list_issues(IssueFilter( + state='in_progress', + labels=['needs-deployment'] + )) + + if not issues: + return False + + issue = issues[0] + self.log(f"Deploying #{issue.number}: {issue.title}") + + # Simulate deployment + time.sleep(2) + + # Close issue + issue.state = IssueState.CLOSED + issue.closed_at = datetime.now(timezone.utc) + issue.labels = [l for l in issue.labels if l.name != 'needs-deployment'] + issue.labels.append(Label(name='deployed')) + + self.backend.update_issue(issue) + + # Add deployment comment + self.add_comment(issue, + "Deployed to staging. 🚀\n\n" + "**Deployment info:**\n" + "- Environment: staging\n" + "- Version: v1.2.3\n" + "- Status: Healthy\n\n" + "Feature is live and ready for user testing." + ) + + self.log(f"✓ Deployed #{issue.number}") + return True + + +def run_single_agent(agent: BaseAgent, poll_interval: int = 5): + """Run a single agent in a loop.""" + agent.connect() + agent.log("Starting pipeline agent...") + agent.log("Press Ctrl+C to stop\n") + + while True: + try: + if agent.process_one(): + agent.log("Task completed, checking for more work...") + else: + agent.log(f"No work available, waiting {poll_interval}s...") + time.sleep(poll_interval) + except KeyboardInterrupt: + agent.log("Shutting down...") + break + except Exception as e: + agent.log(f"Error: {e}") + time.sleep(poll_interval) + + +def run_roundrobin(agents: List[BaseAgent], poll_interval: int = 5): + """Run all agents in round-robin fashion.""" + print("🤖 Starting multi-agent pipeline (round-robin mode)") + print(" Agents:", ", ".join([a.role for a in agents])) + print(" Press Ctrl+C to stop\n") + + # Connect all agents + for agent in agents: + agent.connect() + + while True: + try: + work_done = False + for agent in agents: + if agent.process_one(): + work_done = True + time.sleep(1) # Brief pause between agents + + if not work_done: + print(f"⏸️ No work available for any agent, waiting {poll_interval}s...") + time.sleep(poll_interval) + + except KeyboardInterrupt: + print("\n\n⚠️ Shutting down all agents...") + break + except Exception as e: + print(f"❌ Error: {e}") + time.sleep(poll_interval) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Multi-agent pipeline') + parser.add_argument('--agent', choices=['coder', 'reviewer', 'tester', 'deployer'], + help='Run specific agent') + parser.add_argument('--mode', choices=['single', 'roundrobin'], default='single', + help='Execution mode') + parser.add_argument('--poll-interval', type=int, default=5, + help='Polling interval in seconds') + + args = parser.parse_args() + + try: + if args.mode == 'roundrobin': + agents = [ + CoderAgent(), + ReviewerAgent(), + TesterAgent(), + DeployerAgent() + ] + run_roundrobin(agents, args.poll_interval) + else: + if not args.agent: + print("Error: --agent required in single mode") + print("Use --mode=roundrobin to run all agents") + sys.exit(1) + + agent_map = { + 'coder': CoderAgent(), + 'reviewer': ReviewerAgent(), + 'tester': TesterAgent(), + 'deployer': DeployerAgent() + } + + agent = agent_map[args.agent] + run_single_agent(agent, args.poll_interval) + + except KeyboardInterrupt: + print("\n\n⚠️ Interrupted by user") + sys.exit(0) + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/examples/agents/simple_task_executor.py b/examples/agents/simple_task_executor.py new file mode 100755 index 0000000..30f99a5 --- /dev/null +++ b/examples/agents/simple_task_executor.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Simple Task Executor Agent + +This agent demonstrates a basic workflow: +1. Query for available tasks (open issues with specific labels) +2. Claim a task by assigning it to itself +3. Execute the task (simulated) +4. Report completion and close the issue + +Usage: + export GITEA_URL=https://gitea.example.com + export GITEA_TOKEN=your-token + export GITEA_OWNER=your-org + export GITEA_REPO=your-repo + + python simple_task_executor.py +""" + +import os +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from issue_tracker.backends.gitea import GiteaBackend +from issue_tracker.core.models import Issue, Label, User, Comment, IssueState +from issue_tracker.core.interfaces import IssueFilter + + +class SimpleTaskExecutor: + """A simple agent that claims and executes tasks from issue tracker.""" + + def __init__(self, agent_id: str = "agent-executor"): + self.agent_id = agent_id + self.backend = None + + def connect(self): + """Connect to Gitea backend from environment variables.""" + base_url = os.environ.get('GITEA_URL') + token = os.environ.get('GITEA_TOKEN') + owner = os.environ.get('GITEA_OWNER') + repo = os.environ.get('GITEA_REPO') + + if not all([base_url, token, owner, repo]): + raise ValueError( + "Missing required environment variables: " + "GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO" + ) + + self.backend = GiteaBackend() + self.backend.connect({ + 'base_url': base_url, + 'token': token, + 'owner': owner, + 'repo': repo + }) + print(f"✓ Connected to {base_url}/{owner}/{repo}") + + def find_available_task(self): + """Find an open task that's ready to be worked on.""" + print("\n🔍 Searching for available tasks...") + + # Look for issues labeled as ready and not assigned + issues = self.backend.list_issues(IssueFilter( + state='open', + labels=['ready'], + limit=10 + )) + + # Filter to unassigned issues + available = [issue for issue in issues if not issue.assignees] + + if available: + print(f" Found {len(available)} available task(s)") + return available[0] + else: + print(" No available tasks found") + return None + + def claim_task(self, issue: Issue): + """Claim a task by assigning it to this agent.""" + print(f"\n📌 Claiming issue #{issue.number}: {issue.title}") + + # Update issue state and assignee + issue.state = IssueState.IN_PROGRESS + issue.assignees = [User(id=self.agent_id, username=self.agent_id)] + self.backend.update_issue(issue) + + # Add comment announcing claim + comment = Comment( + id=None, + body=f"🤖 Task claimed by {self.agent_id}\n\nStarting work...", + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(issue.id, comment) + print(f" ✓ Issue #{issue.number} claimed") + + def execute_task(self, issue: Issue): + """Execute the task (simulated with sleep).""" + print(f"\n⚙️ Executing task #{issue.number}...") + + # Simulate work by sleeping + work_time = 2 + for i in range(work_time): + time.sleep(1) + print(f" Working... ({i+1}/{work_time}s)") + + # Add progress comment + comment = Comment( + id=None, + body=f"Implementation complete.\n\n" + f"**Changes made:**\n" + f"- Analyzed requirements\n" + f"- Implemented solution\n" + f"- Verified functionality\n\n" + f"Ready for review.", + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(issue.id, comment) + print(f" ✓ Task #{issue.number} completed") + + def complete_task(self, issue: Issue): + """Mark task as complete by closing the issue.""" + print(f"\n✅ Completing issue #{issue.number}...") + + # Close the issue + issue.state = IssueState.CLOSED + issue.closed_at = datetime.now(timezone.utc) + self.backend.update_issue(issue) + + # Add completion comment + comment = Comment( + id=None, + body=f"🎉 Task completed by {self.agent_id}\n\n" + f"All work has been finished and verified.", + author=User(id=self.agent_id, username=self.agent_id), + created_at=datetime.now(timezone.utc) + ) + self.backend.add_comment(issue.id, comment) + print(f" ✓ Issue #{issue.number} closed") + + def run_once(self): + """Execute one task cycle.""" + # Find available task + task = self.find_available_task() + if not task: + return False + + # Claim and execute + self.claim_task(task) + self.execute_task(task) + self.complete_task(task) + + return True + + def run_loop(self, max_iterations=None): + """Run continuously, processing tasks as they become available.""" + print(f"\n🤖 {self.agent_id} starting task execution loop...") + print(f" Press Ctrl+C to stop\n") + + iteration = 0 + while True: + iteration += 1 + + if max_iterations and iteration > max_iterations: + print(f"\n⚠️ Reached maximum iterations ({max_iterations})") + break + + # Try to process one task + processed = self.run_once() + + if processed: + print(f"\n✓ Completed iteration {iteration}") + else: + print(f"\n⏸️ No tasks available (iteration {iteration})") + print(" Waiting 5 seconds before checking again...") + time.sleep(5) + + +def main(): + """Main entry point.""" + try: + # Create and initialize agent + agent = SimpleTaskExecutor(agent_id="agent-executor") + agent.connect() + + # Run once or in loop + import sys + if '--once' in sys.argv: + agent.run_once() + elif '--max-iterations' in sys.argv: + idx = sys.argv.index('--max-iterations') + max_iter = int(sys.argv[idx + 1]) + agent.run_loop(max_iterations=max_iter) + else: + agent.run_loop() + + except KeyboardInterrupt: + print("\n\n⚠️ Interrupted by user") + sys.exit(0) + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/issue_tracker/backends/local/backend.py b/issue_tracker/backends/local/backend.py index b6c7d25..831271a 100644 --- a/issue_tracker/backends/local/backend.py +++ b/issue_tracker/backends/local/backend.py @@ -81,12 +81,8 @@ class LocalSQLiteBackend(LocalBackend, SyncableBackend): with open(schema_path, 'r') as f: schema_sql = f.read() - # Execute schema in parts (SQLite doesn't like multiple statements) - for statement in schema_sql.split(';'): - statement = statement.strip() - if statement: - self.connection.execute(statement) - self.connection.commit() + # Use executescript to handle multi-line statements (triggers, views, etc.) + self.connection.executescript(schema_sql) def _get_next_issue_number(self) -> int: """Get the next available issue number.""" diff --git a/issue_tracker/backends/local/schema.sql b/issue_tracker/backends/local/schema.sql index 1dda808..b0a3f85 100644 --- a/issue_tracker/backends/local/schema.sql +++ b/issue_tracker/backends/local/schema.sql @@ -160,30 +160,10 @@ LEFT JOIN issue_assignees ia ON i.id = ia.issue_id LEFT JOIN users u ON ia.user_id = u.id GROUP BY i.id, i.number, i.title, i.state, i.created_at, i.updated_at, i.closed_at, m.title; --- Full-text search setup (if SQLite supports FTS) -CREATE VIRTUAL TABLE IF NOT EXISTS issue_search USING fts5( - issue_id, - title, - description, - labels, - content='issues' -); - --- Trigger to keep FTS index updated -CREATE TRIGGER IF NOT EXISTS issue_search_insert AFTER INSERT ON issues -BEGIN - INSERT INTO issue_search(issue_id, title, description) - VALUES (NEW.id, NEW.title, NEW.description); -END; - -CREATE TRIGGER IF NOT EXISTS issue_search_update AFTER UPDATE ON issues -BEGIN - UPDATE issue_search - SET title = NEW.title, description = NEW.description - WHERE issue_id = NEW.id; -END; - -CREATE TRIGGER IF NOT EXISTS issue_search_delete AFTER DELETE ON issues -BEGIN - DELETE FROM issue_search WHERE issue_id = OLD.id; -END; \ No newline at end of file +-- Full-text search setup (optional - disabled for now due to compatibility issues) +-- Can be enabled later by creating FTS5 virtual table manually +-- CREATE VIRTUAL TABLE IF NOT EXISTS issue_search USING fts5( +-- issue_id UNINDEXED, +-- title, +-- description +-- ); \ No newline at end of file diff --git a/issue_tracker/cli/main.py b/issue_tracker/cli/main.py index ced7bc2..a6e8cb9 100644 --- a/issue_tracker/cli/main.py +++ b/issue_tracker/cli/main.py @@ -56,10 +56,26 @@ cli.add_command(sync_group, name='sync') # Convenience aliases - direct issue commands @cli.command('list') +@click.option('--state', type=click.Choice(['open', 'closed', 'all']), default='open', help='Issue state filter') +@click.option('--assignee', help='Filter by assignee') +@click.option('--label', multiple=True, help='Filter by labels') +@click.option('--milestone', help='Filter by milestone') +@click.option('--search', help='Search in title and description') +@click.option('--limit', type=int, default=30, help='Maximum number of issues to show') +@click.option('--format', 'output_format', type=click.Choice(['table', 'json', 'compact']), default='table', help='Output format') @click.pass_context -def list_issues(ctx): +def list_issues(ctx, state, assignee, label, milestone, search, limit, output_format): """List all issues (alias for 'issue list').""" - ctx.invoke(issue_group.get_command(ctx, 'list')) + ctx.invoke( + issue_group.get_command(ctx, 'list'), + state=state, + assignee=assignee, + label=label, + milestone=milestone, + search=search, + limit=limit, + output_format=output_format + ) @cli.command('show') diff --git a/issue_tracker/core/models.py b/issue_tracker/core/models.py index fae45d5..a8653a9 100644 --- a/issue_tracker/core/models.py +++ b/issue_tracker/core/models.py @@ -10,7 +10,6 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import List, Optional, Dict, Any -from functools import cached_property class IssueState(Enum): @@ -88,7 +87,7 @@ class Label: description: Optional[str] = None backend_id: Optional[str] = None # Backend-specific ID for sync - @cached_property + @property def category(self) -> str: """Categorize label for efficient filtering.""" if self.name.startswith('priority:'): @@ -102,12 +101,12 @@ class Label: else: return 'other' - @cached_property + @property def priority(self) -> Optional[Priority]: """Extract priority if this is a priority label.""" return Priority.from_label(self.name) - @cached_property + @property def issue_type(self) -> Optional[IssueType]: """Extract issue type if this is a type label.""" return IssueType.from_label(self.name) @@ -121,7 +120,7 @@ class LabelCategories: status_labels: List[Label] other_labels: List[Label] - @cached_property + @property def priority(self) -> Optional[Priority]: """Get the issue priority.""" for label in self.priority_labels: @@ -129,7 +128,7 @@ class LabelCategories: return label.priority return None - @cached_property + @property def issue_type(self) -> Optional[IssueType]: """Get the issue type.""" for label in self.type_labels: @@ -205,9 +204,9 @@ class Issue: sync_metadata: Dict[str, Any] = field(default_factory=dict) # Performance Optimization - _label_categories: Optional[LabelCategories] = field(default=None, init=False) + _label_categories: Optional[LabelCategories] = field(default=None, init=False, repr=False) - @cached_property + @property def label_categories(self) -> LabelCategories: """Efficiently categorize labels with caching.""" if self._label_categories is None: @@ -227,12 +226,12 @@ class Issue: else: other_labels.append(label) - self._label_categories = LabelCategories( + object.__setattr__(self, '_label_categories', LabelCategories( priority_labels=priority_labels, type_labels=type_labels, status_labels=status_labels, other_labels=other_labels - ) + )) return self._label_categories @property diff --git a/tests/test_core_models.py b/tests/test_core_models.py new file mode 100644 index 0000000..9cb3403 --- /dev/null +++ b/tests/test_core_models.py @@ -0,0 +1,602 @@ +""" +Test suite for Core Domain Models. + +These tests ensure the domain models (Issue, Label, User, etc.) work correctly, +including state management, validation, and business logic. +""" + +import pytest +from datetime import datetime, timezone +from issue_tracker.core.models import ( + Issue, Label, User, Milestone, Comment, + IssueState, Priority, IssueType, LabelCategories +) + + +@pytest.mark.unit +class TestIssueState: + """Test IssueState enumeration.""" + + def test_from_string_standard_states(self): + """Test converting standard strings to IssueState.""" + assert IssueState.from_string("open") == IssueState.OPEN + assert IssueState.from_string("closed") == IssueState.CLOSED + assert IssueState.from_string("in_progress") == IssueState.IN_PROGRESS + assert IssueState.from_string("blocked") == IssueState.BLOCKED + + def test_from_string_variants(self): + """Test converting variant strings to IssueState.""" + assert IssueState.from_string("in-progress") == IssueState.IN_PROGRESS + assert IssueState.from_string("progress") == IssueState.IN_PROGRESS + assert IssueState.from_string("OPEN") == IssueState.OPEN + + def test_from_string_unknown_defaults_to_open(self): + """Test unknown state strings default to OPEN.""" + assert IssueState.from_string("unknown") == IssueState.OPEN + assert IssueState.from_string("") == IssueState.OPEN + + def test_to_backend_string_gitea(self): + """Test converting to Gitea backend string.""" + assert IssueState.OPEN.to_backend_string("gitea") == "open" + assert IssueState.CLOSED.to_backend_string("gitea") == "closed" + assert IssueState.IN_PROGRESS.to_backend_string("gitea") == "open" + assert IssueState.BLOCKED.to_backend_string("gitea") == "open" + + def test_to_backend_string_github(self): + """Test converting to GitHub backend string.""" + assert IssueState.OPEN.to_backend_string("github") == "open" + assert IssueState.CLOSED.to_backend_string("github") == "closed" + + +@pytest.mark.unit +class TestPriority: + """Test Priority enumeration.""" + + def test_from_label_with_priority_prefix(self): + """Test extracting priority from label.""" + assert Priority.from_label("priority:low") == Priority.LOW + assert Priority.from_label("priority:medium") == Priority.MEDIUM + assert Priority.from_label("priority:high") == Priority.HIGH + assert Priority.from_label("priority:critical") == Priority.CRITICAL + + def test_from_label_without_prefix(self): + """Test non-priority labels return None.""" + assert Priority.from_label("bug") is None + assert Priority.from_label("enhancement") is None + + def test_from_label_invalid_priority(self): + """Test invalid priority returns None.""" + assert Priority.from_label("priority:invalid") is None + + +@pytest.mark.unit +class TestIssueType: + """Test IssueType enumeration.""" + + def test_from_label_standard_types(self): + """Test extracting issue type from label.""" + assert IssueType.from_label("bug") == IssueType.BUG + assert IssueType.from_label("feature") == IssueType.FEATURE + assert IssueType.from_label("enhancement") == IssueType.ENHANCEMENT + assert IssueType.from_label("task") == IssueType.TASK + assert IssueType.from_label("documentation") == IssueType.DOCUMENTATION + assert IssueType.from_label("question") == IssueType.QUESTION + + def test_from_label_case_insensitive(self): + """Test case insensitive type matching.""" + assert IssueType.from_label("BUG") == IssueType.BUG + assert IssueType.from_label("Bug") == IssueType.BUG + + def test_from_label_invalid_type(self): + """Test invalid type returns None.""" + assert IssueType.from_label("invalid") is None + + +@pytest.mark.unit +class TestLabel: + """Test Label model.""" + + def test_label_creation(self): + """Test creating a label.""" + label = Label(name="bug", color="red", description="Bug reports") + assert label.name == "bug" + assert label.color == "red" + assert label.description == "Bug reports" + + def test_label_category_priority(self): + """Test priority label categorization.""" + label = Label(name="priority:high") + assert label.category == "priority" + + def test_label_category_status(self): + """Test status label categorization.""" + label = Label(name="status:in-progress") + assert label.category == "status" + + def test_label_category_type_with_prefix(self): + """Test type label with prefix categorization.""" + label = Label(name="type:bug") + assert label.category == "type" + + def test_label_category_type_without_prefix(self): + """Test type label without prefix categorization.""" + label = Label(name="bug") + assert label.category == "type" + label2 = Label(name="feature") + assert label2.category == "type" + + def test_label_category_other(self): + """Test other label categorization.""" + label = Label(name="good-first-issue") + assert label.category == "other" + + def test_label_priority_property(self): + """Test extracting priority from label.""" + label = Label(name="priority:high") + assert label.priority == Priority.HIGH + + label2 = Label(name="bug") + assert label2.priority is None + + def test_label_issue_type_property(self): + """Test extracting issue type from label.""" + label = Label(name="bug") + assert label.issue_type == IssueType.BUG + + label2 = Label(name="priority:high") + assert label2.issue_type is None + + +@pytest.mark.unit +class TestUser: + """Test User model.""" + + def test_user_creation(self): + """Test creating a user.""" + user = User( + id="user123", + username="alice", + display_name="Alice Smith", + email="alice@example.com" + ) + assert user.id == "user123" + assert user.username == "alice" + assert user.display_name == "Alice Smith" + assert user.email == "alice@example.com" + + +@pytest.mark.unit +class TestMilestone: + """Test Milestone model.""" + + def test_milestone_creation(self): + """Test creating a milestone.""" + now = datetime.now(timezone.utc) + milestone = Milestone( + id="m1", + title="v1.0", + description="First release", + state="open", + due_date=now, + created_at=now, + updated_at=now + ) + assert milestone.id == "m1" + assert milestone.title == "v1.0" + assert milestone.state == "open" + + +@pytest.mark.unit +class TestComment: + """Test Comment model.""" + + def test_comment_creation(self): + """Test creating a comment.""" + author = User(id="user1", username="alice") + now = datetime.now(timezone.utc) + comment = Comment( + id="c1", + body="Great issue!", + author=author, + created_at=now + ) + assert comment.id == "c1" + assert comment.body == "Great issue!" + assert comment.author.username == "alice" + + +@pytest.mark.unit +class TestIssueBasics: + """Test basic Issue model functionality.""" + + def test_issue_creation(self): + """Test creating an issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", + number=1, + title="Test Issue", + description="Test description", + state=IssueState.OPEN, + created_at=now, + updated_at=now + ) + assert issue.id == "issue1" + assert issue.number == 1 + assert issue.title == "Test Issue" + assert issue.state == IssueState.OPEN + + def test_issue_with_labels(self): + """Test creating issue with labels.""" + now = datetime.now(timezone.utc) + labels = [ + Label(name="bug", color="red"), + Label(name="priority:high", color="orange") + ] + issue = Issue( + id="issue1", number=1, title="Bug", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels + ) + assert len(issue.labels) == 2 + + def test_issue_with_assignees(self): + """Test creating issue with assignees.""" + now = datetime.now(timezone.utc) + assignees = [User(id="u1", username="alice")] + issue = Issue( + id="issue1", number=1, title="Task", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + assignees=assignees + ) + assert len(issue.assignees) == 1 + assert issue.primary_assignee.username == "alice" + + def test_primary_assignee_when_none(self): + """Test primary_assignee returns None when no assignees.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Task", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now + ) + assert issue.primary_assignee is None + + +@pytest.mark.unit +class TestIssueLabelCategorization: + """Test Issue label categorization.""" + + def test_label_categories_property(self): + """Test label_categories property organizes labels.""" + now = datetime.now(timezone.utc) + labels = [ + Label(name="bug"), + Label(name="priority:high"), + Label(name="status:in-review"), + Label(name="good-first-issue") + ] + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels + ) + + categories = issue.label_categories + assert len(categories.type_labels) == 1 + assert len(categories.priority_labels) == 1 + assert len(categories.status_labels) == 1 + assert len(categories.other_labels) == 1 + + def test_issue_priority_property(self): + """Test issue.priority extracts priority from labels.""" + now = datetime.now(timezone.utc) + labels = [Label(name="priority:high"), Label(name="bug")] + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels + ) + assert issue.priority == Priority.HIGH + + def test_issue_priority_none_when_no_priority_label(self): + """Test issue.priority is None without priority label.""" + now = datetime.now(timezone.utc) + labels = [Label(name="bug")] + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels + ) + assert issue.priority is None + + def test_issue_type_property(self): + """Test issue.issue_type extracts type from labels.""" + now = datetime.now(timezone.utc) + labels = [Label(name="bug"), Label(name="priority:high")] + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels + ) + assert issue.issue_type == IssueType.BUG + + def test_cache_invalidation(self): + """Test label cache is invalidated when labels change.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[Label(name="bug")] + ) + + # Access to populate cache + assert issue.issue_type == IssueType.BUG + + # Manually modify labels + issue.labels = [Label(name="feature")] + issue.invalidate_cache() + + # Should reflect new labels + assert issue.issue_type == IssueType.FEATURE + + +@pytest.mark.unit +class TestIssueStateTransitions: + """Test Issue state transition methods.""" + + def test_close_issue(self): + """Test closing an open issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now + ) + + issue.close() + + assert issue.state == IssueState.CLOSED + assert issue.closed_at is not None + + def test_close_issue_with_custom_timestamp(self): + """Test closing issue with custom timestamp.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now + ) + + custom_time = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + issue.close(closed_at=custom_time) + + assert issue.closed_at == custom_time + + def test_close_already_closed_issue_raises_error(self): + """Test closing already closed issue raises error.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.CLOSED, created_at=now, updated_at=now, + closed_at=now + ) + + with pytest.raises(ValueError, match="already closed"): + issue.close() + + def test_reopen_issue(self): + """Test reopening a closed issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.CLOSED, created_at=now, updated_at=now, + closed_at=now + ) + + issue.reopen() + + assert issue.state == IssueState.OPEN + assert issue.closed_at is None + + def test_reopen_open_issue_raises_error(self): + """Test reopening non-closed issue raises error.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now + ) + + with pytest.raises(ValueError, match="not closed"): + issue.reopen() + + +@pytest.mark.unit +class TestIssueLabelMethods: + """Test Issue label manipulation methods.""" + + def test_add_label(self): + """Test adding a label to issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[] + ) + + bug_label = Label(name="bug", color="red") + issue.add_label(bug_label) + + assert len(issue.labels) == 1 + assert issue.labels[0].name == "bug" + + def test_add_duplicate_label_ignored(self): + """Test adding duplicate label is ignored.""" + now = datetime.now(timezone.utc) + bug_label = Label(name="bug", color="red") + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[bug_label] + ) + + issue.add_label(bug_label) + + assert len(issue.labels) == 1 + + def test_remove_label(self): + """Test removing a label from issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[Label(name="bug"), Label(name="feature")] + ) + + result = issue.remove_label("bug") + + assert result is True + assert len(issue.labels) == 1 + assert issue.labels[0].name == "feature" + + def test_remove_nonexistent_label_returns_false(self): + """Test removing non-existent label returns False.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[Label(name="bug")] + ) + + result = issue.remove_label("nonexistent") + + assert result is False + assert len(issue.labels) == 1 + + def test_has_label(self): + """Test checking if issue has a label.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=[Label(name="bug"), Label(name="priority:high")] + ) + + assert issue.has_label("bug") + assert issue.has_label("priority:high") + assert not issue.has_label("feature") + + +@pytest.mark.unit +class TestIssueAssigneeMethods: + """Test Issue assignee manipulation methods.""" + + def test_add_assignee(self): + """Test adding an assignee to issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + assignees=[] + ) + + user = User(id="u1", username="alice") + issue.add_assignee(user) + + assert len(issue.assignees) == 1 + assert issue.assignees[0].username == "alice" + + def test_add_duplicate_assignee_ignored(self): + """Test adding duplicate assignee is ignored.""" + now = datetime.now(timezone.utc) + user = User(id="u1", username="alice") + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + assignees=[user] + ) + + issue.add_assignee(user) + + assert len(issue.assignees) == 1 + + def test_remove_assignee(self): + """Test removing an assignee from issue.""" + now = datetime.now(timezone.utc) + user1 = User(id="u1", username="alice") + user2 = User(id="u2", username="bob") + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + assignees=[user1, user2] + ) + + result = issue.remove_assignee("u1") + + assert result is True + assert len(issue.assignees) == 1 + assert issue.assignees[0].username == "bob" + + def test_remove_nonexistent_assignee_returns_false(self): + """Test removing non-existent assignee returns False.""" + now = datetime.now(timezone.utc) + user = User(id="u1", username="alice") + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + assignees=[user] + ) + + result = issue.remove_assignee("nonexistent") + + assert result is False + assert len(issue.assignees) == 1 + + +@pytest.mark.unit +class TestIssueCommentMethods: + """Test Issue comment methods.""" + + def test_add_comment(self): + """Test adding a comment to issue.""" + now = datetime.now(timezone.utc) + issue = Issue( + id="issue1", number=1, title="Test", description="Desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + comments=[] + ) + + author = User(id="u1", username="alice") + comment = Comment(id="c1", body="Great!", author=author, created_at=now) + issue.add_comment(comment) + + assert len(issue.comments) == 1 + assert issue.comments[0].body == "Great!" + + +@pytest.mark.unit +class TestIssueSerialization: + """Test Issue serialization.""" + + def test_to_dict(self): + """Test converting issue to dictionary.""" + now = datetime.now(timezone.utc) + labels = [Label(name="bug", color="red")] + assignees = [User(id="u1", username="alice", display_name="Alice")] + milestone = Milestone(id="m1", title="v1.0") + + issue = Issue( + id="issue1", number=1, title="Test Issue", description="Test desc", + state=IssueState.OPEN, created_at=now, updated_at=now, + labels=labels, assignees=assignees, milestone=milestone, + backend_id="gitea-123", backend_type="gitea" + ) + + issue_dict = issue.to_dict() + + assert issue_dict['id'] == "issue1" + assert issue_dict['number'] == 1 + assert issue_dict['title'] == "Test Issue" + assert issue_dict['state'] == "open" + assert issue_dict['backend_id'] == "gitea-123" + assert issue_dict['backend_type'] == "gitea" + assert len(issue_dict['labels']) == 1 + assert len(issue_dict['assignees']) == 1 + assert issue_dict['milestone']['id'] == "m1" diff --git a/tests/test_gitea_integration.py b/tests/test_gitea_integration.py deleted file mode 100644 index b1d21a7..0000000 --- a/tests/test_gitea_integration.py +++ /dev/null @@ -1,531 +0,0 @@ -""" -Tests for Gitea Issue/Milestone/Label Management Integration - -This test suite provides comprehensive coverage of Gitea API operations for -issue tracking, including issues, milestones, and labels. - -The issue-facade capability provides a unified interface to various issue -tracking backends, including Gitea. This test suite covers the complete -Gitea API integration layer. - -Test Coverage: -- **GiteaConfig**: Configuration and API URL generation -- **IssuesClient**: Full issue CRUD operations, labels, milestones -- **MilestonesClient**: Milestone creation and management -- **LabelsClient**: Label operations -- **GiteaClient**: Main client facade -- **Error Handling**: Error propagation and handling -- **Integration Patterns**: API consistency and compatibility - -Current Status: SKIPPED - Tests need updating for issue-facade architecture -Related Code: capabilities/issue-facade/issue_tracker/backends/gitea/ - -Note: These tests were originally written for a different Gitea client -architecture. They need to be updated to work with the issue-facade backend -pattern (see test_gitea_backend.py for the current backend tests). -""" - -import pytest -from unittest.mock import Mock, MagicMock, patch -from datetime import datetime - -# Skip all tests - need to update for issue-facade architecture -# These tests are for a different Gitea client pattern than currently implemented -pytestmark = pytest.mark.skip( - reason="Tests need updating for issue-facade backend architecture. " - "See test_gitea_backend.py for current Gitea backend tests." -) - - -class TestGiteaConfig: - """Test GiteaConfig functionality.""" - - def test_config_creation(self): - """Test basic config creation.""" - config = GiteaConfig( - gitea_url="https://gitea.example.com", - repo_owner="test_owner", - repo_name="test_repo", - auth_token="test_token" - ) - - assert config.gitea_url == "https://gitea.example.com" - assert config.repo_owner == "test_owner" - assert config.repo_name == "test_repo" - assert config.auth_token == "test_token" - - def test_api_url_properties(self): - """Test API URL property generation.""" - config = GiteaConfig( - gitea_url="https://gitea.example.com", - repo_owner="test_owner", - repo_name="test_repo" - ) - - assert config.base_api_url == "https://gitea.example.com/api/v1" - assert config.repo_api_url == "https://gitea.example.com/api/v1/repos/test_owner/test_repo" - assert config.issues_api_url == "https://gitea.example.com/api/v1/repos/test_owner/test_repo/issues" - - @patch('gitea.config.subprocess.run') - def test_from_git_repository(self, mock_run): - """Test config creation from git repository.""" - mock_run.return_value = Mock( - stdout="https://gitea.example.com/owner/repo.git", - returncode=0 - ) - - config = GiteaConfig.from_git_repository() - - assert config.gitea_url == "https://gitea.example.com" - assert config.repo_owner == "owner" - assert config.repo_name == "repo" - - def test_config_validation(self): - """Test config validation.""" - # Valid config should not raise - config = GiteaConfig( - gitea_url="https://gitea.example.com", - repo_owner="owner", - repo_name="repo" - ) - config.validate() # Should not raise - - # Invalid URL should raise - invalid_config = GiteaConfig( - gitea_url="invalid-url", - repo_owner="owner", - repo_name="repo" - ) - with pytest.raises(Exception): - invalid_config.validate() - - -class TestIssuesClient: - """Test IssuesClient functionality.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_api = Mock() - self.client = IssuesClient(self.mock_api) - - # Mock issue for responses - self.mock_issue = Mock(spec=Issue) - self.mock_issue.number = 1 - self.mock_issue.title = "Test Issue" - self.mock_issue.body = "Test body" - self.mock_issue.state = "open" - self.mock_issue.html_url = "https://gitea.example.com/owner/repo/issues/1" - self.mock_issue.created_at = datetime(2023, 1, 1, 12, 0, 0) - self.mock_issue.updated_at = datetime(2023, 1, 1, 12, 0, 0) - self.mock_issue.assignee = None - self.mock_issue.labels = [] - self.mock_issue.milestone = None - - def test_get_issue(self): - """Test getting a single issue.""" - self.mock_api.get_issue.return_value = self.mock_issue - - result = self.client.get(1) - - assert result == self.mock_issue - self.mock_api.get_issue.assert_called_once_with(1) - - def test_list_issues(self): - """Test listing issues.""" - self.mock_api.list_issues.return_value = [self.mock_issue] - - result = self.client.list() - - assert result == [self.mock_issue] - self.mock_api.list_issues.assert_called_once_with("all", 1, 50) - - def test_list_issues_with_filters(self): - """Test listing issues with filters.""" - self.mock_api.list_issues.return_value = [self.mock_issue] - - result = self.client.list(state="open", page=2, per_page=25) - - assert result == [self.mock_issue] - self.mock_api.list_issues.assert_called_once_with("open", 2, 25) - - def test_create_issue(self): - """Test creating an issue.""" - self.mock_api.create_issue.return_value = self.mock_issue - - result = self.client.create("Test Title", "Test Body") - - assert result == self.mock_issue - self.mock_api.create_issue.assert_called_once() - - def test_create_issue_with_options(self): - """Test creating an issue with optional fields.""" - self.mock_api.create_issue.return_value = self.mock_issue - - result = self.client.create( - "Test Title", - "Test Body", - assignees=["user1"], - milestone=1, - labels=["bug", "priority:high"] - ) - - assert result == self.mock_issue - self.mock_api.create_issue.assert_called_once() - - def test_update_issue(self): - """Test updating an issue.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.update(1, title="New Title") - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_close_issue(self): - """Test closing an issue.""" - closed_issue = Mock(spec=Issue) - closed_issue.state = "closed" - self.mock_api.update_issue.return_value = closed_issue - - result = self.client.close(1) - - assert result.state == "closed" - self.mock_api.update_issue.assert_called_once() - - def test_reopen_issue(self): - """Test reopening an issue.""" - opened_issue = Mock(spec=Issue) - opened_issue.state = "open" - self.mock_api.update_issue.return_value = opened_issue - - result = self.client.reopen(1) - - assert result.state == "open" - self.mock_api.update_issue.assert_called_once() - - def test_add_labels(self): - """Test adding labels to an issue.""" - # Mock getting current issue - self.mock_issue.labels = [Mock(name="existing")] - self.mock_api.get_issue.return_value = self.mock_issue - - # Mock update result - updated_issue = Mock(spec=Issue) - updated_issue.labels = [Mock(name="existing"), Mock(name="new")] - self.mock_api.update_issue.return_value = updated_issue - - result = self.client.add_labels(1, ["new"]) - - assert len(result.labels) == 2 - self.mock_api.get_issue.assert_called_once_with(1) - self.mock_api.update_issue.assert_called_once() - - def test_remove_labels(self): - """Test removing labels from an issue.""" - # Mock getting current issue - label1 = Mock(name="keep") - label2 = Mock(name="remove") - self.mock_issue.labels = [label1, label2] - self.mock_api.get_issue.return_value = self.mock_issue - - # Mock update result - updated_issue = Mock(spec=Issue) - updated_issue.labels = [label1] - self.mock_api.update_issue.return_value = updated_issue - - result = self.client.remove_labels(1, ["remove"]) - - assert len(result.labels) == 1 - self.mock_api.get_issue.assert_called_once_with(1) - self.mock_api.update_issue.assert_called_once() - - def test_assign_to_milestone(self): - """Test assigning issue to milestone.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.assign_to_milestone(1, 5) - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_remove_from_milestone(self): - """Test removing issue from milestone.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.remove_from_milestone(1) - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_set_labels(self): - """Test replacing all labels on an issue.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.set_labels(1, ["bug", "priority:high"]) - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_update_title(self): - """Test updating only issue title.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.update_title(1, "New Title") - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_update_body(self): - """Test updating only issue body.""" - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.update_body(1, "New Body") - - assert result == self.mock_issue - self.mock_api.update_issue.assert_called_once() - - def test_set_priority(self): - """Test setting issue priority.""" - # Mock getting current issue - self.mock_issue.labels = [Mock(name="bug")] - self.mock_api.get_issue.return_value = self.mock_issue - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.set_priority(1, Priority.HIGH) - - assert result == self.mock_issue - self.mock_api.get_issue.assert_called_once_with(1) - self.mock_api.update_issue.assert_called_once() - - def test_set_status(self): - """Test setting issue status.""" - # Mock getting current issue - self.mock_issue.labels = [Mock(name="bug")] - self.mock_api.get_issue.return_value = self.mock_issue - self.mock_api.update_issue.return_value = self.mock_issue - - result = self.client.set_status(1, ProjectState.ACTIVE) - - assert result == self.mock_issue - self.mock_api.get_issue.assert_called_once_with(1) - self.mock_api.update_issue.assert_called_once() - - def test_to_dict(self): - """Test converting issue to dictionary.""" - result = self.client.to_dict(self.mock_issue) - - expected_keys = ['number', 'title', 'body', 'state', 'html_url', - 'created_at', 'updated_at', 'assignee', 'labels', 'milestone'] - - assert all(key in result for key in expected_keys) - assert result['number'] == 1 - assert result['title'] == "Test Issue" - assert result['state'] == "open" - - -class TestMilestonesClient: - """Test MilestonesClient functionality.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_api = Mock() - self.client = MilestonesClient(self.mock_api) - - self.mock_milestone = Mock(spec=Milestone) - self.mock_milestone.id = 1 - self.mock_milestone.title = "Test Milestone" - - def test_list_milestones(self): - """Test listing milestones.""" - self.mock_api.list_milestones.return_value = [self.mock_milestone] - - result = self.client.list() - - assert result == [self.mock_milestone] - self.mock_api.list_milestones.assert_called_once_with("all") - - def test_list_open_milestones(self): - """Test listing open milestones.""" - self.mock_api.list_milestones.return_value = [self.mock_milestone] - - result = self.client.list_open() - - assert result == [self.mock_milestone] - self.mock_api.list_milestones.assert_called_once_with("open") - - def test_create_milestone(self): - """Test creating a milestone.""" - self.mock_api.create_milestone.return_value = self.mock_milestone - - result = self.client.create("Test Milestone", "Description") - - assert result == self.mock_milestone - self.mock_api.create_milestone.assert_called_once() - - -class TestLabelsClient: - """Test LabelsClient functionality.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_api = Mock() - self.client = LabelsClient(self.mock_api) - - self.mock_label = Mock(spec=Label) - self.mock_label.id = 1 - self.mock_label.name = "bug" - - def test_list_labels(self): - """Test listing labels.""" - self.mock_api.list_labels.return_value = [self.mock_label] - - result = self.client.list() - - assert result == [self.mock_label] - self.mock_api.list_labels.assert_called_once() - - def test_create_label(self): - """Test creating a label.""" - self.mock_api.create_label.return_value = self.mock_label - - result = self.client.create("bug", "red", "Bug reports") - - assert result == self.mock_label - self.mock_api.create_label.assert_called_once() - - -class TestGiteaClient: - """Test the main GiteaClient facade.""" - - @patch('gitea.client.GiteaApiClient') - def test_client_initialization(self, mock_api_client): - """Test GiteaClient initialization.""" - config = GiteaConfig( - gitea_url="https://gitea.example.com", - repo_owner="test_owner", - repo_name="test_repo" - ) - - client = GiteaClient(config) - - assert isinstance(client.issues, IssuesClient) - assert isinstance(client.milestones, MilestonesClient) - assert isinstance(client.labels, LabelsClient) - mock_api_client.assert_called_once_with(config) - - @patch('gitea.client.GiteaConfig.from_git_repository') - @patch('gitea.client.GiteaApiClient') - def test_client_auto_config(self, mock_api_client, mock_from_git): - """Test GiteaClient with auto-detected config.""" - mock_config = Mock() - mock_from_git.return_value = mock_config - - client = GiteaClient() - - mock_from_git.assert_called_once() - mock_api_client.assert_called_once_with(mock_config) - - -class TestErrorHandling: - """Test error handling throughout the facade.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_api = Mock() - self.client = IssuesClient(self.mock_api) - - def test_gitea_error_propagation(self): - """Test that GiteaError is properly propagated.""" - self.mock_api.get_issue.side_effect = GiteaError("API Error") - - with pytest.raises(GiteaError): - self.client.get(1) - - def test_not_found_error_propagation(self): - """Test that GiteaNotFoundError is properly propagated.""" - self.mock_api.get_issue.side_effect = GiteaNotFoundError("Issue not found") - - with pytest.raises(GiteaNotFoundError): - self.client.get(999) - - def test_auth_error_propagation(self): - """Test that GiteaAuthError is properly propagated.""" - self.mock_api.create_issue.side_effect = GiteaAuthError("Unauthorized") - - with pytest.raises(GiteaAuthError): - self.client.create("Title", "Body") - - -class TestIntegrationPatterns: - """Test integration patterns and best practices.""" - - @patch('gitea.client.GiteaApiClient') - def test_consistent_interface(self, mock_api_client): - """Test that the facade provides consistent interfaces.""" - config = GiteaConfig(gitea_url="https://gitea.example.com", - repo_owner="owner", repo_name="repo") - client = GiteaClient(config) - - # All sub-clients should be available - assert hasattr(client, 'issues') - assert hasattr(client, 'milestones') - assert hasattr(client, 'labels') - - # All should have consistent method patterns - assert hasattr(client.issues, 'list') - assert hasattr(client.issues, 'get') - assert hasattr(client.issues, 'create') - assert hasattr(client.issues, 'update') - - assert hasattr(client.milestones, 'list') - assert hasattr(client.milestones, 'create') - - assert hasattr(client.labels, 'list') - assert hasattr(client.labels, 'create') - - def test_backward_compatibility_dict_conversion(self): - """Test that to_dict provides backward compatibility.""" - mock_api = Mock() - client = IssuesClient(mock_api) - - # Create a mock issue with all expected attributes - mock_issue = Mock(spec=Issue) - mock_issue.number = 1 - mock_issue.title = "Test" - mock_issue.body = "Body" - mock_issue.state = "open" - mock_issue.html_url = "https://example.com" - mock_issue.created_at = datetime(2023, 1, 1) - mock_issue.updated_at = datetime(2023, 1, 1) - mock_issue.assignee = None - mock_issue.labels = [] - mock_issue.milestone = None - - result = client.to_dict(mock_issue) - - # Should contain all expected fields for backward compatibility - required_fields = ['number', 'title', 'body', 'state', 'html_url', - 'created_at', 'updated_at', 'assignee', 'labels', 'milestone'] - - for field in required_fields: - assert field in result, f"Missing required field: {field}" - - def test_label_operations_consistency(self): - """Test that label operations work consistently.""" - mock_api = Mock() - client = IssuesClient(mock_api) - - # Mock issue with labels - mock_issue = Mock() - mock_issue.labels = [Mock(name="bug"), Mock(name="priority:high")] - mock_api.get_issue.return_value = mock_issue - mock_api.update_issue.return_value = mock_issue - - # Test all label operations - client.add_labels(1, ["new-label"]) - client.remove_labels(1, ["old-label"]) - client.set_labels(1, ["label1", "label2"]) - - # Should have made appropriate API calls - assert mock_api.get_issue.call_count == 2 # add_labels and remove_labels - assert mock_api.update_issue.call_count == 3 # all three operations \ No newline at end of file diff --git a/tests/test_local_backend.py b/tests/test_local_backend.py new file mode 100644 index 0000000..64d829f --- /dev/null +++ b/tests/test_local_backend.py @@ -0,0 +1,751 @@ +""" +Test suite for Local SQLite Backend functionality. + +These tests ensure the local backend correctly implements the IssueBackend interface +and provides reliable offline issue tracking. +""" + +import pytest +import tempfile +from datetime import datetime, timezone, timedelta +from pathlib import Path + +from issue_tracker.backends.local.backend import LocalSQLiteBackend +from issue_tracker.core.models import Issue, Label, User, Milestone, Comment, IssueState +from issue_tracker.core.interfaces import IssueFilter + + +@pytest.mark.unit +class TestLocalBackendInitialization: + """Test local backend initialization and connection.""" + + def test_backend_initialization(self): + """Test backend initializes with correct type and capabilities.""" + backend = LocalSQLiteBackend() + + assert backend.backend_type == "local" + assert backend.capabilities.supports_milestones + assert backend.capabilities.supports_assignees + assert backend.capabilities.supports_comments + assert backend.capabilities.supports_labels + assert backend.capabilities.supports_search + assert backend.capabilities.supports_bulk_operations + assert not backend.capabilities.supports_webhooks + assert not backend.capabilities.supports_real_time + + def test_connect_creates_database(self): + """Test connect creates database file and schema.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.db" + backend = LocalSQLiteBackend() + + backend.connect({'db_path': str(db_path)}) + + assert db_path.exists() + assert backend.connection is not None + assert backend.test_connection() + + backend.disconnect() + + def test_disconnect_closes_connection(self): + """Test disconnect properly closes database connection.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + + backend.disconnect() + + assert backend.connection is None + assert not backend.test_connection() + + def test_schema_initialization(self): + """Test database schema is properly initialized.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + + # Verify tables exist + cursor = backend.connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + tables = {row[0] for row in cursor.fetchall()} + + expected_tables = { + 'issues', 'labels', 'users', 'milestones', 'comments', + 'issue_labels', 'issue_assignees', 'sync_history' + } + + assert expected_tables.issubset(tables) + + backend.disconnect() + + +@pytest.mark.unit +class TestIssueCRUD: + """Test issue CRUD operations.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_issue(self, backend): + """Test creating a new issue.""" + issue = Issue( + id=None, + number=0, + title="Test Issue", + description="Test description", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + created = backend.create_issue(issue) + + assert created.id is not None + assert created.number == 1 + assert created.title == "Test Issue" + assert created.description == "Test description" + assert created.state == IssueState.OPEN + + def test_create_multiple_issues_increments_numbers(self, backend): + """Test issue numbers increment correctly.""" + issue1 = Issue( + id=None, number=0, title="Issue 1", description="Desc 1", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + issue2 = Issue( + id=None, number=0, title="Issue 2", description="Desc 2", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + created1 = backend.create_issue(issue1) + created2 = backend.create_issue(issue2) + + assert created1.number == 1 + assert created2.number == 2 + + def test_get_issue_by_id(self, backend): + """Test retrieving issue by ID.""" + issue = Issue( + id=None, number=0, title="Test", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + created = backend.create_issue(issue) + + retrieved = backend.get_issue(created.id) + + assert retrieved is not None + assert retrieved.id == created.id + assert retrieved.title == "Test" + + def test_get_issue_by_number(self, backend): + """Test retrieving issue by number.""" + issue = Issue( + id=None, number=0, title="Test", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + created = backend.create_issue(issue) + + retrieved = backend.get_issue_by_number(created.number) + + assert retrieved is not None + assert retrieved.number == created.number + assert retrieved.title == "Test" + + def test_get_nonexistent_issue_returns_none(self, backend): + """Test getting non-existent issue returns None.""" + assert backend.get_issue("nonexistent-id") is None + assert backend.get_issue_by_number(999) is None + + def test_update_issue(self, backend): + """Test updating an existing issue.""" + issue = Issue( + id=None, number=0, title="Original", description="Original desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + created = backend.create_issue(issue) + + created.title = "Updated" + created.description = "Updated desc" + created.state = IssueState.CLOSED + created.closed_at = datetime.now(timezone.utc) + + updated = backend.update_issue(created) + + assert updated.title == "Updated" + assert updated.description == "Updated desc" + assert updated.state == IssueState.CLOSED + + # Verify changes persisted + retrieved = backend.get_issue(created.id) + assert retrieved.title == "Updated" + + def test_delete_issue(self, backend): + """Test deleting an issue.""" + issue = Issue( + id=None, number=0, title="To Delete", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + created = backend.create_issue(issue) + + result = backend.delete_issue(created.id) + + assert result is True + assert backend.get_issue(created.id) is None + + def test_delete_nonexistent_issue_returns_false(self, backend): + """Test deleting non-existent issue returns False.""" + result = backend.delete_issue("nonexistent-id") + assert result is False + + +@pytest.mark.unit +class TestIssueWithLabels: + """Test issue operations with labels.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_issue_with_labels(self, backend): + """Test creating issue with labels.""" + labels = [ + Label(name="bug", color="red", description="Bug reports"), + Label(name="priority:high", color="orange") + ] + + issue = Issue( + id=None, number=0, title="Bug Report", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=labels + ) + + created = backend.create_issue(issue) + retrieved = backend.get_issue(created.id) + + assert len(retrieved.labels) == 2 + assert any(l.name == "bug" for l in retrieved.labels) + assert any(l.name == "priority:high" for l in retrieved.labels) + + def test_update_issue_labels(self, backend): + """Test updating issue labels.""" + issue = Issue( + id=None, number=0, title="Test", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[Label(name="bug")] + ) + created = backend.create_issue(issue) + + # Update labels + created.labels = [ + Label(name="bug"), + Label(name="enhancement"), + Label(name="priority:low") + ] + backend.update_issue(created) + + retrieved = backend.get_issue(created.id) + assert len(retrieved.labels) == 3 + + +@pytest.mark.unit +class TestIssueWithAssignees: + """Test issue operations with assignees.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_issue_with_assignees(self, backend): + """Test creating issue with assignees.""" + assignees = [ + User(id="user1", username="alice", display_name="Alice"), + User(id="user2", username="bob", display_name="Bob") + ] + + issue = Issue( + id=None, number=0, title="Assigned Issue", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + assignees=assignees + ) + + created = backend.create_issue(issue) + retrieved = backend.get_issue(created.id) + + assert len(retrieved.assignees) == 2 + assert any(u.username == "alice" for u in retrieved.assignees) + assert any(u.username == "bob" for u in retrieved.assignees) + + +@pytest.mark.unit +class TestIssueWithMilestone: + """Test issue operations with milestones.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_issue_with_milestone(self, backend): + """Test creating issue with milestone.""" + milestone = Milestone( + id=None, + title="v1.0", + description="First release", + state="open", + due_date=datetime.now(timezone.utc) + timedelta(days=30) + ) + created_milestone = backend.create_milestone(milestone) + + issue = Issue( + id=None, number=0, title="Feature", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + milestone=created_milestone + ) + + created_issue = backend.create_issue(issue) + retrieved = backend.get_issue(created_issue.id) + + assert retrieved.milestone is not None + assert retrieved.milestone.title == "v1.0" + + +@pytest.mark.unit +class TestListAndFilter: + """Test listing and filtering issues.""" + + @pytest.fixture + def backend(self): + """Create backend with sample data.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + + # Create sample issues + backend.create_issue(Issue( + id=None, number=0, title="Open Bug", description="Bug desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + labels=[Label(name="bug")] + )) + + backend.create_issue(Issue( + id=None, number=0, title="Closed Feature", description="Feature desc", + state=IssueState.CLOSED, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + closed_at=datetime.now(timezone.utc), + labels=[Label(name="enhancement")] + )) + + backend.create_issue(Issue( + id=None, number=0, title="In Progress Task", description="Task desc", + state=IssueState.IN_PROGRESS, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + )) + + yield backend + backend.disconnect() + + def test_list_all_issues(self, backend): + """Test listing all issues.""" + issues = backend.list_issues() + assert len(issues) == 3 + + def test_filter_by_state_open(self, backend): + """Test filtering by open state.""" + filter_criteria = IssueFilter(state="open") + issues = backend.list_issues(filter_criteria) + assert len(issues) == 1 + assert issues[0].state == IssueState.OPEN + + def test_filter_by_state_closed(self, backend): + """Test filtering by closed state.""" + filter_criteria = IssueFilter(state="closed") + issues = backend.list_issues(filter_criteria) + assert len(issues) == 1 + assert issues[0].state == IssueState.CLOSED + + def test_search_issues(self, backend): + """Test searching issues by text.""" + filter_criteria = IssueFilter(search="Bug") + issues = backend.list_issues(filter_criteria) + assert len(issues) == 1 + assert "Bug" in issues[0].title + + def test_search_issues_method(self, backend): + """Test search_issues method.""" + issues = backend.search_issues("Feature") + assert len(issues) == 1 + assert "Feature" in issues[0].title + + def test_filter_with_limit(self, backend): + """Test filtering with limit.""" + filter_criteria = IssueFilter(limit=2) + issues = backend.list_issues(filter_criteria) + assert len(issues) == 2 + + def test_filter_with_offset(self, backend): + """Test filtering with offset and limit.""" + filter_criteria = IssueFilter(limit=2, offset=1) + issues = backend.list_issues(filter_criteria) + assert len(issues) == 2 + + +@pytest.mark.unit +class TestLabelOperations: + """Test label management operations.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_label(self, backend): + """Test creating a label.""" + label = Label(name="bug", color="red", description="Bug reports") + created = backend.create_label(label) + + assert created.name == "bug" + assert created.color == "red" + + def test_get_labels(self, backend): + """Test getting all labels.""" + backend.create_label(Label(name="bug", color="red")) + backend.create_label(Label(name="enhancement", color="blue")) + + labels = backend.get_labels() + assert len(labels) == 2 + + def test_update_label(self, backend): + """Test updating a label.""" + label = Label(name="bug", color="red", description="Old desc") + backend.create_label(label) + + # Create new label with updated values (Label is frozen/immutable) + updated_label = Label(name="bug", color="orange", description="New desc") + backend.update_label(updated_label) + + labels = backend.get_labels() + bug_label = next(l for l in labels if l.name == "bug") + assert bug_label.color == "orange" + assert bug_label.description == "New desc" + + def test_delete_label(self, backend): + """Test deleting a label.""" + backend.create_label(Label(name="bug", color="red")) + + result = backend.delete_label("bug") + assert result is True + + labels = backend.get_labels() + assert len(labels) == 0 + + +@pytest.mark.unit +class TestUserOperations: + """Test user management operations.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + + # Create test users + backend.connection.execute(""" + INSERT INTO users (id, username, display_name, email) + VALUES (?, ?, ?, ?) + """, ("user1", "alice", "Alice Smith", "alice@example.com")) + backend.connection.execute(""" + INSERT INTO users (id, username, display_name, email) + VALUES (?, ?, ?, ?) + """, ("user2", "bob", "Bob Jones", "bob@example.com")) + backend.connection.commit() + + yield backend + backend.disconnect() + + def test_get_users(self, backend): + """Test getting all users.""" + users = backend.get_users() + assert len(users) == 2 + + def test_get_user_by_id(self, backend): + """Test getting user by ID.""" + user = backend.get_user("user1") + assert user is not None + assert user.username == "alice" + + def test_search_users(self, backend): + """Test searching users.""" + users = backend.search_users("alice") + assert len(users) == 1 + assert users[0].username == "alice" + + +@pytest.mark.unit +class TestMilestoneOperations: + """Test milestone management operations.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_create_milestone(self, backend): + """Test creating a milestone.""" + milestone = Milestone( + id=None, + title="v1.0", + description="First release", + state="open", + due_date=datetime.now(timezone.utc) + timedelta(days=30) + ) + + created = backend.create_milestone(milestone) + assert created.id is not None + assert created.title == "v1.0" + + def test_get_milestones(self, backend): + """Test getting all milestones.""" + backend.create_milestone(Milestone(id=None, title="v1.0", state="open")) + backend.create_milestone(Milestone(id=None, title="v2.0", state="open")) + + milestones = backend.get_milestones() + assert len(milestones) == 2 + + def test_update_milestone(self, backend): + """Test updating a milestone.""" + milestone = Milestone(id=None, title="v1.0", description="Old", state="open") + created = backend.create_milestone(milestone) + + created.description = "Updated" + created.state = "closed" + backend.update_milestone(created) + + milestones = backend.get_milestones() + updated = next(m for m in milestones if m.id == created.id) + assert updated.description == "Updated" + assert updated.state == "closed" + + def test_delete_milestone(self, backend): + """Test deleting a milestone.""" + milestone = Milestone(id=None, title="v1.0", state="open") + created = backend.create_milestone(milestone) + + result = backend.delete_milestone(created.id) + assert result is True + + milestones = backend.get_milestones() + assert len(milestones) == 0 + + +@pytest.mark.unit +class TestCommentOperations: + """Test comment operations.""" + + @pytest.fixture + def backend(self): + """Create backend with an issue.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + + # Create test issue + issue = Issue( + id=None, number=0, title="Test", description="Desc", + state=IssueState.OPEN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + backend.create_issue(issue) + + yield backend + backend.disconnect() + + def test_add_comment(self, backend): + """Test adding a comment to an issue.""" + issue = backend.get_issue_by_number(1) + author = User(id="user1", username="alice") + + comment = Comment( + id=None, + body="Great issue!", + author=author, + created_at=datetime.now(timezone.utc) + ) + + created = backend.add_comment(issue.id, comment) + assert created.id is not None + assert created.body == "Great issue!" + + def test_get_comments(self, backend): + """Test getting comments for an issue.""" + issue = backend.get_issue_by_number(1) + author = User(id="user1", username="alice") + + comment1 = Comment( + id=None, body="First comment", author=author, + created_at=datetime.now(timezone.utc) + ) + comment2 = Comment( + id=None, body="Second comment", author=author, + created_at=datetime.now(timezone.utc) + ) + + backend.add_comment(issue.id, comment1) + backend.add_comment(issue.id, comment2) + + comments = backend.get_comments(issue.id) + assert len(comments) == 2 + assert comments[0].body == "First comment" + + def test_update_comment(self, backend): + """Test updating a comment.""" + issue = backend.get_issue_by_number(1) + author = User(id="user1", username="alice") + + comment = Comment( + id=None, body="Original", author=author, + created_at=datetime.now(timezone.utc) + ) + created = backend.add_comment(issue.id, comment) + + created.body = "Updated" + backend.update_comment(created) + + comments = backend.get_comments(issue.id) + assert comments[0].body == "Updated" + + def test_delete_comment(self, backend): + """Test deleting a comment.""" + issue = backend.get_issue_by_number(1) + author = User(id="user1", username="alice") + + comment = Comment( + id=None, body="To delete", author=author, + created_at=datetime.now(timezone.utc) + ) + created = backend.add_comment(issue.id, comment) + + result = backend.delete_comment(created.id) + assert result is True + + comments = backend.get_comments(issue.id) + assert len(comments) == 0 + + +@pytest.mark.unit +class TestSyncOperations: + """Test synchronization support.""" + + @pytest.fixture + def backend(self): + """Create a temporary backend for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + backend = LocalSQLiteBackend() + backend.connect({'db_path': str(Path(tmpdir) / "test.db")}) + yield backend + backend.disconnect() + + def test_finalize_sync_logs_success(self, backend): + """Test successful sync is logged.""" + backend.finalize_sync(success=True) + + cursor = backend.connection.execute( + "SELECT * FROM sync_history WHERE success = 1" + ) + rows = cursor.fetchall() + assert len(rows) == 1 + + def test_get_issues_modified_since(self, backend): + """Test getting issues modified after a timestamp.""" + old_time = datetime.now(timezone.utc) - timedelta(hours=2) + + # Create issue before timestamp + issue1 = Issue( + id=None, number=0, title="Old", description="Desc", + state=IssueState.OPEN, + created_at=old_time, + updated_at=old_time + ) + backend.create_issue(issue1) + + # Create issue after timestamp + new_time = datetime.now(timezone.utc) + issue2 = Issue( + id=None, number=0, title="New", description="Desc", + state=IssueState.OPEN, + created_at=new_time, + updated_at=new_time + ) + backend.create_issue(issue2) + + # Get issues modified since 1 hour ago + since_time = datetime.now(timezone.utc) - timedelta(hours=1) + modified_issues = backend.get_issues_modified_since(since_time) + + assert len(modified_issues) == 1 + assert modified_issues[0].title == "New" + + def test_get_sync_conflicts_returns_empty(self, backend): + """Test local backend has no sync conflicts.""" + conflicts = backend.get_sync_conflicts() + assert conflicts == [] + + def test_prepare_for_sync(self, backend): + """Test prepare_for_sync doesn't fail.""" + # Should not raise + backend.prepare_for_sync()