Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added backend/app/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 11 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions backend/app/github_client.py
Original file line number Diff line number Diff line change
@@ -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}")
26 changes: 26 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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"}
89 changes: 89 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file added backend/app/routes/__init__.py
Empty file.
138 changes: 138 additions & 0 deletions backend/app/routes/marketplace.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading