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:
@@ -1,14 +1,58 @@
|
||||
from fastapi import APIRouter, Request
|
||||
"""Plane webhook handlers — full implementation."""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
from ..db import get_db
|
||||
import logging
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from ..config import settings
|
||||
from ..db import (
|
||||
get_db,
|
||||
get_task_by_plane_id,
|
||||
get_next_work_item_id,
|
||||
update_task_stage,
|
||||
)
|
||||
from ..stages import get_next_stage, get_agent_for_stage, get_qg_for_stage, get_previous_stage
|
||||
from ..qg.checks import QG_CHECKS
|
||||
from ..notifications import notify_stage_change, notify_qg_failure, notify_error
|
||||
from ..agents.launcher import launcher
|
||||
from ..plane_sync import (
|
||||
notify_stage_change as plane_notify_stage,
|
||||
notify_qg_failure as plane_notify_qg,
|
||||
notify_done as plane_notify_done,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("orchestrator.webhooks.plane")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def verify_plane_signature(body: bytes, signature: str) -> bool:
|
||||
"""Verify Plane webhook HMAC-SHA256 signature."""
|
||||
if not settings.plane_webhook_secret:
|
||||
return True # Skip verification if no secret configured
|
||||
expected = hmac.new(
|
||||
settings.plane_webhook_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
@router.post("/plane")
|
||||
async def plane_webhook(request: Request):
|
||||
"""Handle Plane webhook events."""
|
||||
body = await request.body()
|
||||
|
||||
# Verify HMAC signature
|
||||
signature = request.headers.get("X-Plane-Signature", "")
|
||||
if not verify_plane_signature(body, signature):
|
||||
logger.warning("Plane webhook: invalid signature")
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
payload = json.loads(body)
|
||||
|
||||
# Log event
|
||||
@@ -18,32 +62,216 @@ async def plane_webhook(request: Request):
|
||||
("plane", payload.get("event", "unknown"), body.decode()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
event = payload.get("event")
|
||||
action = payload.get("action", "")
|
||||
data = payload.get("data", {})
|
||||
|
||||
if event == "work_item.created":
|
||||
await handle_work_item_created(data, conn)
|
||||
elif event == "comment.created":
|
||||
await handle_comment(data, conn)
|
||||
if (event == "work_item.created") or (event == "issue" and action == "created"):
|
||||
await handle_work_item_created(data)
|
||||
elif (event == "comment.created") or (event == "issue_comment" and action == "created"):
|
||||
await handle_comment(data)
|
||||
|
||||
conn.close()
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
async def handle_work_item_created(data: dict, conn):
|
||||
"""New work item -> create task record."""
|
||||
async def handle_work_item_created(data: dict):
|
||||
"""
|
||||
New work item created in Plane.
|
||||
1. Generate work_item_id
|
||||
2. Create task in DB
|
||||
3. Create branch in Gitea
|
||||
4. Create initial docs folder
|
||||
5. Set stage to 'analysis'
|
||||
"""
|
||||
plane_id = data.get("id", "")
|
||||
name = data.get("name", "untitled")
|
||||
repo = settings.default_repo
|
||||
|
||||
# Generate work item ID
|
||||
work_item_id = get_next_work_item_id(repo)
|
||||
|
||||
# Create slug from name
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30]
|
||||
branch = f"feature/{work_item_id}-{slug}"
|
||||
|
||||
# Insert task into DB
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (plane_id, repo, stage) VALUES (?, ?, ?)",
|
||||
(plane_id, "enduro-trails", "analysis"),
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(plane_id, work_item_id, repo, branch, "analysis", plane_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Create branch in Gitea
|
||||
try:
|
||||
await _create_gitea_branch(repo, branch)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create branch '{branch}': {e}")
|
||||
# Task is created, branch creation failed — log but don't crash
|
||||
notify_error(0, f"Branch creation failed: {e}")
|
||||
return
|
||||
|
||||
# Create initial docs structure via Gitea API (create file)
|
||||
try:
|
||||
await _create_initial_docs(repo, branch, work_item_id, name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create initial docs: {e}")
|
||||
|
||||
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")
|
||||
|
||||
|
||||
async def handle_comment(data: dict, conn):
|
||||
"""Check for :approved: reaction -> advance stage."""
|
||||
comment_body = data.get("comment", "")
|
||||
async def handle_comment(data: dict):
|
||||
"""
|
||||
Handle comment event — check for :approved: or :rejected:.
|
||||
Advance or rollback stage accordingly.
|
||||
"""
|
||||
comment_body = data.get("comment", data.get("body", data.get("comment_html", "")))
|
||||
plane_id = data.get("work_item_id", data.get("issue_id", ""))
|
||||
|
||||
if not plane_id:
|
||||
logger.warning("Comment event without work_item_id, skipping")
|
||||
return
|
||||
|
||||
task = get_task_by_plane_id(plane_id)
|
||||
if not task:
|
||||
logger.warning(f"No task found for plane_id={plane_id}")
|
||||
return
|
||||
|
||||
task_id = task["id"]
|
||||
current_stage = task["stage"]
|
||||
repo = task["repo"]
|
||||
work_item_id = task.get("work_item_id", "")
|
||||
branch = task.get("branch", "")
|
||||
|
||||
if ":rejected:" in comment_body:
|
||||
# Rollback to previous stage
|
||||
prev_stage = get_previous_stage(current_stage)
|
||||
if prev_stage:
|
||||
update_task_stage(task_id, prev_stage)
|
||||
notify_stage_change(task_id, current_stage, prev_stage)
|
||||
logger.info(f"Task {task_id}: rejected, rolled back {current_stage} → {prev_stage}")
|
||||
return
|
||||
|
||||
if ":approved:" in comment_body:
|
||||
# TODO: Determine which task, advance QG
|
||||
pass
|
||||
# Try to advance stage
|
||||
await _try_advance_stage(task_id, current_stage, repo, work_item_id, branch)
|
||||
|
||||
|
||||
async def _try_advance_stage(
|
||||
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str
|
||||
):
|
||||
"""Run QG check for current stage and advance if passed."""
|
||||
qg_name = get_qg_for_stage(current_stage)
|
||||
next_stage = get_next_stage(current_stage)
|
||||
|
||||
if not next_stage:
|
||||
logger.info(f"Task {task_id}: already at terminal stage '{current_stage}'")
|
||||
return
|
||||
|
||||
# Run QG check if one is required
|
||||
if qg_name:
|
||||
qg_func = QG_CHECKS.get(qg_name)
|
||||
if not qg_func:
|
||||
logger.error(f"QG function '{qg_name}' not found in registry")
|
||||
return
|
||||
|
||||
# Determine args based on QG function
|
||||
if qg_name in ("check_analysis_complete", "check_architecture_done", "check_tests_passed"):
|
||||
passed, reason = qg_func(repo, work_item_id)
|
||||
elif qg_name == "check_ci_green":
|
||||
passed, reason = qg_func(repo, branch)
|
||||
elif qg_name == "check_review_approved":
|
||||
# Find PR number by branch via Gitea API
|
||||
import httpx as _httpx
|
||||
from ..config import settings as _s
|
||||
_owner = _s.gitea_owner
|
||||
_url = f"{_s.gitea_url}/api/v1/repos/{_owner}/{repo}/pulls?state=open&limit=50"
|
||||
_headers = {"Authorization": f"token {_s.gitea_token}"}
|
||||
try:
|
||||
_resp = _httpx.get(_url, headers=_headers, timeout=10)
|
||||
_prs = _resp.json()
|
||||
_pr_number = None
|
||||
for _pr in _prs:
|
||||
if _pr.get("head", {}).get("ref") == branch:
|
||||
_pr_number = _pr["number"]
|
||||
break
|
||||
if _pr_number:
|
||||
passed, reason = qg_func(repo, _pr_number)
|
||||
else:
|
||||
# No open PR but review file exists — check file-based
|
||||
import os
|
||||
_review_path = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/12-review.md")
|
||||
_review_path2 = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/09-review.md")
|
||||
if os.path.isfile(_review_path) or os.path.isfile(_review_path2):
|
||||
passed, reason = True, "Review file exists (file-based approval)"
|
||||
else:
|
||||
passed, reason = False, "No open PR found and no review file"
|
||||
except Exception as _e:
|
||||
passed, reason = False, f"Error finding PR: {_e}"
|
||||
else:
|
||||
passed, reason = False, f"Unknown QG: {qg_name}"
|
||||
|
||||
if not passed:
|
||||
notify_qg_failure(task_id, current_stage, qg_name, reason)
|
||||
plane_notify_qg(work_item_id, current_stage, qg_name, reason)
|
||||
return
|
||||
|
||||
# Advance stage
|
||||
update_task_stage(task_id, next_stage)
|
||||
notify_stage_change(task_id, current_stage, next_stage)
|
||||
plane_notify_stage(work_item_id, current_stage, next_stage)
|
||||
|
||||
# Launch agent associated with the current stage's transition
|
||||
agent = get_agent_for_stage(current_stage)
|
||||
if agent:
|
||||
try:
|
||||
task_desc = f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\nStage: {next_stage}"
|
||||
run_id = launcher.launch(agent, repo, task_desc, task_id=task_id)
|
||||
plane_notify_stage(work_item_id, current_stage, next_stage, agent)
|
||||
logger.info(f"Task {task_id}: launched agent '{agent}', run_id={run_id}")
|
||||
except Exception as e:
|
||||
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
|
||||
logger.error(f"Agent launch failed: {e}")
|
||||
|
||||
|
||||
async def _create_gitea_branch(repo: str, branch: str):
|
||||
"""Create a new branch in Gitea from main."""
|
||||
owner = settings.gitea_owner
|
||||
url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/branches"
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
payload = {"new_branch_name": branch, "old_branch_name": "main"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, headers=headers, timeout=10)
|
||||
if resp.status_code == 409:
|
||||
logger.info(f"Branch '{branch}' already exists")
|
||||
return
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Created branch '{branch}' in {owner}/{repo}")
|
||||
|
||||
|
||||
async def _create_initial_docs(repo: str, branch: str, work_item_id: str, name: str):
|
||||
"""Create initial business request doc in the feature branch."""
|
||||
owner = settings.gitea_owner
|
||||
file_path = f"docs/work-items/{work_item_id}/00-business-request.md"
|
||||
url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/contents/{file_path}"
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
|
||||
import base64
|
||||
content = f"# Business Request: {name}\n\nWork Item ID: {work_item_id}\n\n## Description\n\nTBD\n"
|
||||
encoded = base64.b64encode(content.encode()).decode()
|
||||
|
||||
payload = {
|
||||
"message": f"docs: init {work_item_id} business request",
|
||||
"content": encoded,
|
||||
"branch": branch,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, headers=headers, timeout=10)
|
||||
if resp.status_code in (201, 422): # 422 = already exists
|
||||
return
|
||||
resp.raise_for_status()
|
||||
|
||||
Reference in New Issue
Block a user