feat(webhook): start pipeline on In Progress status (not on create)
Feature 1. work_item.created no longer starts the pipeline (soft QG-0 log only); the issue stays in the backlog until moved to In Progress. The pipeline-start body is extracted into start_pipeline(); a new issue updated handler routes a state change to In Progress -> handle_status_start, which is idempotent: an existing task for the plane_id is NOT re-created or restarted (protects handle_comment, which also flips issues to In Progress). Real Plane payload: event=issue, action=updated, data.state.id. Existing m6/plane_webhook/dedup tests updated to drive the new trigger; new test_status_trigger.py covers created-no-op / start / idempotent.
This commit is contained in:
@@ -92,38 +92,139 @@ async def plane_webhook(request: Request):
|
||||
return {"status": "ignored", "reason": "unknown project"}
|
||||
|
||||
if (event == "work_item.created") or (event == "issue" and action == "created"):
|
||||
# Feature 1: creation NO LONGER starts the pipeline. Slava keeps the
|
||||
# backlog until he moves an issue to In Progress. We only run a soft
|
||||
# QG-0 sanity log here (no branch, no analyst, no task row).
|
||||
await handle_work_item_created(data, project_id)
|
||||
elif (event == "work_item.updated") or (event == "issue" and action == "updated"):
|
||||
# Feature 1 & 2: status changes drive the pipeline.
|
||||
# Backlog/Todo/Triage -> In Progress : START the pipeline (idempotent)
|
||||
# -> Approved : advance (== :approved: comment)
|
||||
# -> Rejected : rollback (== :rejected: comment)
|
||||
await handle_issue_updated(data, project_id)
|
||||
elif (event == "comment.created") or (event == "issue_comment" and action == "created"):
|
||||
await handle_comment(data, project_id)
|
||||
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
async def handle_work_item_created(data: dict, project_id: str = ""):
|
||||
def _state_id(data: dict) -> str:
|
||||
"""Extract the new Plane state UUID from an 'issue updated' payload.
|
||||
|
||||
Real payload (verified from prod events): data.state is
|
||||
{id, name, color, group}. Some payloads carry state as a bare UUID string.
|
||||
"""
|
||||
New work item created in Plane.
|
||||
QG-0: validate title, description, priority.
|
||||
If valid: create branch, init docs, launch analyst.
|
||||
If invalid: comment with what's missing, set Blocked.
|
||||
state = data.get("state")
|
||||
if isinstance(state, dict):
|
||||
return state.get("id", "") or ""
|
||||
if isinstance(state, str):
|
||||
return state
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_issue_updated(data: dict, project_id: str = ""):
|
||||
"""Feature 1 & 2: react to a Plane issue status change.
|
||||
|
||||
Routes the NEW state UUID (data.state.id) to:
|
||||
- in_progress : start the pipeline if this issue has no task yet
|
||||
(idempotent — an existing task is NOT restarted; protects handle_comment
|
||||
which also flips issues to In Progress during approve/answer flows).
|
||||
- approved : same as a :approved: comment (advance current stage).
|
||||
- rejected : same as a :rejected: comment (rollback + relaunch).
|
||||
Any other status (Needs Input, In Review, Blocked, Done, board stages, etc.)
|
||||
is ignored here — those are statuses the orchestrator itself sets.
|
||||
"""
|
||||
from ..plane_sync import PLANE_STATES
|
||||
|
||||
plane_id = str(data.get("id") or "")
|
||||
new_state = _state_id(data)
|
||||
if not plane_id or not new_state:
|
||||
logger.info("issue updated without id/state, ignoring")
|
||||
return
|
||||
|
||||
if new_state == PLANE_STATES["in_progress"]:
|
||||
await handle_status_start(data, project_id)
|
||||
elif new_state == PLANE_STATES["approved"]:
|
||||
await handle_verdict(data, project_id, approved=True)
|
||||
elif new_state == PLANE_STATES["rejected"]:
|
||||
await handle_verdict(data, project_id, approved=False)
|
||||
else:
|
||||
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
||||
|
||||
|
||||
async def handle_status_start(data: dict, project_id: str = ""):
|
||||
"""Feature 1: an issue moved into In Progress -> start the pipeline.
|
||||
|
||||
Idempotent: if a task already exists for this plane_id, do nothing (no dup,
|
||||
no analyst restart). This is what makes handle_comment's set_issue_in_progress
|
||||
safe — by then the task already exists, so the start is skipped.
|
||||
"""
|
||||
plane_id = str(data.get("id") or "")
|
||||
existing = get_task_by_plane_id(plane_id)
|
||||
if existing:
|
||||
logger.info(
|
||||
f"Status->In Progress for {plane_id}: task already exists "
|
||||
f"(stage={existing.get('stage')}), not restarting"
|
||||
)
|
||||
return
|
||||
logger.info(f"Status->In Progress for {plane_id}: starting pipeline")
|
||||
await start_pipeline(data, project_id)
|
||||
|
||||
|
||||
async def handle_verdict(data: dict, project_id: str, approved: bool):
|
||||
"""Feature 2 (variant B): a status verdict mirrors the comment verdicts.
|
||||
|
||||
Approved status == :approved: comment -> _try_advance_stage.
|
||||
Rejected status == :rejected: comment -> rollback to previous stage + relaunch
|
||||
(reason is unknown from a status change; Slava writes it in a separate
|
||||
comment, so we pass a fixed note).
|
||||
"""
|
||||
plane_id = str(data.get("id") or "")
|
||||
task = get_task_by_plane_id(plane_id)
|
||||
if not task:
|
||||
logger.warning(f"Verdict status for {plane_id} but no task found, ignoring")
|
||||
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 approved:
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
logger.info(f"Task {task_id}: Approved status -> advance from {current_stage}")
|
||||
await _try_advance_stage(task_id, current_stage, repo, work_item_id, branch)
|
||||
return
|
||||
|
||||
# Rejected: mirror the :rejected: comment rollback branch.
|
||||
reason = "(rejected via status, see latest comment)"
|
||||
await _rollback_stage(
|
||||
task_id, current_stage, repo, work_item_id, branch, reason
|
||||
)
|
||||
|
||||
|
||||
async def handle_work_item_created(data: dict, project_id: str = ""):
|
||||
"""Feature 1: creation does NOT start the pipeline anymore.
|
||||
|
||||
The pipeline is started when Slava moves the issue into In Progress
|
||||
(handle_status_start -> start_pipeline). On creation we only run a SOFT QG-0
|
||||
sanity check and log the result — NO branch, NO docs, NO analyst, NO task row
|
||||
— so the issue can sit in the backlog until Slava is ready.
|
||||
"""
|
||||
plane_id = data.get("id", "")
|
||||
name = data.get("name", "untitled")
|
||||
description = data.get("description_stripped", data.get("description", ""))
|
||||
priority = data.get("priority", {})
|
||||
priority_name = priority if isinstance(priority, str) else priority.get("name", "")
|
||||
errors = _qg0_errors(name, description)
|
||||
if errors:
|
||||
logger.info(f"work_item.created {plane_id}: soft QG-0 warnings: {errors}")
|
||||
else:
|
||||
logger.info(f"work_item.created {plane_id} ('{name}'): in backlog, awaiting In Progress")
|
||||
|
||||
# ORCH-6: resolve repo / prefix / Plane project from the registry instead of
|
||||
# the single hardcoded default_repo.
|
||||
if not project_id:
|
||||
project_id = data.get("project") or data.get("project_id") or ""
|
||||
proj = get_project_by_plane_id(project_id)
|
||||
if not proj:
|
||||
logger.warning(f"handle_work_item_created: unknown project '{project_id}', ignoring {plane_id}")
|
||||
return
|
||||
repo = proj.repo
|
||||
plane_project_id = proj.plane_project_id
|
||||
|
||||
# QG-0 validation
|
||||
def _qg0_errors(name: str, description: str) -> list:
|
||||
"""QG-0 validation: returns a list of human-readable problems (empty = OK)."""
|
||||
errors = []
|
||||
if not name or len(name) < 5:
|
||||
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
@@ -132,6 +233,36 @@ async def handle_work_item_created(data: dict, project_id: str = ""):
|
||||
if not description or len(description.strip()) < 20:
|
||||
errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
async def start_pipeline(data: dict, project_id: str = ""):
|
||||
"""Feature 1: start the pipeline for an issue (moved to In Progress).
|
||||
|
||||
This is the body extracted from the old handle_work_item_created: resolve the
|
||||
project, run QG-0 (hard — blocks on failure), create the work item id +
|
||||
branch + initial docs, insert the task row, and enqueue the analyst.
|
||||
|
||||
Callers (handle_status_start) already guarantee no existing task for this
|
||||
plane_id, so this never duplicates.
|
||||
"""
|
||||
plane_id = data.get("id", "")
|
||||
name = data.get("name", "untitled")
|
||||
description = data.get("description_stripped", data.get("description", ""))
|
||||
|
||||
# ORCH-6: resolve repo / prefix / Plane project from the registry instead of
|
||||
# the single hardcoded default_repo.
|
||||
if not project_id:
|
||||
project_id = data.get("project") or data.get("project_id") or ""
|
||||
proj = get_project_by_plane_id(project_id)
|
||||
if not proj:
|
||||
logger.warning(f"start_pipeline: unknown project '{project_id}', ignoring {plane_id}")
|
||||
return
|
||||
repo = proj.repo
|
||||
plane_project_id = proj.plane_project_id
|
||||
|
||||
# QG-0 validation (hard gate on pipeline start)
|
||||
errors = _qg0_errors(name, description)
|
||||
if errors:
|
||||
# QG-0 failed
|
||||
error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors)
|
||||
@@ -240,36 +371,7 @@ async def handle_comment(data: dict, project_id: str = ""):
|
||||
if ":rejected:" in comment_body:
|
||||
# Extract reason (text after :rejected:)
|
||||
reason = comment_body.split(":rejected:", 1)[-1].strip()[:300]
|
||||
|
||||
if current_stage == "analysis":
|
||||
# Already in analysis — just relaunch analyst with rejection reason
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "
|
||||
f"Reason: {reason}\nRevise and improve."
|
||||
)
|
||||
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||
from ..plane_sync import add_comment as _plane_comment
|
||||
_plane_comment(work_item_id, f"\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f: {reason}", author="analyst")
|
||||
logger.info(f"Task {task_id}: rejected at analysis, enqueued analyst (job_id={new_job})")
|
||||
else:
|
||||
# Rollback to previous stage
|
||||
prev_stage = get_previous_stage(current_stage)
|
||||
if prev_stage:
|
||||
update_task_stage(task_id, prev_stage)
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
notify_stage_change(task_id, current_stage, prev_stage)
|
||||
plane_notify_stage(work_item_id, current_stage, prev_stage)
|
||||
from ..plane_sync import add_comment as _plane_comment, STAGE_AUTHORS
|
||||
_plane_comment(
|
||||
work_item_id,
|
||||
f"\U0001f504 \u041e\u0442\u043a\u0430\u0442: {current_stage} \u2192 {prev_stage}. \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {reason}",
|
||||
author=STAGE_AUTHORS.get(prev_stage, "stream"),
|
||||
)
|
||||
logger.info(f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}")
|
||||
await _rollback_stage(task_id, current_stage, repo, work_item_id, branch, reason)
|
||||
return
|
||||
|
||||
if ":approved:" in comment_body:
|
||||
@@ -338,6 +440,72 @@ async def handle_comment(data: dict, project_id: str = ""):
|
||||
logger.error(f"Failed to check issue state: {e}")
|
||||
|
||||
|
||||
async def _rollback_stage(
|
||||
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str,
|
||||
reason: str,
|
||||
):
|
||||
"""Shared :rejected: / Rejected-status rollback (Feature 2 variant B).
|
||||
|
||||
Both the :rejected: comment and a status change to Rejected funnel here so
|
||||
the two mechanisms behave identically:
|
||||
- at analysis: relaunch the analyst with the rejection reason;
|
||||
- otherwise: roll back to the previous stage and relaunch its agent
|
||||
(via the existing rollback notify + an enqueue of the prev-stage agent).
|
||||
"""
|
||||
if current_stage == "analysis":
|
||||
# Already in analysis — just relaunch analyst with rejection reason
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "
|
||||
f"Reason: {reason}\nRevise and improve."
|
||||
)
|
||||
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||
from ..plane_sync import add_comment as _plane_comment
|
||||
_plane_comment(work_item_id, f"\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f: {reason}", author="analyst")
|
||||
logger.info(f"Task {task_id}: rejected at analysis, enqueued analyst (job_id={new_job})")
|
||||
return
|
||||
|
||||
# Rollback to previous stage
|
||||
prev_stage = get_previous_stage(current_stage)
|
||||
if not prev_stage:
|
||||
logger.info(f"Task {task_id}: rejected at {current_stage} but no previous stage")
|
||||
return
|
||||
update_task_stage(task_id, prev_stage)
|
||||
notify_stage_change(task_id, current_stage, prev_stage)
|
||||
# Feature 3: plane_notify_stage moves the board to the prev stage's status.
|
||||
plane_notify_stage(work_item_id, current_stage, prev_stage)
|
||||
# Then put it back to In Progress so the relaunched agent is clearly working.
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
from ..plane_sync import add_comment as _plane_comment, STAGE_AUTHORS
|
||||
_plane_comment(
|
||||
work_item_id,
|
||||
f"\U0001f504 \u041e\u0442\u043a\u0430\u0442: {current_stage} \u2192 {prev_stage}. \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {reason}",
|
||||
author=STAGE_AUTHORS.get(prev_stage, "stream"),
|
||||
)
|
||||
# Relaunch the previous stage's agent so the rollback actually re-runs work.
|
||||
# STAGE_AUTHORS maps a stage directly to the role that OWNS work in it
|
||||
# (analysis->analyst, architecture->architect, ...), which is exactly the
|
||||
# agent we must re-run on a rollback into prev_stage.
|
||||
from ..plane_sync import STAGE_AUTHORS as _STAGE_AUTHORS
|
||||
prev_agent = _STAGE_AUTHORS.get(prev_stage)
|
||||
if prev_agent:
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: {prev_stage}\nNote: Stakeholder REJECTED. Reason: {reason}\n"
|
||||
f"Revise and improve."
|
||||
)
|
||||
new_job = enqueue_job(prev_agent, repo, task_desc, task_id=task_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}, "
|
||||
f"enqueued {prev_agent} (job_id={new_job})"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}")
|
||||
|
||||
|
||||
async def _try_advance_stage(
|
||||
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user