refactor(plane_sync): parameterize project_id (backward compatible)
ORCH-6: sync functions resolve the issue PROJECT_ID via the registry (get_project_by_repo) and accept project_id; default stays enduro so existing ET callers keep working.
This commit is contained in:
@@ -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"<p>{text}</p>"
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user