Skip to content

Commit a0cdbb8

Browse files
authored
Merge pull request #233 from DevanshuNEU/feat/clear-error-messaging
feat: clear error messaging with upgrade prompts
2 parents 7a4e5bf + 9517b59 commit a0cdbb8

5 files changed

Lines changed: 304 additions & 6 deletions

File tree

backend/routes/repos.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,43 @@ async def add_repository(
140140
"analysis": analysis.to_dict(),
141141
"message": "Repository added successfully. Ready for indexing."
142142
}
143+
except HTTPException:
144+
raise # Re-raise HTTPExceptions (like REPO_TOO_LARGE) as-is
143145
except Exception as e:
144146
logger.error("Failed to add repository", error=str(e), user_id=user_id)
145147
capture_exception(e)
146148
raise HTTPException(status_code=400, detail=str(e))
147149

148150

151+
@router.delete("/{repo_id}")
152+
async def delete_repository(
153+
repo_id: str,
154+
auth: AuthContext = Depends(require_auth)
155+
):
156+
"""Delete a repository and all its indexed data."""
157+
user_id = auth.user_id
158+
159+
if not user_id:
160+
raise HTTPException(status_code=401, detail="User ID required")
161+
162+
# Verify ownership
163+
repo = get_repo_or_404(repo_id, user_id)
164+
165+
try:
166+
success = repo_manager.delete_repo(repo_id)
167+
if not success:
168+
raise HTTPException(status_code=500, detail="Failed to delete repository")
169+
170+
logger.info("Repository deleted", repo_id=repo_id, user_id=user_id)
171+
return {"message": "Repository deleted successfully"}
172+
except HTTPException:
173+
raise
174+
except Exception as e:
175+
logger.error("Failed to delete repository", repo_id=repo_id, error=str(e))
176+
capture_exception(e)
177+
raise HTTPException(status_code=500, detail=str(e))
178+
179+
149180
@router.post("/{repo_id}/index")
150181
async def index_repository(
151182
repo_id: str,

backend/services/repo_manager.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,26 @@ def get_last_indexed_commit(self, repo_id: str) -> str:
156156
def update_last_commit(self, repo_id: str, commit_sha: str, function_count: int = 0):
157157
"""Update last indexed commit"""
158158
self.db.update_last_indexed(repo_id, commit_sha, function_count)
159+
160+
def delete_repo(self, repo_id: str) -> bool:
161+
"""Delete repository and clean up local files"""
162+
import shutil
163+
164+
repo = self.get_repo(repo_id)
165+
if not repo:
166+
return False
167+
168+
# Clean up local clone first (before DB delete)
169+
local_path = repo.get("local_path")
170+
if local_path and Path(local_path).exists():
171+
try:
172+
shutil.rmtree(local_path)
173+
logger.info("Deleted local repo files", repo_id=repo_id, path=local_path)
174+
except Exception as e:
175+
logger.warning("Failed to delete local files", repo_id=repo_id, error=str(e))
176+
177+
# Delete from database (cascades to embeddings, dependencies, etc.)
178+
self.db.delete_repository(repo_id)
179+
180+
logger.info("Deleted repository", repo_id=repo_id)
181+
return True

backend/services/repo_validator.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class RepoValidator:
100100
'.ruff_cache',
101101
'egg-info',
102102
'.eggs',
103+
'repos', # Cloned repositories (CodeIntel internal)
103104
}
104105

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

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

