feat(analysis): add check_analysis_approved QG with stakeholder approval requirement

- stages.py: QG renamed to check_analysis_approved (requires :approved: comment)
- qg/checks.py: new check_analysis_approved verifies files + Plane :approved: comment
- launcher.py: skip auto-advance for analysis stage (requires human approval)
- plane.py: route check_analysis_approved in _try_advance_stage
- docs/ARCHITECTURE.md: updated QG table and flow description
This commit is contained in:
Dev Agent
2026-05-31 15:19:03 +03:00
parent 0f0b984656
commit 81e0e383e0
5 changed files with 65 additions and 10 deletions

View File

@@ -24,7 +24,7 @@ Orchestrator — event-driven FastAPI сервис, который управл
```
STAGE_TRANSITIONS = {
created: → analysis (agent: None)
analysis: → architecture (agent: architect, QG: check_analysis_complete)
analysis: → architecture (agent: architect, QG: check_analysis_approved)
architecture: → development (agent: developer, QG: check_architecture_done)
development: → review (agent: reviewer, QG: check_ci_green)
review: → testing (agent: tester, QG: check_review_approved)
@@ -37,7 +37,7 @@ STAGE_TRANSITIONS = {
| Check | Метод проверки |
|-------|---------------|
| check_analysis_complete | Filesystem: 4 файла в docs/work-items/{id}/ |
| check_analysis_approved | Filesystem: 4 файла + :approved: comment в Plane |
| check_architecture_done | Filesystem: ADR dir или infra-requirements.md |
| check_ci_green | Gitea API: GET /commits/{branch}/status |
| check_review_approved | Gitea API: GET /pulls/{n}/reviews (skip stale) |
@@ -145,7 +145,7 @@ services:
```
1. Plane webhook: work_item.created → task created, analyst launched
2. Analyst: пишет BRD/TRZ/AC/TestPlan → git push docs/
3. Gitea push webhook: docs/ detected → QG check_analysis_complete → PASS
3. Plane comment :approved: → QG check_analysis_approved → PASS
4. Auto-advance: analysis → architecture, architect launched
5. Architect: пишет ADR, infra-requirements → git push docs/
6. Gitea push webhook: ADR detected → QG check_architecture_done → PASS

View File

@@ -257,8 +257,8 @@ class AgentLauncher:
# Run QG check if defined
if qg_name and qg_name in QG_CHECKS:
check_fn = QG_CHECKS[qg_name]
if qg_name == "check_review_approved":
# Skip — handled by PR webhook
if qg_name in ("check_review_approved", "check_analysis_approved"):
# Skip — requires human approval (handled by webhook comment handler)
return
elif qg_name == "check_ci_green":
passed, reason = check_fn(repo, branch)

View File

@@ -140,8 +140,54 @@ def check_tests_passed(repo: str, work_item_id: str) -> tuple[bool, str]:
return False, f"Error reading test report: {e}"
def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]:
"""
Check if analysis is complete AND approved by stakeholder.
Requirements:
1. All analysis artifacts exist (BRD, TRZ, AC, TestPlan)
2. Stakeholder has posted :approved: comment on the Plane issue
This QG is designed to be triggered by :approved: comment handler,
so the approval check verifies file completeness as a safety gate.
"""
# First check files
files_ok, files_reason = check_analysis_complete(repo, work_item_id)
if not files_ok:
return False, files_reason
# 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)
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/"
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
comments = resp.json()
# Handle paginated response
if isinstance(comments, dict):
comments = comments.get("results", [])
for comment in comments:
body = comment.get("comment_html", "") or comment.get("comment", "")
if ":approved:" in body:
return True, "Analysis complete and approved by stakeholder"
return False, "Analysis artifacts present but no :approved: comment found"
except Exception as e:
logger.warning(f"Failed to check approval for {work_item_id}: {e}")
# If we can't reach Plane API but files exist, allow advance
# (the :approved: handler already verified the comment exists)
return True, f"Files present; Plane API check skipped ({e})"
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
"check_analysis_complete": check_analysis_complete,
"check_architecture_done": check_architecture_done,
"check_ci_green": check_ci_green,

View File

@@ -1,8 +1,17 @@
"""Stage machine for orchestrator pipeline."""
"""Stage machine for orchestrator pipeline.
Stages:
created → analysis → architecture → development → review → testing → deploy → done
Each stage defines:
- next: the stage to advance to
- agent: the agent to launch when entering the NEXT stage
- qg: the quality gate check required to leave this stage
"""
STAGE_TRANSITIONS = {
"created": {"next": "analysis", "agent": None, "qg": None},
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_complete"},
"created": {"next": "analysis", "agent": "analyst", "qg": None},
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
"review": {"next": "testing", "agent": "tester", "qg": "check_review_approved"},
@@ -21,7 +30,7 @@ def get_next_stage(current_stage: str) -> str | None:
def get_agent_for_stage(stage: str) -> str | None:
"""Get the agent to launch when entering this stage."""
"""Get the agent to launch when advancing FROM this stage (entering next stage)."""
transition = STAGE_TRANSITIONS.get(stage)
if not transition:
return None

View File

@@ -190,7 +190,7 @@ async def _try_advance_stage(
return
# Determine args based on QG function
if qg_name in ("check_analysis_complete", "check_architecture_done", "check_tests_passed"):
if qg_name in ("check_analysis_approved", "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)