2313 lines
88 KiB
Python
2313 lines
88 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastapi import FastAPI
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import JSON, select
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
|
||
|
||
from app.api import pmflow_os_api # noqa: E402
|
||
from app.core.database import get_db # noqa: E402
|
||
from app.dependencies.auth import get_current_user # noqa: E402
|
||
from app.models import ( # noqa: E402
|
||
ComplianceCheckRun,
|
||
DevWorkspaceBinding,
|
||
FeedbackIngest,
|
||
FeishuItem,
|
||
GitHubWebhookDelivery,
|
||
HumanCapacityOverride,
|
||
IterationRecommendation,
|
||
ModelCatalogItem,
|
||
ModelEvalRun,
|
||
ModelRecommendationSnapshot,
|
||
PMFlowApproval,
|
||
PMFlowAIFeedbackEvent,
|
||
PMFlowChangeItem,
|
||
PMFlowChangeSet,
|
||
PMFlowEvidenceLink,
|
||
PMFlowLaunchDraft,
|
||
PMFlowThread,
|
||
PMFlowThreadMessage,
|
||
ProgressEvent,
|
||
Project,
|
||
ProjectModelBinding,
|
||
PromptPackage,
|
||
ReleaseValidationRun,
|
||
RepoBinding,
|
||
RevenueEvent,
|
||
ROISnapshot,
|
||
SemanticFact,
|
||
Signal,
|
||
Task,
|
||
TaskCodeLink,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
SkillTag,
|
||
UserSkillBinding,
|
||
UserWorkload,
|
||
)
|
||
from app.models.base import Base # noqa: E402
|
||
|
||
|
||
TABLES = [
|
||
User.__table__,
|
||
SkillTag.__table__,
|
||
UserSkillBinding.__table__,
|
||
UserWorkload.__table__,
|
||
HumanCapacityOverride.__table__,
|
||
FeishuItem.__table__,
|
||
Signal.__table__,
|
||
Project.__table__,
|
||
UserProject.__table__,
|
||
PMFlowLaunchDraft.__table__,
|
||
PMFlowThread.__table__,
|
||
PMFlowThreadMessage.__table__,
|
||
PMFlowChangeSet.__table__,
|
||
PMFlowChangeItem.__table__,
|
||
PMFlowApproval.__table__,
|
||
PMFlowAIFeedbackEvent.__table__,
|
||
PMFlowEvidenceLink.__table__,
|
||
TruthNode.__table__,
|
||
Task.__table__,
|
||
SemanticFact.__table__,
|
||
ModelCatalogItem.__table__,
|
||
ProjectModelBinding.__table__,
|
||
ModelRecommendationSnapshot.__table__,
|
||
ModelEvalRun.__table__,
|
||
PromptPackage.__table__,
|
||
DevWorkspaceBinding.__table__,
|
||
RepoBinding.__table__,
|
||
TaskCodeLink.__table__,
|
||
ProgressEvent.__table__,
|
||
ReleaseValidationRun.__table__,
|
||
FeedbackIngest.__table__,
|
||
IterationRecommendation.__table__,
|
||
RevenueEvent.__table__,
|
||
ROISnapshot.__table__,
|
||
ComplianceCheckRun.__table__,
|
||
GitHubWebhookDelivery.__table__,
|
||
]
|
||
|
||
|
||
def _use_sqlite_json_types() -> None:
|
||
for table in TABLES:
|
||
for column in table.columns:
|
||
type_name = column.type.__class__.__name__.upper()
|
||
if type_name in {"JSONB", "ARRAY"}:
|
||
column.type = JSON()
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def db_env():
|
||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||
_use_sqlite_json_types()
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES))
|
||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||
async with maker() as session:
|
||
yield session
|
||
await engine.dispose()
|
||
|
||
|
||
async def _seed_user_project(session, *, tenant_id: int = 1, user_role: str = "MEMBER", member: bool = True):
|
||
user = User(
|
||
tenant_id=tenant_id,
|
||
username=f"user-{tenant_id}-{user_role}-{member}",
|
||
email=f"user-{tenant_id}-{user_role}-{member}@example.com",
|
||
password_hash="hashed",
|
||
name="Owner",
|
||
role=user_role,
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
project = Project(
|
||
tenant_id=tenant_id,
|
||
name="真实项目",
|
||
title="真实项目",
|
||
description="从真实后端读取的项目",
|
||
owner_id=user.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
session.add(project)
|
||
await session.flush()
|
||
if member:
|
||
session.add(UserProject(tenant_id=tenant_id, user_id=user.id, project_id=project.id))
|
||
await session.commit()
|
||
return user, project
|
||
|
||
|
||
def _build_app(session, current_user) -> FastAPI:
|
||
app = FastAPI()
|
||
app.include_router(pmflow_os_api.router)
|
||
|
||
async def _fake_get_db():
|
||
yield session
|
||
|
||
app.dependency_overrides[get_db] = _fake_get_db
|
||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||
return app
|
||
|
||
|
||
async def _seed_open_change_set(session, *, user, project, risk_level: str = "high", item_count: int = 1) -> None:
|
||
thread = PMFlowThread(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
launch_draft_id=None,
|
||
title="容量计划测试线程",
|
||
objective="构造真实 ChangeSet 人工触点",
|
||
status="active",
|
||
source=f"capacity-test-{risk_level}-{item_count}",
|
||
)
|
||
session.add(thread)
|
||
await session.flush()
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=1,
|
||
thread_id=thread.id,
|
||
project_id=project.id,
|
||
role="user",
|
||
content="需要人工确认容量触点。",
|
||
source="web",
|
||
attachments=[],
|
||
message_metadata={},
|
||
status="sent",
|
||
)
|
||
session.add(message)
|
||
await session.flush()
|
||
change_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="open",
|
||
summary="容量计划 ChangeSet",
|
||
headline="容量计划 ChangeSet",
|
||
extraction_scope="latest_message",
|
||
routing_meta={},
|
||
created_by=user.id,
|
||
)
|
||
session.add(change_set)
|
||
await session.flush()
|
||
for index in range(item_count):
|
||
session.add(
|
||
PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=change_set.id,
|
||
project_id=project.id,
|
||
group="risk" if risk_level == "high" else "field_update",
|
||
object_type="Risk" if risk_level == "high" else "Task",
|
||
object_id=None,
|
||
title=f"容量触点 {index + 1}",
|
||
summary="用于验证动态容量基线。",
|
||
diff="pending review",
|
||
payload={},
|
||
confidence=0.9,
|
||
risk_level=risk_level,
|
||
status="pending_review",
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_empty_project_returns_compatible_empty_projection(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["project"]["id"] == str(project.id)
|
||
assert payload["requirements"] == []
|
||
assert payload["tasks"] == []
|
||
assert payload["changeSets"] == []
|
||
assert "truth_nodes_empty" in " ".join(payload["projectionWarnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_projects_memory_from_truth_nodes_and_semantic_facts(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
requirement = TruthNode(
|
||
tenant_id=1,
|
||
node_type="requirement",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="真实需求",
|
||
content={"summary": "来自 truth_nodes 的需求", "stage": "planning"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
risk = TruthNode(
|
||
tenant_id=1,
|
||
node_type="risk",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="真实风险",
|
||
content={"summary": "来自 truth_nodes 的风险", "risk_level": "high", "mitigation": "人工确认"},
|
||
version=1,
|
||
status="confirmed",
|
||
)
|
||
session.add_all([requirement, risk])
|
||
await session.flush()
|
||
session.add(
|
||
SemanticFact(
|
||
tenant_id=1,
|
||
entity_type="truth_node",
|
||
entity_id=requirement.id,
|
||
fact_namespace="project_memory",
|
||
fact_key="target_user",
|
||
fact_value_text="个人开发者",
|
||
fact_value_norm="个人开发者",
|
||
value_type="keyword",
|
||
confidence=0.91,
|
||
evidence_source_type="truth_node",
|
||
evidence_source_id=requirement.id,
|
||
evidence_excerpt="来自 truth node",
|
||
is_active=True,
|
||
)
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
payload = response.json()["data"]
|
||
assert payload["requirements"][0]["title"] == "真实需求"
|
||
assert payload["risks"][0]["level"] == "high"
|
||
assert payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 2
|
||
assert payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 1
|
||
assert payload["projectMemory"]["semanticFacts"][0]["source"] == "semantic_facts"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_memory_read_models_expose_items_assets_context_and_writebacks(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
requirement = TruthNode(
|
||
tenant_id=1,
|
||
node_type="requirement",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="支持批量导入文件",
|
||
content={"summary": "支持批量导入 PDF、Word 和 Markdown。", "stage": "planning", "tags": ["import"]},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
decision = TruthNode(
|
||
tenant_id=1,
|
||
node_type="decision",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="首版限制导入数量",
|
||
content={"summary": "首版单次最多导入 50 个文件。", "owner": "产品负责人"},
|
||
version=1,
|
||
status="confirmed",
|
||
)
|
||
session.add_all([requirement, decision])
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=requirement.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees={},
|
||
ai_breakdown_payload={},
|
||
manual_overrides={},
|
||
title="实现批量导入入口",
|
||
description="把已确认需求拆成开发任务。",
|
||
required_skill_tag_ids=[],
|
||
status="todo",
|
||
priority="high",
|
||
)
|
||
thread = PMFlowThread(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
launch_draft_id=None,
|
||
title="项目线程",
|
||
objective="沉淀项目记忆",
|
||
status="active",
|
||
source="project_memory_test",
|
||
)
|
||
session.add_all([task, thread])
|
||
await session.flush()
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=1,
|
||
thread_id=thread.id,
|
||
project_id=project.id,
|
||
role="user",
|
||
content="需要支持批量导入文件。",
|
||
source="web",
|
||
attachments=[],
|
||
message_metadata={},
|
||
status="sent",
|
||
)
|
||
session.add(message)
|
||
await session.flush()
|
||
change_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="committed",
|
||
summary="批量导入写入项目记忆",
|
||
headline="批量导入写入",
|
||
extraction_scope="latest_message",
|
||
routing_meta={"scene": "project_memory_test"},
|
||
created_by=user.id,
|
||
committed_at=datetime.utcnow(),
|
||
)
|
||
session.add(change_set)
|
||
await session.flush()
|
||
change_item = PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=change_set.id,
|
||
project_id=project.id,
|
||
group="new_object",
|
||
object_type="Requirement",
|
||
object_id=str(requirement.id),
|
||
title="支持批量导入文件",
|
||
summary="支持批量导入 PDF、Word 和 Markdown。",
|
||
diff="新增需求",
|
||
payload={},
|
||
confidence=0.94,
|
||
risk_level="medium",
|
||
status="committed",
|
||
)
|
||
session.add(change_item)
|
||
await session.flush()
|
||
session.add_all(
|
||
[
|
||
PMFlowEvidenceLink(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
change_set_id=change_set.id,
|
||
change_item_id=change_item.id,
|
||
source_type="conversation",
|
||
source_id=str(message.id),
|
||
title="对话证据",
|
||
excerpt="需要支持批量导入文件。",
|
||
display_label="对话",
|
||
evidence_metadata={},
|
||
),
|
||
SemanticFact(
|
||
tenant_id=1,
|
||
entity_type="truth_node",
|
||
entity_id=requirement.id,
|
||
fact_namespace="project_memory",
|
||
fact_key="import_scope",
|
||
fact_value_text="PDF、Word、Markdown",
|
||
fact_value_norm="pdf_word_markdown",
|
||
value_type="keyword",
|
||
confidence=0.91,
|
||
evidence_source_type="conversation",
|
||
evidence_source_id=message.id,
|
||
evidence_excerpt="支持批量导入文件。",
|
||
is_active=True,
|
||
),
|
||
]
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
overview = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/overview")
|
||
items = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/items?category=requirement")
|
||
filtered_items = await client.get(
|
||
f"/pmflow-os/projects/{project.id}/project-memory/items?category=requirement&stage=planning&source_type=conversation&status=confirmed"
|
||
)
|
||
assets = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/assets")
|
||
filtered_assets = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/assets?source_type=conversation&status=indexed")
|
||
context = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/context-packs")
|
||
writebacks = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/writebacks")
|
||
item_detail = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/items/truth-node-{requirement.id}")
|
||
|
||
assert overview.status_code == 200
|
||
overview_payload = overview.json()["data"]
|
||
assert overview_payload["metrics"][1]["key"] == "requirements"
|
||
assert overview_payload["requirements"][0]["title"] == "支持批量导入文件"
|
||
assert items.json()["data"]["items"][0]["category"] == "requirement"
|
||
assert filtered_items.json()["data"]["items"][0]["evidenceRefs"][0]["sourceType"] == "conversation"
|
||
assert assets.json()["data"]["assets"][0]["assetType"] == "conversation"
|
||
assert filtered_assets.json()["data"]["assets"][0]["status"] == "indexed"
|
||
assert context.json()["data"]["contextPacks"][0]["status"] == "ready"
|
||
assert writebacks.json()["data"]["writebacks"][0]["itemCounts"]["committed"] == 1
|
||
assert item_detail.status_code == 200
|
||
assert item_detail.json()["data"]["item"]["id"] == f"truth-node-{requirement.id}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_task_projection_comes_from_tasks_table(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="真实宪章",
|
||
content={"business_goal": "真实任务来源"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["必须来自 tasks 表"]},
|
||
manual_overrides=None,
|
||
title="真实开发任务",
|
||
description="从 tasks 表投影出来",
|
||
required_skill_tag_ids=[],
|
||
status="in_progress",
|
||
priority="high",
|
||
)
|
||
session.add(task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
payload = response.json()["data"]
|
||
assert payload["tasks"][0]["title"] == "真实开发任务"
|
||
assert payload["tasks"][0]["requirementId"] == str(charter.id)
|
||
assert payload["tasks"][0]["acceptanceCriteria"] == ["必须来自 tasks 表"]
|
||
assert all(item["title"] != "重构会议转写主链" for item in payload["tasks"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
|
||
payload = response.json()["data"]
|
||
assert payload["execution"]["status"] == "queued"
|
||
assert payload["execution"]["prompt"] == ""
|
||
assert payload["execution"]["testSummary"] == "pending"
|
||
assert payload["releaseGate"]["status"] == "empty"
|
||
assert "缺少执行证据" in payload["releaseGate"]["title"]
|
||
assert payload["releaseSignals"]["status"] == "empty"
|
||
assert "release_feedback_empty" in " ".join(payload["releaseSignals"]["warnings"])
|
||
assert "prompt_package_empty" in " ".join(payload["warnings"])
|
||
assert payload["activeDevTasks"] == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_capabilities_returns_read_only_snapshot_with_warnings(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
await _seed_open_change_set(session, user=user, project=project, risk_level="medium", item_count=1)
|
||
skill = SkillTag(
|
||
tenant_id=1,
|
||
name="产品判断",
|
||
slug="product-judgement",
|
||
category="product",
|
||
description="判断需求范围与验收结果",
|
||
is_system=True,
|
||
sort_order=1,
|
||
)
|
||
session.add(skill)
|
||
await session.flush()
|
||
session.add_all(
|
||
[
|
||
UserSkillBinding(
|
||
tenant_id=1,
|
||
user_id=user.id,
|
||
skill_tag_id=skill.id,
|
||
proficiency_level="advanced",
|
||
proficiency=4,
|
||
level="advanced",
|
||
years_experience=2,
|
||
),
|
||
UserWorkload(
|
||
tenant_id=1,
|
||
user_id=user.id,
|
||
current_assigned_hours=18,
|
||
max_available_hours_per_week=40,
|
||
workload_ratio=45,
|
||
),
|
||
DevWorkspaceBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
user_id=user.id,
|
||
workspace_key="cursor-local",
|
||
repo_root="/tmp/pmflow",
|
||
editor_type="cursor",
|
||
status="active",
|
||
),
|
||
]
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["projectId"] == str(project.id)
|
||
assert {item["mode"] for item in payload["executionModes"]} >= {
|
||
"ai_direct",
|
||
"ai_with_user_acceptance",
|
||
"ai_with_technical_review",
|
||
"needs_human_judgement",
|
||
"blocked",
|
||
}
|
||
assert payload["personalReadiness"]["waitingForUser"]["pendingConfirmations"] == 1
|
||
assert payload["teamSignals"][0]["displayName"] == "Owner"
|
||
assert payload["teamSignals"][0]["toolStatus"]["connectedTools"] == ["Cursor"]
|
||
assert payload["teamSignals"][0]["referenceSkills"] == ["产品判断"]
|
||
assert "repo_missing" in " ".join(payload["warnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_capabilities_requires_project_membership(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session, user_role="MEMBER", member=False)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities")
|
||
|
||
assert response.status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capacity_plan_uses_current_touch_pool_for_dynamic_baseline(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session, user_role="OWNER")
|
||
await _seed_open_change_set(session, user=user, project=project, risk_level="high", item_count=3)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
member_plan = payload["memberPlans"][0]
|
||
assert payload["teamPlannedTouchMinutes"]["focus"] > 0
|
||
assert member_plan["suggestedBaseline"]["focus"] > member_plan["suggestedBaseline"]["acceptance"]
|
||
assert member_plan["plannedTouchMinutes"]["total"] > 0
|
||
assert "动态基线来自当前任务池" in " ".join(member_plan["explanations"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capacity_override_simulate_apply_and_permission_boundary(db_env) -> None:
|
||
session = db_env
|
||
owner, project = await _seed_user_project(session, user_role="OWNER")
|
||
member = User(
|
||
tenant_id=1,
|
||
username="capacity-member",
|
||
email="capacity-member@example.com",
|
||
password_hash="hashed",
|
||
name="普通成员",
|
||
role="MEMBER",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
session.add(member)
|
||
await session.flush()
|
||
session.add(UserProject(tenant_id=1, user_id=member.id, project_id=project.id))
|
||
await session.commit()
|
||
payload = {
|
||
"userId": str(owner.id),
|
||
"overrideType": "overtime",
|
||
"addedMinutes": 120,
|
||
"overtimeType": "focus",
|
||
"maxPlannedUtilization": 0.95,
|
||
"reason": "今天需要负责人兜住高风险判断",
|
||
}
|
||
|
||
owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="OWNER"))
|
||
async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client:
|
||
simulated = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides/simulate", json=payload)
|
||
applied = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload)
|
||
plan = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||
|
||
assert simulated.status_code == 200
|
||
assert simulated.json()["data"]["afterPlan"]["effectiveMinutes"] == 600
|
||
assert "加班只增加时间预算" in " ".join(simulated.json()["data"]["impactSummary"])
|
||
assert applied.status_code == 200
|
||
assert applied.json()["data"]["reason"] == payload["reason"]
|
||
member_plan = next(item for item in plan.json()["data"]["memberPlans"] if item["userId"] == str(owner.id))
|
||
assert member_plan["effectiveMinutes"] == 600
|
||
assert member_plan["manualOverride"]["overrideType"] == "overtime"
|
||
|
||
member_app = _build_app(session, SimpleNamespace(id=member.id, tenant_id=1, role="MEMBER"))
|
||
async with AsyncClient(transport=ASGITransport(app=member_app), base_url="http://test") as client:
|
||
forbidden = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload)
|
||
|
||
assert forbidden.status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_expired_capacity_override_is_not_used_in_today_plan(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session, user_role="OWNER")
|
||
yesterday = datetime.now() - timedelta(days=1)
|
||
session.add(
|
||
HumanCapacityOverride(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
user_id=user.id,
|
||
date=datetime.now().date(),
|
||
override_type="overtime",
|
||
added_minutes=180,
|
||
overtime_type="general",
|
||
max_planned_utilization=0.95,
|
||
work_type_bias={},
|
||
high_risk_limit=None,
|
||
reason="已过期的加班安排",
|
||
expires_at=yesterday,
|
||
created_by=user.id,
|
||
)
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||
overrides = await client.get(f"/pmflow-os/projects/{project.id}/capacity-overrides")
|
||
|
||
assert response.status_code == 200
|
||
member_plan = response.json()["data"]["memberPlans"][0]
|
||
assert member_plan["effectiveMinutes"] == 480
|
||
assert member_plan["manualOverride"] is None
|
||
assert overrides.status_code == 200
|
||
assert overrides.json()["data"][0]["reason"] == "已过期的加班安排"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_summary_projects_real_prompt_repo_tasks_and_events(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="真实执行宪章",
|
||
content={"business_goal": "进入真实开发执行桥"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["必须投影真实 dev-sync 数据"]},
|
||
manual_overrides=None,
|
||
title="接入真实执行摘要",
|
||
description="从 execution-summary 渲染执行桥",
|
||
required_skill_tag_ids=[],
|
||
status="in_progress",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="pmflow-app",
|
||
default_branch="main",
|
||
installation_id="install-1",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.flush()
|
||
session.add_all(
|
||
[
|
||
PromptPackage(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
charter_id=charter.id,
|
||
task_id=task.id,
|
||
package_kind="development",
|
||
status="active",
|
||
package_text="真实开发提示词",
|
||
package_payload={"scope": "execution"},
|
||
generated_from={"source": "truth_state"},
|
||
),
|
||
TaskCodeLink(
|
||
tenant_id=1,
|
||
task_id=task.id,
|
||
repo_binding_id=repo.id,
|
||
branch_name="codex/main-flow-phase-d-execution",
|
||
pr_number=128,
|
||
last_commit_sha="abcdef1234567890",
|
||
status="review",
|
||
),
|
||
ProgressEvent(
|
||
tenant_id=1,
|
||
task_id=task.id,
|
||
project_id=project.id,
|
||
user_id=user.id,
|
||
repo_binding_id=repo.id,
|
||
event_type="commit",
|
||
source="github",
|
||
derived_task_status="in_progress",
|
||
summary="真实 commit 已回流",
|
||
payload={
|
||
"workflow_state": "coding",
|
||
"branch_name": "codex/main-flow-phase-d-execution",
|
||
"commit_sha": "abcdef1234567890",
|
||
"pr_number": 128,
|
||
},
|
||
occurred_at=datetime(2026, 4, 26, 10, 30, 0),
|
||
),
|
||
GitHubWebhookDelivery(
|
||
tenant_id=1,
|
||
delivery_id="delivery-1",
|
||
event_type="push",
|
||
repo_binding_id=repo.id,
|
||
project_id=project.id,
|
||
installation_id="install-1",
|
||
status="matched",
|
||
payload={"repository": {"full_name": "pmflow/pmflow-app"}},
|
||
received_at=datetime(2026, 4, 26, 10, 31, 0),
|
||
),
|
||
]
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["promptPackage"]["package_text"] == "真实开发提示词"
|
||
assert payload["repoBindings"][0]["full_name"] == "pmflow/pmflow-app"
|
||
assert payload["repoBindings"][0]["task_link_count"] == 1
|
||
assert payload["activeDevTasks"][0]["title"] == "接入真实执行摘要"
|
||
assert payload["activeDevTasks"][0]["code_link"]["pr_number"] == 128
|
||
assert payload["recentProgressEvents"][0]["summary"] == "真实 commit 已回流"
|
||
assert payload["recentDeliveries"][0]["repository_full_name"] == "pmflow/pmflow-app"
|
||
assert payload["warnings"] == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_handoff_creates_prompt_package_and_progress_event(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="执行交接宪章",
|
||
content={"business_goal": "把确认任务交给真实开发执行桥"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["生成可追踪 handoff 起点"]},
|
||
manual_overrides=None,
|
||
title="发起真实执行交接",
|
||
description="从任务、项目记忆和仓库生成 PromptPackage",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="execution-bridge",
|
||
default_branch="main",
|
||
installation_id="install-2",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-handoffs",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"runtime_target": "local_bridge",
|
||
"note": "交给本地桥接执行",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["promptPackage"]["task_id"] == str(task.id)
|
||
assert payload["promptPackage"]["package_kind"] == "execution_handoff"
|
||
assert "发起真实执行交接" in payload["promptPackage"]["package_text"]
|
||
assert "pmflow/execution-bridge" in payload["promptPackage"]["package_text"]
|
||
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_created"
|
||
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/execution-bridge"
|
||
assert payload["execution"]["status"] == "pushed"
|
||
assert payload["warnings"] == []
|
||
|
||
prompt_rows = (await session.execute(select(PromptPackage).where(PromptPackage.task_id == task.id))).scalars().all()
|
||
event_rows = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalars().all()
|
||
assert len(prompt_rows) == 1
|
||
assert len(event_rows) == 1
|
||
assert event_rows[0].payload["runtime_target"] == "local_bridge"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_handoff_without_repo_creates_draft_warning(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="无仓库交接宪章",
|
||
content={"business_goal": "允许先生成执行交接草案"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="缺少仓库也能建草案",
|
||
description="仓库后续绑定",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="medium",
|
||
)
|
||
session.add(task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-handoffs",
|
||
json={"task_id": str(task.id), "runtime_target": "manual"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["promptPackage"]["task_id"] == str(task.id)
|
||
assert payload["recentProgressEvents"][0]["repo"] is None
|
||
assert "repo_bindings_empty" in " ".join(payload["warnings"])
|
||
event = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalar_one()
|
||
assert event.payload["handoff_state"] == "draft"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_handoff_consume_records_pickup_event(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="交接领取宪章",
|
||
content={"business_goal": "执行交接被运行端领取后可追踪"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["领取交接包后进入开发中"]},
|
||
manual_overrides=None,
|
||
title="领取执行交接包",
|
||
description="Codex 消费 PromptPackage",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="handoff-consume",
|
||
default_branch="main",
|
||
installation_id="install-3",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
create_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-handoffs",
|
||
json={"task_id": str(task.id), "repo_binding_id": str(repo.id), "runtime_target": "local_bridge"},
|
||
)
|
||
prompt_package_id = create_response.json()["data"]["promptPackage"]["id"]
|
||
consume_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-handoffs/{prompt_package_id}/consume",
|
||
json={"runtime_target": "codex", "consumer_label": "Codex", "note": "开始开发"},
|
||
)
|
||
|
||
assert consume_response.status_code == 200
|
||
payload = consume_response.json()["data"]
|
||
assert payload["execution"]["status"] == "coding"
|
||
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_consumed"
|
||
assert payload["recentProgressEvents"][0]["derived_task_status"] == "coding"
|
||
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/handoff-consume"
|
||
|
||
prompt = (
|
||
await session.execute(select(PromptPackage).where(PromptPackage.id == int(prompt_package_id)))
|
||
).scalar_one()
|
||
latest_handoff = prompt.package_payload["latest_handoff"]
|
||
assert latest_handoff["handoff_state"] == "consumed"
|
||
assert latest_handoff["consumer_label"] == "Codex"
|
||
assert latest_handoff["runtime_target"] == "codex"
|
||
event_types = [
|
||
row.event_type
|
||
for row in (
|
||
await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id).order_by(ProgressEvent.id.asc()))
|
||
)
|
||
.scalars()
|
||
.all()
|
||
]
|
||
assert event_types == ["handoff_created", "handoff_consumed"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_progress_writeback_records_branch_pr_commit_and_test(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="执行回写宪章",
|
||
content={"business_goal": "把运行端结果回流到执行摘要"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["回流 branch/commit/PR/test"]},
|
||
manual_overrides=None,
|
||
title="执行结果写回",
|
||
description="Codex 或 Local Bridge 写回进展",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="writeback",
|
||
default_branch="main",
|
||
installation_id="install-4",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
commit_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "commit",
|
||
"source": "codex",
|
||
"branch_name": "codex/main-flow-d3",
|
||
"commit_sha": "abc123def456",
|
||
"workflow_state": "coding",
|
||
},
|
||
)
|
||
pr_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "pr_opened",
|
||
"source": "github_agent",
|
||
"branch_name": "codex/main-flow-d3",
|
||
"pr_number": 77,
|
||
},
|
||
)
|
||
test_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "test_failed",
|
||
"source": "local_bridge",
|
||
"branch_name": "codex/main-flow-d3",
|
||
"test_summary": "单测失败:ExecutionBridgePage 回写表单缺少断言",
|
||
},
|
||
)
|
||
|
||
assert commit_response.status_code == 200
|
||
assert commit_response.json()["data"]["execution"]["status"] == "coding"
|
||
assert pr_response.status_code == 200
|
||
pr_payload = pr_response.json()["data"]
|
||
assert pr_payload["execution"]["status"] == "pr_open"
|
||
assert pr_payload["execution"]["prStatus"] == "PR #77 · linked"
|
||
assert test_response.status_code == 200
|
||
test_payload = test_response.json()["data"]
|
||
assert test_payload["execution"]["status"] == "failed"
|
||
assert test_payload["execution"]["testSummary"] == "单测失败:ExecutionBridgePage 回写表单缺少断言"
|
||
assert test_payload["releaseGate"]["status"] == "blocked"
|
||
assert "单测失败" in " ".join(test_payload["releaseGate"]["blockingReasons"])
|
||
assert test_payload["recentProgressEvents"][0]["event_type"] == "test_failed"
|
||
assert test_payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/writeback"
|
||
|
||
link = (await session.execute(select(TaskCodeLink).where(TaskCodeLink.task_id == task.id))).scalar_one()
|
||
assert link.branch_name == "codex/main-flow-d3"
|
||
assert link.pr_number == 77
|
||
assert link.last_commit_sha == "abc123def456"
|
||
events = (
|
||
await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id).order_by(ProgressEvent.id.asc()))
|
||
).scalars().all()
|
||
assert [event.event_type for event in events] == ["commit", "pr_opened", "test_failed"]
|
||
assert events[0].payload["protocol_version"] == "execution_result_protocol_v1"
|
||
assert events[0].payload["execution_result"]["branchName"] == "codex/main-flow-d3"
|
||
assert events[0].payload["execution_result"]["commitSha"] == "abc123def456"
|
||
assert events[1].payload["execution_result"]["prNumber"] == 77
|
||
assert events[2].payload["execution_result"]["testSummary"] == "单测失败:ExecutionBridgePage 回写表单缺少断言"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_gate_marks_test_passed_execution_as_candidate(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="发布候选宪章",
|
||
content={"business_goal": "测试通过后进入发布候选"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["测试通过时生成发布候选"]},
|
||
manual_overrides=None,
|
||
title="发布候选任务",
|
||
description="提供 commit / PR / test evidence",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="release-candidate",
|
||
default_branch="main",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "commit",
|
||
"source": "codex",
|
||
"branch_name": "codex/release-candidate",
|
||
"commit_sha": "abc123def456",
|
||
},
|
||
)
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "pr_opened",
|
||
"source": "github_agent",
|
||
"branch_name": "codex/release-candidate",
|
||
"pr_number": 99,
|
||
},
|
||
)
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "test_passed",
|
||
"source": "local_bridge",
|
||
"branch_name": "codex/release-candidate",
|
||
"test_summary": "单测与构建全部通过",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["releaseGate"]["status"] == "needs_confirmation"
|
||
assert "发布候选" in payload["releaseGate"]["title"]
|
||
assert payload["releaseGate"]["blockingReasons"] == []
|
||
assert any(item["key"] == "test" and item["status"] == "passed" for item in payload["releaseGate"]["checks"])
|
||
assert any(item["type"] == "test_passed" for item in payload["releaseGate"]["evidence"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_gate_blocks_explicit_failed_event(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="失败回写宪章",
|
||
content={"business_goal": "failed event 必须阻塞发布"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="失败回写任务",
|
||
description="显式 failed event",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
session.add(task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"event_type": "failed",
|
||
"source": "codex",
|
||
"execution_status": "failed",
|
||
"blocker_summary": "Codex 执行失败:typecheck 未通过",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["execution"]["status"] == "failed"
|
||
assert payload["releaseGate"]["status"] == "blocked"
|
||
assert "typecheck 未通过" in " ".join(payload["releaseGate"]["blockingReasons"])
|
||
assert payload["releaseGate"]["evidence"][0]["type"] == "failed"
|
||
event = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalar_one()
|
||
assert event.payload["execution_result"]["status"] == "failed"
|
||
assert event.payload["execution_result"]["blockerSummary"] == "Codex 执行失败:typecheck 未通过"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_validation_run_creates_ready_record_from_ready_gate(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="Ready 发布验证宪章",
|
||
content={"business_goal": "ready evidence 可以固化为发布验证记录"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="Ready 发布任务",
|
||
description="测试通过并合并",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="ready-release",
|
||
default_branch="main",
|
||
status="active",
|
||
)
|
||
session.add_all([task, repo])
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "test_passed",
|
||
"source": "local_bridge",
|
||
"branch_name": "codex/ready-release",
|
||
"test_summary": "单测、typecheck、build 全部通过",
|
||
},
|
||
)
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "merged",
|
||
"source": "github_agent",
|
||
"branch_name": "codex/ready-release",
|
||
"commit_sha": "ready123456789",
|
||
"pr_number": 108,
|
||
"summary": "PR #108 已合并",
|
||
},
|
||
)
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-validation-runs",
|
||
json={"decision": "enter_release_candidate", "note": "确认进入发布候选"},
|
||
)
|
||
summary_response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
run_payload = payload["releaseValidationRun"]
|
||
assert run_payload["status"] == "ready"
|
||
assert run_payload["projectId"] == str(project.id)
|
||
assert run_payload["validationSummary"]["releaseGateStatus"] == "ready"
|
||
assert run_payload["validationSummary"]["humanDecision"] == "enter_release_candidate"
|
||
assert run_payload["validationSummary"]["deploymentExecuted"] is False
|
||
assert run_payload["validationSummary"]["createdBy"] == str(user.id)
|
||
assert run_payload["validationSummary"]["evidence"]
|
||
assert run_payload["rollbackPlan"]["deploymentExecuted"] is False
|
||
assert payload["executionSummary"]["releaseGate"]["latestValidationRun"]["id"] == run_payload["id"]
|
||
assert summary_response.json()["data"]["releaseGate"]["latestValidationRun"]["status"] == "ready"
|
||
rows = (await session.execute(select(ReleaseValidationRun).where(ReleaseValidationRun.project_id == project.id))).scalars().all()
|
||
assert len(rows) == 1
|
||
assert rows[0].status == "ready"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_validation_run_creates_blocked_record_from_failed_gate(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="Blocked 发布验证宪章",
|
||
content={"business_goal": "失败 evidence 固化为阻塞记录"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="Blocked 发布任务",
|
||
description="测试失败",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="high",
|
||
)
|
||
session.add(task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"event_type": "test_failed",
|
||
"source": "codex",
|
||
"test_summary": "发布前验证失败:回归测试未通过",
|
||
},
|
||
)
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-validation-runs",
|
||
json={"decision": "keep_blocked", "note": "阻塞发布"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
run_payload = payload["releaseValidationRun"]
|
||
assert run_payload["status"] == "blocked"
|
||
assert run_payload["validationSummary"]["releaseGateStatus"] == "blocked"
|
||
assert "回归测试未通过" in " ".join(run_payload["validationSummary"]["blockingReasons"])
|
||
assert any(item["status"] == "blocked" for item in run_payload["riskItems"])
|
||
rows = (await session.execute(select(ReleaseValidationRun).where(ReleaseValidationRun.project_id == project.id))).scalars().all()
|
||
assert len(rows) == 1
|
||
assert rows[0].status == "blocked"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_validation_run_rejects_empty_gate(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-validation-runs",
|
||
json={"decision": "enter_release_candidate", "note": "证据为空时不能成功"},
|
||
)
|
||
|
||
assert response.status_code == 409
|
||
assert response.json()["message"] == "release_validation_evidence_empty"
|
||
rows = (await session.execute(select(ReleaseValidationRun).where(ReleaseValidationRun.project_id == project.id))).scalars().all()
|
||
assert rows == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_feedback_ingest_creates_signal_and_iteration_recommendation(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-feedback-ingests",
|
||
json={
|
||
"source_type": "manual",
|
||
"title": "首批试用用户反馈",
|
||
"content": "用户反馈上传入口难找,希望下一轮优化 onboarding。",
|
||
"metrics": {"negative_feedback_count": 3, "activation_drop": "12%"},
|
||
"samples": [{"channel": "访谈", "text": "入口难找"}],
|
||
},
|
||
)
|
||
summary_response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
assert payload["ingest"]["title"] == "首批试用用户反馈"
|
||
assert payload["recommendation"]["feedbackIngestId"] == payload["ingest"]["id"]
|
||
assert payload["recommendation"]["changeSetCreated"] is False
|
||
assert payload["releaseSignals"]["status"] == "active"
|
||
assert payload["releaseSignals"]["feedback"][0]["metrics"]["negative_feedback_count"] == 3
|
||
assert payload["releaseSignals"]["recommendations"][0]["feedbackIngestId"] == payload["ingest"]["id"]
|
||
assert payload["releaseSignals"]["relationship"]["changeSetCreated"] is False
|
||
assert payload["executionSummary"]["releaseSignals"]["status"] == "active"
|
||
assert summary_response.json()["data"]["releaseSignals"]["feedback"][0]["title"] == "首批试用用户反馈"
|
||
feedback_rows = (await session.execute(select(FeedbackIngest).where(FeedbackIngest.project_id == project.id))).scalars().all()
|
||
recommendation_rows = (
|
||
await session.execute(select(IterationRecommendation).where(IterationRecommendation.project_id == project.id))
|
||
).scalars().all()
|
||
assert len(feedback_rows) == 1
|
||
assert len(recommendation_rows) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_signals_aggregate_revenue_roi_and_compliance_risks(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
now = datetime.utcnow()
|
||
session.add(
|
||
RevenueEvent(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
source_type="manual",
|
||
category="subscription",
|
||
currency="USD",
|
||
amount_usd=1200.0,
|
||
external_ref="rev_001",
|
||
event_payload={"plan": "team"},
|
||
occurred_at=now,
|
||
)
|
||
)
|
||
session.add(
|
||
ROISnapshot(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
range_key="monthly",
|
||
window_start=now - timedelta(days=30),
|
||
window_end=now,
|
||
total_revenue_usd=1200.0,
|
||
total_cost_usd=1800.0,
|
||
roi_ratio=-0.3333,
|
||
snapshot_payload={"reason": "paid acquisition cost spike"},
|
||
)
|
||
)
|
||
session.add(
|
||
ComplianceCheckRun(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
status="blocked",
|
||
score=58.0,
|
||
checklist=[{"key": "privacy", "status": "failed"}],
|
||
issues=[{"title": "缺少隐私授权记录", "severity": "high"}],
|
||
summary={"owner": "legal"},
|
||
)
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
|
||
assert response.status_code == 200
|
||
release_signals = response.json()["data"]["releaseSignals"]
|
||
assert release_signals["status"] == "active"
|
||
assert release_signals["summary"]["revenueEventCount"] == 1
|
||
assert release_signals["summary"]["roiSnapshotCount"] == 1
|
||
assert release_signals["summary"]["complianceCheckCount"] == 1
|
||
assert release_signals["summary"]["riskSignalCount"] == 2
|
||
assert release_signals["revenueEvents"][0]["amountUsd"] == 1200.0
|
||
assert release_signals["roiSnapshots"][0]["riskLevel"] == "high"
|
||
assert release_signals["complianceChecks"][0]["riskLevel"] == "high"
|
||
assert "revenue" in release_signals["relationship"]["businessSignalTypes"]
|
||
assert "roi" in release_signals["relationship"]["businessSignalTypes"]
|
||
assert "compliance" in release_signals["relationship"]["businessSignalTypes"]
|
||
assert any("release_roi_risk" in item for item in release_signals["warnings"])
|
||
assert any("release_compliance_risk" in item for item in release_signals["warnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_iteration_recommendation_creates_reusable_changeset_draft(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
ingest_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-feedback-ingests",
|
||
json={
|
||
"source_type": "manual",
|
||
"title": "发布后反馈回流",
|
||
"content": "用户反馈首次配置成功率低,希望下一轮优化 onboarding 和错误提示。",
|
||
"metrics": {"negative_feedback_count": 5},
|
||
},
|
||
)
|
||
recommendation_id = ingest_response.json()["data"]["recommendation"]["id"]
|
||
draft_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/iteration-recommendations/{recommendation_id}/change-set-drafts",
|
||
json={"note": "进入下一轮待确认变更"},
|
||
)
|
||
reuse_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/iteration-recommendations/{recommendation_id}/change-set-drafts",
|
||
json={},
|
||
)
|
||
truth_response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert draft_response.status_code == 200
|
||
payload = draft_response.json()["data"]
|
||
detail = payload["changeSetDetail"]
|
||
assert detail["changeSet"]["status"] == "open"
|
||
assert detail["changeSet"]["rawStatus"] == "open"
|
||
assert detail["evidence"][0]["sourceType"] == "iteration_recommendation"
|
||
object_types = {item["objectType"] for item in detail["items"]}
|
||
assert object_types.issubset({"Requirement", "Task", "Risk", "Decision"})
|
||
assert {"Requirement", "Task", "Decision"}.issubset(object_types)
|
||
assert all(item["status"] == "pending_review" for item in detail["items"])
|
||
assert payload["executionSummary"]["releaseSignals"]["relationship"]["changeSetCreated"] is True
|
||
assert payload["executionSummary"]["releaseSignals"]["recommendations"][0]["changeSetCreated"] is True
|
||
assert payload["executionSummary"]["releaseSignals"]["recommendations"][0]["changeSetId"] == detail["changeSet"]["id"]
|
||
|
||
assert reuse_response.status_code == 200
|
||
assert reuse_response.json()["data"]["changeSetDetail"]["changeSet"]["id"] == detail["changeSet"]["id"]
|
||
assert "iteration_recommendation_changeset_reused" in reuse_response.json()["data"]["warnings"]
|
||
truth_payload = truth_response.json()["data"]
|
||
assert truth_payload["activeChangeSetId"] == detail["changeSet"]["id"]
|
||
assert truth_payload["changeItems"]
|
||
assert all(item["status"] == "pending_review" for item in truth_payload["changeItems"])
|
||
|
||
change_sets = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.project_id == project.id))).scalars().all()
|
||
tasks = (await session.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||
assert len(change_sets) == 1
|
||
assert tasks == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_iteration_recommendation_group_batches_quality_and_reuses_changeset_draft(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-feedback-ingests",
|
||
json={
|
||
"source_type": "manual",
|
||
"title": "回答质量反馈 A",
|
||
"content": "bad answer 多次不准,用户要求下一轮补齐失败样本。",
|
||
"metrics": {"negative_feedback_count": 4},
|
||
},
|
||
)
|
||
await client.post(
|
||
f"/pmflow-os/projects/{project.id}/release-feedback-ingests",
|
||
json={
|
||
"source_type": "manual",
|
||
"title": "回答质量反馈 B",
|
||
"content": "出现幻觉和不准回答,需要改进 prompt quality。",
|
||
"metrics": {"negative_feedback_count": 2},
|
||
},
|
||
)
|
||
summary_response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
|
||
release_signals = summary_response.json()["data"]["releaseSignals"]
|
||
group = release_signals["recommendationGroups"][0]
|
||
draft_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/iteration-recommendation-groups/{group['groupKey']}/change-set-drafts",
|
||
json={"recommendation_ids": group["recommendationIds"], "note": "批量进入下一轮待确认"},
|
||
)
|
||
reuse_response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/iteration-recommendation-groups/{group['groupKey']}/change-set-drafts",
|
||
json={"recommendation_ids": group["recommendationIds"]},
|
||
)
|
||
|
||
assert summary_response.status_code == 200
|
||
recommendations = release_signals["recommendations"]
|
||
assert group["groupKey"] == "quality"
|
||
assert group["mergedCount"] == 2
|
||
assert group["impactLevel"] == "high"
|
||
assert group["riskLevel"] == "high"
|
||
assert group["reviewPriority"] == "p0"
|
||
assert set(group["suggestedChangeTypes"]).issuperset({"Requirement", "Decision", "Task", "Risk"})
|
||
assert {item["mergedCount"] for item in recommendations} == {2}
|
||
assert sum(1 for item in recommendations if item["duplicateOf"]) == 1
|
||
assert all(item["groupKey"] == "quality" for item in recommendations)
|
||
|
||
assert draft_response.status_code == 200
|
||
payload = draft_response.json()["data"]
|
||
detail = payload["changeSetDetail"]
|
||
assert detail["changeSet"]["status"] == "open"
|
||
assert detail["evidence"][0]["sourceType"] == "iteration_recommendation_group"
|
||
assert detail["evidence"][0]["metadata"]["groupKey"] == "quality"
|
||
assert set(detail["evidence"][0]["metadata"]["recommendationIds"]) == set(group["recommendationIds"])
|
||
object_types = {item["objectType"] for item in detail["items"]}
|
||
assert object_types.issubset({"Requirement", "Task", "Risk", "Decision"})
|
||
assert {"Requirement", "Decision", "Task", "Risk"}.issubset(object_types)
|
||
assert all(item["status"] == "pending_review" for item in detail["items"])
|
||
updated_groups = payload["executionSummary"]["releaseSignals"]["recommendationGroups"]
|
||
assert updated_groups[0]["changeSetCreated"] is True
|
||
assert updated_groups[0]["changeSetId"] == detail["changeSet"]["id"]
|
||
assert all(
|
||
item["changeSetId"] == detail["changeSet"]["id"]
|
||
for item in payload["executionSummary"]["releaseSignals"]["recommendations"]
|
||
)
|
||
|
||
assert reuse_response.status_code == 200
|
||
assert reuse_response.json()["data"]["changeSetDetail"]["changeSet"]["id"] == detail["changeSet"]["id"]
|
||
assert "iteration_recommendation_group_changeset_reused" in reuse_response.json()["data"]["warnings"]
|
||
change_sets = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.project_id == project.id))).scalars().all()
|
||
tasks = (await session.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||
assert len(change_sets) == 1
|
||
assert tasks == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_projects_reviewer_routing_for_pending_changesets(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
reviewer = User(
|
||
tenant_id=1,
|
||
username="reviewer",
|
||
email="reviewer@example.com",
|
||
password_hash="hashed",
|
||
name="审查负责人",
|
||
role="REVIEWER",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
owner = User(
|
||
tenant_id=1,
|
||
username="project-owner",
|
||
email="project-owner@example.com",
|
||
password_hash="hashed",
|
||
name="项目 Owner",
|
||
role="OWNER",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
session.add_all([reviewer, owner])
|
||
await session.flush()
|
||
session.add_all(
|
||
[
|
||
UserProject(tenant_id=1, user_id=reviewer.id, project_id=project.id),
|
||
UserProject(tenant_id=1, user_id=owner.id, project_id=project.id),
|
||
]
|
||
)
|
||
thread = PMFlowThread(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
title="审查线程",
|
||
objective="验证 reviewer routing",
|
||
status="active",
|
||
source="project_launch",
|
||
)
|
||
session.add(thread)
|
||
await session.flush()
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=1,
|
||
thread_id=thread.id,
|
||
project_id=project.id,
|
||
role="user",
|
||
content="本轮包含高风险和跨对象变更。",
|
||
source="web",
|
||
attachments=[],
|
||
message_metadata={},
|
||
status="sent",
|
||
)
|
||
session.add(message)
|
||
await session.flush()
|
||
change_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="open",
|
||
summary="高风险跨域变更待审",
|
||
headline="跨域高风险 ChangeSet",
|
||
extraction_scope="latest_message",
|
||
routing_meta={},
|
||
created_by=user.id,
|
||
)
|
||
session.add(change_set)
|
||
await session.flush()
|
||
risk_item = PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=change_set.id,
|
||
project_id=project.id,
|
||
group="risk",
|
||
object_type="Risk",
|
||
object_id=None,
|
||
title="新增风险:权限收敛失败",
|
||
summary="需要审查权限和数据暴露风险。",
|
||
diff="新增 high risk。",
|
||
payload={},
|
||
confidence=0.92,
|
||
risk_level="high",
|
||
status="pending_review",
|
||
)
|
||
task_item = PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=change_set.id,
|
||
project_id=project.id,
|
||
group="field_update",
|
||
object_type="Task",
|
||
object_id=None,
|
||
title="更新任务:补审查口径",
|
||
summary="任务验收增加 review gate。",
|
||
diff="更新验收标准。",
|
||
payload={},
|
||
confidence=0.86,
|
||
risk_level="medium",
|
||
status="pending_review",
|
||
)
|
||
session.add_all([risk_item, task_item])
|
||
await session.flush()
|
||
session.add(
|
||
PMFlowEvidenceLink(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
change_set_id=change_set.id,
|
||
change_item_id=risk_item.id,
|
||
source_type="iteration_recommendation",
|
||
source_id="iteration-1",
|
||
title="迭代建议证据",
|
||
excerpt="发布后反馈建议下一轮修复权限风险。",
|
||
display_label="Iteration recommendation",
|
||
evidence_metadata={},
|
||
)
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
routing = payload["reviewerRouting"]
|
||
assert routing["status"] == "ready"
|
||
assert routing["warningCode"] is None
|
||
assert routing["summary"]["totalPendingReview"] == 2
|
||
assert routing["summary"]["highRiskCount"] == 1
|
||
assert routing["summary"]["missingReviewerCount"] == 0
|
||
assert routing["summary"]["iterationDraftCount"] == 1
|
||
item = routing["items"][0]
|
||
assert item["projectId"] == str(project.id)
|
||
assert item["changeSetId"] == str(change_set.id)
|
||
assert item["sourceType"] == "iteration_recommendation"
|
||
assert item["pendingItemCount"] == 2
|
||
assert item["evidenceCount"] == 1
|
||
assert item["riskLevel"] == "high"
|
||
assert item["priority"] == "urgent"
|
||
assert item["changeTypes"] == ["Risk", "Task"]
|
||
assert item["suggestedReviewer"]["role"] == "REVIEWER"
|
||
assert item["suggestedReviewer"]["displayName"] == "审查负责人"
|
||
assert item["targetUrl"] == f"/change-review?projectId={project.id}&changeSetId={change_set.id}"
|
||
assert "high risk" in item["reviewReason"]
|
||
assert payload["approvals"] == []
|
||
assert not any("reviewer_routing_warning" in warning for warning in payload["projectionWarnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_reviewer_routing_warns_without_owner_or_reviewer_role(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
thread = PMFlowThread(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
title="审查线程",
|
||
objective="验证 reviewer routing warning",
|
||
status="active",
|
||
source="project_launch",
|
||
)
|
||
session.add(thread)
|
||
await session.flush()
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=1,
|
||
thread_id=thread.id,
|
||
project_id=project.id,
|
||
role="user",
|
||
content="本轮缺少审查角色。",
|
||
source="web",
|
||
attachments=[],
|
||
message_metadata={},
|
||
status="sent",
|
||
)
|
||
session.add(message)
|
||
await session.flush()
|
||
change_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="open",
|
||
summary="缺少 reviewer",
|
||
headline="待审但无审查角色",
|
||
extraction_scope="latest_message",
|
||
routing_meta={},
|
||
created_by=user.id,
|
||
)
|
||
session.add(change_set)
|
||
await session.flush()
|
||
session.add(
|
||
PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=change_set.id,
|
||
project_id=project.id,
|
||
group="risk",
|
||
object_type="Risk",
|
||
object_id=None,
|
||
title="新增风险:缺少审查角色",
|
||
summary="应提示 warning,不虚构 reviewer。",
|
||
diff="新增 high risk。",
|
||
payload={},
|
||
confidence=0.92,
|
||
risk_level="high",
|
||
status="pending_review",
|
||
)
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()["data"]
|
||
routing = payload["reviewerRouting"]
|
||
assert routing["status"] == "warning"
|
||
assert routing["warningCode"] == "reviewer_missing"
|
||
assert routing["summary"]["missingReviewerCount"] == 1
|
||
item = routing["items"][0]
|
||
assert item["suggestedReviewer"] is None
|
||
assert item["fallbackReviewerRole"] == "OWNER"
|
||
assert any("reviewer_routing_warning" in warning for warning in payload["projectionWarnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_reviewer_routing_empty_without_pending_changesets(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert response.status_code == 200
|
||
routing = response.json()["data"]["reviewerRouting"]
|
||
assert routing == {
|
||
"status": "empty",
|
||
"warningCode": None,
|
||
"items": [],
|
||
"summary": {
|
||
"totalPendingReview": 0,
|
||
"highRiskCount": 0,
|
||
"missingReviewerCount": 0,
|
||
"iterationDraftCount": 0,
|
||
"requestedReviewCount": 0,
|
||
"approvedCount": 0,
|
||
"changesRequestedCount": 0,
|
||
"rejectedCount": 0,
|
||
},
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_truth_state_team_feed_aggregates_review_execution_and_release_events(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
reviewer = User(
|
||
tenant_id=1,
|
||
username="team-feed-reviewer",
|
||
email="team-feed-reviewer@example.com",
|
||
password_hash="hashed",
|
||
name="审查负责人",
|
||
role="REVIEWER",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
session.add(reviewer)
|
||
await session.flush()
|
||
session.add(UserProject(tenant_id=1, user_id=reviewer.id, project_id=project.id))
|
||
thread = PMFlowThread(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
title="团队动态线程",
|
||
objective="验证 Team Feed 只读聚合",
|
||
status="active",
|
||
source="project_launch",
|
||
)
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="团队动态宪章",
|
||
content={"business_goal": "聚合审查、执行和发布门禁事件"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add_all([thread, charter])
|
||
await session.flush()
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=1,
|
||
thread_id=thread.id,
|
||
project_id=project.id,
|
||
role="user",
|
||
content="生成 Team Feed 动态流。",
|
||
source="web",
|
||
attachments=[],
|
||
message_metadata={},
|
||
status="sent",
|
||
created_at=datetime(2026, 4, 26, 9, 55, 0),
|
||
)
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload={"acceptance_criteria": ["Team Feed 只读展示执行进展"]},
|
||
manual_overrides=None,
|
||
title="回写 Team Feed 执行事件",
|
||
description="执行进展进入团队动态",
|
||
required_skill_tag_ids=[],
|
||
status="in_progress",
|
||
priority="high",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="team-feed",
|
||
default_branch="main",
|
||
status="active",
|
||
)
|
||
session.add_all([message, task, repo])
|
||
await session.flush()
|
||
requested_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="open",
|
||
summary="待审变更已请求 reviewer",
|
||
headline="请求审查的 ChangeSet",
|
||
extraction_scope="latest_message",
|
||
routing_meta={
|
||
"reviewDecision": {
|
||
"action": "request_review",
|
||
"approvalState": "requested",
|
||
"requestedAt": "2026-04-26T10:00:00",
|
||
"reviewerId": str(reviewer.id),
|
||
"decisionReason": "请复核高风险变更。",
|
||
}
|
||
},
|
||
created_by=user.id,
|
||
created_at=datetime(2026, 4, 26, 9, 58, 0),
|
||
)
|
||
approved_set = PMFlowChangeSet(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
thread_id=thread.id,
|
||
source_message_id=message.id,
|
||
status="reviewed",
|
||
summary="审查已通过但未自动提交",
|
||
headline="已通过审查的 ChangeSet",
|
||
extraction_scope="latest_message",
|
||
routing_meta={
|
||
"reviewDecision": {
|
||
"action": "approve",
|
||
"approvalState": "approved",
|
||
"decidedAt": "2026-04-26T10:20:00",
|
||
"reviewerId": str(reviewer.id),
|
||
"decisionReason": "reviewer 通过,不自动 commit。",
|
||
}
|
||
},
|
||
created_by=user.id,
|
||
reviewed_at=datetime(2026, 4, 26, 10, 20, 0),
|
||
created_at=datetime(2026, 4, 26, 10, 5, 0),
|
||
)
|
||
session.add_all([requested_set, approved_set])
|
||
await session.flush()
|
||
requested_item = PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=requested_set.id,
|
||
project_id=project.id,
|
||
group="risk",
|
||
object_type="Risk",
|
||
object_id=None,
|
||
title="新增风险:审查动态",
|
||
summary="用于 Team Feed 的 review request。",
|
||
diff="新增风险。",
|
||
payload={},
|
||
confidence=0.9,
|
||
risk_level="high",
|
||
status="pending_review",
|
||
)
|
||
approved_item = PMFlowChangeItem(
|
||
tenant_id=1,
|
||
change_set_id=approved_set.id,
|
||
project_id=project.id,
|
||
group="field_update",
|
||
object_type="Task",
|
||
object_id=None,
|
||
title="更新任务:审查通过",
|
||
summary="用于 Team Feed 的 review decision。",
|
||
diff="更新任务。",
|
||
payload={},
|
||
confidence=0.86,
|
||
risk_level="medium",
|
||
status="accepted",
|
||
reviewed_at=datetime(2026, 4, 26, 10, 20, 0),
|
||
)
|
||
session.add_all([requested_item, approved_item])
|
||
await session.flush()
|
||
session.add_all(
|
||
[
|
||
PMFlowEvidenceLink(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
change_set_id=requested_set.id,
|
||
change_item_id=requested_item.id,
|
||
source_type="thread_message",
|
||
source_id=str(message.id),
|
||
title="审查请求证据",
|
||
excerpt="高风险变更需要审查。",
|
||
display_label="Thread",
|
||
evidence_metadata={},
|
||
),
|
||
PMFlowEvidenceLink(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
change_set_id=approved_set.id,
|
||
change_item_id=approved_item.id,
|
||
source_type="thread_message",
|
||
source_id=str(message.id),
|
||
title="审查决策证据",
|
||
excerpt="reviewer 已通过。",
|
||
display_label="Thread",
|
||
evidence_metadata={},
|
||
),
|
||
PMFlowApproval(
|
||
tenant_id=1,
|
||
project_id=project.id,
|
||
change_set_id=approved_set.id,
|
||
change_item_id=approved_item.id,
|
||
actor_user_id=reviewer.id,
|
||
action="approve",
|
||
decision="approved",
|
||
before_status="pending_review",
|
||
after_status="accepted",
|
||
before_payload={},
|
||
after_payload={"approvalState": "approved"},
|
||
reason="reviewer 通过,不自动 commit。",
|
||
created_at=datetime(2026, 4, 26, 10, 20, 0),
|
||
),
|
||
ProgressEvent(
|
||
tenant_id=1,
|
||
task_id=task.id,
|
||
project_id=project.id,
|
||
user_id=user.id,
|
||
repo_binding_id=repo.id,
|
||
event_type="test_failed",
|
||
source="codex",
|
||
derived_task_status="failed",
|
||
summary="Team Feed 单测失败,发布门禁阻塞。",
|
||
payload={"test_summary": "Team Feed 单测失败", "pr_number": 42},
|
||
occurred_at=datetime(2026, 4, 26, 11, 0, 0),
|
||
),
|
||
]
|
||
)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
|
||
assert response.status_code == 200
|
||
feed = response.json()["data"]["feed"]
|
||
titles = [item["title"] for item in feed]
|
||
assert "已请求 ChangeSet 审查" in titles
|
||
assert "审查已通过" in titles
|
||
assert "ChangeSet 已完成审查" in titles
|
||
assert "测试失败" in titles
|
||
assert "发布门禁已阻塞" in titles
|
||
assert any("请求审查的 ChangeSet" in item["summary"] and "证据 1 条" in item["summary"] for item in feed)
|
||
assert any("reviewer 通过,不自动 commit" in item["summary"] for item in feed)
|
||
assert titles.index("发布门禁已阻塞") < titles.index("已请求 ChangeSet 审查")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_progress_writeback_rejects_repo_outside_project(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
other_project = Project(
|
||
tenant_id=1,
|
||
name="其它回写项目",
|
||
title="其它回写项目",
|
||
description="不能被当前项目进展事件引用",
|
||
owner_id=user.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
session.add(other_project)
|
||
await session.flush()
|
||
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=project.id,
|
||
created_by=user.id,
|
||
title="当前项目宪章",
|
||
content={"business_goal": "校验 repo project 边界"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
repo = RepoBinding(
|
||
tenant_id=1,
|
||
project_id=other_project.id,
|
||
provider="github",
|
||
owner="pmflow",
|
||
repo_name="other-writeback",
|
||
default_branch="main",
|
||
status="active",
|
||
)
|
||
session.add_all([charter, repo])
|
||
await session.flush()
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="当前项目任务",
|
||
description="不能绑定其它项目 repo",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="medium",
|
||
)
|
||
session.add(task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-progress-events",
|
||
json={
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": str(repo.id),
|
||
"event_type": "commit",
|
||
"source": "codex",
|
||
"branch_name": "codex/wrong-repo",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 422
|
||
assert response.json()["message"] == "repo_binding_not_found"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execution_handoff_rejects_task_outside_project(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session)
|
||
other_project = Project(
|
||
tenant_id=1,
|
||
name="其它真实项目",
|
||
title="其它真实项目",
|
||
description="不能被当前项目交接",
|
||
owner_id=user.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
session.add(other_project)
|
||
await session.flush()
|
||
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
|
||
await session.flush()
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
project_id=other_project.id,
|
||
created_by=user.id,
|
||
title="其它项目宪章",
|
||
content={"business_goal": "不能跨项目交接"},
|
||
version=1,
|
||
status="aligned",
|
||
)
|
||
session.add(charter)
|
||
await session.flush()
|
||
other_task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=charter.id,
|
||
project_id=other_project.id,
|
||
assignee_id=user.id,
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="其它项目任务",
|
||
description="不能被当前项目 handoff",
|
||
required_skill_tag_ids=[],
|
||
status="confirmed",
|
||
priority="medium",
|
||
)
|
||
session.add(other_task)
|
||
await session.commit()
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
response = await client.post(
|
||
f"/pmflow-os/projects/{project.id}/execution-handoffs",
|
||
json={"task_id": str(other_task.id), "runtime_target": "codex"},
|
||
)
|
||
|
||
assert response.status_code == 404
|
||
assert response.json()["message"] == "task_not_found"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_permission_and_tenant_boundary(db_env) -> None:
|
||
session = db_env
|
||
user, project = await _seed_user_project(session, member=True)
|
||
_, forbidden_project = await _seed_user_project(session, tenant_id=1, member=False)
|
||
_, other_tenant_project = await _seed_user_project(session, tenant_id=2, member=True)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
ok_response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
|
||
forbidden_response = await client.get(f"/pmflow-os/projects/{forbidden_project.id}/truth-state")
|
||
not_found_response = await client.get(f"/pmflow-os/projects/{other_tenant_project.id}/truth-state")
|
||
|
||
assert ok_response.status_code == 200
|
||
assert forbidden_response.status_code == 403
|
||
assert not_found_response.status_code == 404
|