252255
# Check extension
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { useState } from 'react'
2+
import { motion, AnimatePresence } from 'framer-motion'
3+
import { X, Loader2, CheckCircle2, Rocket, AlertTriangle, Zap } from 'lucide-react'
4+
import { Button } from '@/components/ui/button'
5+
6+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
7+
8+
interface UpgradeLimitModalProps {
9+
isOpen: boolean
10+
onClose: () => void
11+
errorMessage: string
12+
repoName?: string
13+
}
14+
15+
export function UpgradeLimitModal({ isOpen, onClose, errorMessage, repoName }: UpgradeLimitModalProps) {
16+
const [email, setEmail] = useState('')
17+
const [sending, setSending] = useState(false)
18+
const [sent, setSent] = useState(false)
19+
const [error, setError] = useState('')
20+
21+
const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
22+
23+
const handleSubmit = async (e: React.FormEvent) => {
24+
e.preventDefault()
25+
if (!isValidEmail || sending) return
26+
27+
setSending(true)
28+
setError('')
29+
30+
try {
31+
const response = await fetch(`${API_URL}/api/v1/feedback/waitlist`, {
32+
method: 'POST',
33+
headers: { 'Content-Type': 'application/json' },
34+
body: JSON.stringify({ email, plan: 'pro' }),
35+
})
36+
if (!response.ok) throw new Error('Failed to submit')
37+
setSent(true)
38+
} catch {
39+
setError('Something went wrong. Please try again.')
40+
} finally {
41+
setSending(false)
42+
}
43+
}
44+
45+
const handleClose = () => {
46+
if (!sending) {
47+
onClose()
48+
setEmail('')
49+
setError('')
50+
setSent(false)
51+
}
52+
}
53+
54+
return (
55+
<AnimatePresence>
56+
{isOpen && (
57+
<motion.div
58+
initial={{ opacity: 0 }}
59+
animate={{ opacity: 1 }}
60+
exit={{ opacity: 0 }}
61+
className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
62+
onClick={handleClose}
63+
>
64+
<motion.div
65+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
66+
animate={{ opacity: 1, scale: 1, y: 0 }}
67+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
68+
onClick={e => e.stopPropagation()}
69+
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
70+
>
71+
{/* Header with warning */}
72+
<div className="relative p-6 pb-4 border-b border-border bg-gradient-to-br from-amber-500/10 to-transparent">
73+
<button
74+
onClick={handleClose}
75+
disabled={sending}
76+
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"
77+
>
78+
<X className="w-4 h-4" />
79+
</button>
80+
81+
<div className="flex items-start gap-4">
82+
<div className="w-12 h-12 rounded-xl bg-amber-500/20 border border-amber-500/30 flex items-center justify-center shrink-0">
83+
<AlertTriangle className="w-6 h-6 text-amber-400" />
84+
</div>
85+
<div>
86+
<h3 className="text-lg font-semibold text-foreground">
87+
{repoName ? `Can't import ${repoName}` : 'Repository Limit Reached'}
88+
</h3>
89+
<p className="text-sm text-muted-foreground mt-1">
90+
{errorMessage}
91+
</p>
92+
</div>
93+
</div>
94+
</div>
95+
96+
{/* Limit comparison */}
97+
<div className="p-6 border-b border-border">
98+
<p className="text-sm font-medium text-foreground mb-3">Plan Comparison</p>
99+
<div className="grid grid-cols-2 gap-3">
100+
<div className="p-3 rounded-lg bg-muted/50 border border-border">
101+
<p className="text-xs text-muted-foreground mb-1">Free (Current)</p>
102+
<p className="text-sm font-medium text-foreground">2,000 functions</p>
103+
<p className="text-xs text-muted-foreground">500 files per repo</p>
104+
</div>
105+
<div className="p-3 rounded-lg bg-accent/10 border border-accent/30">
106+
<p className="text-xs text-accent mb-1">Pro</p>
107+
<p className="text-sm font-medium text-foreground">20,000 functions</p>
108+
<p className="text-xs text-muted-foreground">5,000 files per repo</p>
109+
</div>
110+
</div>
111+
</div>
112+
113+
{/* Waitlist form */}
114+
<div className="p-6">
115+
{sent ? (
116+
<motion.div
117+
initial={{ opacity: 0, scale: 0.9 }}
118+
animate={{ opacity: 1, scale: 1 }}
119+
className="py-4 text-center"
120+
>
121+
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
122+
<p className="text-lg font-medium text-foreground">You're on the list!</p>
123+
<p className="text-sm text-muted-foreground mt-1">
124+
We'll notify you when Pro is available
125+
</p>
126+
<Button onClick={handleClose} variant="outline" className="mt-4">
127+
Got it
128+
</Button>
129+
</motion.div>
130+
) : (
131+
<>
132+
<p className="text-sm text-muted-foreground mb-3">
133+
Join the Pro waitlist to unlock higher limits
134+
</p>
135+
<form onSubmit={handleSubmit} className="space-y-3">
136+
<div className="flex gap-2">
137+
<input
138+
type="email"
139+
value={email}
140+
onChange={e => setEmail(e.target.value)}
141+
placeholder="your@email.com"
142+
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"
143+
disabled={sending}
144+
/>
145+
<Button type="submit" disabled={!isValidEmail || sending} className="gap-2 shrink-0">
146+
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
147+
{sending ? 'Joining...' : 'Join'}
148+
</Button>
149+
</div>
150+
{error && <p className="text-sm text-red-500">{error}</p>}
151+
</form>
152+
153+
<div className="flex items-center gap-2 mt-4 p-3 rounded-lg bg-accent/5 border border-accent/20">
154+
<Zap className="w-4 h-4 text-accent shrink-0" />
155+
<p className="text-xs text-muted-foreground">
156+
Early members get <span className="text-foreground font-medium">30% off</span> for the first year
157+
</p>
158+
</div>
159+
160+
<button
161+
onClick={handleClose}
162+
className="w-full mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors"
163+
>
164+
Continue with Free tier
165+
</button>
166+
</>
167+
)}
168+
</div>
169+
</motion.div>
170+
</motion.div>
171+
)}
172+
</AnimatePresence>
173+
)
174+
}

frontend/src/components/dashboard/DashboardHome.tsx

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,47 @@ import { StyleInsights } from '../StyleInsights'
2626
import { ImpactAnalyzer } from '../ImpactAnalyzer'
2727
import { DashboardStats } from './DashboardStats'
2828
import { IndexingProgressModal } from '../IndexingProgressModal'
29+
import { UpgradeLimitModal } from '../UpgradeLimitModal'
2930
import type { Repository } from '../../types'
3031
import type { GitHubRepo } from '../../hooks/useGitHubRepos'
3132
import { API_URL } from '../../config/api'
3233

