Skip to content
Merged
23 changes: 8 additions & 15 deletions dashboard/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,6 @@
redis.hset('stats:repo_scores', { [slug]: report.healthScore }),
redis.hset('stats:repo_last_seen', { [slug]: new Date().toISOString() }),
redis.sadd('stats:unique_ips', ip),
// Watchlist (recently checked) — dedupe then prepend
redis.lrange('devlens:watchlist', 0, 99).then(async (existing: any[]) => {
for (const item of existing ?? []) {
if (item?.slug === slug) await redis.lrem('devlens:watchlist', 0, item)
}
await redis.lpush('devlens:watchlist', {
slug,
score: report.healthScore,
description: report.description ?? null,
language: report.language ?? null,
savedAt: new Date().toISOString(),
})
await redis.ltrim('devlens:watchlist', 0, 99)
}),
]).catch(() => {})
// ─────────────────────────────────────

Expand All @@ -74,6 +60,13 @@
if (e.code === 'rate_limited') {
return NextResponse.json({ error: 'rate_limited', message: e.message }, { status: 429 })
}
return NextResponse.json({ error: e.message ?? 'Analysis failed' }, { status: 500 })
if (e.code === 'not_found') {
return NextResponse.json({ error: 'repository_not_found', message: 'Repository not found or inaccessible. Check the owner/name and make sure the repository is public.' }, { status: 404 })
}
if (e.code === 'private_repository') {
return NextResponse.json({ error: 'private_repository', message: 'Private repositories are not supported.' }, { status: 403 })
}
console.error('analysis error', e)

Check warning on line 69 in dashboard/app/api/analyze/route.ts

View workflow job for this annotation

GitHub Actions / dashboard

Unexpected console statement

Check warning on line 69 in dashboard/app/api/analyze/route.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected console statement
return NextResponse.json({ error: 'analysis_failed', message: 'Unable to analyze this repository right now. Please verify the repository and try again.' }, { status: 500 })
}
}
64 changes: 40 additions & 24 deletions dashboard/app/api/watchlist/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import { normalizeWatchEntry } from '@/lib/watchlist-validation.mjs';

const redis = Redis.fromEnv();

const KEY = 'devlens:watchlist';
const LOCK_KEY = 'devlens:watchlist:lock';
const MAX = 100;
Expand All @@ -19,22 +18,46 @@
}

async function acquireLock(): Promise<boolean> {
const result = await redis.set(LOCK_KEY, `${Date.now()}-${Math.random()}`, { nx: true, ex: 5 });
return result === 'OK';
return (await redis.set(LOCK_KEY, `${Date.now()}-${Math.random()}`, { nx: true, ex: 5 })) === 'OK';
}
async function releaseLock() { try { await redis.del(LOCK_KEY); } catch {} }

function dedupe(entries: WatchEntry[]): WatchEntry[] {
const seen = new Set<string>();
const result: WatchEntry[] = [];
for (const item of entries) {
const normalized = normalizeWatchEntry(item);
if (!normalized || seen.has(normalized.slug)) continue;
seen.add(normalized.slug);
result.push({ ...normalized, savedAt: item.savedAt || normalized.savedAt });
if (result.length >= MAX) break;
}
return result;
}

