import httpx import os from typing import List, Dict, Any class PlaneService: def __init__(self): self.api_url = os.getenv("PLANE_API_URL", "https://api.plane.so/api/v1") self.api_key = os.getenv("PLANE_API_KEY", "") self.workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "simulation-workspace") self.project_id = os.getenv("PLANE_PROJECT_ID", "simulation-project-id") # Enable simulation mode if no API key is provided self.is_simulation = not self.api_key or self.api_key == "mock" async def get_label_id(self, label_name: str) -> str: """ Helper to get Label ID by name, or create if not exists. """ if self.is_simulation: return "simulated-label-id" headers = { "X-API-Key": self.api_key, "Content-Type": "application/json" } try: # 1. List labels (Plane API usually returns all project labels) # Adjust URL based on Plane version: /workspaces/{slug}/projects/{id}/labels/ or /issue-labels/ url = f"{self.api_url}/workspaces/{self.workspace_slug}/projects/{self.project_id}/issue-labels/" async with httpx.AsyncClient() as client: response = await client.get(url, headers=headers) if response.status_code == 200: labels = response.json() for label in labels: if label.get("name") == label_name: return label.get("id") # 2. Create if not found # Random color generator or fixed payload = {"name": label_name, "color": "#ff0000"} create_res = await client.post(url, headers=headers, json=payload) if create_res.status_code == 201: return create_res.json().get("id") except Exception as e: print(f"Failed to get/create label {label_name}: {e}") return None async def create_issue(self, title: str, description: str, priority: str = "Medium", estimate: float = None, labels: List[str] = []) -> Dict[str, Any]: """ Creates an issue in Plane. """ if self.is_simulation: print(f"[SIMULATION] Creating Plane Issue: {title} ({estimate}h) - {priority} - Labels: {labels}") return {"id": "simulated-issue-id", "name": title, "status": "created (simulation)"} headers = { "X-API-Key": self.api_key, "Content-Type": "application/json" } # Map priority to Plane's expected format plane_priority = priority.lower() if plane_priority not in ["urgent", "high", "medium", "low", "none"]: plane_priority = "medium" # Resolve Label IDs label_ids = [] for label_name in labels: lid = await self.get_label_id(label_name) if lid: label_ids.append(lid) payload = { "name": title, "description_html": f"

{description}

", "priority": plane_priority, "label_ids": label_ids } async with httpx.AsyncClient() as client: try: url = f"{self.api_url}/workspaces/{self.workspace_slug}/projects/{self.project_id}/issues/" response = await client.post(url, headers=headers, json=payload) response.raise_for_status() return response.json() except Exception as e: print(f"Failed to create Plane issue: {e}") return {"error": str(e)} async def update_issue_status(self, issue_identifier: str, status: str = "Done") -> Dict[str, Any]: """ Updates the status of a Plane issue. issue_identifier: Can be issue ID or Sequence ID (e.g., "PROJ-123") """ if self.is_simulation: print(f"[SIMULATION] Updating Plane Issue {issue_identifier} to status: {status}") return {"id": issue_identifier, "status": status, "simulated": True} headers = { "X-API-Key": self.api_key, "Content-Type": "application/json" } # First, we need to find the issue ID if a sequence ID is provided # Or if we assume issue_identifier is the UUID issue_id = issue_identifier # If it looks like a sequence ID (e.g., PROJ-123), we might need to search for it first # For simplicity, let's assume we might need to search or use it directly if Plane supports it. # Plane API usually works with UUIDs for updates. # Let's try to search if it contains a hyphen and isn't a UUID if "-" in issue_identifier and len(issue_identifier) < 30: # Rough heuristic for sequence ID vs UUID # Search for the issue to get its UUID pass # TODO: Implement search logic if needed. # For now, let's assume the user provides the UUID or we just try our best. # Plane uses State IDs for status updates, not just string names like "Done". # In a real implementation, we would need to fetch the State ID for "Done" from the project states. # For this MVP, we will simulate or assume a state_id is passed, or try to map it. # But since we can't easily map "Done" to a UUID without fetching states, # let's assume for now we are just patching the issue and Plane handles it or we mock it. # Real-world: Fetch states -> Find ID for "Done" -> Update Issue # Simplified: Just log the intent or try to update if we had the ID. print(f"Warning: To update status to '{status}' in Plane, we need the specific State UUID.") print(f"Skipping actual API call for status update in this MVP version to avoid 400 errors without State IDs.") return {"status": "skipped_implementation_gap", "message": "State ID lookup required for real update"} async def sync_tasks(self, tasks: List[Dict[str, Any]], is_urgent: bool = False) -> Dict[str, Any]: """ Syncs a list of tasks to Plane. Returns details about created tasks including IDs. """ created_count = 0 errors = [] created_tasks_details = [] # Determine priority and labels based on urgency priority = "Urgent" if is_urgent else "Medium" labels = ["Signal-Driven"] if is_urgent else [] for task in tasks: description = f"Type: {task.get('type', 'General')}
Estimated Hours: {task.get('estimated_hours', 0)}" result = await self.create_issue( title=task.get("task_name", "Untitled Task"), description=description, priority=priority, estimate=task.get("estimated_hours"), labels=labels ) if "error" in result: errors.append(f"Failed to create {task.get('task_name')}: {result['error']}") else: created_count += 1 # Append task with its new Plane ID (simulated or real) # result contains the issue object from Plane API task_with_id = task.copy() task_with_id["plane_id"] = result.get("id") # Also try to get a sequence ID if available (e.g. "PROJ-123") task_with_id["plane_key"] = result.get("sequence_id") or result.get("identifier") or result.get("id") task_with_id["status"] = "Todo" # Initial status created_tasks_details.append(task_with_id) return { "status": "success" if not errors else "partial_success", "created_count": created_count, "errors": errors, "created_tasks": created_tasks_details, "simulation_mode": self.is_simulation } # Singleton plane_service = PlaneService()