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:
188
tests/test_qg.py
Normal file
188
tests/test_qg.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import pytest
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
import httpx
|
||||
|
||||
# Override DB path before importing app
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
from src.qg.checks import (
|
||||
check_analysis_complete,
|
||||
check_architecture_done,
|
||||
check_ci_green,
|
||||
check_review_approved,
|
||||
check_tests_passed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_work_item_dir(tmp_path, monkeypatch):
|
||||
"""Create temp repo structure for filesystem checks."""
|
||||
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||
repo_dir = tmp_path / "enduro-trails"
|
||||
repo_dir.mkdir()
|
||||
return repo_dir
|
||||
|
||||
|
||||
class TestCheckAnalysisComplete:
|
||||
def test_all_files_present(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "01-brd.md").write_text("# BRD")
|
||||
(wi_dir / "02-trz.md").write_text("# TRZ")
|
||||
(wi_dir / "03-acceptance-criteria.md").write_text("# AC")
|
||||
(wi_dir / "04-test-plan.yaml").write_text("tests: []")
|
||||
|
||||
passed, reason = check_analysis_complete("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_missing_files(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-002"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "01-brd.md").write_text("# BRD")
|
||||
|
||||
passed, reason = check_analysis_complete("enduro-trails", "ET-002")
|
||||
assert passed is False
|
||||
assert "Missing files" in reason
|
||||
|
||||
def test_no_directory(self, setup_work_item_dir):
|
||||
passed, reason = check_analysis_complete("enduro-trails", "ET-999")
|
||||
assert passed is False
|
||||
|
||||
|
||||
class TestCheckArchitectureDone:
|
||||
def test_adr_directory_with_files(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
adr_dir = repo_dir / "docs" / "work-items" / "ET-001" / "06-adr"
|
||||
adr_dir.mkdir(parents=True)
|
||||
(adr_dir / "001-use-postgres.md").write_text("# ADR")
|
||||
|
||||
passed, reason = check_architecture_done("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_infra_requirements(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "07-infra-requirements.md").write_text("# Infra")
|
||||
|
||||
passed, reason = check_architecture_done("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_empty_adr_directory(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
adr_dir = repo_dir / "docs" / "work-items" / "ET-001" / "06-adr"
|
||||
adr_dir.mkdir(parents=True)
|
||||
|
||||
passed, reason = check_architecture_done("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
def test_nothing_present(self, setup_work_item_dir):
|
||||
passed, reason = check_architecture_done("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
|
||||
class TestCheckCIGreen:
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_success(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"state": "success"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is True
|
||||
assert "green" in reason.lower()
|
||||
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_pending(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"state": "pending"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is False
|
||||
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_branch_not_found(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "nonexistent")
|
||||
assert passed is False
|
||||
|
||||
|
||||
class TestCheckReviewApproved:
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_approved(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = [
|
||||
{"state": "APPROVED", "user": {"login": "reviewer1"}}
|
||||
]
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_review_approved("enduro-trails", 1)
|
||||
assert passed is True
|
||||
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_changes_requested(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = [
|
||||
{"state": "REQUEST_CHANGES", "user": {"login": "reviewer1"}}
|
||||
]
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_review_approved("enduro-trails", 1)
|
||||
assert passed is False
|
||||
assert "Changes requested" in reason
|
||||
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_no_reviews(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_review_approved("enduro-trails", 1)
|
||||
assert passed is False
|
||||
|
||||
|
||||
class TestCheckTestsPassed:
|
||||
def test_report_with_pass(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: PASS\n")
|
||||
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_report_without_pass(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: FAIL\n")
|
||||
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
def test_no_report(self, setup_work_item_dir):
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "not found" in reason.lower()
|
||||
@@ -1,20 +1,26 @@
|
||||
import pytest
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
# Override DB path before importing app
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_HOST_REPOS_DIR"] = "/home/slin/repos"
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_GITEA_OWNER"] = "admin"
|
||||
os.environ["ORCH_DEFAULT_REPO"] = "enduro-trails"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from src.main import app
|
||||
from src.db import init_db
|
||||
from src.db import init_db, get_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Ensure DB tables exist before each test."""
|
||||
# Remove old test db if exists
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
@@ -33,7 +39,16 @@ def test_health():
|
||||
assert resp.json()["service"] == "orchestrator"
|
||||
|
||||
|
||||
def test_plane_webhook_accepts():
|
||||
def test_status_endpoint():
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
assert "active_tasks" in resp.json()
|
||||
|
||||
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
def test_plane_webhook_creates_task(mock_docs, mock_branch):
|
||||
"""work_item.created → task in DB with stage=analysis."""
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "test-123", "name": "Test task", "project": "proj-1"}
|
||||
@@ -41,32 +56,208 @@ def test_plane_webhook_accepts():
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
# Verify task was created
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'test-123'").fetchone()
|
||||
conn.close()
|
||||
assert task is not None
|
||||
assert task["stage"] == "analysis"
|
||||
assert task["work_item_id"] is not None
|
||||
assert "feature/" in task["branch"]
|
||||
|
||||
def test_plane_webhook_comment():
|
||||
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
def test_plane_webhook_generates_sequential_ids(mock_docs, mock_branch):
|
||||
"""Multiple work items get sequential IDs."""
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "item-1", "name": "First task", "project": "proj-1"}
|
||||
})
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "item-2", "name": "Second task", "project": "proj-1"}
|
||||
})
|
||||
|
||||
conn = get_db()
|
||||
tasks = conn.execute("SELECT work_item_id FROM tasks ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
ids = [t["work_item_id"] for t in tasks]
|
||||
assert ids[0] == "ET-001"
|
||||
assert ids[1] == "ET-002"
|
||||
|
||||
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane.launcher")
|
||||
def test_plane_approved_advances_stage(mock_launcher, mock_docs, mock_branch, tmp_path, monkeypatch):
|
||||
"""Comment :approved: at stage=analysis → advance to architecture."""
|
||||
# Patch repos_dir for QG check
|
||||
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||
|
||||
# Create task first
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "adv-001", "name": "Advance test", "project": "proj-1"}
|
||||
})
|
||||
|
||||
# Get the task to find work_item_id
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'adv-001'").fetchone()
|
||||
conn.close()
|
||||
work_item_id = task["work_item_id"]
|
||||
|
||||
# Create required analysis files
|
||||
wi_dir = tmp_path / "enduro-trails" / "docs" / "work-items" / work_item_id
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "01-brd.md").write_text("# BRD")
|
||||
(wi_dir / "02-trz.md").write_text("# TRZ")
|
||||
(wi_dir / "03-acceptance-criteria.md").write_text("# AC")
|
||||
(wi_dir / "04-test-plan.yaml").write_text("tests: []")
|
||||
|
||||
# Mock launcher
|
||||
mock_launcher.launch.return_value = 1
|
||||
|
||||
# Send approved comment
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "comment.created",
|
||||
"data": {"comment": "LGTM :approved:"}
|
||||
"data": {
|
||||
"work_item_id": "adv-001",
|
||||
"comment": "Looks good :approved:"
|
||||
}
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
# Verify stage advanced
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'adv-001'").fetchone()
|
||||
conn.close()
|
||||
assert task["stage"] == "architecture"
|
||||
|
||||
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
def test_plane_rejected_rolls_back(mock_docs, mock_branch):
|
||||
"""Comment :rejected: rolls back stage."""
|
||||
# Create task
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "rej-001", "name": "Reject test", "project": "proj-1"}
|
||||
})
|
||||
|
||||
# Manually set stage to architecture
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage = 'architecture' WHERE plane_id = 'rej-001'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Send rejected comment
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "comment.created",
|
||||
"data": {
|
||||
"work_item_id": "rej-001",
|
||||
"comment": "Not ready :rejected:"
|
||||
}
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify stage rolled back
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'rej-001'").fetchone()
|
||||
conn.close()
|
||||
assert task["stage"] == "analysis"
|
||||
|
||||
|
||||
def test_gitea_webhook_push():
|
||||
"""Push event is accepted."""
|
||||
resp = client.post(
|
||||
"/webhook/gitea",
|
||||
json={"ref": "refs/heads/feature/test", "repository": {"name": "enduro-trails"}},
|
||||
json={"ref": "refs/heads/feature/test", "repository": {"name": "enduro-trails"}, "commits": []},
|
||||
headers={"X-Gitea-Event": "push"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
|
||||
def test_gitea_webhook_pr():
|
||||
@patch("src.webhooks.gitea.launcher")
|
||||
def test_gitea_push_with_adr_advances_stage(mock_launcher):
|
||||
"""Push with ADR files at architecture stage → advance to development."""
|
||||
mock_launcher.launch.return_value = 1
|
||||
|
||||
# Create a task at architecture stage
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
|
||||
("push-001", "ET-010", "enduro-trails", "feature/ET-010-test", "architecture"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Push with ADR file
|
||||
resp = client.post(
|
||||
"/webhook/gitea",
|
||||
json={
|
||||
"action": "reviewed",
|
||||
"pull_request": {"state": "approved", "number": 1}
|
||||
"ref": "refs/heads/feature/ET-010-test",
|
||||
"repository": {"name": "enduro-trails"},
|
||||
"commits": [
|
||||
{"added": ["docs/work-items/ET-010/06-adr/001-decision.md"], "modified": []}
|
||||
],
|
||||
},
|
||||
headers={"X-Gitea-Event": "push"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify stage advanced
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'push-001'").fetchone()
|
||||
conn.close()
|
||||
assert task["stage"] == "development"
|
||||
mock_launcher.launch.assert_called_once()
|
||||
|
||||
|
||||
@patch("src.webhooks.gitea.check_ci_green")
|
||||
@patch("src.webhooks.gitea.launcher")
|
||||
def test_gitea_ci_success_advances_to_review(mock_launcher, mock_ci):
|
||||
"""CI success at development stage → advance to review."""
|
||||
mock_ci.return_value = (True, "CI green")
|
||||
mock_launcher.launch.return_value = 2
|
||||
|
||||
# Create a task at development stage
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
|
||||
("ci-001", "ET-011", "enduro-trails", "feature/ET-011-test", "development"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# CI status success
|
||||
resp = client.post(
|
||||
"/webhook/gitea",
|
||||
json={
|
||||
"state": "success",
|
||||
"branches": [{"name": "feature/ET-011-test"}],
|
||||
"repository": {"name": "enduro-trails"},
|
||||
},
|
||||
headers={"X-Gitea-Event": "status"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify stage advanced
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'ci-001'").fetchone()
|
||||
conn.close()
|
||||
assert task["stage"] == "review"
|
||||
|
||||
|
||||
def test_gitea_webhook_pr():
|
||||
"""PR event is accepted."""
|
||||
resp = client.post(
|
||||
"/webhook/gitea",
|
||||
json={
|
||||
"action": "opened",
|
||||
"pull_request": {"head": {"ref": "feature/test"}, "number": 1},
|
||||
"repository": {"name": "enduro-trails"},
|
||||
},
|
||||
headers={"X-Gitea-Event": "pull_request"}
|
||||
)
|
||||
@@ -74,18 +265,17 @@ def test_gitea_webhook_pr():
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
|
||||
def test_status_endpoint():
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
assert "active_tasks" in resp.json()
|
||||
|
||||
|
||||
def test_plane_webhook_creates_task():
|
||||
"""Verify that work_item.created actually inserts a task."""
|
||||
def test_plane_webhook_event_logged():
|
||||
"""Events are logged in the events table."""
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "task-456", "name": "New feature", "project": "proj-2"}
|
||||
"event": "test.event",
|
||||
"data": {"foo": "bar"}
|
||||
})
|
||||
resp = client.get("/status")
|
||||
tasks = resp.json()["active_tasks"]
|
||||
assert any(t["plane_id"] == "task-456" for t in tasks)
|
||||
|
||||
conn = get_db()
|
||||
event = conn.execute(
|
||||
"SELECT * FROM events WHERE event_type = 'test.event'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert event is not None
|
||||
assert event["source"] == "plane"
|
||||
|
||||
Reference in New Issue
Block a user