Skip to content
Merged
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
31 changes: 31 additions & 0 deletions backend/routes/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,43 @@ async def add_repository(
"analysis": analysis.to_dict(),
"message": "Repository added successfully. Ready for indexing."
}
except HTTPException:
raise # Re-raise HTTPExceptions (like REPO_TOO_LARGE) as-is
except Exception as e:
logger.error("Failed to add repository", error=str(e), user_id=user_id)
capture_exception(e)
raise HTTPException(status_code=400, detail=str(e))


@router.delete("/{repo_id}")
async def delete_repository(
repo_id: str,
auth: AuthContext = Depends(require_auth)
):
"""Delete a repository and all its indexed data."""
user_id = auth.user_id

if not user_id:
raise HTTPException(status_code=401, detail="User ID required")

# Verify ownership
repo = get_repo_or_404(repo_id, user_id)

try:
success = repo_manager.delete_repo(repo_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete repository")

logger.info("Repository deleted", repo_id=repo_id, user_id=user_id)
return {"message": "Repository deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete repository", repo_id=repo_id, error=str(e))
capture_exception(e)
raise HTTPException(status_code=500, detail=str(e))


@router.post("/{repo_id}/index")
async def index_repository(
repo_id: str,
Expand Down
23 changes: 23 additions & 0 deletions backend/services/repo_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,26 @@ def get_last_indexed_commit(self, repo_id: str) -> str:
def update_last_commit(self, repo_id: str, commit_sha: str, function_count: int = 0):
"""Update last indexed commit"""
self.db.update_last_indexed(repo_id, commit_sha, function_count)

def delete_repo(self, repo_id: str) -> bool:
"""Delete repository and clean up local files"""
import shutil

repo = self.get_repo(repo_id)
if not repo:
return False

# Clean up local clone first (before DB delete)
local_path = repo.get("local_path")
if local_path and Path(local_path).exists():
try:
shutil.rmtree(local_path)
logger.info("Deleted local repo files", repo_id=repo_id, path=local_path)
except Exception as e:
logger.warning("Failed to delete local files", repo_id=repo_id, error=str(e))

# Delete from database (cascades to embeddings, dependencies, etc.)
self.db.delete_repository(repo_id)

logger.info("Deleted repository", repo_id=repo_id)
return True
7 changes: 5 additions & 2 deletions backend/services/repo_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class RepoValidator:
'.ruff_cache',
'egg-info',
'.eggs',
'repos', # Cloned repositories (CodeIntel internal)
}

# Average functions per file by language (rough estimates)
Expand Down Expand Up @@ -245,8 +246,10 @@ def _find_code_files(self, repo_path: str) -> tuple[list[Path], Optional[str]]:
if file_path.is_symlink():
continue

# Skip files in excluded directories
if any(skip_dir in file_path.parts for skip_dir in self.SKIP_DIRS):
# Skip files in excluded directories (use repo-relative path to avoid
# false positives when repo is stored in a folder named 'repos', etc.)
rel_parts = file_path.relative_to(repo_root).parts
if any(skip_dir in rel_parts for skip_dir in self.SKIP_DIRS):
continue

# Check extension
Expand Down
174 changes: 174 additions & 0 deletions frontend/src/components/UpgradeLimitModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Loader2, CheckCircle2, Rocket, AlertTriangle, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button'

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'

interface UpgradeLimitModalProps {
isOpen: boolean
onClose: () => void
errorMessage: string
repoName?: string
}

export function UpgradeLimitModal({ isOpen, onClose, errorMessage, repoName }: UpgradeLimitModalProps) {
const [email, setEmail] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')

const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!isValidEmail || sending) return

setSending(true)
setError('')

try {
const response = await fetch(`${API_URL}/api/v1/feedback/waitlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, plan: 'pro' }),
})
if (!response.ok) throw new Error('Failed to submit')
setSent(true)
} catch {
setError('Something went wrong. Please try again.')
} finally {
setSending(false)
}
}

const handleClose = () => {
if (!sending) {
onClose()
setEmail('')
setError('')
setSent(false)
}
}

return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={handleClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
onClick={e => e.stopPropagation()}
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
>
{/* Header with warning */}
<div className="relative p-6 pb-4 border-b border-border bg-gradient-to-br from-amber-500/10 to-transparent">
<button
onClick={handleClose}
disabled={sending}
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="w-4 h-4" />
</button>

<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-500/20 border border-amber-500/30 flex items-center justify-center shrink-0">
<AlertTriangle className="w-6 h-6 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
{repoName ? `Can't import ${repoName}` : 'Repository Limit Reached'}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{errorMessage}
</p>
</div>
</div>
</div>

{/* Limit comparison */}
<div className="p-6 border-b border-border">
<p className="text-sm font-medium text-foreground mb-3">Plan Comparison</p>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/50 border border-border">
<p className="text-xs text-muted-foreground mb-1">Free (Current)</p>
<p className="text-sm font-medium text-foreground">2,000 functions</p>
<p className="text-xs text-muted-foreground">500 files per repo</p>
</div>
<div className="p-3 rounded-lg bg-accent/10 border border-accent/30">
<p className="text-xs text-accent mb-1">Pro</p>
<p className="text-sm font-medium text-foreground">20,000 functions</p>
<p className="text-xs text-muted-foreground">5,000 files per repo</p>
</div>
</div>
</div>

{/* Waitlist form */}
<div className="p-6">
{sent ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="py-4 text-center"
>
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-lg font-medium text-foreground">You're on the list!</p>
<p className="text-sm text-muted-foreground mt-1">
We'll notify you when Pro is available
</p>
<Button onClick={handleClose} variant="outline" className="mt-4">
Got it
</Button>
</motion.div>
) : (
<>
<p className="text-sm text-muted-foreground mb-3">
Join the Pro waitlist to unlock higher limits
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="your@email.com"
className="flex-1 px-4 py-2.5 bg-muted border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent/50"
disabled={sending}
/>
<Button type="submit" disabled={!isValidEmail || sending} className="gap-2 shrink-0">
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
{sending ? 'Joining...' : 'Join'}
</Button>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>

<div className="flex items-center gap-2 mt-4 p-3 rounded-lg bg-accent/5 border border-accent/20">
<Zap className="w-4 h-4 text-accent shrink-0" />
<p className="text-xs text-muted-foreground">
Early members get <span className="text-foreground font-medium">30% off</span> for the first year
</p>
</div>

<button
onClick={handleClose}
className="w-full mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Continue with Free tier
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
75 changes: 71 additions & 4 deletions frontend/src/components/dashboard/DashboardHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,47 @@ import { StyleInsights } from '../StyleInsights'
import { ImpactAnalyzer } from '../ImpactAnalyzer'
import { DashboardStats } from './DashboardStats'
import { IndexingProgressModal } from '../IndexingProgressModal'
import { UpgradeLimitModal } from '../UpgradeLimitModal'
import type { Repository } from '../../types'
import type { GitHubRepo } from '../../hooks/useGitHubRepos'
import { API_URL } from '../../config/api'

const MAX_FREE_REPOS = 3

// Safe stringify that won't crash on circular refs
function safeStringify(obj: unknown, maxLen = 200): string {
try {
return JSON.stringify(obj).slice(0, maxLen)
} catch {
return String(obj).slice(0, maxLen)
}
}

// Extract error message from API response (handles nested detail objects)
function extractErrorMessage(err: any, fallback: string): string {
// FastAPI wraps in detail, but handle both cases
const detail = err?.detail || err

if (typeof detail === 'string') return detail
if (typeof detail?.message === 'string') return detail.message
if (typeof err?.message === 'string') return err.message

// Last resort: stringify (but keep it short, safe from circular refs)
if (detail && typeof detail === 'object') {
const msg = detail.message || detail.error
if (msg) return String(msg)
return safeStringify(detail)
}
return fallback
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Check if error is a limit/upgrade error (handles both wrapped and unwrapped)
function isUpgradeError(err: any): boolean {
const detail = err?.detail || err
const code = detail?.error || detail?.error_code
return ['REPO_TOO_LARGE', 'REPO_LIMIT_REACHED'].includes(code)
}

type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact'

export function DashboardHome() {
Expand All @@ -49,6 +84,14 @@ export function DashboardHome() {
const [indexingRepoId, setIndexingRepoId] = useState<string | null>(null)
const [indexingRepoName, setIndexingRepoName] = useState<string>('')
const [showIndexingModal, setShowIndexingModal] = useState(false)

// Upgrade prompt modal state
const [upgradeModal, setUpgradeModal] = useState<{ show: boolean; message: string; repoName?: string }>({ show: false, message: '' })

// Helper to show upgrade modal with context
const showUpgradeModal = (err: any, repoName?: string) => {
setUpgradeModal({ show: true, message: extractErrorMessage(err, 'Repository exceeds free tier limits'), repoName })
}

// Auto-open GitHub import modal if redirected from OAuth callback
useEffect(() => {
Expand Down Expand Up @@ -96,7 +139,11 @@ export function DashboardHome() {

if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.detail || 'Failed to add repository')
if (isUpgradeError(err)) {
showUpgradeModal(err, name)
return
}
throw new Error(extractErrorMessage(err, 'Failed to add repository'))
}

const data = await response.json()
Expand All @@ -110,7 +157,11 @@ export function DashboardHome() {

if (!indexResponse.ok) {
const err = await indexResponse.json().catch(() => ({}))
throw new Error(err.detail?.message || err.detail || 'Failed to start indexing')
if (isUpgradeError(err)) {
showUpgradeModal(err, name)
return
}
throw new Error(extractErrorMessage(err, 'Failed to start indexing'))
}

// Show indexing progress modal
Expand Down Expand Up @@ -150,7 +201,11 @@ export function DashboardHome() {

if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.detail || `Failed to add ${repo.name}`)
if (isUpgradeError(err)) {
showUpgradeModal(err, repo.name)
continue
}
throw new Error(extractErrorMessage(err, `Failed to add ${repo.name}`))
}

const data = await response.json()
Expand All @@ -164,7 +219,11 @@ export function DashboardHome() {

if (!indexResponse.ok) {
const err = await indexResponse.json().catch(() => ({}))
const errMsg = err.detail?.message || err.detail || 'Indexing failed to start'
if (isUpgradeError(err)) {
showUpgradeModal(err, repo.name)
continue
}
const errMsg = extractErrorMessage(err, 'Indexing failed to start')
console.error(`Failed to start indexing for ${repo.name}:`, err)
toast.warning(`${repo.name} added but indexing failed`, {
description: errMsg
Expand Down Expand Up @@ -439,6 +498,14 @@ export function DashboardHome() {
maxSelectable={MAX_FREE_REPOS}
currentRepoCount={repos.length}
/>

{/* Upgrade Limit Modal */}
<UpgradeLimitModal
isOpen={upgradeModal.show}
onClose={() => setUpgradeModal({ show: false, message: '' })}
errorMessage={upgradeModal.message}
repoName={upgradeModal.repoName}
/>
</div>
)
}