Skip to content
Closed
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
42 changes: 41 additions & 1 deletion backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
# Alembic configuration for SolFoundry backend.
#
# Manages database schema migrations for PostgreSQL.
# The connection URL is read from the DATABASE_URL environment variable
# at runtime (see alembic/env.py).

[alembic]
script_location = migrations/alembic
prepend_sys_path = .
# DB URL is overridden at runtime by env.py reading DATABASE_URL env var.
# Fallback only used when DATABASE_URL is not set.
sqlalchemy.url = postgresql+asyncpg://localhost/solfoundry
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost/solfoundry

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
213 changes: 171 additions & 42 deletions backend/app/api/contributors.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,62 @@
"""Contributor profiles and reputation API router.

Provides CRUD endpoints for contributor profiles and reputation tracking
including per-bounty history, leaderboard rankings, and tier progression.
Provides CRUD endpoints for contributor profiles and delegates reputation
operations to the reputation service. All contributor queries now hit
PostgreSQL via async sessions.
"""

from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query

from app.auth import get_current_user_id
from app.constants import INTERNAL_SYSTEM_USER_ID
from app.exceptions import ContributorNotFoundError, TierNotUnlockedError
from app.models.contributor import (
ContributorCreate, ContributorResponse, ContributorListResponse, ContributorUpdate,
ContributorCreate,
ContributorListResponse,
ContributorResponse,
ContributorUpdate,
)
from app.models.reputation import (
ReputationHistoryEntry,
ReputationRecordCreate,
ReputationSummary,
)
from app.models.reputation import ReputationRecordCreate, ReputationSummary, ReputationHistoryEntry
from app.services import contributor_service, reputation_service

router = APIRouter(prefix="/contributors", tags=["contributors"])


