feat(scribe): merge_snippets — unify found one-off snippets into one canonical
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s
Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS new near-duplicates; merge is the CURE for the ones already scattered. - services/snippets.py: merge_snippets(user_id, target_id, source_ids) — keep the target's scalar fields (name/when_to_use/signature/language/ code), union the sources' locations + extra tags onto it (so the survivor carries every call site as a location), trash the sources (recoverable), re-embed the survivor. Pure merge_snippet_fields() factored out for unit testing. Returns (survivor_note, merged_ids). - mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th), and a create_snippet dedup-path nudge toward merge over a forced copy. - routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share- aware (can_write target + every source, rule #78) with a same-owner guard (cross-owner merge is out of scope). - plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json 0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson). - Tests: pure merge-helper union/dedup; MCP tool (requires a source, survivor+merged_ids, not-found); route handler + 5-tool registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
@@ -82,7 +82,38 @@ async def test_update_snippet_missing_raises():
|
||||
await update_snippet(123, name="x")
|
||||
|
||||
|
||||
def test_register_attaches_four_tools():
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_snippets_requires_a_source():
|
||||
from scribe.mcp.tools.snippets import merge_snippets
|
||||
with pytest.raises(ValueError):
|
||||
await merge_snippets(target_id=1, source_ids=[])
|
||||
with pytest.raises(ValueError):
|
||||
await merge_snippets(target_id=1, source_ids=[1]) # only self → nothing to merge
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_snippets_returns_survivor_and_merged_ids():
|
||||
survivor = _fake_snippet()
|
||||
with patch("scribe.services.snippets.merge_snippets",
|
||||
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
|
||||
from scribe.mcp.tools.snippets import merge_snippets
|
||||
out = await merge_snippets(target_id=1, source_ids=[2, 3, 1])
|
||||
assert out["merged_ids"] == [2, 3]
|
||||
assert out["snippet"]["name"] == "debounce"
|
||||
# target_id passed through; self-id filtered out of the source list.
|
||||
assert mock_merge.await_args.args[1] == 1
|
||||
assert mock_merge.await_args.args[2] == [2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_snippets_not_found_raises():
|
||||
with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)):
|
||||
from scribe.mcp.tools.snippets import merge_snippets
|
||||
with pytest.raises(ValueError):
|
||||
await merge_snippets(target_id=1, source_ids=[2])
|
||||
|
||||
|
||||
def test_register_attaches_all_tools():
|
||||
from scribe.mcp.tools import snippets
|
||||
names: list[str] = []
|
||||
|
||||
@@ -97,4 +128,5 @@ def test_register_attaches_four_tools():
|
||||
snippets.register(FakeMcp())
|
||||
assert set(names) == {
|
||||
"list_snippets", "create_snippet", "get_snippet", "update_snippet",
|
||||
"merge_snippets",
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_snippet_handlers_callable():
|
||||
from scribe.routes import snippets as routes
|
||||
for name in (
|
||||
"list_snippets_route", "create_snippet_route", "get_snippet_route",
|
||||
"update_snippet_route", "delete_snippet_route",
|
||||
"update_snippet_route", "delete_snippet_route", "merge_snippet_route",
|
||||
):
|
||||
assert callable(getattr(routes, name))
|
||||
|
||||
|
||||
@@ -122,6 +122,22 @@ def test_parse_legacy_single_location_line_still_works():
|
||||
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f")
|
||||
|
||||
|
||||
def test_merge_snippet_fields_unions_locations_and_tags():
|
||||
target_fields = {"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]}
|
||||
src1 = ({"language": "py", "locations": [{"repo": "b", "path": "b.py", "symbol": "g"}]},
|
||||
["py", "snippet", "helper"])
|
||||
src2 = ({"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]}, # dup loc
|
||||
["snippet"])
|
||||
locs, extra = s.merge_snippet_fields(target_fields, ["py", "snippet", "core"], [src1, src2])
|
||||
# target location first, then unique source locations; dup dropped.
|
||||
assert locs == [
|
||||
{"repo": "a", "path": "a.py", "symbol": "f"},
|
||||
{"repo": "b", "path": "b.py", "symbol": "g"},
|
||||
]
|
||||
# extra tags unioned, language + "snippet" markers excluded.
|
||||
assert extra == ["core", "helper"]
|
||||
|
||||
|
||||
def test_snippet_to_dict_includes_parsed_fields():
|
||||
class FakeNote:
|
||||
title = "debounce — rate-limit"
|
||||
|
||||
Reference in New Issue
Block a user