diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 000000000..db42a559b --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "postgresql+asyncpg://solfoundry:solfoundry_dev@localhost:5432/solfoundry" + redis_url: str = "redis://localhost:6379/0" + secret_key: str = "change-me-in-production" + github_token: str = "" + github_webhook_secret: str = "" + solana_rpc_url: str = "https://api.devnet.solana.com" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +settings = Settings() \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 000000000..62680d2c3 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.database_url, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + yield session \ No newline at end of file diff --git a/backend/app/github_client.py b/backend/app/github_client.py new file mode 100644 index 000000000..043f33707 --- /dev/null +++ b/backend/app/github_client.py @@ -0,0 +1,50 @@ +"""GitHub REST API client for repo discovery and metadata enrichment.""" +import httpx + +from app.config import settings + +GITHUB_API = "https://api.github.com" + + +class GitHubClient: + def __init__(self, token: str = ""): + self.token = token or settings.github_token + self.headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if self.token: + self.headers["Authorization"] = f"Bearer {self.token}" + + async def get_repo_metadata(self, owner: str, name: str) -> dict: + """Fetch enriched metadata for a single public repo.""" + async with httpx.AsyncClient(timeout=15, headers=self.headers) as client: + resp = await client.get(f"{GITHUB_API}/repos/{owner}/{name}") + resp.raise_for_status() + return resp.json() + + async def search_repos(self, q: str, sort: str = "stars", order: str = "desc", per_page: int = 20) -> list[dict]: + """Search GitHub repos by query (language, stars, topics).""" + params = {"q": q, "sort": sort, "order": order, "per_page": min(per_page, 100)} + async with httpx.AsyncClient(timeout=15, headers=self.headers) as client: + resp = await client.get(f"{GITHUB_API}/search/repositories", params=params) + resp.raise_for_status() + return resp.json().get("items", []) + + @staticmethod + def parse_repo_url(url: str) -> tuple[str, str]: + """Extract (owner, name) from a GitHub repo URL.""" + url = url.rstrip("/") + parts = url.split("/") + if "github.com" in url: + # https://github.com/owner/repo + idx = url.index("github.com/") + tail = url[idx + len("github.com/"):] + segs = [s for s in tail.split("/") if s] + if len(segs) >= 2: + return segs[0], segs[1] + # fallback: treat last two path segments as owner/name + parts = [s for s in parts if s] + if len(parts) >= 2: + return parts[-2], parts[-1] + raise ValueError(f"Cannot parse repo URL: {url}") \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..45627a25a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,26 @@ +"""SolFoundry FastAPI application.""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routes.marketplace import router as marketplace_router + +app = FastAPI( + title="SolFoundry API", + description="Backend API for SolFoundry — the marketplace for AI agents and human developers", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(marketplace_router) + + +@app.get("/health") +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 000000000..1f612f82f --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,89 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +class RepoListing(Base): + __tablename__ = "repo_listings" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + repo_url: Mapped[str] = mapped_column(String(255), unique=True, index=True) + owner: Mapped[str] = mapped_column(String(128), index=True) + name: Mapped[str] = mapped_column(String(128), index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + language: Mapped[str | None] = mapped_column(String(64), index=True) + stars: Mapped[int] = mapped_column(default=0) + topics: Mapped[list] = mapped_column(JSONB, default=list) + funding_goals: Mapped[list["FundingGoal"]] = relationship( + back_populates="repo_listing", + cascade="all, delete-orphan", + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class FundingGoal(Base): + __tablename__ = "funding_goals" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + repo_listing_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("repo_listings.id", ondelete="CASCADE"), index=True + ) + title: Mapped[str] = mapped_column(String(200)) + description: Mapped[str] = mapped_column(Text) + target_amount: Mapped[float] = mapped_column(Numeric(20, 2)) + raised_amount: Mapped[float] = mapped_column(Numeric(20, 2), default=0) + currency: Mapped[str] = mapped_column(String(16), default="FNDRY") + status: Mapped[str] = mapped_column(String(16), default="active", index=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + repo_listing: Mapped[RepoListing] = relationship(back_populates="funding_goals") + contributions: Mapped[list["Contribution"]] = relationship( + back_populates="funding_goal", + cascade="all, delete-orphan", + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class Contribution(Base): + __tablename__ = "contributions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + funding_goal_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("funding_goals.id", ondelete="CASCADE"), index=True + ) + contributor_address: Mapped[str] = mapped_column(String(96), index=True) + amount: Mapped[float] = mapped_column(Numeric(20, 2)) + currency: Mapped[str] = mapped_column(String(16), default="FNDRY") + message: Mapped[str | None] = mapped_column(Text, nullable=True) + transaction_signature: Mapped[str | None] = mapped_column(String(128), nullable=True) + + funding_goal: Mapped[FundingGoal] = relationship(back_populates="contributions") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +class Distribution(Base): + __tablename__ = "distributions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + funding_goal_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("funding_goals.id", ondelete="CASCADE"), index=True + ) + recipient_address: Mapped[str] = mapped_column(String(96), index=True) + amount: Mapped[float] = mapped_column(Numeric(20, 2)) + currency: Mapped[str] = mapped_column(String(16), default="FNDRY") + milestone: Mapped[str | None] = mapped_column(String(200), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="pending", index=True) + transaction_signature: Mapped[str | None] = mapped_column(String(128), nullable=True) + + funding_goal: Mapped[FundingGoal] = relationship() + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) \ No newline at end of file diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/routes/marketplace.py b/backend/app/routes/marketplace.py new file mode 100644 index 000000000..0d55a23a0 --- /dev/null +++ b/backend/app/routes/marketplace.py @@ -0,0 +1,138 @@ +"""API routes for the GitHub Repo Marketplace.""" +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas import ( + ContributionCreate, ContributionRead, + DistributionCreate, DistributionRead, + FundingGoalCreate, FundingGoalRead, + RepoListingCreate, RepoListingRead, +) +from app.services.marketplace import MarketplaceService + +router = APIRouter(prefix="/marketplace", tags=["marketplace"]) + + +def get_service(db: AsyncSession = Depends(get_db)) -> MarketplaceService: + return MarketplaceService(db) + + +# ── Repo Listings ───────────────────────────────────────────────────────────── + + +@router.get("/repos", response_model=list[RepoListingRead]) +async def list_repos( + language: str | None = Query(None), + min_stars: int = Query(0, ge=0), + search: str | None = Query(None), + sort_by: str = Query("stars", pattern="^(stars|name|created)$"), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + svc: MarketplaceService = Depends(get_service), +): + return await svc.list_repos( + language=language, min_stars=min_stars, search=search, + sort_by=sort_by, limit=limit, offset=offset, + ) + + +@router.post("/repos", response_model=RepoListingRead, status_code=201) +async def add_repo(data: RepoListingCreate, svc: MarketplaceService = Depends(get_service)): + return await svc.add_repo(data) + + +@router.get("/repos/{repo_id}", response_model=RepoListingRead) +async def get_repo(repo_id: uuid.UUID, svc: MarketplaceService = Depends(get_service)): + repo = await svc.get_repo(repo_id) + if not repo: + raise HTTPException(404, detail="Repo listing not found") + return repo + + +# ── Funding Goals ───────────────────────────────────────────────────────────── + + +@router.post("/repos/{repo_id}/goals", response_model=FundingGoalRead, status_code=201) +async def create_goal( + repo_id: uuid.UUID, + data: FundingGoalCreate, + svc: MarketplaceService = Depends(get_service), +): + return await svc.create_funding_goal(repo_id, data) + + +@router.get("/repos/{repo_id}/goals", response_model=list[FundingGoalRead]) +async def list_goals( + repo_id: uuid.UUID, + status: str | None = Query(None), + svc: MarketplaceService = Depends(get_service), +): + return await svc.list_funding_goals(repo_id=repo_id, status=status) + + +@router.get("/goals", response_model=list[FundingGoalRead]) +async def list_all_goals( + status: str | None = Query(None), + svc: MarketplaceService = Depends(get_service), +): + return await svc.list_funding_goals(status=status) + + +@router.get("/stats") +async def get_stats(svc: MarketplaceService = Depends(get_service)): + return await svc.get_funding_goal_stats() + + +# ── Contributions ───────────────────────────────────────────────────────────── + + +@router.post("/goals/{goal_id}/contributions", response_model=ContributionRead, status_code=201) +async def contribute( + goal_id: uuid.UUID, + data: ContributionCreate, + svc: MarketplaceService = Depends(get_service), +): + return await svc.contribute(goal_id, data) + + +@router.get("/goals/{goal_id}/contributions", response_model=list[ContributionRead]) +async def list_contributions( + goal_id: uuid.UUID, + svc: MarketplaceService = Depends(get_service), +): + return await svc.list_contributions(goal_id) + + +# ── Distributions (Payments) ────────────────────────────────────────────────── + + +@router.post("/goals/{goal_id}/distributions", response_model=DistributionRead, status_code=201) +async def create_distribution( + goal_id: uuid.UUID, + data: DistributionCreate, + svc: MarketplaceService = Depends(get_service), +): + return await svc.create_distribution(goal_id, data) + + +@router.get("/goals/{goal_id}/distributions", response_model=list[DistributionRead]) +async def list_distributions( + goal_id: uuid.UUID, + svc: MarketplaceService = Depends(get_service), +): + return await svc.list_distributions(goal_id) + + +@router.patch("/distributions/{dist_id}/complete", response_model=DistributionRead) +async def complete_distribution( + dist_id: uuid.UUID, + transaction_signature: str, + svc: MarketplaceService = Depends(get_service), +): + dist = await svc.complete_distribution(dist_id, transaction_signature) + if not dist: + raise HTTPException(404, detail="Distribution not found") + return dist \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 000000000..fcada7895 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,90 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class RepoListingBase(BaseModel): + repo_url: str + description: Optional[str] = None + language: Optional[str] = None + topics: list[str] = Field(default_factory=list) + + +class RepoListingCreate(RepoListingBase): + pass + + +class RepoListingRead(RepoListingBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + owner: str + name: str + stars: int + created_at: datetime + updated_at: datetime + + +class FundingGoalCreate(BaseModel): + title: str + description: str + target_amount: float + currency: str = "FNDRY" + deadline: Optional[datetime] = None + + +class FundingGoalRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_listing_id: uuid.UUID + title: str + description: str + target_amount: float + raised_amount: float + currency: str + status: str + deadline: Optional[datetime] + created_at: datetime + + +class ContributionCreate(BaseModel): + contributor_address: str + amount: float + currency: str = "FNDRY" + message: Optional[str] = None + transaction_signature: Optional[str] = None + + +class ContributionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + funding_goal_id: uuid.UUID + contributor_address: str + amount: float + currency: str + message: Optional[str] + created_at: datetime + + +class DistributionCreate(BaseModel): + recipient_address: str + amount: float + currency: str = "FNDRY" + milestone: Optional[str] = None + + +class DistributionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + funding_goal_id: uuid.UUID + recipient_address: str + amount: float + currency: str + milestone: Optional[str] + status: str + created_at: datetime \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/services/marketplace.py b/backend/app/services/marketplace.py new file mode 100644 index 000000000..010298775 --- /dev/null +++ b/backend/app/services/marketplace.py @@ -0,0 +1,189 @@ +"""Business logic for the GitHub Repo Marketplace.""" +import uuid + +from sqlalchemy import select, func, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import RepoListing, FundingGoal, Contribution, Distribution +from app.schemas import ( + ContributionCreate, DistributionCreate, FundingGoalCreate, RepoListingCreate, +) +from app.github_client import GitHubClient + + +class MarketplaceService: + def __init__(self, db: AsyncSession): + self.db = db + self.gh = GitHubClient() + + # ── Repo Listing ────────────────────────────────────────────────────────── + + async def list_repos( + self, + language: str | None = None, + min_stars: int = 0, + search: str | None = None, + sort_by: str = "stars", + limit: int = 20, + offset: int = 0, + ) -> list[RepoListing]: + stmt = select(RepoListing) + if language: + stmt = stmt.where(RepoListing.language == language) + if min_stars > 0: + stmt = stmt.where(RepoListing.stars >= min_stars) + if search: + stmt = stmt.where( + RepoListing.name.ilike(f"%{search}%") + | RepoListing.description.ilike(f"%{search}%") + ) + if sort_by == "stars": + stmt = stmt.order_by(RepoListing.stars.desc()) + elif sort_by == "name": + stmt = stmt.order_by(RepoListing.name) + stmt = stmt.offset(offset).limit(limit) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def add_repo(self, data: RepoListingCreate) -> RepoListing: + owner, name = GitHubClient.parse_repo_url(data.repo_url) + repo = RepoListing( + repo_url=data.repo_url, + owner=owner, + name=name, + description=data.description, + language=data.language, + topics=data.topics or [], + ) + # Enrich with GitHub metadata + try: + meta = await self.gh.get_repo_metadata(owner, name) + repo.description = repo.description or meta.get("description") + repo.language = repo.language or meta.get("language") + repo.stars = meta.get("stargazers_count", 0) + repo.topics = meta.get("topics", repo.topics) + except Exception: + pass # keep minimal data if GitHub API is unavailable + self.db.add(repo) + await self.db.commit() + await self.db.refresh(repo) + return repo + + async def get_repo(self, repo_id: uuid.UUID) -> RepoListing | None: + result = await self.db.execute(select(RepoListing).where(RepoListing.id == repo_id)) + return result.scalar_one_or_none() + + # ── Funding Goal ────────────────────────────────────────────────────────── + + async def create_funding_goal( + self, repo_id: uuid.UUID, data: FundingGoalCreate + ) -> FundingGoal: + goal = FundingGoal( + repo_listing_id=repo_id, + title=data.title, + description=data.description, + target_amount=data.target_amount, + currency=data.currency, + deadline=data.deadline, + ) + self.db.add(goal) + await self.db.commit() + await self.db.refresh(goal) + return goal + + async def list_funding_goals( + self, repo_id: uuid.UUID | None = None, status: str | None = None + ) -> list[FundingGoal]: + stmt = select(FundingGoal) + if repo_id: + stmt = stmt.where(FundingGoal.repo_listing_id == repo_id) + if status: + stmt = stmt.where(FundingGoal.status == status) + stmt = stmt.order_by(FundingGoal.created_at.desc()) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def get_funding_goal_stats(self) -> dict: + """Aggregate stats across all funding goals.""" + total_stmt = select(func.count(FundingGoal.id)) + active_stmt = select(func.count(FundingGoal.id)).where(FundingGoal.status == "active") + raised_stmt = select(func.coalesce(func.sum(FundingGoal.raised_amount), 0)) + goal_stmt = select(func.coalesce(func.sum(FundingGoal.target_amount), 0)) + total = (await self.db.execute(total_stmt)).scalar() or 0 + active = (await self.db.execute(active_stmt)).scalar() or 0 + raised = float((await self.db.execute(raised_stmt)).scalar()) + target = float((await self.db.execute(goal_stmt)).scalar()) + return { + "total_funding_goals": total, + "active_funding_goals": active, + "total_raised": raised, + "total_target": target, + "completion_pct": round((raised / target * 100) if target > 0 else 0, 2), + } + + # ── Contribution ────────────────────────────────────────────────────────── + + async def contribute(self, goal_id: uuid.UUID, data: ContributionCreate) -> Contribution: + contrib = Contribution( + funding_goal_id=goal_id, + contributor_address=data.contributor_address, + amount=data.amount, + currency=data.currency, + message=data.message, + transaction_signature=data.transaction_signature, + ) + self.db.add(contrib) + # Update raised amount + stmt = select(FundingGoal).where(FundingGoal.id == goal_id) + goal = (await self.db.execute(stmt)).scalar_one() + goal.raised_amount = float(goal.raised_amount) + data.amount + if float(goal.raised_amount) >= float(goal.target_amount): + goal.status = "funded" + await self.db.commit() + await self.db.refresh(contrib) + return contrib + + async def list_contributions(self, goal_id: uuid.UUID) -> list[Contribution]: + stmt = ( + select(Contribution) + .where(Contribution.funding_goal_id == goal_id) + .order_by(Contribution.created_at.desc()) + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + # ── Distribution (Payment) ──────────────────────────────────────────────── + + async def create_distribution( + self, goal_id: uuid.UUID, data: DistributionCreate + ) -> Distribution: + dist = Distribution( + funding_goal_id=goal_id, + recipient_address=data.recipient_address, + amount=data.amount, + currency=data.currency, + milestone=data.milestone, + ) + self.db.add(dist) + await self.db.commit() + await self.db.refresh(dist) + return dist + + async def list_distributions(self, goal_id: uuid.UUID) -> list[Distribution]: + stmt = ( + select(Distribution) + .where(Distribution.funding_goal_id == goal_id) + .order_by(Distribution.created_at.desc()) + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def complete_distribution(self, dist_id: uuid.UUID, tx_sig: str) -> Distribution | None: + stmt = select(Distribution).where(Distribution.id == dist_id) + dist = (await self.db.execute(stmt)).scalar_one_or_none() + if dist: + dist.status = "completed" + dist.transaction_signature = tx_sig + await self.db.commit() + await self.db.refresh(dist) + return dist \ No newline at end of file diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/test_marketplace.py b/backend/app/tests/test_marketplace.py new file mode 100644 index 000000000..ac663e2e6 --- /dev/null +++ b/backend/app/tests/test_marketplace.py @@ -0,0 +1,39 @@ +"""Tests for the GitHub Repo Marketplace.""" +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + + +@pytest.fixture +def client(): + transport = ASGITransport(app=app) + return AsyncClient(transport=transport, base_url="http://test") + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_marketplace_stats(client): + resp = await client.get("/marketplace/stats") + assert resp.status_code == 200 + body = resp.json() + assert "total_funding_goals" in body + assert "total_raised" in body + + +@pytest.mark.asyncio +async def test_add_repo_validation(client): + resp = await client.post("/marketplace/repos", json={"repo_url": "not-a-url"}) + assert resp.status_code == 422 # validation error + + +@pytest.mark.asyncio +async def test_list_repos_empty(client): + resp = await client.get("/marketplace/repos") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..e622d73bc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.29.0 +pydantic==2.9.0 +pydantic-settings==2.5.0 +httpx==0.27.0 +python-dotenv==1.0.1 +alembic==1.13.0 \ No newline at end of file diff --git a/test-pr-permission.txt b/test-pr-permission.txt new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/test-pr-permission.txt @@ -0,0 +1 @@ +test