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
439 changes: 439 additions & 0 deletions README.md

Large diffs are not rendered by default.

Empty file added backend/app/__init__.py
Empty file.
Empty file added backend/app/ai/__init__.py
Empty file.
22 changes: 22 additions & 0 deletions backend/app/ai/prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# app/ai/prompts.py — versioned prompt registry (§8.3, NFR-MAINT-5)
from dataclasses import dataclass

from pydantic import BaseModel


@dataclass(frozen=True)
class PromptTemplate:
system: str
response_schema: type[BaseModel] | None = None


# Drafted by Tarun Nagpal (Squad A) in Phase 1 so Squad C has something concrete to build against.
# Real tuning happens in Phase 2 (§8.6.1) — this is a skeleton, not a validated prompt.
WORK_PACKAGE_EXTRACTION_PROMPT_V1 = PromptTemplate(
system=(
"You are extracting discrete work packages from a software requirements document. "
"Return each work package as a short title, a one-paragraph description, and a category. "
"Do not invent requirements that are not present in the source text."
),
response_schema=None, # set once WorkPackageExtractionResult schema lands (Squad C, §5.3.2)
)
Empty file added backend/app/api/__init__.py
Empty file.
Empty file added backend/app/api/v1/__init__.py
Empty file.
153 changes: 153 additions & 0 deletions backend/app/api/v1/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# app/api/v1/auth.py — GitHub OAuth flow, §13.2/§13.3
from datetime import datetime, timedelta, timezone

import httpx
from fastapi import APIRouter, HTTPException, Response, status
from sqlalchemy import select

from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.redis_client import get_redis
from app.core.security import (
create_access_token,
generate_oauth_state,
generate_refresh_token,
hash_refresh_token,
verify_refresh_token,
)
from app.models.user import RefreshToken, User
from app.schemas.auth import RefreshRequest, TokenPair, UserOut

router = APIRouter()

GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_USER_URL = "https://api.github.com/user"
GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
OAUTH_STATE_TTL_SECONDS = 600


@router.get("/github/login")
async def github_login():
state = generate_oauth_state()
redis = await get_redis()
await redis.set(f"oauth_state:{state}", "1", ex=OAUTH_STATE_TTL_SECONDS)

params = {
"client_id": settings.GITHUB_CLIENT_ID,
"redirect_uri": settings.GITHUB_OAUTH_REDIRECT_URI,
"scope": "read:user user:email",
"state": state,
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return Response(status_code=status.HTTP_302_FOUND, headers={"Location": f"{GITHUB_AUTHORIZE_URL}?{query}"})


@router.get("/github/callback", response_model=TokenPair)
async def github_callback(code: str, state: str):
redis = await get_redis()
if not await redis.get(f"oauth_state:{state}"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_or_expired_state")
await redis.delete(f"oauth_state:{state}")

async with httpx.AsyncClient(timeout=10.0) as client:
token_resp = await client.post(
GITHUB_TOKEN_URL,
headers={"Accept": "application/json"},
data={
"client_id": settings.GITHUB_CLIENT_ID,
"client_secret": settings.GITHUB_CLIENT_SECRET,
"code": code,
"redirect_uri": settings.GITHUB_OAUTH_REDIRECT_URI,
},
)
token_resp.raise_for_status()
github_token = token_resp.json().get("access_token")
if not github_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="github_token_exchange_failed")

gh_headers = {"Authorization": f"Bearer {github_token}", "Accept": "application/vnd.github+json"}
profile_resp = await client.get(GITHUB_USER_URL, headers=gh_headers)
profile_resp.raise_for_status()
profile = profile_resp.json()

email = profile.get("email")
if not email:
emails_resp = await client.get(GITHUB_EMAILS_URL, headers=gh_headers)
emails_resp.raise_for_status()
primary = next((e for e in emails_resp.json() if e.get("primary")), None)
email = primary["email"] if primary else f"{profile['id']}+{profile['login']}@users.noreply.github.com"

async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.oauth_provider == "github", User.oauth_subject == str(profile["id"]))
)
user = result.scalar_one_or_none()
if user is None:
user = User(
email=email,
oauth_provider="github",
oauth_subject=str(profile["id"]),
display_name=profile.get("name") or profile["login"],
avatar_url=profile.get("avatar_url"),
)
db.add(user)
await db.flush()

