diff --git a/.env.example b/.env.example index fb3d6ea..443d9cd 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,7 @@ MOBILE_REDIRECT_URI=devcard://oauth/callback # ─── Server ─── PORT=3000 NODE_ENV=development + +# ─── AI (optional — enables AI-generated developer summaries) ─── +# Get a free key at https://aistudio.google.com/app/apikey +GEMINI_API_KEY= diff --git a/apps/backend/prisma/migrations/20260519000000_github_insights_cache/migration.sql b/apps/backend/prisma/migrations/20260519000000_github_insights_cache/migration.sql new file mode 100644 index 0000000..3b791d8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260519000000_github_insights_cache/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "github_insights_cache" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "github_insights_cache_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "github_insights_cache_user_id_key" ON "github_insights_cache"("user_id"); + +-- AddForeignKey +ALTER TABLE "github_insights_cache" ADD CONSTRAINT "github_insights_cache_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 13dec57..059b51f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { ownedViews CardView[] @relation("cardOwner") viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] + githubInsightsCache GitHubInsightsCache? @@unique([provider, providerId]) @@map("users") @@ -124,3 +125,16 @@ model FollowLog { @@map("follow_logs") } + +model GitHubInsightsCache { + id String @id @default(uuid()) + userId String @unique @map("user_id") + payload Json // serialized GitHubInsights + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("github_insights_cache") +} diff --git a/apps/backend/src/__tests__/github-insights.test.ts b/apps/backend/src/__tests__/github-insights.test.ts new file mode 100644 index 0000000..59fdf86 --- /dev/null +++ b/apps/backend/src/__tests__/github-insights.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { githubInsightsRoutes } from '../routes/github-insights.js'; + +// ─── Mocks ─── + +const mockPrisma = { + oAuthToken: { + findUnique: vi.fn(), + }, + gitHubInsightsCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, +}; + +const mockRedis = { + get: vi.fn(), + set: vi.fn(), +} as any; + +// Stable encrypted token value (decrypt mock returns 'ghp_test_token') +const ENCRYPTED_TOKEN = 'encrypted_github_token'; +const DECRYPTED_TOKEN = 'ghp_test_token'; + +// Mock encryption module +vi.mock('../utils/encryption.js', () => ({ + decrypt: vi.fn((val: string) => { + if (val === ENCRYPTED_TOKEN) return DECRYPTED_TOKEN; + throw new Error('Decryption failed'); + }), +})); + +// ─── GitHub API mock responses ─── + +const mockGitHubUser = { + login: 'octocat', + public_repos: 3, + followers: 100, + following: 50, + created_at: '2020-01-01T00:00:00Z', +}; + +const mockGitHubRepos = [ + { + name: 'awesome-project', + description: 'My best project', + html_url: 'https://github.com/octocat/awesome-project', + stargazers_count: 42, + forks_count: 10, + language: 'TypeScript', + fork: false, + updated_at: '2024-01-01T00:00:00Z', + }, + { + name: 'another-repo', + description: null, + html_url: 'https://github.com/octocat/another-repo', + stargazers_count: 5, + forks_count: 1, + language: 'JavaScript', + fork: false, + updated_at: '2023-06-01T00:00:00Z', + }, + { + name: 'forked-repo', + description: 'A fork', + html_url: 'https://github.com/octocat/forked-repo', + stargazers_count: 0, + forks_count: 0, + language: 'Python', + fork: true, + updated_at: '2023-01-01T00:00:00Z', + }, +]; + +// ─── App builder ─── + +async function buildApp() { + const app = Fastify(); + + app.decorate('prisma', mockPrisma); + app.decorate('redis', mockRedis); + app.decorate('authenticate', async (request: any) => { + request.user = { id: 'user-123' }; + }); + + app.register(githubInsightsRoutes, { prefix: '/api/analytics' }); + await app.ready(); + return app; +} + +// ─── Tests ─── + +describe('GET /api/analytics/github-insights', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: no cache + mockRedis.get.mockResolvedValue(null); + mockPrisma.gitHubInsightsCache.findUnique.mockResolvedValue(null); + mockPrisma.gitHubInsightsCache.upsert.mockResolvedValue({}); + mockRedis.set.mockResolvedValue('OK'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 400 when GitHub is not connected', async () => { + mockPrisma.oAuthToken.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('GitHub account not connected'); + expect(res.json().requiresAuth).toBe(true); + }); + + it('returns live insights when no cache exists', async () => { + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user user:email', + }); + + // Mock GitHub API calls + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => mockGitHubUser, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockGitHubRepos, + }), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + + expect(body.source).toBe('live'); + expect(body.username).toBe('octocat'); + expect(body.totalRepos).toBe(3); + expect(body.followers).toBe(100); + expect(body.primaryLanguage).toBe('TypeScript'); + expect(body.topRepos).toHaveLength(2); // forked repo excluded + expect(body.topRepos[0].name).toBe('awesome-project'); + expect(body.topRepos[0].stars).toBe(42); + expect(body.totalStars).toBe(47); // 42 + 5 (fork excluded) + expect(body.languageStats).toHaveLength(2); // TypeScript + JavaScript (fork excluded) + expect(body.fetchedAt).toBeDefined(); + }); + + it('returns cached data from Redis when available', async () => { + const cachedInsights = { + username: 'octocat', + totalRepos: 3, + totalStars: 47, + totalForks: 11, + followers: 100, + following: 50, + topRepos: [], + languageStats: [], + primaryLanguage: 'TypeScript', + accountCreatedAt: '2020-01-01T00:00:00Z', + fetchedAt: new Date().toISOString(), + aiSummary: null, + }; + + mockRedis.get.mockResolvedValue(JSON.stringify(cachedInsights)); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.source).toBe('cache'); + expect(body.username).toBe('octocat'); + // GitHub API should NOT have been called + expect(mockPrisma.oAuthToken.findUnique).not.toHaveBeenCalled(); + }); + + it('returns cached data from DB when Redis misses', async () => { + mockRedis.get.mockResolvedValue(null); + + const cachedInsights = { + username: 'octocat', + totalRepos: 3, + totalStars: 47, + totalForks: 11, + followers: 100, + following: 50, + topRepos: [], + languageStats: [], + primaryLanguage: 'TypeScript', + accountCreatedAt: '2020-01-01T00:00:00Z', + fetchedAt: new Date().toISOString(), + aiSummary: null, + }; + + mockPrisma.gitHubInsightsCache.findUnique.mockResolvedValue({ + userId: 'user-123', + payload: cachedInsights, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.source).toBe('cache'); + expect(body.username).toBe('octocat'); + // Redis should have been warmed up + expect(mockRedis.set).toHaveBeenCalled(); + }); + + it('bypasses cache when ?refresh=true', async () => { + // Even with Redis data present, should fetch fresh + mockRedis.get.mockResolvedValue(JSON.stringify({ username: 'stale' })); + + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user user:email', + }); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubUser }) + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubRepos }), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights?refresh=true', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().source).toBe('live'); + expect(res.json().username).toBe('octocat'); + }); + + it('returns 401 when GitHub token is expired', async () => { + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user', + }); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' }) + .mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' }), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(401); + expect(res.json().requiresAuth).toBe(true); + }); + + it('correctly excludes forked repos from star and language counts', async () => { + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user', + }); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubUser }) + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubRepos }), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + const body = res.json(); + // Python only appears in the forked repo — should not be in languageStats + const pythonStat = body.languageStats.find((l: any) => l.language === 'Python'); + expect(pythonStat).toBeUndefined(); + // forked-repo should not appear in topRepos + const forkedRepo = body.topRepos.find((r: any) => r.name === 'forked-repo'); + expect(forkedRepo).toBeUndefined(); + }); + + it('sets statsAreCapped=true when user has more than 200 repos', async () => { + const heavyUser = { ...mockGitHubUser, public_repos: 250 }; + + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user', + }); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => heavyUser }) + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubRepos }) + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubRepos }), // page 2 + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().statsAreCapped).toBe(true); + }); + + it('sets statsAreCapped=false when user has 200 or fewer repos', async () => { + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ + accessToken: ENCRYPTED_TOKEN, + scopes: 'read:user', + }); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubUser }) // public_repos: 3 + .mockResolvedValueOnce({ ok: true, json: async () => mockGitHubRepos }), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/github-insights', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().statsAreCapped).toBe(false); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 8e8cf38..bf11405 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,6 +17,7 @@ import { publicRoutes } from './routes/public.js'; import { followRoutes } from './routes/follow.js'; import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; +import { githubInsightsRoutes } from './routes/github-insights.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -89,6 +90,7 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); + await app.register(githubInsightsRoutes, { prefix: '/api/analytics' }); // ─── Health Check ─── app.get('/health', async () => ({ diff --git a/apps/backend/src/routes/github-insights.ts b/apps/backend/src/routes/github-insights.ts new file mode 100644 index 0000000..bcc4fc6 --- /dev/null +++ b/apps/backend/src/routes/github-insights.ts @@ -0,0 +1,356 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { decrypt } from '../utils/encryption.js'; +import type { + GitHubInsights, + GitHubRepo, + GitHubLanguageStat, +} from '@devcard/shared'; + +// ─── Constants ─── + +const GITHUB_API = 'https://api.github.com'; +/** Cache TTL: 1 hour in seconds (Redis) and milliseconds (DB) */ +const CACHE_TTL_SECONDS = 60 * 60; +const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000; +const REDIS_KEY_PREFIX = 'github_insights:'; +/** + * Maximum repos fetched for analysis (2 pages × 100). + * + * ⚠️ KNOWN LIMITATION: For users with more than 200 public repos, + * `totalStars`, `totalForks`, and `languageStats` are computed from + * the most-recently-updated 200 repos only, while `totalRepos` always + * reflects the full count from the GitHub user profile. + * These fields are therefore marked as estimates when the cap is hit — + * see `statsAreCapped` in the response. + */ +const MAX_REPOS_PAGES = 2; +const REPOS_PER_PAGE = 100; +const MAX_REPOS_FOR_ANALYSIS = MAX_REPOS_PAGES * REPOS_PER_PAGE; +/** Top repos to surface in the response */ +const TOP_REPOS_COUNT = 5; + +// ─── Route ─── + +export async function githubInsightsRoutes(app: FastifyInstance) { + /** + * GET /api/analytics/github-insights + * + * Returns AI-enriched GitHub activity insights for the authenticated user. + * Requires the user to have a connected GitHub OAuth token. + * + * Cache strategy (two layers): + * 1. Redis — fast in-memory, TTL 1 hour + * 2. DB — fallback if Redis is unavailable, TTL 1 hour + * + * Query params: + * ?refresh=true — bypass cache and force a fresh fetch + */ + app.get('/github-insights', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest<{ Querystring: { refresh?: string } }>, reply: FastifyReply) => { + const userId = (request.user as any).id; + const forceRefresh = request.query.refresh === 'true'; + + // ── 1. Check Redis cache ── + if (!forceRefresh) { + const cached = await getCachedFromRedis(app, userId); + if (cached) { + return reply.send({ ...cached, source: 'cache' }); + } + } + + // ── 2. Check DB cache ── + if (!forceRefresh) { + const dbCached = await getCachedFromDb(app, userId); + if (dbCached) { + // Warm Redis back up + await setCachedInRedis(app, userId, dbCached); + return reply.send({ ...dbCached, source: 'cache' }); + } + } + + // ── 3. Fetch GitHub OAuth token ── + const oauthToken = await app.prisma.oAuthToken.findUnique({ + where: { userId_platform: { userId, platform: 'github' } }, + }); + + if (!oauthToken) { + return reply.status(400).send({ + error: 'GitHub account not connected', + message: 'Connect your GitHub account to view insights.', + requiresAuth: true, + }); + } + + let accessToken: string; + try { + accessToken = decrypt(oauthToken.accessToken); + } catch { + return reply.status(500).send({ error: 'Failed to decrypt GitHub token' }); + } + + // ── 4. Fetch data from GitHub API ── + try { + const insights = await buildInsights(accessToken); + + // ── 5. Generate AI summary (optional — only if key is configured) ── + insights.aiSummary = await generateAiSummary(insights); + + // ── 6. Persist to both caches ── + await Promise.all([ + setCachedInRedis(app, userId, insights), + upsertCachedInDb(app, userId, insights), + ]); + + return reply.send({ ...insights, source: 'live' }); + } catch (err: any) { + app.log.error('GitHub insights fetch error:', err); + + if (err.status === 401 || err.status === 403) { + return reply.status(401).send({ + error: 'GitHub token expired or insufficient permissions', + requiresAuth: true, + }); + } + + return reply.status(502).send({ + error: 'Failed to fetch GitHub data', + message: err.message, + }); + } + }); +} + +// ─── GitHub Data Fetching ─── + +async function githubFetch(path: string, token: string): Promise { + const res = await fetch(`${GITHUB_API}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + if (!res.ok) { + const err: any = new Error(`GitHub API error: ${res.status} ${res.statusText}`); + err.status = res.status; + throw err; + } + + return res.json() as Promise; +} + +async function buildInsights(accessToken: string): Promise { + // Fetch user profile and first page of repos in parallel + const [ghUser, firstPageRepos] = await Promise.all([ + githubFetch('/user', accessToken), + githubFetch( + `/user/repos?per_page=${REPOS_PER_PAGE}&sort=updated&type=owner&page=1`, + accessToken, + ), + ]); + + let allRepos = firstPageRepos; + let statsAreCapped = false; + + // Fetch second page if the user has more than one page of repos + if (ghUser.public_repos > REPOS_PER_PAGE) { + try { + const secondPage = await githubFetch( + `/user/repos?per_page=${REPOS_PER_PAGE}&sort=updated&type=owner&page=2`, + accessToken, + ); + allRepos = [...firstPageRepos, ...secondPage]; + } catch { + // Non-fatal — proceed with what we have + } + + // If the user has more repos than our cap, flag it so the client + // can surface a disclaimer ("stats based on most recent 200 repos") + if (ghUser.public_repos > MAX_REPOS_FOR_ANALYSIS) { + statsAreCapped = true; + } + } + + // ── Language aggregation (own repos only, forks excluded) ── + const languageBytes: Record = {}; + for (const repo of allRepos) { + if (repo.language && !repo.fork) { + languageBytes[repo.language] = (languageBytes[repo.language] ?? 0) + 1; + } + } + + const totalLangCount = Object.values(languageBytes).reduce((a, b) => a + b, 0); + const languageStats: GitHubLanguageStat[] = Object.entries(languageBytes) + .sort(([, a], [, b]) => b - a) + .map(([language, bytes]) => ({ + language, + bytes, + percentage: totalLangCount > 0 + ? Math.round((bytes / totalLangCount) * 1000) / 10 + : 0, + })); + + // ── Top repos by stars (own repos only, forks excluded) ── + const ownRepos = allRepos.filter((r) => !r.fork); + + const topRepos: GitHubRepo[] = ownRepos + .sort((a, b) => b.stargazers_count - a.stargazers_count) + .slice(0, TOP_REPOS_COUNT) + .map((r) => ({ + name: r.name, + description: r.description ?? null, + url: r.html_url, + stars: r.stargazers_count, + forks: r.forks_count, + language: r.language ?? null, + isForked: r.fork, + updatedAt: r.updated_at, + })); + + // ── Aggregate totals (own repos only, forks excluded) ── + const totalStars = ownRepos.reduce((sum, r) => sum + r.stargazers_count, 0); + const totalForks = ownRepos.reduce((sum, r) => sum + r.forks_count, 0); + + return { + username: ghUser.login, + totalRepos: ghUser.public_repos, + totalStars, + totalForks, + followers: ghUser.followers, + following: ghUser.following, + topRepos, + languageStats, + primaryLanguage: languageStats[0]?.language ?? null, + accountCreatedAt: ghUser.created_at, + fetchedAt: new Date().toISOString(), + aiSummary: null, // filled in after + statsAreCapped, + }; +} + +// ─── AI Summary (Gemini) ─── + +async function generateAiSummary(insights: GitHubInsights): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) return null; + + const topLangs = insights.languageStats + .slice(0, 3) + .map((l) => `${l.language} (${l.percentage}%)`) + .join(', '); + + const topRepoNames = insights.topRepos + .slice(0, 3) + .map((r) => `${r.name} (⭐${r.stars})`) + .join(', '); + + const prompt = [ + `You are a developer profile analyst. Write a concise 2-sentence professional summary for a GitHub developer.`, + `Be specific, factual, and encouraging. Do not use generic filler phrases.`, + ``, + `Developer stats:`, + `- Username: ${insights.username}`, + `- Public repos: ${insights.totalRepos}`, + `- Total stars earned: ${insights.totalStars}`, + `- Followers: ${insights.followers}`, + `- Primary languages: ${topLangs || 'not available'}`, + `- Top repositories: ${topRepoNames || 'none'}`, + `- GitHub member since: ${new Date(insights.accountCreatedAt).getFullYear()}`, + ].join('\n'); + + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 120, temperature: 0.7 }, + }), + }, + ); + + if (!res.ok) return null; + + const data = (await res.json()) as any; + const text: string | undefined = + data?.candidates?.[0]?.content?.parts?.[0]?.text; + + return text?.trim() ?? null; + } catch { + // AI is optional — never fail the whole request because of it + return null; + } +} + +// ─── Cache Helpers ─── + +function redisKey(userId: string): string { + return `${REDIS_KEY_PREFIX}${userId}`; +} + +async function getCachedFromRedis( + app: FastifyInstance, + userId: string, +): Promise { + try { + const raw = await app.redis.get(redisKey(userId)); + if (!raw) return null; + return JSON.parse(raw) as GitHubInsights; + } catch { + return null; + } +} + +async function setCachedInRedis( + app: FastifyInstance, + userId: string, + insights: GitHubInsights, +): Promise { + try { + await app.redis.set( + redisKey(userId), + JSON.stringify(insights), + 'EX', + CACHE_TTL_SECONDS, + ); + } catch { + // Redis is optional + } +} + +async function getCachedFromDb( + app: FastifyInstance, + userId: string, +): Promise { + try { + const row = await app.prisma.gitHubInsightsCache.findUnique({ + where: { userId }, + }); + if (!row) return null; + if (new Date(row.expiresAt) < new Date()) return null; + return row.payload as unknown as GitHubInsights; + } catch { + return null; + } +} + +async function upsertCachedInDb( + app: FastifyInstance, + userId: string, + insights: GitHubInsights, +): Promise { + try { + const expiresAt = new Date(Date.now() + CACHE_TTL_MS); + await app.prisma.gitHubInsightsCache.upsert({ + where: { userId }, + update: { payload: insights as any, expiresAt }, + create: { userId, payload: insights as any, expiresAt }, + }); + } catch { + // DB cache is best-effort + } +} diff --git a/apps/mobile/src/navigation/MainTabs.tsx b/apps/mobile/src/navigation/MainTabs.tsx index 11e4e9a..b57282e 100644 --- a/apps/mobile/src/navigation/MainTabs.tsx +++ b/apps/mobile/src/navigation/MainTabs.tsx @@ -14,6 +14,7 @@ import WebViewScreen from '../screens/WebViewScreen'; import ConnectPlatformsScreen from '../screens/ConnectPlatformsScreen'; import ViewsScreen from '../screens/ViewsScreen'; +import GitHubInsightsScreen from '../screens/GitHubInsightsScreen'; // ─── Types ─── @@ -31,6 +32,7 @@ export type RootStackParamList = { WebViewConnect: { platform: string; profileUrl: string; displayName: string }; ConnectPlatforms: undefined; Views: undefined; + GitHubInsights: undefined; }; // ─── Tab Bar Icon ─── @@ -117,6 +119,11 @@ export default function MainTabs() { component={ViewsScreen} options={{ title: 'Card Views Analytics', headerShown: true, headerStyle: { backgroundColor: COLORS.bgPrimary }, headerTintColor: COLORS.textPrimary }} /> + ); } diff --git a/apps/mobile/src/screens/GitHubInsightsScreen.tsx b/apps/mobile/src/screens/GitHubInsightsScreen.tsx new file mode 100644 index 0000000..f05693f --- /dev/null +++ b/apps/mobile/src/screens/GitHubInsightsScreen.tsx @@ -0,0 +1,625 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + RefreshControl, + Linking, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; +import { useAuth } from '../context/AuthContext'; +import { API_BASE_URL } from '../config'; +import type { GitHubInsights, GitHubRepo, GitHubLanguageStat } from '@devcard/shared'; + +// ─── Language color map (subset of github-linguist) ─── + +const LANGUAGE_COLORS: Record = { + TypeScript: '#3178C6', + JavaScript: '#F7DF1E', + Python: '#3572A5', + Rust: '#DEA584', + Go: '#00ADD8', + Java: '#B07219', + 'C++': '#F34B7D', + C: '#555555', + Ruby: '#701516', + Swift: '#F05138', + Kotlin: '#A97BFF', + Dart: '#00B4AB', + PHP: '#4F5D95', + 'C#': '#178600', + HTML: '#E34C26', + CSS: '#563D7C', + Shell: '#89E051', + Vue: '#41B883', + Svelte: '#FF3E00', +}; + +function getLangColor(lang: string): string { + return LANGUAGE_COLORS[lang] ?? COLORS.primary; +} + +// ─── Sub-components ─── + +function StatCard({ + icon, + label, + value, +}: { + icon: string; + label: string; + value: string | number; +}) { + return ( + + + {value} + {label} + + ); +} + +function LanguageBar({ stats }: { stats: GitHubLanguageStat[] }) { + const top = stats.slice(0, 6); + return ( + + Language Breakdown + {/* Stacked bar */} + + {top.map((s) => ( + + ))} + + {/* Legend */} + + {top.map((s) => ( + + + {s.language} + {s.percentage}% + + ))} + + + ); +} + +function RepoCard({ repo }: { repo: GitHubRepo }) { + return ( + Linking.openURL(repo.url)} + activeOpacity={0.8} + accessibilityRole="link" + accessibilityLabel={`Open ${repo.name} on GitHub`}> + + + + {repo.name} + + + {repo.description ? ( + + {repo.description} + + ) : null} + + {repo.language ? ( + + + {repo.language} + + ) : null} + + + {repo.stars} + + + + {repo.forks} + + + + ); +} + +function AiSummaryCard({ summary }: { summary: string }) { + return ( + + + + AI Developer Summary + + {summary} + + ); +} + +// ─── Main Screen ─── + +export default function GitHubInsightsScreen() { + const { token } = useAuth(); + const [insights, setInsights] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [requiresConnect, setRequiresConnect] = useState(false); + + const fetchInsights = useCallback( + async (forceRefresh = false) => { + if (!token) { + setLoading(false); + return; + } + setError(null); + try { + const url = `${API_BASE_URL}/api/analytics/github-insights${forceRefresh ? '?refresh=true' : ''}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + // Read the body once — it can only be consumed once per response + const body = await res.json().catch(() => ({})); + + if (!res.ok) { + if (res.status === 400 && body.requiresAuth) { + setRequiresConnect(true); + return; + } + setError(body.message ?? 'Failed to load GitHub insights'); + return; + } + + setInsights(body as GitHubInsights); + } catch { + setError('Network error — please check your connection'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [token], + ); + + useEffect(() => { + fetchInsights(); + }, [fetchInsights]); + + const onRefresh = useCallback(() => { + setRefreshing(true); + fetchInsights(true); + }, [fetchInsights]); + + // ── Loading state ── + if (loading) { + return ( + + + Analyzing your GitHub activity… + + ); + } + + // ── Not connected state ── + if (requiresConnect) { + return ( + + + GitHub Not Connected + + Connect your GitHub account in Settings → Connected Platforms to unlock + activity insights. + + + ); + } + + // ── Error state ── + if (error) { + return ( + + + Something went wrong + {error} + { + setLoading(true); + fetchInsights(); + }}> + Try Again + + + ); + } + + // ── Empty state ── + if (!insights) { + return ( + + + No Data Yet + + We couldn't find any GitHub activity to analyze. + + + ); + } + + // ── Main content ── + return ( + + + }> + + {/* Header */} + + + + {insights.username} + + Member since {new Date(insights.accountCreatedAt).getFullYear()} + + + + + {/* AI Summary */} + {insights.aiSummary ? ( + + ) : null} + + {/* Stats grid */} + + + + + + + + {/* Primary language badge */} + {insights.primaryLanguage ? ( + + + + Primary language:{' '} + + {insights.primaryLanguage} + + + + ) : null} + + {/* Language breakdown */} + {insights.languageStats.length > 0 ? ( + + ) : null} + + {/* Top repositories */} + {insights.topRepos.length > 0 ? ( + + Top Repositories + {insights.topRepos.map((repo) => ( + + ))} + + ) : null} + + {/* Cache notice */} + + Last updated {new Date(insights.fetchedAt).toLocaleString()} · Pull down to + refresh + + + {/* Capped stats disclaimer */} + {insights.statsAreCapped ? ( + + + + Stats are based on your most recently updated 200 repos. Users with + 200+ repos may see partial star, fork, and language counts. + + + ) : null} + + + ); +} + +// ─── Styles ─── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.bgPrimary, + }, + center: { + justifyContent: 'center', + alignItems: 'center', + padding: SPACING.xl, + }, + scrollContent: { + padding: SPACING.lg, + paddingBottom: SPACING.xxl, + }, + + // Loading + loadingText: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + marginTop: SPACING.md, + }, + + // Empty / error + emptyTitle: { + color: COLORS.textPrimary, + fontSize: FONT_SIZE.xl, + fontWeight: '700', + marginTop: SPACING.md, + textAlign: 'center', + }, + emptyDesc: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.sm, + textAlign: 'center', + marginTop: SPACING.sm, + lineHeight: 20, + }, + retryButton: { + marginTop: SPACING.lg, + backgroundColor: COLORS.primary, + paddingHorizontal: SPACING.xl, + paddingVertical: SPACING.sm, + borderRadius: BORDER_RADIUS.full, + }, + retryText: { + color: COLORS.white, + fontWeight: '700', + fontSize: FONT_SIZE.md, + }, + + // Page header + pageHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: SPACING.lg, + gap: SPACING.md, + }, + pageHeaderText: { + flex: 1, + }, + pageTitle: { + color: COLORS.textPrimary, + fontSize: FONT_SIZE.xl, + fontWeight: '800', + }, + pageSubtitle: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + marginTop: 2, + }, + + // AI card + aiCard: { + backgroundColor: 'rgba(139, 92, 246, 0.12)', + borderRadius: BORDER_RADIUS.lg, + padding: SPACING.md, + borderWidth: 1, + borderColor: 'rgba(139, 92, 246, 0.3)', + marginBottom: SPACING.lg, + }, + aiHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: SPACING.sm, + }, + aiTitle: { + color: COLORS.accentLight, + fontSize: FONT_SIZE.sm, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + aiText: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.sm, + lineHeight: 20, + }, + + // Stats grid + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: SPACING.sm, + marginBottom: SPACING.lg, + }, + statCard: { + flex: 1, + minWidth: '45%', + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.md, + alignItems: 'center', + borderWidth: 1, + borderColor: COLORS.border, + ...SHADOWS.card, + }, + statValue: { + color: COLORS.textPrimary, + fontSize: FONT_SIZE.xl, + fontWeight: '800', + marginTop: SPACING.xs, + }, + statLabel: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + marginTop: 2, + }, + + // Primary language badge + primaryLangBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.md, + borderWidth: 1, + borderColor: COLORS.border, + marginBottom: SPACING.lg, + }, + primaryLangText: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.sm, + }, + + // Section + section: { + marginBottom: SPACING.lg, + }, + sectionTitle: { + color: COLORS.textPrimary, + fontSize: FONT_SIZE.md, + fontWeight: '700', + marginBottom: SPACING.md, + }, + + // Language bar + langBar: { + flexDirection: 'row', + height: 10, + borderRadius: BORDER_RADIUS.full, + overflow: 'hidden', + backgroundColor: COLORS.bgElevated, + marginBottom: SPACING.md, + }, + langBarSegment: { + height: '100%', + }, + langLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: SPACING.sm, + }, + langLegendItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + langDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + langName: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.xs, + }, + langPct: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + }, + + // Repo card + repoCard: { + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.md, + borderWidth: 1, + borderColor: COLORS.border, + marginBottom: SPACING.sm, + }, + repoHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: 4, + }, + repoName: { + color: COLORS.primary, + fontSize: FONT_SIZE.md, + fontWeight: '700', + flex: 1, + }, + repoDesc: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.sm, + lineHeight: 18, + marginBottom: SPACING.sm, + }, + repoMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.md, + }, + repoLang: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + repoStat: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + repoMetaText: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + }, + + // Cache notice + cacheNotice: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + textAlign: 'center', + marginTop: SPACING.md, + }, + + // Capped stats disclaimer + cappedNotice: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: SPACING.xs, + backgroundColor: 'rgba(245, 158, 11, 0.08)', + borderRadius: BORDER_RADIUS.sm, + padding: SPACING.sm, + borderWidth: 1, + borderColor: 'rgba(245, 158, 11, 0.25)', + marginTop: SPACING.sm, + }, + cappedNoticeText: { + flex: 1, + color: COLORS.warning, + fontSize: FONT_SIZE.xs, + lineHeight: 16, + }, +}); diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 80de203..5e6c26b 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -194,6 +194,14 @@ export default function HomeScreen({ navigation }: Props) { Analytics + (navigation as any).navigate('GitHubInsights')} + activeOpacity={0.85}> + 🐙 + GitHub + + (navigation as any).navigate('DevCardView', { username: user?.username || '' })} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4a4a9dc..2f3e304 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -158,3 +158,67 @@ export interface OAuthTokenInfo { connected: boolean; scopes: string; } + +// ─── GitHub Insights Types ─── + +export interface GitHubRepo { + name: string; + description: string | null; + url: string; + stars: number; + forks: number; + language: string | null; + isForked: boolean; + updatedAt: string; +} + +export interface GitHubLanguageStat { + language: string; + /** Percentage of total bytes across all repos (0–100) */ + percentage: number; + /** Raw byte count */ + bytes: number; +} + +export interface GitHubInsights { + /** GitHub username */ + username: string; + /** Total public repositories (always the full count from GitHub profile) */ + totalRepos: number; + /** + * Total stars received across analysed repos (own repos only, forks excluded). + * May be an undercount for users with more than 200 repos — check `statsAreCapped`. + */ + totalStars: number; + /** + * Total forks received across analysed repos (own repos only, forks excluded). + * May be an undercount for users with more than 200 repos — check `statsAreCapped`. + */ + totalForks: number; + /** Total followers */ + followers: number; + /** Total following */ + following: number; + /** Top 5 repos by star count */ + topRepos: GitHubRepo[]; + /** + * Language breakdown sorted by usage. + * Computed from the most-recently-updated repos fetched (max 200). + * May be skewed for users with more than 200 repos — check `statsAreCapped`. + */ + languageStats: GitHubLanguageStat[]; + /** Primary (most-used) language */ + primaryLanguage: string | null; + /** Account creation date */ + accountCreatedAt: string; + /** ISO timestamp of when this data was fetched/cached */ + fetchedAt: string; + /** AI-generated developer summary (null if AI key not configured) */ + aiSummary: string | null; + /** + * True when the user has more than 200 public repos and star/fork/language + * stats are therefore based on a subset (most recently updated 200 repos). + * The UI should surface a disclaimer when this is true. + */ + statsAreCapped: boolean; +}