diff --git a/dashboard/app/api/pr/route.ts b/dashboard/app/api/pr/route.ts new file mode 100644 index 0000000..8c3f8b5 --- /dev/null +++ b/dashboard/app/api/pr/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { parseRepoSlug } from '@/lib/repo-validation.mjs' +import { consumeRateLimit, requestIdentity } from '@/lib/rate-limit.mjs' +import { getRedis, getJson, setJson } from '@/lib/redis' +import { buildPrIntelligence } from '@/lib/pr-intelligence.mjs' + +export const dynamic = 'force-dynamic' +const GH = 'https://api.github.com' + +async function gh(path: string, token?: string): Promise { + const res = await fetch(GH + path, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, cache: 'no-store' }) + if (!res.ok) throw new Error('GitHub API ' + res.status + ': ' + res.statusText) + return res.json() +} + +export async function GET(req: NextRequest) { + const params = new URL(req.url).searchParams + const repoInput = params.get('repo') + const numberInput = params.get('number') + if (!repoInput || !numberInput) return NextResponse.json({ error: 'repo and number params required' }, { status: 400 }) + const repo = parseRepoSlug(repoInput) + const number = Number(numberInput) + if (!repo || !Number.isInteger(number) || number < 1 || number > 1000000) return NextResponse.json({ error: 'Invalid repository or pull request number' }, { status: 400 }) + try { + const session = await auth() + const token = (session as any)?.accessToken ?? process.env.GITHUB_TOKEN + const limit = await consumeRateLimit(getRedis(), requestIdentity(req, (session as any)?.user?.email), 'pr') + if (!limit.allowed) return NextResponse.json({ error: 'rate_limited' }, { status: 429, headers: limit.headers }) + const cacheKey = 'pr-intelligence:' + repo.owner + ':' + repo.name + ':' + number + if (!params.has('force')) { + const cached = await getJson(cacheKey) + if (cached) return NextResponse.json(cached, { headers: { 'X-DevLens-Cache': 'HIT' } }) + } + const base = '/repos/' + repo.owner + '/' + repo.name + '/pulls/' + number + const [pr, reviews, checks, files] = await Promise.all([ + gh(base, token), + gh(base + '/reviews?per_page=100', token), + gh('/repos/' + repo.owner + '/' + repo.name + '/commits/' + number + '/check-runs?per_page=100', token).catch(() => ({ check_runs: [] })), + gh(base + '/files?per_page=100', token), + ]) + const result = buildPrIntelligence(pr, reviews, checks.check_runs ?? [], files) + const response = { ...result, cached: false } + await setJson(cacheKey, response, 300) + return NextResponse.json(response, { headers: { 'X-DevLens-Cache': 'MISS' } }) + } catch (e: any) { + return NextResponse.json({ error: e.message ?? 'PR intelligence failed' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/dashboard/app/pr/page.tsx b/dashboard/app/pr/page.tsx new file mode 100644 index 0000000..9d9963c --- /dev/null +++ b/dashboard/app/pr/page.tsx @@ -0,0 +1,40 @@ +"use client" + +import { useState } from 'react' + +type Report = { number: number; title: string; state: string; draft: boolean; author: string | null; htmlUrl: string; additions: number; deletions: number; changedFiles: number; changeSize: string; ageHours: number | null; mergeable: boolean | null; review: { approvals: number; changesRequested: number; commented: number }; checks: { total: number; successful: number; failed: number; pending: number; status: string }; sensitiveFiles: string[]; riskSignals: string[] } + +export default function PrIntelligencePage() { + const [repo, setRepo] = useState('') + const [number, setNumber] = useState('') + const [report, setReport] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + async function analyze() { + setError(''); setReport(null); setLoading(true) + try { + const res = await fetch('/api/pr?repo=' + encodeURIComponent(repo) + '&number=' + encodeURIComponent(number)) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'PR analysis failed') + setReport(data) + } catch (e: any) { setError(e.message || 'PR analysis failed') } + finally { setLoading(false) } + } + return
+

PR Intelligence

+

Evidence-based pull request analysis covering change size, review state, CI status, age, mergeability, and sensitive-file exposure.

+
+ setRepo(e.target.value)} placeholder="owner/repository" style={{ flex: 1, minWidth: 240, padding: 12, border: '1px solid var(--divider)', borderRadius: 8, background: 'var(--surface)' }} /> + setNumber(e.target.value)} placeholder="PR #" inputMode="numeric" style={{ width: 120, padding: 12, border: '1px solid var(--divider)', borderRadius: 8, background: 'var(--surface)' }} /> + +
+ {error &&

{error}

} + {report &&
+

{report.title}

#{report.number} · {report.author ?? 'unknown'} · {report.draft ? 'Draft' : report.state}

Open on GitHub
+
+ {[['Change', report.changeSize], ['Lines', '+' + report.additions + ' / -' + report.deletions], ['Files', String(report.changedFiles)], ['Reviews', report.review.approvals + ' approved / ' + report.review.changesRequested + ' changes requested'], ['CI', report.checks.status], ['Mergeability', report.mergeable === null ? 'unknown' : report.mergeable ? 'mergeable' : 'blocked']].map(([label, value]) =>
{label}
{value}
)} +
+

Evidence signals

{report.riskSignals.length ?
    {report.riskSignals.map(s =>
  • {s.replaceAll('_', ' ')}
  • )}
:

No detected risk signals from the available evidence.

}{report.sensitiveFiles.length > 0 && <>

Sensitive files touched

    {report.sensitiveFiles.map(f =>
  • {f}
  • )}
}
+
} +
+} \ No newline at end of file diff --git a/dashboard/components/Nav.tsx b/dashboard/components/Nav.tsx index 880df2a..554ef25 100644 --- a/dashboard/components/Nav.tsx +++ b/dashboard/components/Nav.tsx @@ -18,6 +18,7 @@ export default function Nav() { { href: "/stats", label: "Stats" }, { href: "/security", label: "Security" }, { href: "/dependencies", label: "Dependencies" }, + { href: "/pr", label: "PR Intelligence" }, { href: "/docs", label: "Docs" }, { href: "/changelog", label: "Changelog" }, { href: "/sponsor", label: "Sponsor" }, diff --git a/dashboard/lib/pr-intelligence.mjs b/dashboard/lib/pr-intelligence.mjs new file mode 100644 index 0000000..610b913 --- /dev/null +++ b/dashboard/lib/pr-intelligence.mjs @@ -0,0 +1,52 @@ +export function classifyChangeSize(additions, deletions, filesChanged) { + const lines = Math.max(0, Number(additions) || 0) + Math.max(0, Number(deletions) || 0) + const files = Math.max(0, Number(filesChanged) || 0) + if (lines <= 50 && files <= 5) return 'small' + if (lines <= 250 && files <= 15) return 'medium' + if (lines <= 750 && files <= 30) return 'large' + return 'very_large' +} + +export function summarizeReviewState(reviews = []) { + const latestByUser = new Map() + for (const review of reviews) { + if (!review?.user?.login) continue + latestByUser.set(review.user.login, review) + } + const latest = [...latestByUser.values()] + return { + approvals: latest.filter(r => r.state === 'APPROVED').length, + changesRequested: latest.filter(r => r.state === 'CHANGES_REQUESTED').length, + commented: latest.filter(r => r.state === 'COMMENTED').length, + } +} + +export function summarizeChecks(checks = []) { + const list = Array.isArray(checks) ? checks : [] + const successful = list.filter(c => c.conclusion === 'success').length + const failed = list.filter(c => ['failure', 'cancelled', 'timed_out', 'action_required'].includes(c.conclusion)).length + const pending = list.filter(c => !c.conclusion || c.status !== 'completed').length + return { total: list.length, successful, failed, pending, status: failed ? 'failing' : pending ? 'pending' : list.length ? 'passing' : 'no_checks' } +} + +export function detectSensitiveFiles(files = []) { + const patterns = [/^\.github\//i, /(^|\/)package-lock\.json$/i, /(^|\/)pnpm-lock\.yaml$/i, /(^|\/)yarn\.lock$/i, /(^|\/)requirements[^/]*\.txt$/i, /(^|\/)Dockerfile/i, /(^|\/)\.env/i, /(^|\/)terraform/i, /(^|\/)auth/i, /(^|\/)security/i] + return files.filter(file => patterns.some(pattern => pattern.test(file.filename || ''))).map(file => file.filename).slice(0, 30) +} + +export function buildPrIntelligence(pr, reviews, checks, files) { + const review = summarizeReviewState(reviews) + const ci = summarizeChecks(checks) + const sensitiveFiles = detectSensitiveFiles(files) + const size = classifyChangeSize(pr.additions, pr.deletions, pr.changed_files) + const createdAt = new Date(pr.created_at).getTime() + const ageHours = Number.isFinite(createdAt) ? Math.max(0, Math.round((Date.now() - createdAt) / 3600000)) : null + const riskSignals = [] + if (size === 'large' || size === 'very_large') riskSignals.push('large_change') + if (ci.status === 'failing') riskSignals.push('failing_checks') + if (ci.status === 'pending') riskSignals.push('pending_checks') + if (review.changesRequested > 0) riskSignals.push('changes_requested') + if (sensitiveFiles.length) riskSignals.push('sensitive_files_touched') + if (pr.mergeable === false) riskSignals.push('not_mergeable') + return { number: pr.number, title: pr.title, state: pr.state, draft: Boolean(pr.draft), author: pr.user?.login ?? null, htmlUrl: pr.html_url, base: pr.base?.ref ?? null, head: pr.head?.ref ?? null, additions: pr.additions, deletions: pr.deletions, changedFiles: pr.changed_files, changeSize: size, ageHours, mergeable: pr.mergeable, mergeableState: pr.mergeable_state ?? null, review, checks: ci, sensitiveFiles, riskSignals, generatedAt: new Date().toISOString() } +} \ No newline at end of file diff --git a/dashboard/lib/rate-limit.mjs b/dashboard/lib/rate-limit.mjs index 1fe8199..59dfcff 100644 --- a/dashboard/lib/rate-limit.mjs +++ b/dashboard/lib/rate-limit.mjs @@ -50,6 +50,7 @@ export function rateLimitPolicy(endpoint) { history: { limit: 30, windowSeconds: 60 }, org: { limit: 3, windowSeconds: 60 }, watchlist: { limit: 30, windowSeconds: 60 }, + pr: { limit: 10, windowSeconds: 60 }, } return policies[endpoint] ?? { limit: 10, windowSeconds: 60 } } diff --git a/dashboard/scripts/pr-intelligence.test.mjs b/dashboard/scripts/pr-intelligence.test.mjs new file mode 100644 index 0000000..2cb09fd --- /dev/null +++ b/dashboard/scripts/pr-intelligence.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildPrIntelligence, classifyChangeSize, detectSensitiveFiles, summarizeChecks, summarizeReviewState } from '../lib/pr-intelligence.mjs' + +test('classifies PR change size', () => { + assert.equal(classifyChangeSize(10, 10, 2), 'small') + assert.equal(classifyChangeSize(100, 100, 8), 'medium') + assert.equal(classifyChangeSize(350, 350, 20), 'large') + assert.equal(classifyChangeSize(1000, 1000, 40), 'very_large') +}) + +test('deduplicates reviews by reviewer', () => { + const result = summarizeReviewState([{ user: { login: 'a' }, state: 'COMMENTED' }, { user: { login: 'a' }, state: 'APPROVED' }, { user: { login: 'b' }, state: 'CHANGES_REQUESTED' }]) + assert.deepEqual(result, { approvals: 1, changesRequested: 1, commented: 0 }) +}) + +test('summarizes CI checks', () => { + assert.deepEqual(summarizeChecks([{ status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'failure' }, { status: 'in_progress', conclusion: null }]), { total: 3, successful: 1, failed: 1, pending: 1, status: 'failing' }) +}) + +test('detects sensitive files', () => { + assert.deepEqual(detectSensitiveFiles([{ filename: 'src/app.ts' }, { filename: '.github/workflows/ci.yml' }, { filename: 'package-lock.json' }]), ['.github/workflows/ci.yml', 'package-lock.json']) +}) + +test('builds evidence signals', () => { + const result = buildPrIntelligence({ number: 42, title: 'Update auth', state: 'open', draft: false, user: { login: 'ossama' }, html_url: 'https://github.com/example/repo/pull/42', base: { ref: 'main' }, head: { ref: 'feature/auth' }, additions: 400, deletions: 200, changed_files: 18, created_at: new Date().toISOString(), mergeable: false, mergeable_state: 'blocked' }, [{ user: { login: 'reviewer' }, state: 'APPROVED' }], [{ status: 'completed', conclusion: 'success' }], [{ filename: 'auth/login.ts' }]) + assert.equal(result.changeSize, 'large') + assert.equal(result.review.approvals, 1) + assert.equal(result.checks.status, 'passing') + assert.ok(result.riskSignals.includes('sensitive_files_touched')) + assert.ok(result.riskSignals.includes('not_mergeable')) +}) \ No newline at end of file