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:
Dev Agent
2026-05-22 01:57:02 +03:00
parent b428163c32
commit b545665e2d
16 changed files with 1729 additions and 102 deletions

View File

@@ -1,14 +1,53 @@
from fastapi import APIRouter, Request
"""Gitea webhook handlers — full implementation."""
import hmac
import subprocess
import os
import hashlib
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_repo_branch, update_task_stage
from ..stages import get_next_stage, get_agent_for_stage
from ..qg.checks import check_ci_green, check_review_approved
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
logger = logging.getLogger("orchestrator.webhooks.gitea")
router = APIRouter()
# Max retries for developer on request_changes
MAX_DEV_RETRIES = 3
def verify_gitea_signature(body: bytes, signature: str) -> bool:
"""Verify Gitea webhook HMAC-SHA256 signature."""
if not settings.gitea_webhook_secret:
return True # Skip verification if no secret configured
expected = hmac.new(
settings.gitea_webhook_secret.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
@router.post("/gitea")
async def gitea_webhook(request: Request):
"""Handle Gitea webhook events."""
body = await request.body()
# Verify HMAC signature
signature = request.headers.get("X-Gitea-Signature", "")
if not verify_gitea_signature(body, signature):
logger.warning("Gitea webhook: invalid signature")
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
# Log event
@@ -19,36 +58,232 @@ async def gitea_webhook(request: Request):
("gitea", event_type, body.decode()),
)
conn.commit()
conn.close()
if event_type == "push":
await handle_push(payload, conn)
elif event_type == "pull_request":
await handle_pr(payload, conn)
await handle_push(payload)
elif event_type in ("pull_request", "pull_request_approved", "pull_request_review_approved"):
await handle_pr(payload)
elif event_type == "status":
await handle_ci_status(payload, conn)
await handle_ci_status(payload)
conn.close()
return {"status": "accepted"}
async def handle_push(payload: dict, conn):
"""Push event — log for now."""
pass
async def handle_push(payload: dict):
"""
Push event:
- If stage=architecture and push contains ADR files → advance to development
- If stage=development and push contains src/ → wait for CI
"""
ref = payload.get("ref", "")
# Extract branch: refs/heads/feature/ET-003-slug → feature/ET-003-slug
if not ref.startswith("refs/heads/"):
return
branch = ref.removeprefix("refs/heads/")
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
task = get_task_by_repo_branch(repo_name, branch)
if not task:
logger.debug(f"Push to '{branch}' — no matching task found")
return
task_id = task["id"]
current_stage = task["stage"]
work_item_id = task.get("work_item_id", "")
# Collect modified files from commits
modified_files = set()
for commit in payload.get("commits", []):
modified_files.update(commit.get("added", []))
modified_files.update(commit.get("modified", []))
if current_stage == "architecture":
# Check if ADR files were pushed
has_adr = any(
f"docs/work-items/{work_item_id}/06-adr/" in f
or f"docs/work-items/{work_item_id}/07-infra-requirements.md" == f
for f in modified_files
)
if has_adr:
# Advance to development
next_stage = "development"
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)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
run_id = launcher.launch(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: push triggered {current_stage}{next_stage}, launched '{agent}' (run_id={run_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
elif current_stage == "development":
# Source files pushed — just log, wait for CI
has_src = any(f.startswith("src/") for f in modified_files)
if has_src:
logger.info(f"Task {task_id}: source push detected on '{branch}', waiting for CI")
async def handle_pr(payload: dict, conn):
"""PR event — check reviews, CI status."""
async def handle_ci_status(payload: dict):
"""
CI status update:
- If state=success and stage=development → advance to review, launch reviewer
- If state=failure → log
"""
state = payload.get("state", "")
# Extract branch from target_url or branches
branches = payload.get("branches", [])
branch = ""
if branches:
branch = branches[0].get("name", "")
# Alternative: find branch by SHA from tasks DB
if not branch:
sha = payload.get("sha", "")
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
# Try to find task by checking git branch containing this SHA
try:
result = subprocess.run(
["git", "-C", os.path.join(settings.repos_dir, repo_name),
"branch", "-r", "--contains", sha],
capture_output=True, text=True, timeout=10,
)
for line in result.stdout.strip().splitlines():
b = line.strip().replace("origin/", "")
if b.startswith("feature/"):
branch = b
break
except Exception:
pass
if not branch:
logger.debug(f"CI status event: could not determine branch for sha={sha}")
return
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
task = get_task_by_repo_branch(repo_name, branch)
if not task:
return
task_id = task["id"]
current_stage = task["stage"]
work_item_id = task.get("work_item_id", "")
if state == "success" and current_stage == "development":
# Verify CI is actually green via API (double-check)
passed, reason = check_ci_green(repo_name, branch)
if passed:
next_stage = "review"
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)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
run_id = launcher.launch(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: CI green → {next_stage}, launched '{agent}' (run_id={run_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
else:
notify_qg_failure(task_id, current_stage, "check_ci_green", reason)
elif state == "failure":
logger.warning(f"Task {task_id}: CI failed on branch '{branch}'")
notify_error(task_id, f"CI failed on branch '{branch}'")
async def handle_pr(payload: dict):
"""
PR event:
- action=reviewed + approved → advance to testing, launch tester
- action=reviewed + request_changes → back to development, relaunch developer (max 3x)
- action=closed + merged → stage=done
"""
action = payload.get("action", "")
pr = payload.get("pull_request", {})
review = payload.get("review", {})
if action == "reviewed" and pr.get("state") == "approved":
# TODO: QG-5 check -> launch Tester
pass
# Get branch from PR head
head_branch = pr.get("head", {}).get("ref", "")
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
if not head_branch:
return
async def handle_ci_status(payload: dict, conn):
"""CI status update — check if all green -> advance."""
state = payload.get("state", "")
if state == "success":
# TODO: Check all required contexts green -> advance stage
pass
task = get_task_by_repo_branch(repo_name, head_branch)
if not task:
logger.debug(f"PR event for branch '{head_branch}' — no matching task")
return
task_id = task["id"]
current_stage = task["stage"]
work_item_id = task.get("work_item_id", "")
if action == "reviewed":
# Gitea sends review.state (older) or review.type (newer format)
review_state = review.get("state", "").upper()
if not review_state and review.get("type", ""):
# Map type field: "pull_request_review_approved" -> "APPROVED"
rtype = review.get("type", "")
if "approved" in rtype.lower():
review_state = "APPROVED"
elif "request_changes" in rtype.lower() or "rejected" in rtype.lower():
review_state = "REQUEST_CHANGES"
if review_state == "APPROVED" and current_stage == "review":
# Advance to testing
pr_number = pr.get("number")
passed, reason = check_review_approved(repo_name, pr_number)
if passed:
next_stage = "testing"
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)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\nStage: {next_stage}"
run_id = launcher.launch(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: PR approved → {next_stage}, launched '{agent}' (run_id={run_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
else:
notify_qg_failure(task_id, current_stage, "check_review_approved", reason)
elif review_state == "REQUEST_CHANGES" and current_stage == "review":
# Count retries
conn = get_db()
retry_count = conn.execute(
"SELECT COUNT(*) as cnt FROM agent_runs WHERE task_id = ? AND agent = 'developer'",
(task_id,),
).fetchone()["cnt"]
conn.close()
if retry_count < MAX_DEV_RETRIES:
# Back to development, relaunch developer
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
try:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\n"
f"Stage: development\nNote: Changes requested in review (attempt {retry_count + 1}/{MAX_DEV_RETRIES})"
)
run_id = launcher.launch("developer", repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: changes requested, relaunching developer (attempt {retry_count + 1})")
except Exception as e:
notify_error(task_id, f"Failed to relaunch developer: {e}")
else:
notify_error(task_id, f"Max developer retries ({MAX_DEV_RETRIES}) reached, escalating")
logger.error(f"Task {task_id}: max retries reached, needs manual intervention")
elif action == "closed" and pr.get("merged", False):
update_task_stage(task_id, "done")
notify_stage_change(task_id, current_stage, "done")
logger.info(f"Task {task_id}: PR merged, stage → done")

View File

@@ -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()