From e269ac9d5cb7ebb42bdf0f0478a0b67ae49caa4a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 10:16:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(plan):=20services/planning=20=E2=80=94=20s?= =?UTF-8?q?tart=5Fplanning=20aggregator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fabledassistant/services/planning.py | 63 ++++++++++++++++++++++++ tests/test_services_planning.py | 46 +++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/fabledassistant/services/planning.py create mode 100644 tests/test_services_planning.py diff --git a/src/fabledassistant/services/planning.py b/src/fabledassistant/services/planning.py new file mode 100644 index 0000000..0bb9597 --- /dev/null +++ b/src/fabledassistant/services/planning.py @@ -0,0 +1,63 @@ +"""Planning service — start_planning aggregates plan-task creation with the +project's applicable Rulebook rules and a little context, so planning happens +in Scribe and rules surface at the planning moment. +""" +from __future__ import annotations + +from fabledassistant.services import notes as notes_svc +from fabledassistant.services import projects as projects_svc +from fabledassistant.services import rulebooks as rulebooks_svc + +PLAN_TEMPLATE = """## Goal + +## Approach + +## Steps +- [ ] + +## Verification +""" + + +async def start_planning(user_id: int, project_id: int, title: str) -> dict: + """Create a plan-task seeded with the plan template and return it with the + project's applicable rules + brief context. + + Returns: + { + "task": , + "applicable_rules": [...], + "subscribed_rulebooks": [...], + "applicable_rules_truncated": bool, + "project_goal": str, + "open_task_count": int, + } + """ + project = await projects_svc.get_project(user_id, project_id) + if project is None: + raise ValueError(f"project {project_id} not found") + + note = await notes_svc.create_note( + user_id, + title=title, + body=PLAN_TEMPLATE, + status="todo", + task_kind="plan", + project_id=project_id, + ) + + applicable = await rulebooks_svc.get_applicable_rules( + project_id=project_id, user_id=user_id, + ) + _, open_count = await notes_svc.list_notes( + user_id, is_task=True, status="todo", project_id=project_id, limit=1, + ) + + return { + "task": note.to_dict(), + "applicable_rules": applicable["rules"], + "subscribed_rulebooks": applicable["subscribed_rulebooks"], + "applicable_rules_truncated": applicable["truncated"], + "project_goal": getattr(project, "goal", "") or "", + "open_task_count": open_count, + } diff --git a/tests/test_services_planning.py b/tests/test_services_planning.py new file mode 100644 index 0000000..b831f7a --- /dev/null +++ b/tests/test_services_planning.py @@ -0,0 +1,46 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_start_planning_creates_plan_task_and_returns_rules(): + fake_note = MagicMock() + fake_note.to_dict.return_value = {"id": 5, "title": "Plan it", "task_kind": "plan"} + applicable = { + "rules": [{"id": 1, "title": "dev is home", "statement": "...", + "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}], + "truncated": False, + "subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}], + } + with patch("fabledassistant.services.planning.notes_svc.create_note", + AsyncMock(return_value=fake_note)) as mock_create, \ + patch("fabledassistant.services.planning.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable)), \ + patch("fabledassistant.services.planning.notes_svc.list_notes", + AsyncMock(return_value=([], 3))), \ + patch("fabledassistant.services.planning.projects_svc.get_project", + AsyncMock(return_value=MagicMock(goal="ship it"))): + from fabledassistant.services.planning import start_planning + out = await start_planning(user_id=7, project_id=3, title="Plan it") + + # Created a plan-task (status set => task, kind=plan) + kwargs = mock_create.call_args.kwargs + assert kwargs["task_kind"] == "plan" + assert kwargs["status"] == "todo" + assert kwargs["project_id"] == 3 + assert "## Goal" in kwargs["body"] # seeded template + # Returned shape + assert out["task"]["id"] == 5 + assert out["applicable_rules"][0]["title"] == "dev is home" + assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}] + assert out["open_task_count"] == 3 + + +@pytest.mark.asyncio +async def test_start_planning_raises_when_project_not_found(): + with patch("fabledassistant.services.planning.projects_svc.get_project", + AsyncMock(return_value=None)): + from fabledassistant.services.planning import start_planning + with pytest.raises(ValueError, match="project 999 not found"): + await start_planning(user_id=7, project_id=999, title="x")