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
49 changes: 49 additions & 0 deletions dashboard/app/api/pr/route.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any>(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 })
}
}
40 changes: 40 additions & 0 deletions dashboard/app/pr/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Report | null>(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 <main style={{ maxWidth: 1100, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 800 }}>PR Intelligence</h1>
<p style={{ color: 'var(--text-muted)', maxWidth: 760 }}>Evidence-based pull request analysis covering change size, review state, CI status, age, mergeability, and sensitive-file exposure.</p>
<section style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 24 }}>
<input value={repo} onChange={e => setRepo(e.target.value)} placeholder="owner/repository" style={{ flex: 1, minWidth: 240, padding: 12, border: '1px solid var(--divider)', borderRadius: 8, background: 'var(--surface)' }} />
<input value={number} onChange={e => setNumber(e.target.value)} placeholder="PR #" inputMode="numeric" style={{ width: 120, padding: 12, border: '1px solid var(--divider)', borderRadius: 8, background: 'var(--surface)' }} />
<button onClick={analyze} disabled={loading} style={{ padding: '12px 18px', borderRadius: 8, border: 0, background: 'var(--primary)', color: 'white', fontWeight: 700 }}>{loading ? 'Analyzing…' : 'Analyze PR'}</button>
</section>
{error && <p style={{ color: '#b42318', marginTop: 16 }}>{error}</p>}
{report && <section style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<div style={{ padding: 20, border: '1px solid var(--divider)', borderRadius: 12 }}><h2 style={{ margin: 0 }}>{report.title}</h2><p style={{ color: 'var(--text-muted)' }}>#{report.number} · {report.author ?? 'unknown'} · {report.draft ? 'Draft' : report.state}</p><a href={report.htmlUrl} target="_blank" rel="noopener noreferrer">Open on GitHub</a></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(150px,1fr))', gap: 12 }}>
{[['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]) => <div key={label} style={{ padding: 16, border: '1px solid var(--divider)', borderRadius: 10 }}><small style={{ color: 'var(--text-muted)' }}>{label}</small><div style={{ fontWeight: 700, marginTop: 6 }}>{value}</div></div>)}
</div>
<div style={{ padding: 20, border: '1px solid var(--divider)', borderRadius: 12 }}><h3>Evidence signals</h3>{report.riskSignals.length ? <ul>{report.riskSignals.map(s => <li key={s}>{s.replaceAll('_', ' ')}</li>)}</ul> : <p>No detected risk signals from the available evidence.</p>}{report.sensitiveFiles.length > 0 && <><h3>Sensitive files touched</h3><ul>{report.sensitiveFiles.map(f => <li key={f}><code>{f}</code></li>)}</ul></>}</div>
</section>}
</main>
}
1 change: 1 addition & 0 deletions dashboard/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
52 changes: 52 additions & 0 deletions dashboard/lib/pr-intelligence.mjs
Original file line number Diff line number Diff line change
@@ -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() }
}
1 change: 1 addition & 0 deletions dashboard/lib/rate-limit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
32 changes: 32 additions & 0 deletions dashboard/scripts/pr-intelligence.test.mjs
Original file line number Diff line number Diff line change
@@ -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'))
})
Loading