feat: full pipeline fixes - CI status branch lookup, review webhook routing, auto-advance, plane sync
- handle_ci_status: fallback git branch -r --contains when branches[] empty - webhook router: handle pull_request_approved event type - handle_pr: map review.type to review.state for new Gitea format - launcher: auto-advance stage after agent completion (_try_advance_stage) - plane_sync: notify Plane on stage changes - stages.py: stage machine with QG definitions - notifications.py: stage change notifications - safe.directory fix for container git operations
This commit is contained in:
129
src/plane_sync.py
Normal file
129
src/plane_sync.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Plane API sync — update issue state and add comments."""
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.plane_sync")
|
||||
|
||||
PLANE_BASE = f"{settings.plane_api_url}/api/v1"
|
||||
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"
|
||||
|
||||
# Map orchestrator stages to Plane states
|
||||
STAGE_TO_STATE = {
|
||||
"created": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10", # Todo
|
||||
"analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"architecture": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"development": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"review": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"testing": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"deploy": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # Done
|
||||
}
|
||||
|
||||
|
||||
def find_issue_id(work_item_id: str) -> str | None:
|
||||
"""Find Plane issue UUID by work_item_id (e.g. 'ET-002')."""
|
||||
# Primary: lookup from DB (plane_issue_id column)
|
||||
try:
|
||||
from .db import get_db
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT plane_issue_id FROM tasks WHERE work_item_id = ? AND plane_issue_id IS NOT NULL",
|
||||
(work_item_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
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/"
|
||||
try:
|
||||
# First try search by work_item_id
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, params={"search": work_item_id}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results = data.get("results", data if isinstance(data, list) else [])
|
||||
for issue in results:
|
||||
seq = issue.get("sequence_id")
|
||||
identifier = f"ET-{seq:03d}" if seq else ""
|
||||
if identifier == work_item_id or work_item_id in issue.get("name", ""):
|
||||
return issue["id"]
|
||||
# Fallback: get all issues and match by sequence_id number
|
||||
if work_item_id.startswith("ET-"):
|
||||
try:
|
||||
target_num = int(work_item_id.split("-")[1])
|
||||
except (IndexError, ValueError):
|
||||
target_num = None
|
||||
if target_num:
|
||||
resp2 = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp2.raise_for_status()
|
||||
data2 = resp2.json()
|
||||
results2 = data2.get("results", data2 if isinstance(data2, list) else [])
|
||||
for issue in results2:
|
||||
if issue.get("sequence_id") == target_num:
|
||||
return issue["id"]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find issue for {work_item_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def update_issue_state(work_item_id: str, stage: str):
|
||||
"""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)
|
||||
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}/"
|
||||
try:
|
||||
resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Plane: {work_item_id} state -> {stage} ({state_id[:8]}...)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update Plane state for {work_item_id}: {e}")
|
||||
|
||||
|
||||
def add_comment(work_item_id: str, text: str):
|
||||
"""Add a comment to Plane issue."""
|
||||
issue_id = find_issue_id(work_item_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/"
|
||||
html = f"<p>{text}</p>"
|
||||
try:
|
||||
resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Plane: comment added to {work_item_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
||||
|
||||
|
||||
def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None):
|
||||
"""Notify Plane about stage transition."""
|
||||
update_issue_state(work_item_id, new_stage)
|
||||
|
||||
msg = f"🔄 Stage: {old_stage} → {new_stage}"
|
||||
if agent:
|
||||
msg += f" (launching {agent})"
|
||||
add_comment(work_item_id, msg)
|
||||
|
||||
|
||||
def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str):
|
||||
"""Notify Plane about QG failure."""
|
||||
add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}")
|
||||
|
||||
|
||||
def notify_done(work_item_id: str):
|
||||
"""Mark issue as Done in Plane."""
|
||||
update_issue_state(work_item_id, "done")
|
||||
add_comment(work_item_id, "✅ Task completed! PR merged and deployed.")
|
||||
Reference in New Issue
Block a user