async function releaseLock() {
try { await redis.del(LOCK_KEY); } catch {}
async function readCanonicalList(): Promise<WatchEntry[]> {
const raw = await redis.lrange<WatchEntry>(KEY, 0, MAX - 1);
const canonical = dedupe(raw ?? []);
if ((raw ?? []).length !== canonical.length) {
let locked = false;
try {
locked = await acquireLock();
if (locked) {
await redis.del(KEY);
if (canonical.length) await redis.rpush(KEY, ...canonical);
}
} finally { if (locked) await releaseLock(); }
}
return canonical;
}

export async function GET(req: Request) {
try {
const limit = await consumeRateLimit(redis, requestIdentity(req), 'watchlist');
if (!limit.allowed) return NextResponse.json({ error: 'rate_limited', message: 'Watchlist rate limit exceeded. Try again shortly.' }, { status: 429, headers: limit.headers });
const list = await redis.lrange<WatchEntry>(KEY, 0, MAX - 1);
return NextResponse.json({ list: list ?? [] }, { headers: limit.headers });
return NextResponse.json({ list: await readCanonicalList() }, { headers: limit.headers });
} catch (err) {
console.error('watchlist GET error', err);

Check warning on line 60 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / dashboard

Unexpected console statement

Check warning on line 60 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected console statement
return NextResponse.json({ list: [], degraded: true });
}
}
Expand All @@ -51,40 +74,33 @@
try {
locked = await acquireLock();
if (!locked) return NextResponse.json({ ok: false, error: 'watchlist_busy' }, { status: 409 });
const existing = await redis.lrange<WatchEntry>(KEY, 0, MAX - 1);
for (const item of existing ?? []) {
if (item?.slug === entry.slug) await redis.lrem(KEY, 0, item);
}
await redis.lpush(KEY, entry);
await redis.ltrim(KEY, 0, MAX - 1);
const existing = dedupe((await redis.lrange<WatchEntry>(KEY, 0, MAX - 1)) ?? []);
const next = [entry, ...existing.filter(item => item.slug !== entry.slug)].slice(0, MAX);
await redis.del(KEY);
await redis.rpush(KEY, ...next);
return NextResponse.json({ ok: true }, { headers: limit.headers });
} catch (err) {
console.error('watchlist POST error', err);

Check warning on line 83 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / dashboard

Unexpected console statement

Check warning on line 83 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected console statement
return NextResponse.json({ ok: false, error: 'watchlist_unavailable' }, { status: 503 });
} finally {
if (locked) await releaseLock();
}
} finally { if (locked) await releaseLock(); }
}

