mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(observability): revision_count + audit_log query index (migration 045)
Adds tasks.revision_count (the O(1) rework counter — forward-only, existing
rows default 0) and the composite index audit_log(target_id, event_type,
timestamp) that powers the cycle-time and rework reconstruction queries.
Verified the real upgrade/downgrade/upgrade chain on a throwaway pgvector PG.
First task of the 0.10.0 observability dashboards.
* feat(observability): count reworks + attribute qa_fail/pr_fail to the rejector
Every transition into needs_revision increments tasks.revision_count at the
single audit chokepoint (exactly once per bounce, across all paths incl. pr_fail
and ceo_reject), so the rework rate is an O(1) read. A QA or PR-review bounce
also emits a named task.qa_fail / task.pr_fail audit event carrying the
rejector's agent_id, so the per-agent rework scorecard charges the rejection to
the reviewer who made it, not the developer who owns the task.
* feat(observability): cycle-time, bottleneck, rework, and scorecard metrics
MetricsService gains four read methods on the audit_log + tasks data: per-stage
cycle time reconstructed from the transition journey (excluding the named
qa_fail/pr_fail events), bottleneck distribution (cumulative dwell + live parked
counts), rework rate (overall/by-team/by-agent with rejector attribution + cost
via spawn-session task_id), and a fused per-agent/per-cell scorecard. Dataclass
models with to_dict(). Verified against a real Postgres journey.
* feat(observability): cycle-time/bottleneck/rework/scorecard read endpoints
Thin read-only routes on the dashboard router delegating to MetricsService:
/metrics/cycle-time, /metrics/bottlenecks, /metrics/rework, and
/metrics/scorecard/{agent,team}. 404 when an agent scorecard target is absent.
5 route tests (200 + shape + the agent-404 case).
* feat(panel): Delivery observability tab (cycle-time, bottlenecks, rework, scorecards)
A third Metrics tab built on the observability endpoints: a per-stage
cycle-time bar chart, a bottleneck panel (worst stage + cumulative dwell +
live parked counts), a rework panel (rate + by-team + by-agent attribution +
cost), and per-cell scorecards. Reuses Recharts + Card/Badge/Skeleton and the
React-Query hook pattern; observabilityApi mirrors usageApi with mock-mode
fallbacks. tsc + eslint clean; 113 panel tests pass.
* docs(observability): changelog + CLAUDE.md for the delivery dashboards
* feat(gateway-health): recover a broken-but-alive agent instead of protecting it
The verb-heartbeat cannot tell a quiet-healthy agent from one whose MCP gateway
is broken (a corrupted /app/.venv firing no verb) yet whose container is up — the
reaper's live-skip would shield it forever. The reaper now probes the gateway
out-of-band (docker exec: does the gateway venv import its deps?) and, once it
has been broken past gateway_health_grace_seconds (tolerating a transient probe
miss), kills + evicts the container so it falls through to release + respawn.
Probe-inconclusive or healthy spares the container. Gated by
gateway_health_enabled (default-on reliability fix; in the panel Feature Flags).
Defers the optional agent-side self-check + full registry re-adoption — the
reaper's docker-liveness fallback already recovers a broken-after-restart agent.
* docs(gateway-health): changelog + CLAUDE.md for broken-but-alive recovery
* docs(observability): user-facing docs for the Delivery dashboards + gateway-health
Documents the new Metrics -> Delivery tab (cycle-time, bottlenecks, rework with
rejector attribution, cell scorecards) in the panel guide and the operations
health-and-metrics guide, and adds the gateway-health env vars + an agent-gateway
recovery note. Published MkDocs site only; settings.md's default-off flag table
intentionally omits the default-on gateway-health flag (same as overload-break).
* chore(release): cut 0.10.0 (changelog section + version refs)
* fix(gateway): exempt PM coordinators from single-task claim guards
A Main/Cell PM plans and delegates many root tasks in parallel; the work
then runs in the delegated cells, not in the PM's own hands. But the
claim-time concurrency guards meant for developers — already_active and
paused (the latter firing after i_am_idle auto-pauses the PM's own
umbrella) — were applied to the PM too, so once it held one root it could
never plan a second: it thrashed between its claimed roots and respawned
forever, burning tokens for zero progress.
_run_claim_guards now skips already_active/paused for the coordinator PM
roles (_COORDINATOR_ROLES = {main_pm, cell_pm}); only unmet_dependency — a
real upstream sequence constraint, which parks the root back to pending —
still gates a PM. paused_tasks_guard also excludes the target task itself,
so a PM re-entering its own paused umbrella never self-blocks.
Tests: a coordinator plans a second root with one in_progress + one paused
sibling (full path + claimed-recovery path), the paused target exclusion,
and the developer guards still fire. Repurposed the pre-fix test that
asserted the now-removed PM block.
* fix(metrics): coerce SQL avg/extract hours aggregates to float (panel toFixed crash)
EXTRACT(epoch ...) returns numeric on PostgreSQL 14+, which asyncpg surfaces
as a Decimal; a Decimal serializes to a quoted JSON string, so the panel's
avg_cycle_hours.toFixed(1) (and the other hours fields) threw 'toFixed is not
a function' and blanked the Delivery tab.
A single _as_hours helper now rounds every SQL-averaged hours field to a real
float — avg_cycle_hours on the new scorecards plus the pre-existing
avg_completion_hours / avg_blocked_hours / longest_blocked_hours. Token and
cost fields were already float()-cast and are unaffected.
Regression test asserts _as_hours coerces Decimal -> float and preserves the
None/zero behavior.
* feat(panel): edit a task's sequence from the details page
A task's sequence (order within siblings, lower runs first) was display-only
with no way to change it from the UI, and TaskUpdate didn't carry the field
so PATCH couldn't set it either. The details page's Dependencies tab now has
an inline sequence editor mirroring the parent / dependency editors, and
PATCH /tasks/{id} accepts a sequence field (owner or privileged role) through
the existing generic update path.
* fix(mypy): green the full make-quality type gate
make quality runs 'mypy roboco/ tests/', which the per-module checks on the
0.10.0 branch never exercised. Two issues surfaced:
- The coordinator-exemption change added role_str to
Choreographer._run_claim_guards but not to the ChoreographerHelpers
protocol base, so the composed Choreographer had incompatible base-class
signatures. Sync the protocol signature.
- The gateway-health / stale-reaper tests stubbed methods by direct
assignment (orch._m = AsyncMock()) and typed their duck-typed task doubles
as object, tripping method-assign / assignment / attr-defined. Switch to
monkeypatch.setattr (keeping a local mock ref for the assertions) and type
the doubles as Any — no type: ignore.
Full mypy roboco/ tests/ clean (785 files); the 21 runtime tests pass.
* fix(metrics): static cycle-time SQL — clear bandit B608 (CI gate)
The cycle-time query interpolated an optional team clause into the text() SQL
via an f-string, which bandit flags as B608 (hardcoded SQL) and turned the
merge gate red. The team value was always a bound parameter, so it was a false
positive — but the f-string is the trigger. Rebuilt as one static query with
(CAST(:team AS text) IS NULL OR a.details->>'team' = :team) and an always-bound
team param (CAST, not ::text — SQLAlchemy's :param parser collides with
PostgreSQL's :: cast operator, which broke the query as a stray param).
Full make quality green vs a real pgvector PG (all 21 gate steps).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
493 lines
16 KiB
Python
493 lines
16 KiB
Python
"""roboco.api.schemas.tasks coverage — pure-Python conversion helpers.
|
|
|
|
The route layer is owned by another agent; here we cover the pure
|
|
data-mapping helpers: convert_plan, convert_checkpoints,
|
|
convert_progress_updates, convert_commits, parse_uuid_or_none,
|
|
_parse_uuid_list, transform_update_data, and task_to_response/
|
|
task_list_to_response (with a stub TaskTable to avoid DB).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from roboco.api.schemas.tasks import (
|
|
TaskUpdate,
|
|
_parse_uuid_list,
|
|
convert_checkpoints,
|
|
convert_commits,
|
|
convert_plan,
|
|
convert_progress_updates,
|
|
enrich_task_with_context,
|
|
parse_uuid_or_none,
|
|
task_list_to_response,
|
|
task_to_response,
|
|
transform_update_data,
|
|
)
|
|
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
|
|
|
_ORDER_DEFAULT = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_convert_plan_returns_none_for_empty() -> None:
|
|
assert convert_plan(None) is None
|
|
assert convert_plan({}) is None
|
|
|
|
|
|
def test_convert_plan_with_full_data() -> None:
|
|
sub_id = uuid4()
|
|
data = {
|
|
"approach": "Implement X using Y",
|
|
"sub_tasks": [
|
|
{
|
|
"id": str(sub_id),
|
|
"title": "Sub 1",
|
|
"description": "do thing",
|
|
"completed": True,
|
|
"order": 1,
|
|
"estimated_hours": 2.5,
|
|
"notes": "n",
|
|
},
|
|
],
|
|
"technical_considerations": ["c1"],
|
|
"risks": [{"name": "r1"}],
|
|
"open_questions": [{"q": "?"}],
|
|
}
|
|
plan = convert_plan(data)
|
|
assert plan is not None
|
|
assert plan.approach == "Implement X using Y"
|
|
assert plan.sub_tasks[0].id == sub_id
|
|
assert plan.sub_tasks[0].completed is True
|
|
|
|
|
|
def test_convert_plan_coerces_invalid_uuid_to_fresh() -> None:
|
|
"""Non-UUID id strings are silently replaced with a new UUID."""
|
|
data: dict[str, Any] = {
|
|
"approach": "x",
|
|
"sub_tasks": [{"id": "not-a-uuid", "title": "t", "order": 0}],
|
|
}
|
|
plan = convert_plan(data)
|
|
assert plan is not None
|
|
assert isinstance(plan.sub_tasks[0].id, UUID)
|
|
|
|
|
|
def test_convert_plan_passes_through_uuid_id() -> None:
|
|
"""When id is already a UUID instance, it's preserved."""
|
|
sub_id = uuid4()
|
|
data: dict[str, Any] = {
|
|
"approach": "x",
|
|
"sub_tasks": [{"id": sub_id, "title": "t", "order": 0}],
|
|
}
|
|
plan = convert_plan(data)
|
|
assert plan is not None
|
|
assert plan.sub_tasks[0].id == sub_id
|
|
|
|
|
|
def test_convert_plan_with_non_uuid_non_string_id() -> None:
|
|
"""Numeric ids are also coerced to a fresh UUID."""
|
|
data: dict[str, Any] = {
|
|
"approach": "x",
|
|
"sub_tasks": [{"id": 12345, "title": "t", "order": 0}],
|
|
}
|
|
plan = convert_plan(data)
|
|
assert plan is not None
|
|
assert isinstance(plan.sub_tasks[0].id, UUID)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_checkpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_convert_checkpoints_empty() -> None:
|
|
assert convert_checkpoints(None) == []
|
|
assert convert_checkpoints([]) == []
|
|
|
|
|
|
def test_convert_checkpoints_with_data() -> None:
|
|
cp_id = uuid4()
|
|
agent_id = uuid4()
|
|
data = [
|
|
{
|
|
"id": cp_id,
|
|
"timestamp": datetime.now(UTC),
|
|
"agent_id": agent_id,
|
|
"state_summary": "halfway",
|
|
"remaining_work": ["x"],
|
|
"notes": "fine",
|
|
}
|
|
]
|
|
result = convert_checkpoints(data)
|
|
assert len(result) == 1
|
|
assert result[0].id == cp_id
|
|
assert result[0].state_summary == "halfway"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_progress_updates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_convert_progress_updates_empty() -> None:
|
|
assert convert_progress_updates(None) == []
|
|
assert convert_progress_updates([]) == []
|
|
|
|
|
|
def test_convert_progress_updates_with_data() -> None:
|
|
agent_id = uuid4()
|
|
data = [
|
|
{
|
|
"timestamp": datetime.now(UTC),
|
|
"agent_id": agent_id,
|
|
"message": "step done",
|
|
"percentage": 50,
|
|
}
|
|
]
|
|
result = convert_progress_updates(data)
|
|
assert len(result) == 1
|
|
assert result[0].message == "step done"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_commits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_convert_commits_empty() -> None:
|
|
assert convert_commits(None) == []
|
|
assert convert_commits([]) == []
|
|
|
|
|
|
def test_convert_commits_with_data() -> None:
|
|
agent_id = uuid4()
|
|
data = [
|
|
{
|
|
"hash": "abc123",
|
|
"message": "fix",
|
|
"timestamp": datetime.now(UTC),
|
|
"author_agent_id": agent_id,
|
|
}
|
|
]
|
|
result = convert_commits(data)
|
|
assert len(result) == 1
|
|
assert result[0].hash == "abc123"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_uuid_or_none / _parse_uuid_list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_uuid_or_none_with_valid_uuid() -> None:
|
|
raw = uuid4()
|
|
assert parse_uuid_or_none(str(raw)) == raw
|
|
|
|
|
|
def test_parse_uuid_or_none_with_empty() -> None:
|
|
assert parse_uuid_or_none("") is None
|
|
assert parse_uuid_or_none(None) is None
|
|
|
|
|
|
def test_parse_uuid_or_none_with_invalid_returns_none() -> None:
|
|
assert parse_uuid_or_none("not-a-uuid") is None
|
|
|
|
|
|
def test_parse_uuid_list_with_valid() -> None:
|
|
a, b = uuid4(), uuid4()
|
|
out = _parse_uuid_list([str(a), str(b)])
|
|
assert a in out
|
|
assert b in out
|
|
|
|
|
|
def test_parse_uuid_list_skips_empty_strings() -> None:
|
|
raw = uuid4()
|
|
out = _parse_uuid_list([str(raw), "", None]) # type: ignore[list-item]
|
|
assert raw in out
|
|
assert len(out) == 1
|
|
|
|
|
|
def test_parse_uuid_list_with_none() -> None:
|
|
assert _parse_uuid_list(None) == []
|
|
assert _parse_uuid_list([]) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# transform_update_data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_transform_update_data_converts_assigned_to() -> None:
|
|
raw = uuid4()
|
|
update = TaskUpdate(assigned_to=str(raw))
|
|
out = transform_update_data(update)
|
|
assert out["assigned_to"] == raw
|
|
|
|
|
|
def test_transform_update_data_converts_dependency_ids() -> None:
|
|
a, b = uuid4(), uuid4()
|
|
update = TaskUpdate(dependency_ids=[str(a), str(b)])
|
|
out = transform_update_data(update)
|
|
assert a in out["dependency_ids"]
|
|
assert b in out["dependency_ids"]
|
|
|
|
|
|
def test_transform_update_data_skips_unset_fields() -> None:
|
|
"""Empty TaskUpdate produces an empty dict (model_dump exclude_unset)."""
|
|
update = TaskUpdate()
|
|
out = transform_update_data(update)
|
|
assert out == {}
|
|
|
|
|
|
def test_transform_update_data_handles_null_unassign() -> None:
|
|
"""Empty string assigned_to → None (parse_uuid_or_none)."""
|
|
update = TaskUpdate(assigned_to="")
|
|
out = transform_update_data(update)
|
|
assert out["assigned_to"] is None
|
|
|
|
|
|
def test_transform_update_data_passes_sequence() -> None:
|
|
"""sequence is editable via PATCH (panel task-details sequence editor)."""
|
|
new_order = 3
|
|
update = TaskUpdate(sequence=new_order)
|
|
out = transform_update_data(update)
|
|
assert out["sequence"] == new_order
|
|
# Unset sequence is omitted (exclude_unset), so a partial PATCH never
|
|
# clobbers the existing order.
|
|
assert "sequence" not in transform_update_data(TaskUpdate(priority=1))
|
|
|
|
|
|
def test_task_update_sequence_rejects_negative() -> None:
|
|
"""sequence has ge=0 — a negative order is a validation error, not stored."""
|
|
with pytest.raises(ValueError, match="sequence"):
|
|
TaskUpdate(sequence=-1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# task_to_response / task_list_to_response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
|
|
"""Build a TaskTable stand-in that matches task_to_response's reads."""
|
|
return SimpleNamespace(
|
|
id=uuid4(),
|
|
title="t",
|
|
description="d",
|
|
acceptance_criteria=["a"],
|
|
status=TaskStatus.PENDING,
|
|
priority=1,
|
|
sequence=0,
|
|
nature=TaskNature.TECHNICAL,
|
|
task_type=TaskType.CODE,
|
|
project_id=uuid4(),
|
|
product_id=None,
|
|
project=(SimpleNamespace(slug="proj-1") if with_project else None),
|
|
docs_complete=False,
|
|
pr_created=False,
|
|
board_review_complete=False,
|
|
team=Team.BACKEND,
|
|
created_by=uuid4(),
|
|
assigned_to=None,
|
|
parent_task_id=None,
|
|
dependency_ids=[],
|
|
blocker_ids=[],
|
|
created_at=datetime.now(UTC),
|
|
updated_at=None,
|
|
claimed_at=None,
|
|
claimed_by=None,
|
|
started_at=None,
|
|
completed_at=None,
|
|
target_date=None,
|
|
last_heartbeat_at=None,
|
|
estimated_complexity=Complexity.LOW,
|
|
plan=None,
|
|
checkpoints=[],
|
|
progress_updates=[],
|
|
commits=[],
|
|
dev_notes=None,
|
|
qa_notes=None,
|
|
auditor_notes=None,
|
|
pr_reviewer_notes=None,
|
|
doc_notes=None,
|
|
quick_context=None,
|
|
notes_structured=None,
|
|
self_verified=False,
|
|
qa_verified=None,
|
|
branch_name=None,
|
|
pr_number=None,
|
|
pr_url=None,
|
|
)
|
|
|
|
|
|
def test_task_to_response_omits_slug_when_project_not_loaded() -> None:
|
|
stub = _stub_task(with_project=False)
|
|
fake_inspector = MagicMock()
|
|
fake_inspector.unloaded = {"project"}
|
|
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
|
resp = task_to_response(stub) # type: ignore[arg-type]
|
|
assert resp.project_slug is None
|
|
|
|
|
|
def test_task_to_response_includes_slug_when_project_loaded() -> None:
|
|
stub = _stub_task(with_project=True)
|
|
fake_inspector = MagicMock()
|
|
fake_inspector.unloaded = set() # project IS loaded
|
|
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
|
resp = task_to_response(stub) # type: ignore[arg-type]
|
|
assert resp.project_slug == "proj-1"
|
|
|
|
|
|
def test_task_to_response_serializes_all_note_sections() -> None:
|
|
"""Regression: pr_reviewer_notes / doc_notes / notes_structured MUST be in the
|
|
response. The builder previously omitted them, so the panel showed them blank
|
|
even when the DB had them (the recurring "notes invisible" bug)."""
|
|
stub = _stub_task()
|
|
stub.pr_reviewer_notes = "## Findings\n- looks good"
|
|
stub.doc_notes = "Updated the README"
|
|
stub.notes_structured = {"pr_review": {"verdict": "passed"}}
|
|
fake_inspector = MagicMock()
|
|
fake_inspector.unloaded = {"project"}
|
|
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
|
resp = task_to_response(stub) # type: ignore[arg-type]
|
|
assert resp.pr_reviewer_notes == "## Findings\n- looks good"
|
|
assert resp.doc_notes == "Updated the README"
|
|
assert resp.notes_structured == {"pr_review": {"verdict": "passed"}}
|
|
|
|
|
|
def test_task_list_to_response_returns_list() -> None:
|
|
stubs = [_stub_task(), _stub_task()]
|
|
fake_inspector = MagicMock()
|
|
fake_inspector.unloaded = {"project"}
|
|
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
|
out = task_list_to_response(stubs) # type: ignore[arg-type]
|
|
assert len(out) == len(stubs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enrich_task_with_context — covers the work_session + project lookup branches.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_response() -> Any:
|
|
"""Build a TaskResponse-like object that supports model_dump."""
|
|
fake_inspector = MagicMock()
|
|
fake_inspector.unloaded = {"project"}
|
|
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
|
resp = task_to_response(_stub_task()) # type: ignore[arg-type]
|
|
return resp
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enrich_task_with_context_attaches_workssession_and_project() -> None:
|
|
"""Both work_session and project rows present → both keys attached."""
|
|
resp = _stub_response()
|
|
|
|
work_session = MagicMock()
|
|
work_session.id = uuid4()
|
|
work_session.branch_name = "feature/backend/x"
|
|
work_session.status = MagicMock()
|
|
work_session.status.value = "active"
|
|
work_session.commits = ["abc123"]
|
|
work_session.files_modified = ["foo.py"]
|
|
work_session.pr_number = 42
|
|
work_session.pr_url = "https://github.com/x/y/pull/42"
|
|
work_session.pr_status = "open"
|
|
work_session.project_id = uuid4()
|
|
|
|
project = MagicMock()
|
|
project.id = work_session.project_id
|
|
project.name = "Proj"
|
|
project.slug = "proj"
|
|
project.git_url = "https://github.com/example/repo.git"
|
|
project.default_branch = "main"
|
|
|
|
db = MagicMock()
|
|
db.execute = AsyncMock()
|
|
# First execute returns work_session; second returns project.
|
|
ws_result = MagicMock()
|
|
ws_result.scalar_one_or_none.return_value = work_session
|
|
proj_result = MagicMock()
|
|
proj_result.scalar_one_or_none.return_value = project
|
|
db.execute.side_effect = [ws_result, proj_result]
|
|
|
|
enriched = await enrich_task_with_context(resp, db)
|
|
assert enriched.work_session is not None
|
|
assert enriched.project is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enrich_task_with_context_no_work_session() -> None:
|
|
"""No work_session row → enrichment passes through with no work_session set."""
|
|
resp = _stub_response()
|
|
db = MagicMock()
|
|
db.execute = AsyncMock()
|
|
ws_result = MagicMock()
|
|
ws_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = ws_result
|
|
enriched = await enrich_task_with_context(resp, db)
|
|
# Returned object — work_session stays as default (None) since pull was empty.
|
|
assert enriched.work_session is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enrich_task_with_context_status_unknown_when_status_falsy() -> None:
|
|
"""work_session.status is None → 'unknown' fallback string (line 656)."""
|
|
resp = _stub_response()
|
|
|
|
work_session = MagicMock()
|
|
work_session.id = uuid4()
|
|
work_session.branch_name = "feature/x"
|
|
work_session.status = None
|
|
work_session.commits = []
|
|
work_session.files_modified = []
|
|
work_session.pr_number = None
|
|
work_session.pr_url = None
|
|
work_session.pr_status = None
|
|
work_session.project_id = None
|
|
|
|
db = MagicMock()
|
|
db.execute = AsyncMock()
|
|
ws_result = MagicMock()
|
|
ws_result.scalar_one_or_none.return_value = work_session
|
|
db.execute.return_value = ws_result
|
|
enriched = await enrich_task_with_context(resp, db)
|
|
assert enriched.work_session is not None
|
|
assert enriched.work_session.status == "unknown"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enrich_task_with_context_skips_project_when_not_requested() -> None:
|
|
"""include_project=False bypasses project enrichment."""
|
|
resp = _stub_response()
|
|
|
|
work_session = MagicMock()
|
|
work_session.id = uuid4()
|
|
work_session.branch_name = "feature/x"
|
|
work_session.status = MagicMock()
|
|
work_session.status.value = "active"
|
|
work_session.commits = []
|
|
work_session.files_modified = []
|
|
work_session.pr_number = None
|
|
work_session.pr_url = None
|
|
work_session.pr_status = None
|
|
work_session.project_id = uuid4()
|
|
|
|
db = MagicMock()
|
|
db.execute = AsyncMock()
|
|
ws_result = MagicMock()
|
|
ws_result.scalar_one_or_none.return_value = work_session
|
|
db.execute.return_value = ws_result
|
|
enriched = await enrich_task_with_context(resp, db, include_project=False)
|
|
assert enriched.work_session is not None
|
|
assert enriched.project is None
|