diff --git a/src/plane_sync.py b/src/plane_sync.py index 6c44510..762cbbb 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -11,6 +11,35 @@ PLANE_HEADERS = {"X-API-Key": settings.plane_api_token} WORKSPACE = settings.plane_workspace_slug PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c" + +def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str: + """ORCH-6: resolve the Plane project id for a sync call. + + Priority: + 1. explicit project_id arg (caller already knows the project), + 2. project derived from the task's repo in the DB (by work_item_id), + 3. legacy default PROJECT_ID (enduro) for backward compatibility. + """ + if project_id: + return project_id + if work_item_id: + try: + from .db import get_db + from .projects import get_project_by_repo + conn = get_db() + row = conn.execute( + "SELECT repo FROM tasks WHERE work_item_id = ? ORDER BY id DESC LIMIT 1", + (work_item_id,), + ).fetchone() + conn.close() + if row and row[0]: + proj = get_project_by_repo(row[0]) + if proj: + return proj.plane_project_id + except Exception as e: + logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}") + return PROJECT_ID + # Plane state IDs PLANE_STATES = { "backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122", @@ -36,8 +65,9 @@ STAGE_TO_STATE = { } -def find_issue_id(work_item_id: str) -> str | None: +def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: """Find Plane issue UUID by work_item_id (e.g. 'ET-002').""" + project_id = _resolve_project_id(work_item_id, project_id) # Primary: lookup from DB (plane_issue_id column) try: from .db import get_db @@ -52,7 +82,7 @@ def find_issue_id(work_item_id: str) -> str | None: logger.debug(f"DB lookup failed for {work_item_id}: {e}") # Fallback: search via Plane API - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/" try: # First try search by work_item_id resp = httpx.get(url, headers=PLANE_HEADERS, params={"search": work_item_id}, timeout=10) @@ -83,18 +113,19 @@ def find_issue_id(work_item_id: str) -> str | None: return None -def update_issue_state(work_item_id: str, stage: str): +def update_issue_state(work_item_id: str, stage: str, project_id: str = None): """Update Plane issue state based on orchestrator stage.""" state_id = STAGE_TO_STATE.get(stage) if not state_id: return - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10) resp.raise_for_status() @@ -103,14 +134,15 @@ def update_issue_state(work_item_id: str, stage: str): logger.error(f"Failed to update Plane state for {work_item_id}: {e}") -def add_comment(work_item_id: str, text: str): +def add_comment(work_item_id: str, text: str, project_id: str = None): """Add a comment to Plane issue.""" - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/comments/" html = f"

{text}

" try: resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10) @@ -121,33 +153,34 @@ def add_comment(work_item_id: str, text: str): -def set_issue_needs_input(work_item_id: str): +def set_issue_needs_input(work_item_id: str, project_id: str = None): """Set issue to 'Needs Input' state — waiting for stakeholder response.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id) -def set_issue_in_review(work_item_id: str): +def set_issue_in_review(work_item_id: str, project_id: str = None): """Set issue to 'In Review' state — waiting for :approved: or :rejected:.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["in_review"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id) -def set_issue_blocked(work_item_id: str): +def set_issue_blocked(work_item_id: str, project_id: str = None): """Set issue to 'Blocked' state — manual intervention needed.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["blocked"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id) -def set_issue_in_progress(work_item_id: str): +def set_issue_in_progress(work_item_id: str, project_id: str = None): """Set issue to 'In Progress' state — agent working.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id) -def _set_issue_state_direct(work_item_id: str, state_id: str): +def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None): """Set issue state directly by state_id.""" - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10) resp.raise_for_status() @@ -156,9 +189,10 @@ def _set_issue_state_direct(work_item_id: str, state_id: str): logger.error(f"Failed to update Plane state for {work_item_id}: {e}") -def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None): +def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None, project_id: str = None): """Notify Plane about stage transition with links.""" - update_issue_state(work_item_id, new_stage) + project_id = _resolve_project_id(work_item_id, project_id) + update_issue_state(work_item_id, new_stage, project_id) msg = f"🔄 Stage: {old_stage} → {new_stage}" if agent: @@ -193,15 +227,16 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent except Exception: pass - add_comment(work_item_id, msg) + add_comment(work_item_id, msg, project_id) -def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str): +def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str, project_id: str = None): """Notify Plane about QG failure.""" - add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}") + add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}", project_id) -def notify_done(work_item_id: str): +def notify_done(work_item_id: str, project_id: str = None): """Mark issue as Done in Plane.""" - update_issue_state(work_item_id, "done") - add_comment(work_item_id, "✅ Task completed! PR merged and deployed.") + project_id = _resolve_project_id(work_item_id, project_id) + update_issue_state(work_item_id, "done", project_id) + add_comment(work_item_id, "✅ Task completed! PR merged and deployed.", project_id) diff --git a/src/qg/checks.py b/src/qg/checks.py index 7750b9e..01665b1 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -175,11 +175,15 @@ def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = N # Check for :approved: comment via Plane API try: from ..plane_sync import find_issue_id, PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID - issue_id = find_issue_id(work_item_id) + from ..projects import get_project_by_repo + # ORCH-6: verify approval in the issue's own Plane project. + _proj = get_project_by_repo(repo) + _pid = _proj.plane_project_id if _proj else PROJECT_ID + issue_id = find_issue_id(work_item_id, _pid) if not issue_id: return False, "Cannot find Plane issue to verify approval" - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/comments/" resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) resp.raise_for_status() comments = resp.json()