export async function DELETE(req: Request) {
const limit = await consumeRateLimit(redis, requestIdentity(req), 'watchlist');
if (!limit.allowed) return NextResponse.json({ ok: false, error: 'rate_limited', message: 'Watchlist rate limit exceeded. Try again shortly.' }, { status: 429, headers: limit.headers });
const parsed = parseRepoSlug(new URL(req.url).searchParams.get('slug'));
if (!parsed) return NextResponse.json({ ok: false, error: 'invalid_repository' }, { status: 400 });

let locked = false;
try {
locked = await acquireLock();
if (!locked) return NextResponse.json({ ok: false, error: 'watchlist_busy' }, { status: 409 });
const existing = await redis.lrange<WatchEntry>(KEY, 0, MAX - 1);
for (const item of existing ?? []) {
if (item?.slug === parsed.slug) await redis.lrem(KEY, 0, item);
}
const existing = dedupe((await redis.lrange<WatchEntry>(KEY, 0, MAX - 1)) ?? []);
const next = existing.filter(item => item.slug !== parsed.slug);
await redis.del(KEY);
if (next.length) await redis.rpush(KEY, ...next);
return NextResponse.json({ ok: true }, { headers: limit.headers });
} catch (err) {
console.error('watchlist DELETE error', err);

Check warning on line 103 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / dashboard

Unexpected console statement

Check warning on line 103 in dashboard/app/api/watchlist/route.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected console statement
return NextResponse.json({ ok: false, error: 'watchlist_unavailable' }, { status: 503 });
} finally {
if (locked) await releaseLock();
}
} finally { if (locked) await releaseLock(); }
}
8 changes: 7 additions & 1 deletion dashboard/app/badge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RepoReport } from '@/lib/scorer'
import ThemeToggle from '@/components/ThemeToggle'
import { Search, Loader2, ArrowRight, Copy, Check, ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { parseRepoSlug } from '@/lib/repo-validation.mjs'

export default function BadgePage() {
const [input, setInput] = useState('')
Expand All @@ -17,7 +18,12 @@ export default function BadgePage() {
if (!input.trim()) return
setLoading(true); setError(''); setReport(null)
try {
const slug = input.trim().replace('https://github.com/', '').replace(/\/$/, '')
const parsed = parseRepoSlug(input)
if (!parsed) {
setError('Enter a repository as owner/name or a repository-root GitHub URL.')
return
}
const slug = parsed.slug
const res = await fetch(`/api/analyze?repo=${encodeURIComponent(slug)}`)
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Analysis failed'); return }
Expand Down
16 changes: 8 additions & 8 deletions dashboard/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SnippetModal from '@/components/SnippetModal'
import WeightEditor from '@/components/WeightEditor'
import type { RepoReport } from '@/lib/scorer'
import { DEFAULT_WEIGHTS, DimKey } from '@/lib/constants'
import { parseRepoSlug } from '@/lib/repo-validation.mjs'
import Link from 'next/link'
import { signIn } from 'next-auth/react'
import type { WatchEntry } from '@/app/api/watchlist/route'
Expand Down Expand Up @@ -39,7 +40,12 @@ export default function Home() {
if (!input.trim()) return
setLoading(true); setError(null); setReport(null); setHistory([])
try {
const slug = input.trim().replace('https://github.com/', '').replace(/\/$/, '')
const parsed = parseRepoSlug(input)
if (!parsed) {
setError({ type: 'invalid_repository', message: 'Enter a repository as owner/name or a repository-root GitHub URL.' })
return
}
const slug = parsed.slug
const weightsSum = Object.values(weights).reduce((a, b) => a + b, 0)
const sourceWeights = Number.isFinite(weightsSum) && weightsSum > 0 ? weights : DEFAULT_WEIGHTS
const sourceSum = Object.values(sourceWeights).reduce((a, b) => a + b, 0)
Expand All @@ -59,20 +65,14 @@ export default function Home() {
}
setReport(data)

// Save to watchlist so "Recently Checked" and /checked are live
const entry: WatchEntry = {
slug,
score: data.healthScore,
description: data.description ?? null,
language: data.language ?? null,
savedAt: new Date().toISOString(),
}
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry),
}).catch(() => {})
// Optimistic update — prepend and dedupe
// The analyze API is the single writer for recently checked repositories.
setRecentList(prev => [entry, ...prev.filter(w => w.slug !== slug)].slice(0, 10))

const hData = await histRes.json()
Expand Down
14 changes: 6 additions & 8 deletions dashboard/lib/repo-validation.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/
const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/

/**
* Parse only a GitHub owner/name slug or an https://github.com/owner/name URL.
* Returning null keeps untrusted input out of every outbound GitHub request.
*/
/** Parse only a canonical GitHub repository slug or repository-root HTTPS URL. */
export function parseRepoSlug(input) {
if (typeof input !== 'string') return null
const value = input.trim()
Expand All @@ -17,9 +14,7 @@ export function parseRepoSlug(input) {
if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') return null
if (url.username || url.password || url.search || url.hash) return null
path = url.pathname
} catch {
return null
}
} catch { return null }
} else if (value.includes('://') || value.startsWith('//')) {
return null
}
Expand All @@ -30,6 +25,9 @@ export function parseRepoSlug(input) {
const [owner, name] = parts
if (!OWNER_PATTERN.test(owner) || !REPO_PATTERN.test(name)) return null
if (owner.length > 39 || name.length > 100) return null

return { owner, name, slug: `${owner}/${name}` }
}

export function repoInputError() {
return 'Invalid repository. Use owner/name or the repository root URL https://github.com/owner/name.'
}
12 changes: 10 additions & 2 deletions dashboard/lib/scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ async function ghFetch(url: string, token?: string): Promise<any> {
err.code = 'rate_limited'
throw err
}
throw new Error(`GitHub API error ${r.status} ${url}`)
const err: any = new Error(
r.status === 404
? 'Repository not found or inaccessible.'
: 'GitHub request failed.'
)
if (r.status === 404) err.code = 'not_found'
throw err
}
return r.json()
}
Expand Down Expand Up @@ -265,7 +271,9 @@ export async function analyzeRepo(
// private report from ever being served through a public owner/name key.
const repoData = await ghFetch(`${GH}/repos/${owner}/${name}`, token)
if (repoData.private === true) {
throw new Error('Private repositories are not supported')
const err: any = new Error('Private repositories are not supported')
err.code = 'private_repository'
throw err
}

if (redis && !customWeights) {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/scripts/validation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test('accepts owner/name and canonical GitHub HTTPS URLs', () => {

test('rejects non-GitHub, ambiguous, and control-character repository inputs', () => {
for (const value of [
'', 'owner', 'owner/name/extra', 'http://github.com/owner/name',
'', 'owner', 'owner/name/extra', 'https://github.com/owner/name/blob/main/README.md', 'https://github.com/owner/name/tree/main', 'http://github.com/owner/name',
'https://example.com/owner/name', 'https://github.com/owner/name?x=1',
'https://github.com/owner/name#fragment', 'owner/name\u0000', '//github.com/owner/name',
]) assert.equal(parseRepoSlug(value), null, value)
Expand Down
Loading