feat(skills): add metadata and branch fields to skill packs and marketplace skills
This commit is contained in:
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.models.boards import Board
|
||||
from app.models.organization_board_access import OrganizationBoardAccess
|
||||
@@ -15,6 +17,7 @@ from app.models.organization_invite_board_access import OrganizationInviteBoardA
|
||||
from app.models.organization_invites import OrganizationInvite
|
||||
from app.models.organization_members import OrganizationMember
|
||||
from app.models.organizations import Organization
|
||||
from app.models.skill_packs import SkillPack
|
||||
from app.models.users import User
|
||||
from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate
|
||||
from app.services import organizations
|
||||
@@ -107,6 +110,44 @@ def test_normalize_role(value: str, expected: str) -> None:
|
||||
assert organizations.normalize_role(value) == expected
|
||||
|
||||
|
||||
def test_normalize_skill_pack_source_url_normalizes_trivial_variants() -> None:
|
||||
assert (
|
||||
organizations._normalize_skill_pack_source_url("https://github.com/org/repo")
|
||||
== "https://github.com/org/repo"
|
||||
)
|
||||
assert (
|
||||
organizations._normalize_skill_pack_source_url("https://github.com/org/repo/")
|
||||
== "https://github.com/org/repo"
|
||||
)
|
||||
assert (
|
||||
organizations._normalize_skill_pack_source_url(" https://github.com/org/repo.git ")
|
||||
== "https://github.com/org/repo"
|
||||
)
|
||||
|
||||
|
||||
def test_get_default_skill_pack_records_deduplicates_normalized_urls(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
organizations,
|
||||
"DEFAULT_INSTALLER_SKILL_PACKS",
|
||||
(
|
||||
("owner/repo", "pack one", "first"),
|
||||
("owner/repo/", "pack duplicate", "duplicate"),
|
||||
("owner/repo.git", "pack duplicate again", "duplicate again"),
|
||||
("owner/other", "other", "other"),
|
||||
),
|
||||
)
|
||||
now = datetime(2025, 1, 1)
|
||||
records = organizations._get_default_skill_pack_records(org_id=uuid4(), now=now)
|
||||
|
||||
assert len(records) == 2
|
||||
assert {pack.source_url for pack in records} == {
|
||||
"https://github.com/owner/repo",
|
||||
"https://github.com/owner/other",
|
||||
}
|
||||
|
||||
|
||||
def test_role_rank_unknown_role_falls_back_to_member_rank() -> None:
|
||||
assert organizations._role_rank("madeup") == 0
|
||||
assert organizations._role_rank(None) == 0
|
||||
@@ -218,7 +259,119 @@ async def test_ensure_member_for_user_creates_personal_org_and_owner(
|
||||
assert any(
|
||||
isinstance(item, Organization) and item.id == out.organization_id for item in session.added
|
||||
)
|
||||
assert session.committed == 1
|
||||
skill_packs = [
|
||||
item
|
||||
for item in [*session.added, *[record for batch in session.added_all for record in batch]]
|
||||
if isinstance(item, SkillPack)
|
||||
]
|
||||
assert len(skill_packs) == 2
|
||||
pack_sources = {pack.source_url: pack.description for pack in skill_packs}
|
||||
assert (
|
||||
pack_sources["https://github.com/sickn33/antigravity-awesome-skills"]
|
||||
== "The Ultimate Collection of 800+ Agentic Skills for Claude Code/Antigravity/Cursor. "
|
||||
"Battle-tested, high-performance skills for AI agents including official skills from "
|
||||
"Anthropic and Vercel."
|
||||
)
|
||||
assert (
|
||||
pack_sources["https://github.com/BrianRWagner/ai-marketing-skills"]
|
||||
== "Marketing frameworks that AI actually executes. Use for Claude Code, OpenClaw, etc."
|
||||
)
|
||||
assert session.committed == 3
|
||||
assert len(session.added_all) == 0
|
||||
assert {pack.source_url for pack in skill_packs} == {
|
||||
"https://github.com/sickn33/antigravity-awesome-skills",
|
||||
"https://github.com/BrianRWagner/ai-marketing-skills",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_member_for_user_skips_already_existing_default_pack_by_source_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
user = User(clerk_user_id="u1", email=None)
|
||||
existing_pack_source = "https://github.com/sickn33/antigravity-awesome-skills/"
|
||||
|
||||
async def _fake_get_active(_session: Any, _user: User) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_get_first(_session: Any, _user_id: Any) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_fetch_existing_pack_sources(
|
||||
_session: Any,
|
||||
_org_id: Any,
|
||||
) -> set[str]:
|
||||
return {existing_pack_source}
|
||||
|
||||
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
|
||||
monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first)
|
||||
monkeypatch.setattr(
|
||||
organizations,
|
||||
"_fetch_existing_default_pack_sources",
|
||||
_fake_fetch_existing_pack_sources,
|
||||
)
|
||||
|
||||
session = _FakeSession(exec_results=[_FakeExecResult()])
|
||||
|
||||
out = await organizations.ensure_member_for_user(session, user)
|
||||
assert out.user_id == user.id
|
||||
assert out.role == "owner"
|
||||
assert out.organization_id == user.active_organization_id
|
||||
skill_packs = [item for item in session.added if isinstance(item, SkillPack)]
|
||||
assert len(skill_packs) == 1
|
||||
assert skill_packs[0].source_url == "https://github.com/BrianRWagner/ai-marketing-skills"
|
||||
assert session.committed == 2
|
||||
assert len(session.added_all) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_member_for_user_recovers_on_default_install_integrity_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
org_id = uuid4()
|
||||
user = User(clerk_user_id="u1", email=None, active_organization_id=org_id)
|
||||
existing_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=user.id,
|
||||
role="owner",
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _fake_get_active(_session: Any, _user: User) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_get_first(_session: Any, _user_id: Any) -> OrganizationMember | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return None
|
||||
return existing_member
|
||||
|
||||
async def _fake_fetch_existing_pack_sources(
|
||||
_session: Any,
|
||||
_org_id: Any,
|
||||
) -> set[str]:
|
||||
return set()
|
||||
|
||||
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
|
||||
monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first)
|
||||
monkeypatch.setattr(
|
||||
organizations,
|
||||
"_fetch_existing_default_pack_sources",
|
||||
_fake_fetch_existing_pack_sources,
|
||||
)
|
||||
|
||||
session = _FakeSession(
|
||||
exec_results=[_FakeExecResult(), _FakeExecResult()],
|
||||
commit_side_effects=[IntegrityError("statement", [], None)],
|
||||
)
|
||||
|
||||
out = await organizations.ensure_member_for_user(session, user)
|
||||
assert out is existing_member
|
||||
assert out.organization_id == org_id
|
||||
assert call_count == 2
|
||||
assert user.active_organization_id == org_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -485,6 +485,61 @@ async def test_create_skill_pack_rejects_localhost_source_url() -> None:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_pack_is_unique_by_normalized_source_url() -> None:
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
organization, _gateway = await _seed_base(session)
|
||||
await session.commit()
|
||||
|
||||
app = _build_test_app(session_maker, organization=organization)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
first = await client.post(
|
||||
"/api/v1/skills/packs",
|
||||
json={"source_url": "https://github.com/org/repo"},
|
||||
)
|
||||
spaced = await client.post(
|
||||
"/api/v1/skills/packs",
|
||||
json={"source_url": " https://github.com/org/repo.git "},
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/v1/skills/packs",
|
||||
json={"source_url": "https://github.com/org/repo/"},
|
||||
)
|
||||
third = await client.post(
|
||||
"/api/v1/skills/packs",
|
||||
json={"source_url": "https://github.com/org/repo.git"},
|
||||
)
|
||||
packs = await client.get("/api/v1/skills/packs")
|
||||
|
||||
assert first.status_code == 200
|
||||
assert spaced.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert third.status_code == 200
|
||||
assert spaced.json()["id"] == first.json()["id"]
|
||||
assert spaced.json()["source_url"] == first.json()["source_url"]
|
||||
assert second.json()["id"] == first.json()["id"]
|
||||
assert second.json()["source_url"] == first.json()["source_url"]
|
||||
assert third.json()["id"] == first.json()["id"]
|
||||
assert third.json()["source_url"] == first.json()["source_url"]
|
||||
assert packs.status_code == 200
|
||||
pack_items = packs.json()
|
||||
assert len(pack_items) == 1
|
||||
assert pack_items[0]["source_url"] == "https://github.com/org/repo"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skill_packs_includes_skill_count() -> None:
|
||||
engine = await _make_engine()
|
||||
@@ -548,6 +603,109 @@ async def test_list_skill_packs_includes_skill_count() -> None:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_pack_rejects_duplicate_normalized_source_url() -> None:
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
organization, _gateway = await _seed_base(session)
|
||||
pack_a = SkillPack(
|
||||
organization_id=organization.id,
|
||||
source_url="https://github.com/org/repo",
|
||||
name="Pack A",
|
||||
)
|
||||
pack_b = SkillPack(
|
||||
organization_id=organization.id,
|
||||
source_url="https://github.com/org/other-repo",
|
||||
name="Pack B",
|
||||
)
|
||||
session.add(pack_a)
|
||||
session.add(pack_b)
|
||||
await session.commit()
|
||||
await session.refresh(pack_a)
|
||||
await session.refresh(pack_b)
|
||||
|
||||
app = _build_test_app(session_maker, organization=organization)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
response = await client.patch(
|
||||
f"/api/v1/skills/packs/{pack_b.id}",
|
||||
json={"source_url": "https://github.com/org/repo/"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
async with session_maker() as session:
|
||||
pack_rows = (
|
||||
await session.exec(
|
||||
select(SkillPack)
|
||||
.where(col(SkillPack.organization_id) == organization.id)
|
||||
.order_by(col(SkillPack.created_at).asc())
|
||||
)
|
||||
).all()
|
||||
assert len(pack_rows) == 2
|
||||
assert {str(pack.source_url) for pack in pack_rows} == {
|
||||
"https://github.com/org/repo",
|
||||
"https://github.com/org/other-repo",
|
||||
}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_pack_normalizes_source_url_on_update() -> None:
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
organization, _gateway = await _seed_base(session)
|
||||
pack = SkillPack(
|
||||
organization_id=organization.id,
|
||||
source_url="https://github.com/org/old",
|
||||
name="Initial",
|
||||
)
|
||||
session.add(pack)
|
||||
await session.commit()
|
||||
await session.refresh(pack)
|
||||
|
||||
app = _build_test_app(session_maker, organization=organization)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
response = await client.patch(
|
||||
f"/api/v1/skills/packs/{pack.id}",
|
||||
json={"source_url": " https://github.com/org/new.git/ "},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["source_url"] == "https://github.com/org/new"
|
||||
|
||||
async with session_maker() as session:
|
||||
updated = (
|
||||
await session.exec(
|
||||
select(SkillPack).where(col(SkillPack.id) == pack.id),
|
||||
)
|
||||
).one()
|
||||
assert str(updated.source_url) == "https://github.com/org/new"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Path) -> None:
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
@@ -672,3 +830,39 @@ def test_collect_pack_skills_from_repo_supports_top_level_skill_folders(
|
||||
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit"
|
||||
in by_source
|
||||
)
|
||||
|
||||
|
||||
def test_collect_pack_skills_from_repo_streams_large_index(tmp_path: Path) -> None:
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "SKILL.md").write_text("# Fallback Skill\n", encoding="utf-8")
|
||||
|
||||
huge_description = "x" * (300 * 1024)
|
||||
(repo_dir / "skills_index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"id": "oversized",
|
||||
"name": "Huge Index Skill",
|
||||
"description": huge_description,
|
||||
"path": "skills/ignored",
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
skills = _collect_pack_skills_from_repo(
|
||||
repo_dir=repo_dir,
|
||||
source_url="https://github.com/example/oversized-pack",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert len(skills) == 1
|
||||
assert (
|
||||
skills[0].source_url
|
||||
== "https://github.com/example/oversized-pack/tree/main/skills/ignored"
|
||||
)
|
||||
assert skills[0].name == "Huge Index Skill"
|
||||
|
||||
Reference in New Issue
Block a user