3334
const MAX_FREE_REPOS = 3
3435

36+
// Safe stringify that won't crash on circular refs
37+
function safeStringify(obj: unknown, maxLen = 200): string {
38+
try {
39+
return JSON.stringify(obj).slice(0, maxLen)
40+
} catch {
41+
return String(obj).slice(0, maxLen)
42+
}
43+
}
44+
45+
// Extract error message from API response (handles nested detail objects)
46+
function extractErrorMessage(err: any, fallback: string): string {
47+
// FastAPI wraps in detail, but handle both cases
48+
const detail = err?.detail || err
49+
50+
if (typeof detail === 'string') return detail
51+
if (typeof detail?.message === 'string') return detail.message
52+
if (typeof err?.message === 'string') return err.message
53+
54+
// Last resort: stringify (but keep it short, safe from circular refs)
55+
if (detail && typeof detail === 'object') {
56+
const msg = detail.message || detail.error
57+
if (msg) return String(msg)
58+
return safeStringify(detail)
59+
}
60+
return fallback
61+
}
62+
63+
// Check if error is a limit/upgrade error (handles both wrapped and unwrapped)
64+
function isUpgradeError(err: any): boolean {
65+
const detail = err?.detail || err
66+
const code = detail?.error || detail?.error_code
67+
return ['REPO_TOO_LARGE', 'REPO_LIMIT_REACHED'].includes(code)
68+
}
69+
3570
type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact'
3671

3772
export function DashboardHome() {
@@ -49,6 +84,14 @@ export function DashboardHome() {
4984
const [indexingRepoId, setIndexingRepoId] = useState<string | null>(null)
5085
const [indexingRepoName, setIndexingRepoName] = useState<string>('')
5186
const [showIndexingModal, setShowIndexingModal] = useState(false)
87+
88+
// Upgrade prompt modal state
89+
const [upgradeModal, setUpgradeModal] = useState<{ show: boolean; message: string; repoName?: string }>({ show: false, message: '' })
90+
91+
// Helper to show upgrade modal with context
92+
const showUpgradeModal = (err: any, repoName?: string) => {
93+
setUpgradeModal({ show: true, message: extractErrorMessage(err, 'Repository exceeds free tier limits'), repoName })
94+
}
5295

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

97140
if (!response.ok) {
98141
const err = await response.json().catch(() => ({}))
99-
throw new Error(err.detail || 'Failed to add repository')
142+
if (isUpgradeError(err)) {
143+
showUpgradeModal(err, name)
144+
return
145+
}
146+
throw new Error(extractErrorMessage(err, 'Failed to add repository'))
100147
}
101148

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

111158
if (!indexResponse.ok) {
112159
const err = await indexResponse.json().catch(() => ({}))
113-
throw new Error(err.detail?.message || err.detail || 'Failed to start indexing')
160+
if (isUpgradeError(err)) {
161+
showUpgradeModal(err, name)
162+
return
163+
}
164+
throw new Error(extractErrorMessage(err, 'Failed to start indexing'))
114165
}
115166

116167
// Show indexing progress modal
@@ -150,7 +201,11 @@ export function DashboardHome() {
150201

151202
if (!response.ok) {
152203
const err = await response.json().catch(() => ({}))
153-
throw new Error(err.detail || `Failed to add ${repo.name}`)
204+
if (isUpgradeError(err)) {
205+
showUpgradeModal(err, repo.name)
206+
continue
207+
}
208+
throw new Error(extractErrorMessage(err, `Failed to add ${repo.name}`))
154209
}
155210

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

165220
if (!indexResponse.ok) {
166221
const err = await indexResponse.json().catch(() => ({}))
167-
const errMsg = err.detail?.message || err.detail || 'Indexing failed to start'
222+
if (isUpgradeError(err)) {
223+
showUpgradeModal(err, repo.name)
224+
continue
225+
}
226+
const errMsg = extractErrorMessage(err, 'Indexing failed to start')
168227
console.error(`Failed to start indexing for ${repo.name}:`, err)
169228
toast.warning(`${repo.name} added but indexing failed`, {
170229
description: errMsg
@@ -439,6 +498,14 @@ export function DashboardHome() {
439498
maxSelectable={MAX_FREE_REPOS}
440499
currentRepoCount={repos.length}
441500
/>
501+
502+
{/* Upgrade Limit Modal */}
503+
<UpgradeLimitModal
504+
isOpen={upgradeModal.show}
505+
onClose={() => setUpgradeModal({ show: false, message: '' })}
506+
errorMessage={upgradeModal.message}
507+
repoName={upgradeModal.repoName}
508+
/>
442509
</div>
443510
)
444511
}

0 commit comments

Comments
 (0)