feat(rules): project rule + topic suppressions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m18s

Lets a project mute individual rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two new association
tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic),
4 REST endpoints, and an inline "× skip" affordance plus collapsed
"Suppressed (N)" section in the project's Rules tab.

get_applicable_rules now emits suppressed_rules and suppressed_topics
(detail objects with rulebook/topic context, not just IDs) so the UI
can render the suppressed list without a follow-up lookup. The main
rules projection grew topic_id and rulebook_id columns for the per-row
suppress affordance.

Project deletion cascades the suppression rows via hard DELETE — they
are pure associations with no soft-delete column, and restoring a
deleted project should start fresh, not inherit stale mutes.

Project-scoped rules (Rule.project_id) are deliberately not suppressible
— delete them with delete_rule instead.

Implements plan-task #187.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 02:26:20 -04:00
parent c5469214e3
commit 7861607fb8
15 changed files with 729 additions and 50 deletions
+49 -1
View File
@@ -179,13 +179,17 @@ def test_register_attaches_all_sixteen_tools():
return decorator
register(FakeMCP())
assert len(registered) == 18
assert len(registered) == 22
# spot-check a few names
assert "list_rulebooks" in registered
assert "create_rule" in registered
assert "subscribe_project_to_rulebook" in registered
assert "list_always_on_rules" in registered
assert "create_project_rule" in registered
assert "suppress_rule_for_project" in registered
assert "unsuppress_rule_for_project" in registered
assert "suppress_topic_for_project" in registered
assert "unsuppress_topic_for_project" in registered
@pytest.mark.asyncio
@@ -284,3 +288,47 @@ async def test_create_project_rule_uses_explicit_title_when_given():
)
kwargs = mock.call_args.kwargs
assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}