feat: add GitHub activity intelligence engine with mobile UI(fixes #132)#160
feat: add GitHub activity intelligence engine with mobile UI(fixes #132)#160LALA22-7 wants to merge 2 commits into
Conversation
- Add GET /api/analytics/github-insights backend route - Fetch repos, language stats, top repos, star/fork totals from GitHub REST API - Two-layer cache: Redis (1hr TTL) + PostgreSQL fallback - Optional AI developer summary via Gemini 1.5 Flash (GEMINI_API_KEY) - Add GitHubInsightsCache Prisma model + migration - Add GitHubInsights, GitHubRepo, GitHubLanguageStat shared types - Add GitHubInsightsScreen mobile UI with stats grid, language bar, top repos - Wire GitHubInsights into root navigation stack - Add GitHub shortcut button on HomeScreen - 7 new backend tests, all passing
There was a problem hiding this comment.
Pull request overview
Implements the backend + mobile portion of issue #132 (GitHub Activity Intelligence). A new authenticated Fastify route fetches the user's repos/profile from the GitHub REST API, aggregates language/star/fork stats and an optional Gemini-generated AI summary, and caches the result in Redis with a Postgres fallback. The mobile app adds a new GitHubInsights screen reachable from the Home screen shortcut.
Changes:
- New
GET /api/analytics/github-insightsendpoint with two-layer (Redis + DB) caching and optional Gemini AI summary, plus sharedGitHubInsights/GitHubRepo/GitHubLanguageStattypes and aGitHubInsightsCachePrisma model/migration. - New
GitHubInsightsScreenmobile component (stats grid, AI card, language bar, top-repo list, pull-to-refresh, error/empty/not-connected states) wired into the root stack and Home screen. - Backend tests (7) covering cache hits, refresh bypass, missing token, expired token, and fork exclusion;
.env.exampleupdated with optionalGEMINI_API_KEY.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shared/src/types.ts | Adds shared GitHubInsights/GitHubRepo/GitHubLanguageStat types. |
| apps/backend/src/routes/github-insights.ts | New route: fetch + aggregate + AI summary + two-layer cache. |
| apps/backend/src/app.ts | Registers the new route under /api/analytics/github-insights. |
| apps/backend/prisma/schema.prisma | Adds GitHubInsightsCache model with FK to User. |
| apps/backend/prisma/migrations/20260519000000_github_insights_cache/migration.sql | Creates the github_insights_cache table. |
| apps/backend/src/tests/github-insights.test.ts | 7 unit tests for the new route. |
| apps/mobile/src/screens/GitHubInsightsScreen.tsx | New screen rendering insights with all edge states. |
| apps/mobile/src/navigation/MainTabs.tsx | Registers GitHubInsights in the root stack. |
| apps/mobile/src/screens/HomeScreen.tsx | Adds 🐙 GitHub shortcut button. |
| .env.example | Adds optional GEMINI_API_KEY entry. |
Comments suppressed due to low confidence (3)
apps/backend/src/routes/github-insights.ts:174
- The
languageBytesmap andbytesfield onGitHubLanguageStatclaim to represent raw byte counts (see the shared type's JSDoc: "Raw byte count"), but the implementation here simply increments by1per repo, sobytesis actually a count of repositories where that language is the primary language. The resultingpercentageis therefore a percentage of repos, not of code volume. Either rename the field/comment to reflect that it's a repo count, or actually callGET /repos/{owner}/{repo}/languagesfor each repo and sum real byte counts.
// ── Language aggregation ──
const languageBytes: Record<string, number> = {};
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,
}));
apps/backend/src/routes/github-insights.ts:214
totalReposis set fromghUser.public_repos, which includes forks, whiletotalStars,totalForks, andlanguageStatsdeliberately exclude forks. This makes the "Repos" stat inconsistent with the other stats shown to the user. Consider either using the count of non-forked repos fortotalRepos(e.g.,allRepos.filter(r => !r.fork).length) or surfacing both totals explicitly.
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
};
apps/backend/src/routes/github-insights.ts:190
topReposis built by filtering!r.fork, so theisForkedfield on every returnedGitHubRepohere will always befalse. If the field is intended to be meaningful in the API, include forks (and let the consumer filter) or drop the field for this endpoint.
const topRepos: GitHubRepo[] = allRepos
.filter((r) => !r.fork)
.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,
}));
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // If user has more than 100 repos, fetch the second page too (cap at 200) | ||
| let allRepos = firstPageRepos; | ||
| if (ghUser.public_repos > MAX_REPOS_FOR_ANALYSIS) { | ||
| try { | ||
| const secondPage = await githubFetch<any[]>( | ||
| `/user/repos?per_page=100&sort=updated&type=owner&page=2`, | ||
| accessToken, | ||
| ); | ||
| allRepos = [...firstPageRepos, ...secondPage]; | ||
| } catch { | ||
| // Non-fatal — proceed with what we have | ||
| } | ||
| } |
| if (res.status === 400) { | ||
| const body = await res.json(); | ||
| if (body.requiresAuth) { | ||
| setRequiresConnect(true); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.json().catch(() => ({})); |
| 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/github-insights' }); |
- Fix trailing slash 404: move handler to /github-insights under /api/analytics prefix instead of separate plugin registration - Fix consumed response body bug in mobile: read body once, branch on requiresAuth before checking res.ok - Add statsAreCapped field to GitHubInsights type and response: true when user has 200+ repos and stats are a subset - Surface statsAreCapped disclaimer in mobile UI with warning banner - Document pagination cap in constants and JSDoc comments - Add 2 new tests for statsAreCapped (true/false), now 17 tests total
Summary
Hey @Suyash2527 @Midoriya-w 👋
I went ahead and implemented the core slice of this feature — the full backend + mobile UI for GitHub Activity Intelligence. Here's what's been done so far. If you want to pick up any of the remaining pieces (web UI, predictive analytics, recruiter scoring, etc.), feel free to build on top of this — the foundation is all wired up.
Closes #132
Type of Change
What Changed
packages/shared/src/types.ts— AddedGitHubInsights,GitHubRepo, andGitHubLanguageStatshared types consumed by both backend and mobileapps/backend/src/routes/github-insights.ts— New routeGET /api/analytics/github-insightsthat fetches repos + user stats from GitHub REST API, computes language breakdown, top repos, star/fork totals, and optionally generates an AI developer summary via Gemini 1.5 Flashapps/backend/prisma/schema.prisma— AddedGitHubInsightsCachemodel for DB-level cache fallbackapps/backend/prisma/migrations/...— Migration for the new cache tableapps/backend/src/app.ts— Registered the new route at/api/analytics/github-insightsapps/backend/src/__tests__/github-insights.test.ts— 7 tests covering: no token, live fetch, Redis cache hit, DB cache hit, force refresh, expired token, fork exclusion logic (all passing ✅)apps/mobile/src/screens/GitHubInsightsScreen.tsx— New screen with stats grid, AI summary card, stacked language bar, top repos list (tappable → opens GitHub), pull-to-refresh, and all edge-case states (loading, not connected, error, empty)apps/mobile/src/navigation/MainTabs.tsx— WiredGitHubInsightsinto the root stackapps/mobile/src/screens/HomeScreen.tsx— Added 🐙 GitHub shortcut button in the home actions row.env.example— Added optionalGEMINI_API_KEYwith a link to get a free keyHow to Test
pnpm dev:backendGET /api/analytics/github-insightswith a valid JWT — should return live data withsource: "live"source: "cache"(Redis)?refresh=true— should bypass cache and returnsource: "live"againpnpm --filter @devcard/backend test— all 15 tests should passChecklist
pnpm -r run lintpasses).pnpm -r run test)..env.examplewhere necessary.console.logor debug statements left in the code.What's Still Open (for collaborators to pick up)
If you want to contribute on top of this, here are natural next pieces:
/dashboard/github-insightspage in the SvelteKit app consuming the same endpointcontributionsCollection)user:followscope upgrade — Prompt users to re-authorize with broader scopes for richer dataThe endpoint is at
GET /api/analytics/github-insightsand the shared types are in@devcard/shared. Everything is documented in the route file.Additional Context
GEMINI_API_KEYis not set,aiSummaryreturnsnulland the mobile UI simply hides that card. No hard dependency on any paid API.