@router.get("", response_model=ContributorListResponse)
async def list_contributors(
search: Optional[str] = Query(None, description="Search by username or display name"),
skills: Optional[str] = Query(None, description="Comma-separated skill filter"),
badges: Optional[str] = Query(None, description="Comma-separated badge filter"),
search: Optional[str] = Query(
None, description="Search by username or display name"
),
skills: Optional[str] = Query(
None, description="Comma-separated skill filter"
),
badges: Optional[str] = Query(
None, description="Comma-separated badge filter"
),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""List contributors with optional filtering and pagination."""
) -> ContributorListResponse:
"""List contributors with optional filtering and pagination.

Supports text search on username/display_name, skill filtering,
and badge filtering. Results are paginated via ``skip`` and ``limit``.

Args:
search: Case-insensitive substring match.
skills: Comma-separated skill names to filter by.
badges: Comma-separated badge names to filter by.
skip: Pagination offset.
limit: Page size (max 100).

Returns:
Paginated contributor list with total count.
"""
skill_list = skills.split(",") if skills else None
badge_list = badges.split(",") if badges else None
Comment on lines 60 to 61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Comma-separated filters are forwarded without normalization.

Lines 65-66 split raw query strings but do not trim whitespace or drop empty tokens. Requests like ?skills=python, rust, become ["python", " rust", ""], which flows straight into the DB filters and can suppress otherwise valid matches. As per coding guidelines, backend/**: Analyze thoroughly: Input validation and SQL injection vectors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/contributors.py` around lines 65 - 66, The current split of
query params into skill_list and badge_list returns raw tokens (e.g., ["python",
" rust", ""]) which can break DB filters; update the logic around skill_list and
badge_list in contributors.py to split on commas, trim whitespace from each
token, drop any empty tokens, and optionally normalize casing (e.g., lower())
before passing to DB; ensure you still use parameterized queries or ORM filters
to avoid injection risk when using these cleaned lists.

return await contributor_service.list_contributors(
Expand All @@ -35,91 +65,190 @@ async def list_contributors(


@router.post("", response_model=ContributorResponse, status_code=201)
async def create_contributor(data: ContributorCreate):
"""Create a new contributor profile after checking username uniqueness."""
if await contributor_service.get_contributor_by_username(data.username):
raise HTTPException(status_code=409, detail=f"Username '{data.username}' already exists")
async def create_contributor(data: ContributorCreate) -> ContributorResponse:
"""Create a new contributor profile.

Validates that the username is unique before inserting.

Args:
data: Contributor creation payload with username and profile info.

Returns:
The newly created contributor profile.

Raises:
HTTPException 409: Username already exists.
"""
existing = await contributor_service.get_contributor_by_username(data.username)
if existing:
raise HTTPException(
status_code=409, detail=f"Username '{data.username}' already exists"
)
return await contributor_service.create_contributor(data)


@router.get("/leaderboard/reputation", response_model=list[ReputationSummary])
async def get_reputation_leaderboard(
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
):
"""Return contributors ranked by reputation score."""
return await reputation_service.get_reputation_leaderboard(limit=limit, offset=offset)
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[ReputationSummary]:
"""Return contributors ranked by reputation score.

Args:
limit: Maximum number of entries.
offset: Pagination offset.

Returns:
List of reputation summaries sorted by score descending.
"""
return await reputation_service.get_reputation_leaderboard(
limit=limit, offset=offset
)


@router.get("/{contributor_id}", response_model=ContributorResponse)
async def get_contributor(contributor_id: str):
"""Get a single contributor profile by ID from PostgreSQL."""
c = await contributor_service.get_contributor(contributor_id)
if not c:
async def get_contributor(contributor_id: str) -> ContributorResponse:
"""Get a single contributor profile by ID.

Args:
contributor_id: UUID of the contributor.

Returns:
Full contributor profile including stats.

Raises:
HTTPException 404: Contributor not found.
"""
contributor = await contributor_service.get_contributor(contributor_id)
if not contributor:
raise HTTPException(status_code=404, detail="Contributor not found")
return c
return contributor


@router.patch("/{contributor_id}", response_model=ContributorResponse)
async def update_contributor(contributor_id: str, data: ContributorUpdate):
"""Partially update a contributor profile and persist changes."""
c = await contributor_service.update_contributor(contributor_id, data)
if not c:
async def update_contributor(
contributor_id: str, data: ContributorUpdate
) -> ContributorResponse:
"""Partially update a contributor profile.

Only fields present in the request body are updated.

Args:
contributor_id: UUID of the contributor to update.
data: Partial update payload.

Returns:
The updated contributor profile.

Raises:
HTTPException 404: Contributor not found.
"""
contributor = await contributor_service.update_contributor(contributor_id, data)
if not contributor:
raise HTTPException(status_code=404, detail="Contributor not found")
return c
return contributor


@router.delete("/{contributor_id}", status_code=204)
async def delete_contributor(contributor_id: str):
"""Delete a contributor profile from both cache and database."""
if not await contributor_service.delete_contributor(contributor_id):
async def delete_contributor(contributor_id: str) -> None:
"""Delete a contributor profile by ID.

Args:
contributor_id: UUID of the contributor to delete.

Raises:
HTTPException 404: Contributor not found.
"""
deleted = await contributor_service.delete_contributor(contributor_id)
if not deleted:
raise HTTPException(status_code=404, detail="Contributor not found")


@router.get("/{contributor_id}/reputation", response_model=ReputationSummary)
async def get_contributor_reputation(contributor_id: str):
"""Return full reputation profile for a contributor from PostgreSQL."""
async def get_contributor_reputation(
contributor_id: str,
) -> ReputationSummary:
"""Return full reputation profile for a contributor.

Args:
contributor_id: UUID of the contributor.

Returns:
Reputation summary with tier progression and badge info.

Raises:
HTTPException 404: Contributor not found.
"""
summary = await reputation_service.get_reputation(contributor_id)
if summary is None:
raise HTTPException(status_code=404, detail="Contributor not found")
return summary


@router.get("/{contributor_id}/reputation/history", response_model=list[ReputationHistoryEntry])
async def get_contributor_reputation_history(contributor_id: str):
"""Return per-bounty reputation history for a contributor."""
if await contributor_service.get_contributor(contributor_id) is None:
@router.get(
"/{contributor_id}/reputation/history",
response_model=list[ReputationHistoryEntry],
)
async def get_contributor_reputation_history(
contributor_id: str,
) -> list[ReputationHistoryEntry]:
"""Return per-bounty reputation history for a contributor.

Args:
contributor_id: UUID of the contributor.

Returns:
List of reputation history entries sorted newest-first.

Raises:
HTTPException 404: Contributor not found.
"""
contributor = await contributor_service.get_contributor(contributor_id)
if contributor is None:
raise HTTPException(status_code=404, detail="Contributor not found")
return await reputation_service.get_history(contributor_id)


@router.post("/{contributor_id}/reputation", response_model=ReputationHistoryEntry, status_code=201)
@router.post(
"/{contributor_id}/reputation",
response_model=ReputationHistoryEntry,
status_code=201,
)
async def record_contributor_reputation(
contributor_id: str,
data: ReputationRecordCreate,
caller_id: str = Depends(get_current_user_id),
):
) -> ReputationHistoryEntry:
"""Record reputation earned from a completed bounty.

Requires authentication. The caller must be the contributor themselves
Requires authentication. The caller must be the contributor themselves
or the internal system user (all-zeros UUID used by automated pipelines).

Args:
contributor_id: Path parameter -- the contributor receiving reputation.
data: Reputation record payload.
caller_id: Authenticated user ID injected by the auth dependency.

Returns:
The created reputation history entry.

Raises:
HTTPException 400: Path/body contributor_id mismatch.
HTTPException 401: Missing credentials (from auth dependency).
HTTPException 403: Caller is not authorized to record for this contributor.
HTTPException 404: Contributor not found.
"""
if data.contributor_id != contributor_id:
raise HTTPException(status_code=400, detail="contributor_id in path must match body")
raise HTTPException(
status_code=400,
detail="contributor_id in path must match body",
)

# Allow internal system user (automated review pipeline) or the contributor themselves
if caller_id != contributor_id and caller_id != INTERNAL_SYSTEM_USER_ID:
raise HTTPException(status_code=403, detail="Not authorized to record reputation for this contributor")
raise HTTPException(
status_code=403,
detail="Not authorized to record reputation for this contributor",
)

try:
return await reputation_service.record_reputation(data)
Expand Down
11 changes: 11 additions & 0 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,24 @@ async def health_check() -> dict:
db_status = await _check_database()
redis_status = await _check_redis()

# Get counters for sync status
from app.services import contributor_service
from app.services.bounty_service import _bounty_store
from app.services.github_sync import get_last_sync

contributor_count = await contributor_service.count_contributors()
last_sync = get_last_sync()
Comment on lines +52 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Health check may fail entirely if count_contributors() raises an exception.

The count_contributors() database call is not wrapped in error handling. If the database query fails (e.g., connection timeout, query error), the entire health check endpoint will return a 500 error instead of reporting a degraded status. This undermines the purpose of a health check.

Additionally, accessing _bounty_store directly breaks encapsulation—consider exposing a public function like get_bounty_count() in bounty_service.

Proposed fix for resilient health metrics
     # Get counters for sync status
     from app.services import contributor_service
     from app.services.bounty_service import _bounty_store
     from app.services.github_sync import get_last_sync

-    contributor_count = await contributor_service.count_contributors()
+    try:
+        contributor_count = await contributor_service.count_contributors()
+    except Exception:
+        logger.warning("Health check: failed to count contributors")
+        contributor_count = -1  # Indicate unavailable
+
     last_sync = get_last_sync()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/health.py` around lines 52 - 58, The health endpoint
currently calls contributor_service.count_contributors() and accesses
bounty_service._bounty_store directly which can crash the endpoint; wrap the
contributor_service.count_contributors() call in a try/except to catch DB
errors, log the exception, and set a safe fallback (e.g., None or -1) and mark
the metric/state as degraded instead of letting the error propagate, and replace
direct access to _bounty_store by adding and using a public function on
bounty_service (e.g., get_bounty_count()) to obtain bounty metrics; keep
get_last_sync() usage as-is but ensure all failures are handled similarly so the
health response never raises an exception.


is_healthy = db_status == "connected" and redis_status == "connected"

return {
"status": "healthy" if is_healthy else "degraded",
"version": "1.0.0",
"uptime_seconds": round(time.monotonic() - START_TIME),
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"bounties": len(_bounty_store),
"contributors": contributor_count,
"last_sync": last_sync.isoformat() if last_sync else None,
"services": {
"database": db_status,
"redis": redis_status,
Expand Down
Loading
Loading