access_token = create_access_token(user.id)
refresh_token = generate_refresh_token()
db.add(
RefreshToken(
user_id=user.id,
token_hash=hash_refresh_token(refresh_token),
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
)
await db.commit()
await db.refresh(user)

return TokenPair(access_token=access_token, refresh_token=refresh_token, user=UserOut.model_validate(user))


@router.post("/refresh", response_model=TokenPair)
async def refresh(body: RefreshRequest):
async with AsyncSessionLocal() as db:
result = await db.execute(
select(RefreshToken).order_by(RefreshToken.created_at.desc())
)
candidates = result.scalars().all()
matched = next((rt for rt in candidates if verify_refresh_token(body.refresh_token, rt.token_hash)), None)

Comment on lines +115 to +120
if matched is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token")

if matched.used:
# Reuse detected — theft response: revoke the entire token family (§13.2/§13.3).
await db.execute(
RefreshToken.__table__.update()
.where(RefreshToken.user_id == matched.user_id)
.values(used=True)
)
await db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_reused")

if matched.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="refresh_token_expired")

matched.used = True
user = await db.get(User, matched.user_id)

new_access_token = create_access_token(user.id)
Comment on lines +137 to +140
new_refresh_token = generate_refresh_token()
db.add(
RefreshToken(
user_id=user.id,
token_hash=hash_refresh_token(new_refresh_token),
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
)
await db.commit()

return TokenPair(
access_token=new_access_token, refresh_token=new_refresh_token, user=UserOut.model_validate(user)
)
6 changes: 6 additions & 0 deletions backend/app/api/v1/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/chat.py — owner: Tarun Nagpal / Squad A (§9.9)
from fastapi import APIRouter

router = APIRouter()

# TODO(Tarun Nagpal / Squad A (§9.9)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
9 changes: 9 additions & 0 deletions backend/app/api/v1/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# app/api/v1/health.py — §4.9
from fastapi import APIRouter

router = APIRouter()


@router.get("/health")
async def health():
return {"status": "ok"}
6 changes: 6 additions & 0 deletions backend/app/api/v1/knowledge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/knowledge.py — owner: Ishit Desai — Squad B (§9.8)
from fastapi import APIRouter

router = APIRouter()

# TODO(Ishit Desai — Squad B (§9.8)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/meetings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/meetings.py — owner: Parth Shah — Squad D (§9.6)
from fastapi import APIRouter

router = APIRouter()

# TODO(Parth Shah — Squad D (§9.6)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/projects.py — owner: Karan Bosamiya — Squad A
from fastapi import APIRouter

router = APIRouter()

# TODO(Karan Bosamiya — Squad A): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/requirements.py — owner: Meet Shah — Squad C (§9.4)
from fastapi import APIRouter

router = APIRouter()

# TODO(Meet Shah — Squad C (§9.4)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/risks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/risks.py — owner: Parth Shah — Squad D (§9.7)
from fastapi import APIRouter

router = APIRouter()

# TODO(Parth Shah — Squad D (§9.7)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/sprints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/sprints.py — owner: Meet Shah / Parth Harpal — Squad C (§9.5)
from fastapi import APIRouter

router = APIRouter()

# TODO(Meet Shah / Parth Harpal — Squad C (§9.5)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/stories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/stories.py — owner: Meet Shah — Squad C (§9.4)
from fastapi import APIRouter

router = APIRouter()

# TODO(Meet Shah — Squad C (§9.4)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
6 changes: 6 additions & 0 deletions backend/app/api/v1/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# app/api/v1/webhooks.py — owner: Chaitanya Vekaria — Squad F (§9.10)
from fastapi import APIRouter

router = APIRouter()

# TODO(Chaitanya Vekaria — Squad F (§9.10)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md
Empty file added backend/app/core/__init__.py
Empty file.
100 changes: 100 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# app/core/config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
# ── Database ──────────────────────────────────────────────────────────────
# asyncpg driver required — SQLAlchemy async sessions block the event loop
# if you use the sync psycopg2 driver (NFR-PERF-1, §4.7.1).
DATABASE_URL: str

# ── Redis ─────────────────────────────────────────────────────────────────
# Three narrowly-scoped uses (ADR-6): sliding-window rate limiting (NFR-SEC-9),
# LLM response cache keyed by prompt hash (§8.4.2), webhook dedup (§4.7.3).
# Not used as a message broker or task queue.
REDIS_URL: str

# ── AI APIs ───────────────────────────────────────────────────────────────
# All Claude calls go through AIService.call_claude() — never instantiate
# anthropic.Anthropic() directly. This keeps retry/backoff, cost logging,
# and prompt caching in one place (§8.1, NFR-OBS-2, NFR-SCALE-4).
ANTHROPIC_API_KEY: str

# Voyage AI: purpose-built code-embedding model (voyage-code-2).
# Used exclusively for code chunks — gives meaningfully better retrieval
# quality over general-purpose embedders for function/class-level search (§11.2).
VOYAGE_API_KEY: str

# Google Gemini: text-embedding-004 for prose chunks (docs, tickets, meetings).
# Free tier via Google AI Studio — 1500 req/day, sufficient for the hackathon.
# Kept separate from Voyage because code and prose chunks have different
# optimal embedding spaces; one model for both degrades code retrieval (§11.2).
# Get a free key at: aistudio.google.com/app/apikey
GEMINI_API_KEY: str = ""

# Model identifiers are env-configurable so a model version bump is a config
# change, not a code deploy (NFR-MAINT-4). Sonnet for complex generation,
# Haiku for cheap high-frequency classification calls (§8.2).
CLAUDE_SONNET_MODEL: str = "claude-sonnet-4-6"
CLAUDE_HAIKU_MODEL: str = "claude-haiku-4-5-20251001"

# ── Vector Store ──────────────────────────────────────────────────────────
# ChromaDB runs embedded in the FastAPI process — zero ops, no separate service.
# This path is volume-mounted in Docker so vectors survive container restarts.
# Upgrade path to HttpClient mode (separate service) is a one-line config swap (ADR-4).
CHROMA_PERSIST_PATH: str = "/data/chromadb"

# ── Auth ──────────────────────────────────────────────────────────────────
# JWT_SECRET_KEY signs access + refresh tokens (HS256). Must be secret and
# at least 32 random bytes — generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"

# Short-lived access token (15 min) + long-lived refresh token (7 days).
# Access tokens are never stored — they're validated statelessly on every request.
# Refresh tokens ARE stored (hashed) in Postgres for revocation (§13.2, NFR-SEC-2).
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7

# Fernet/AES-256-GCM key for encrypting PATs stored in integrations.encrypted_credentials.
# Must be exactly 32 URL-safe chars — generate with:
# python3 -c "import secrets; print(secrets.token_urlsafe(32)[:32])"
TOKEN_ENCRYPTION_KEY: str
Comment on lines +59 to +62

# ── GitHub OAuth ──────────────────────────────────────────────────────────
# Drives both the login flow (FR-1.1) and the repo connector (Squad B).
# Each developer registers their own OAuth app at github.com/settings/developers
# so callback URLs point to their own localhost. See README §3.5 for exact steps.
GITHUB_CLIENT_ID: str
GITHUB_CLIENT_SECRET: str
GITHUB_OAUTH_REDIRECT_URI: str = "http://localhost:8000/api/v1/auth/github/callback"

# ── Slack ─────────────────────────────────────────────────────────────────
# Incoming webhook URL for high-severity risk alerts (FR-6.7, Squad D).
# Empty string disables alerting gracefully — safe to leave blank in Phase 1.
# Create at: api.slack.com/apps → Incoming Webhooks.
SLACK_WEBHOOK_URL: str = ""

# ── CORS ──────────────────────────────────────────────────────────────────
# Vite dev server default is 5173. Add the production frontend URL here
# when Chaitanya deploys the demo host — never use ["*"] in any environment.
CORS_ORIGINS: list[str] = ["http://localhost:5173"]

# ── Workers ───────────────────────────────────────────────────────────────
# Max concurrent ingestion tasks (repo indexing). Each task holds an embedding
# API connection; keep this below the API's concurrency limit for your plan tier.
INGESTION_WORKER_CONCURRENCY: int = 5

# ── Observability ─────────────────────────────────────────────────────────
# LOG_FORMAT: "json" in production (structured for log aggregators),
# "text" locally for human-readable dev output.
# LOG_PROMPTS: set True locally to see full Claude prompts in logs.
# NEVER set True on the demo host — prompts may contain user data (NFR-SEC-6).
LOG_FORMAT: str = "json"
LOG_PROMPTS: bool = False
Comment on lines +88 to +94

class Config:
env_file = ".env"


settings = Settings()
18 changes: 18 additions & 0 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# app/core/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase

from app.core.config import settings


class Base(DeclarativeBase):
pass


engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)

AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
Loading