From 4be6149b615febddb8c06c08c4af0514251e3ec7 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:16:16 +0530 Subject: [PATCH 01/14] refactor(github): split 1275-line monolith into github/{http,profile,star-gate,index}.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - http.ts: shared fetch utilities, token pool, GraphQL/REST helpers - profile.ts: getProfileSummary, calculateDetailedStreaks, rank/trophy/badge logic - star-gate.ts: checkStarStatus, verifyAndInjectStar, getRepoStarCount - index.ts: barrel re-export (import path unchanged: @/lib/github) - Collapse 4 near-duplicate recursive paginators into 2 generic paginateForward/paginateBackward helpers (~250 lines removed) - Move achievement cache from in-process Map to Redis (Finding 6 combined) Breaking: none — all public exports preserved at @/lib/github --- src/lib/github.ts | 1274 ----------------------------------- src/lib/github/http.ts | 149 ++++ src/lib/github/index.ts | 22 + src/lib/github/profile.ts | 457 +++++++++++++ src/lib/github/star-gate.ts | 685 +++++++++++++++++++ 5 files changed, 1313 insertions(+), 1274 deletions(-) delete mode 100644 src/lib/github.ts create mode 100644 src/lib/github/http.ts create mode 100644 src/lib/github/index.ts create mode 100644 src/lib/github/profile.ts create mode 100644 src/lib/github/star-gate.ts diff --git a/src/lib/github.ts b/src/lib/github.ts deleted file mode 100644 index d0d4c47..0000000 --- a/src/lib/github.ts +++ /dev/null @@ -1,1274 +0,0 @@ -import { - ProfileSummary, - ContributionWeek, - ContributionDay, - GithubRepoNode, -} from "@/types"; -import { getCachedData, setCachedData } from "@/lib/redis"; -import { sendTelegramAlert } from "@/lib/telegram-alert"; - -const GITHUB_TOKENS = (process.env.GITHUB_TOKENS || "") - .split(",") - .filter(Boolean); - -if (GITHUB_TOKENS.length === 0) { - throw new Error("GITHUB_TOKENS environment variable is required"); -} - -const ACHIEVEMENT_CACHE_TTL_MS = 10 * 60 * 1000; -const achievementCache = new Map< - string, - { value: string | null; expiresAt: number } ->(); - -const badgeAssets: Record = { - "pull-shark": - "https://github.githubassets.com/assets/pull-shark-default-498c279a747d.png", - starstruck: - "https://github.githubassets.com/assets/starstruck-default--light-medium-65b31ef2251e.png", - "pair-extraordinaire": - "https://github.githubassets.com/assets/pair-extraordinaire-default-579438a20e01.png", - "galaxy-brain": - "https://github.githubassets.com/assets/galaxy-brain-default-847262c21056.png", - yolo: "https://github.githubassets.com/assets/yolo-default-be0bbff04951.png", - quickdraw: - "https://github.githubassets.com/assets/quickdraw-default--light-medium-5450fadcbe37.png", -}; - -function getRank(val: number, thresholds: number[]): string { - if (val >= thresholds[3]) return "SS"; - if (val >= thresholds[2]) return "S"; - if (val >= thresholds[1]) return "A"; - if (val >= thresholds[0]) return "B"; - return "C"; -} - -function getRankColor(rank: string): string { - switch (rank) { - case "SS": - return "#ff0055"; - case "S": - return "#facc15"; - case "A": - return "#a78bfa"; - case "B": - return "#60a5fa"; - default: - return "#94a3b8"; - } -} - -function getFallbackToken(): string { - const token = GITHUB_TOKENS[Math.floor(Math.random() * GITHUB_TOKENS.length)]; - if (!token) throw new Error("GITHUB_TOKENS environment variable is required"); - return token; -} - -const GITHUB_FETCH_TIMEOUT_MS = 10_000; - -async function githubFetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = GITHUB_FETCH_TIMEOUT_MS, -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { - ...init, - signal: controller.signal, - }); - } finally { - clearTimeout(timeoutId); - } -} - -function buildGitHubGraphQLHeaders(token: string): HeadersInit { - return { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": "GitScore/1.1", - }; -} - -async function fetchGitHubGraphQL( - token: string, - query: string, - variables: Record, - timeoutMs = GITHUB_FETCH_TIMEOUT_MS, -): Promise { - console.log("[GITHUB_API] GraphQL request starting", { - timeoutMs, - variables: JSON.stringify(variables).slice(0, 100), - }); - try { - const response = await githubFetchWithTimeout( - "https://api.github.com/graphql", - { - method: "POST", - headers: buildGitHubGraphQLHeaders(token), - body: JSON.stringify({ query, variables }), - }, - timeoutMs, - ); - console.log("[GITHUB_API] GraphQL response received", { - status: response.status, - statusText: response.statusText, - }); - return response; - } catch (err) { - console.error("[GITHUB_API] GraphQL request failed", { - error: err instanceof Error ? err.message : String(err), - variables: JSON.stringify(variables).slice(0, 100), - }); - throw err; - } -} - -async function checkAchievementStatus(username: string, slug: string) { - const cacheKey = `${normalizeUsername(username)}:${slug}`; - const now = Date.now(); - const cached = achievementCache.get(cacheKey); - if (cached && cached.expiresAt > now) { - return cached.value; - } - - const url = `https://github.com/${encodeURIComponent(username)}?tab=achievements&achievement=${slug}`; - try { - const res = await githubFetchWithTimeout(url, { - method: "HEAD", - headers: { "User-Agent": "GitScore/1.1" }, - }); - const value = res.status === 200 ? slug : null; - achievementCache.set(cacheKey, { - value, - expiresAt: now + ACHIEVEMENT_CACHE_TTL_MS, - }); - return value; - } catch { - achievementCache.set(cacheKey, { - value: null, - expiresAt: now + ACHIEVEMENT_CACHE_TTL_MS, - }); - return null; - } -} - -function calculateDetailedStreaks(weeks: ContributionWeek[]) { - const days = weeks.flatMap((w) => w.contributionDays); - let daily_streak = 0, - daily_best = 0, - tmp_daily = 0; - for (let i = days.length - 1; i >= 0; i--) { - if (days[i].contributionCount > 0) daily_streak++; - else if (i === days.length - 1) continue; - else break; - } - days.forEach((d) => { - if (d.contributionCount > 0) { - tmp_daily++; - if (tmp_daily > daily_best) daily_best = tmp_daily; - } else tmp_daily = 0; - }); - const weekActivity = weeks.map((w) => - w.contributionDays.some((d: ContributionDay) => d.contributionCount > 0), - ); - let weekly_streak = 0, - weekly_best = 0, - tmp_weekly = 0; - for (let i = weekActivity.length - 1; i >= 0; i--) { - if (weekActivity[i]) weekly_streak++; - else if (i === weekActivity.length - 1) continue; - else break; - } - weekActivity.forEach((active) => { - if (active) { - tmp_weekly++; - if (tmp_weekly > weekly_best) weekly_best = tmp_weekly; - } else tmp_weekly = 0; - }); - return { daily_streak, daily_best, weekly_streak, weekly_best }; -} - -export async function getProfileSummary( - username: string, - userToken?: string, -): Promise { - console.log("[GITHUB_API] getProfileSummary starting", { - username, - hasUserToken: !!userToken, - }); - if (!username || username.toLowerCase() === "undefined") { - console.error("[GITHUB_API] Invalid username", { username }); - throw new Error("INVALID_USERNAME"); - } - const token = userToken || getFallbackToken(); - console.log("[GITHUB_API] Using token", { hasUserToken: !!userToken }); - const query = ` - query($login: String!) { - user(login: $login) { - avatarUrl, login, name, bio, followers { totalCount }, following { totalCount } - contributionsCollection { - totalCommitContributions, totalPullRequestContributions, totalIssueContributions, totalPullRequestReviewContributions, totalRepositoryContributions - contributionCalendar { - totalContributions - weeks { contributionDays { contributionCount, date, color } } - } - } - repositories(first: 60, ownerAffiliations: [OWNER], orderBy: {field: STARGAZERS, direction: DESC}) { - nodes { - name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } - openIssues: issues(states: OPEN) { totalCount } - watchers { totalCount } - repositoryTopics(first: 5) { nodes { topic { name } } } - } - } - repositoriesContributedTo(first: 40, includeUserRepositories: true, contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]) { - nodes { - name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } - openIssues: issues(states: OPEN) { totalCount } - watchers { totalCount } - repositoryTopics(first: 5) { nodes { topic { name } } } - } - } - } - } - `; - - const requestSummary = async (authToken: string) => { - console.log("[GITHUB_API] Sending profile summary request", { username }); - return fetchGitHubGraphQL(authToken, query, { login: username }); - }; - - let res = await requestSummary(token); - console.log("[GITHUB_API] Initial profile request completed", { - status: res.status, - }); - if (res.status === 401 || res.status === 403) { - console.log("[GITHUB_API] Auth failed, retrying with fallback token"); - const retryToken = getFallbackToken(); - res = await requestSummary(retryToken); - console.log("[GITHUB_API] Retry request completed", { status: res.status }); - if (res.status === 401 || res.status === 403) { - console.error("[GITHUB_API] Both auth attempts failed"); - await sendTelegramAlert({ - source: "GITHUB_PROFILE_SUMMARY", - message: "GitHub auth failed for profile summary", - error: new Error("GITHUB_AUTH_FAILED"), - context: { username, status: res.status }, - }); - throw new Error("GITHUB_AUTH_FAILED"); - } - } - - if (!res.ok) { - console.error("[GITHUB_API] Profile request failed", { - status: res.status, - }); - await sendTelegramAlert({ - source: "GITHUB_PROFILE_SUMMARY", - message: "Profile request failed", - error: new Error(`GitHub API error: HTTP ${res.status}`), - context: { username, status: res.status }, - }); - throw new Error(`GitHub API error: HTTP ${res.status}`); - } - - console.log("[GITHUB_API] Parsing profile response"); - const body = await res.json(); - if (body.errors) { - console.error("[GITHUB_API] GraphQL errors in response", { - errors: body.errors, - }); - if (body.errors[0]?.message.includes("Could not resolve to a User")) { - console.error("[GITHUB_API] User not found in GitHub", { username }); - throw new Error("USER_NOT_FOUND"); - } - await sendTelegramAlert({ - source: "GITHUB_PROFILE_SUMMARY", - message: "GraphQL error while fetching profile", - error: new Error(body.errors[0]?.message || "graphql_error"), - context: { username }, - }); - throw new Error(`GitHub API error: ${body.errors[0].message}`); - } - const user = body.data?.user; - if (!user) { - console.error("[GITHUB_API] No user data in response", { username }); - throw new Error("USER_NOT_FOUND"); - } - console.log("[GITHUB_API] User data parsed successfully", { - username: user.login, - repos: user.repositories.nodes.length, - }); - - const coll = user.contributionsCollection; - const ownedNodes = user.repositories.nodes || []; - - const authoredRepos = ownedNodes.filter((r: GithubRepoNode) => !r.isFork); - - let total_stars = 0; - authoredRepos.forEach( - (r: GithubRepoNode) => (total_stars += r.stargazerCount), - ); - - const detailedStreaks = calculateDetailedStreaks( - coll.contributionCalendar.weeks, - ); - - const langs = (function (nodes: GithubRepoNode[]) { - const l: Record = {}; - nodes.forEach((n) => { - if (n.primaryLanguage) - l[n.primaryLanguage.name] = - (l[n.primaryLanguage.name] || 0) + - Math.sqrt(n.stargazerCount + 1) + - 1; - }); - const entries = Object.entries(l) - .sort((a, b) => b[1] - a[1]) - .slice(0, 6); - const total = entries.reduce((acc, [, v]) => acc + v, 0) || 1; - const cols = [ - "#facc15", - "#fb923c", - "#4ade80", - "#f472b6", - "#60a5fa", - "#a78bfa", - ]; - return entries.map(([n, v], i) => ({ - name: n, - value: (v / total) * 100, - color: cols[i % cols.length], - })); - })(authoredRepos); - - const t_configs = [ - { name: "Stars", val: total_stars, th: [10, 100, 500, 1000] }, - { - name: "Commits", - val: coll.totalCommitContributions, - th: [100, 500, 1000, 5000], - }, - { - name: "PRs", - val: coll.totalPullRequestContributions, - th: [10, 50, 100, 500], - }, - { name: "Issues", val: coll.totalIssueContributions, th: [5, 20, 50, 100] }, - { - name: "Reviews", - val: coll.totalPullRequestReviewContributions, - th: [5, 20, 50, 100], - }, - { - name: "Followers", - val: user.followers.totalCount, - th: [10, 50, 100, 500], - }, - { - name: "Authored Repos", - val: authoredRepos.length, - th: [10, 30, 50, 100], - }, - ]; - - const trophies = t_configs.map((c) => { - const rank = getRank(c.val, c.th); - return { - name: c.name, - rank, - color: getRankColor(rank), - value: c.val.toString(), - }; - }); - - const slugs = Object.keys(badgeAssets); - console.log("[GITHUB_API] Checking achievement badges", { - badgeCount: slugs.length, - }); - const unlocked = await Promise.all( - slugs.map((s) => { - console.log("[GITHUB_API] Checking achievement", { slug: s }); - return checkAchievementStatus(username, s); - }), - ); - const badges: Record = {}; - const unlockedBadges = unlocked.filter(Boolean); - console.log("[GITHUB_API] Achievement check complete", { - unlockedCount: unlockedBadges.length, - }); - unlockedBadges.forEach((s) => { - if (s) badges[s] = badgeAssets[s]; - }); - - console.log("[GITHUB_API] Assembling profile summary", { - username: user.login, - total_stars, - followers: user.followers.totalCount, - }); - return { - avatar: user.avatarUrl, - username: user.login, - name: user.name, - bio: user.bio, - followers: user.followers.totalCount, - following: user.following.totalCount, - public_repo_count: authoredRepos.length, - total_stars, - original_repos: authoredRepos.reduce( - ( - acc: Record< - string, - { - n: string; - description: string | null; - stars: number; - forks: number; - issues: number; - watchers: number; - primary_lang: string | null; - topics: string[]; - } - >, - r: GithubRepoNode, - ) => ({ - ...acc, - [r.name]: { - n: r.name, - description: r.description, - stars: r.stargazerCount, - forks: r.forkCount, - issues: r.openIssues.totalCount, - watchers: r.watchers.totalCount, - primary_lang: r.primaryLanguage?.name || null, - topics: r.repositoryTopics.nodes.map((t) => t.topic.name), - }, - }), - {}, - ), - career_stats: { - total_contributions: coll.contributionCalendar.totalContributions, - total_commits: coll.totalCommitContributions, - total_prs: coll.totalPullRequestContributions, - total_issues: coll.totalIssueContributions, - total_reviews: coll.totalPullRequestReviewContributions, - daily_streak: detailedStreaks.daily_streak, - daily_best: detailedStreaks.daily_best, - weekly_streak: detailedStreaks.weekly_streak, - weekly_best: detailedStreaks.weekly_best, - top_languages: langs, - commit_activity: (function (weeks: ContributionWeek[]) { - const m: Record = {}; - weeks.forEach((w) => - w.contributionDays.forEach((d: ContributionDay) => { - const date = new Date(d.date); - const key = `${date.getUTCFullYear()}-${String( - date.getUTCMonth() + 1, - ).padStart(2, "0")}`; - m[key] = (m[key] || 0) + d.contributionCount; - }), - ); - return Object.entries(m) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, count]) => ({ month, count })); - })(coll.contributionCalendar.weeks), - trophies, - }, - calendar_data: coll.contributionCalendar, - badges, - }; -} - -const STAR_PAGE_SIZE = 100; -const STAR_QUICK_LIMIT = 6; -const STAR_DEEP_LIMIT = 120; -const STAR_REST_LIMIT = 250; -const STAR_CACHE_TTL = 900; -const STAR_GATE_DEBUG = process.env.STAR_GATE_DEBUG === "1"; - -interface PageInfo { - hasNextPage?: boolean; - endCursor?: string | null; - hasPreviousPage?: boolean; - startCursor?: string | null; -} - -interface StarredRepoNode { - nameWithOwner: string; -} - -interface StargazerNode { - login: string; -} - -interface RestStargazerNode { - login?: string; -} - -interface RestStarredRepoNode { - full_name?: string; -} - -function starGateLog(event: string, payload: Record): void { - if (!STAR_GATE_DEBUG) return; - console.info(`[star-gate] ${event}`, payload); -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "unknown_error"; -} - -function buildGitHubRestHeaders(token?: string): HeadersInit { - if (token) { - return { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "User-Agent": "GitScore/1.1", - }; - } - return { - Accept: "application/vnd.github+json", - "User-Agent": "GitScore/1.1", - }; -} - -async function fetchGitHubRest(url: string, token?: string): Promise { - const first = await githubFetchWithTimeout(url, { - headers: buildGitHubRestHeaders(token), - }); - if ((first.status === 401 || first.status === 403) && token) { - starGateLog("rest_retry_without_token", { url, status: first.status }); - return githubFetchWithTimeout(url, { - headers: buildGitHubRestHeaders(), - }); - } - return first; -} - -function hasNextPageFromLink(linkHeader: string | null): boolean { - if (!linkHeader) return false; - return linkHeader.includes('rel="next"'); -} - -function extractGraphQLErrorMessage(body: unknown): string | null { - const errors = - typeof body === "object" && body !== null - ? (body as { errors?: Array<{ message?: string }> }).errors - : undefined; - if (!errors?.length) return null; - return errors[0]?.message || "graphql_error"; -} - -function normalizeUsername(value: string): string { - return value.trim().toLowerCase(); -} - -async function cacheVerifiedStar( - username: string, - targetRepo: string, -): Promise { - const normalized = normalizeUsername(username); - const cacheKey = `repo:stargazers:${targetRepo}`; - const cached = (await getCachedData(cacheKey)) || []; - if (cached.some((s) => normalizeUsername(s) === normalized)) { - return; - } - cached.push(normalized); - await setCachedData(cacheKey, cached, STAR_CACHE_TTL); -} - -export async function checkStarStatus( - username: string, - userToken?: string, -): Promise { - console.log("[GITHUB_API] checkStarStatus starting", { - username, - hasUserToken: !!userToken, - }); - const normalizedUsername = normalizeUsername(username || ""); - if ( - !normalizedUsername || - normalizedUsername === "undefined" || - normalizedUsername === "null" - ) { - console.log("[GITHUB_API] Invalid username for star check", { username }); - return false; - } - const targetRepo = "0xarchit/github-profile-analyzer"; - - starGateLog("check_start", { - username: normalizedUsername, - hasUserToken: Boolean(userToken), - }); - - if (userToken) { - try { - const res = await githubFetchWithTimeout( - `https://api.github.com/user/starred/${targetRepo}`, - { - headers: { - Authorization: `Bearer ${userToken}`, - Accept: "application/vnd.github+json", - "User-Agent": "GitScore/1.1", - }, - }, - ); - if (res.status === 204) { - await cacheVerifiedStar(normalizedUsername, targetRepo); - starGateLog("check_pass_user_token", { username: normalizedUsername }); - return true; - } - starGateLog("check_user_token_miss", { - username: normalizedUsername, - status: res.status, - }); - } catch (e) { - console.error("REST star check failed:", e); - } - } - - const cacheKey = `repo:stargazers:${targetRepo}`; - - try { - const cachedStargazers = await getCachedData(cacheKey); - if ( - cachedStargazers?.some((u) => normalizeUsername(u) === normalizedUsername) - ) { - starGateLog("check_pass_cache", { username: normalizedUsername }); - return true; - } - - const isVerified = await executeBidirectionalStarCheck(normalizedUsername); - if (isVerified) { - await cacheVerifiedStar(normalizedUsername, targetRepo); - starGateLog("check_pass_deep", { username: normalizedUsername }); - } else { - starGateLog("check_fail_deep", { username: normalizedUsername }); - } - return isVerified; - } catch (err) { - console.error("Optimized star check error:", err); - await sendTelegramAlert({ - source: "STAR_CHECK", - message: "Optimized star check error", - error: err, - context: { username: normalizedUsername }, - }); - starGateLog("check_error", { - username: normalizedUsername, - message: toErrorMessage(err), - }); - return false; - } -} - -export async function verifyAndInjectStar(username: string): Promise { - const normalizedUsername = normalizeUsername(username || ""); - if ( - !normalizedUsername || - normalizedUsername === "undefined" || - normalizedUsername === "null" - ) - return false; - starGateLog("verify_guest_start", { username: normalizedUsername }); - const isVerified = await executeBidirectionalStarCheck(normalizedUsername); - if (isVerified) { - await cacheVerifiedStar( - normalizedUsername, - "0xarchit/github-profile-analyzer", - ); - starGateLog("verify_guest_pass", { username: normalizedUsername }); - } else { - starGateLog("verify_guest_fail", { username: normalizedUsername }); - } - return isVerified; -} - -export async function getRepoStarCount(): Promise { - const url = "https://api.github.com/repos/0xarchit/github-profile-analyzer"; - const token = GITHUB_TOKENS[0] || ""; - const headers: HeadersInit = { - Accept: "application/vnd.github+json", - "User-Agent": "GitScore/1.1", - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - try { - const response = await githubFetchWithTimeout(url, { headers }); - if (!response.ok) { - return null; - } - const payload = (await response.json()) as { stargazers_count?: number }; - return typeof payload.stargazers_count === "number" - ? payload.stargazers_count - : null; - } catch { - return null; - } -} - -async function executeBidirectionalStarCheck( - username: string, -): Promise { - const targetOwner = "0xarchit"; - const targetName = "github-profile-analyzer"; - const token = getFallbackToken(); - if (!token) return false; - - const countQuery = ` - query($owner: String!, $name: String!, $user: String!) { - repository(owner: $owner, name: $name) { stargazerCount } - user(login: $user) { starredRepositories { totalCount } } - } - `; - - try { - const resCount = await fetchGitHubGraphQL(token, countQuery, { - owner: targetOwner, - name: targetName, - user: username, - }); - if (!resCount.ok) { - starGateLog("graphql_count_http_error", { - username, - status: resCount.status, - }); - return executeRestFallbackStarCheck( - username, - targetOwner, - targetName, - token, - ); - } - const countBody = await resCount.json(); - const countError = extractGraphQLErrorMessage(countBody); - if (countError) { - starGateLog("graphql_count_error", { - username, - message: countError, - }); - } - const repoStars = Number(countBody.data?.repository?.stargazerCount || 0); - const userStars = Number( - countBody.data?.user?.starredRepositories?.totalCount || 0, - ); - - const userPages = Math.min( - Math.ceil(userStars / STAR_PAGE_SIZE), - STAR_DEEP_LIMIT, - ); - const repoPages = Math.min( - Math.ceil(repoStars / STAR_PAGE_SIZE), - STAR_DEEP_LIMIT, - ); - - starGateLog("graphql_count", { - username, - repoStars, - userStars, - repoPages, - userPages, - }); - - let graphVerified = false; - - if (userStars <= repoStars) { - graphVerified = await scanUserStarsBidirectional( - username, - targetOwner, - targetName, - token, - userPages, - ); - if (!graphVerified) { - graphVerified = await scanRepoStarsBidirectional( - targetOwner, - targetName, - username, - token, - repoPages, - ); - } - } else { - graphVerified = await scanRepoStarsBidirectional( - targetOwner, - targetName, - username, - token, - repoPages, - ); - if (!graphVerified) { - graphVerified = await scanUserStarsBidirectional( - username, - targetOwner, - targetName, - token, - userPages, - ); - } - } - - if (graphVerified) { - starGateLog("graphql_scan_pass", { username }); - return true; - } - - starGateLog("graphql_scan_miss", { username }); - const restVerified = await executeRestFallbackStarCheck( - username, - targetOwner, - targetName, - token, - repoStars, - userStars, - ); - if (restVerified) { - starGateLog("rest_scan_pass", { username }); - } else { - starGateLog("rest_scan_miss", { username }); - } - return restVerified; - } catch (e) { - console.error("Bidirectional check failed", e); - starGateLog("graphql_scan_exception", { - username, - message: toErrorMessage(e), - }); - return executeRestFallbackStarCheck( - username, - targetOwner, - targetName, - token, - ); - } -} - -async function executeRestFallbackStarCheck( - username: string, - targetOwner: string, - targetName: string, - token: string, - repoStars = 0, - userStars = 0, -): Promise { - const targetRepo = `${targetOwner}/${targetName}`.toLowerCase(); - const repoPages = Math.max( - 1, - Math.min( - Math.ceil((repoStars > 0 ? repoStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), - STAR_REST_LIMIT, - ), - ); - const userPages = Math.max( - 1, - Math.min( - Math.ceil((userStars > 0 ? userStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), - STAR_REST_LIMIT, - ), - ); - - if ( - await scanRepoStargazersRest( - username, - targetOwner, - targetName, - token, - repoPages, - ) - ) { - return true; - } - return scanUserStarredRest(username, targetRepo, token, userPages); -} - -async function scanRepoStargazersRest( - username: string, - targetOwner: string, - targetName: string, - token: string, - maxPages: number, -): Promise { - const targetUser = normalizeUsername(username); - for (let page = 1; page <= maxPages; page++) { - const url = `https://api.github.com/repos/${encodeURIComponent(targetOwner)}/${encodeURIComponent(targetName)}/stargazers?per_page=100&page=${page}`; - const res = await fetchGitHubRest(url, token); - if (!res.ok) { - starGateLog("rest_repo_page_error", { - username: targetUser, - page, - status: res.status, - }); - return false; - } - const body = (await res.json()) as RestStargazerNode[]; - if ( - body.some((item) => normalizeUsername(item.login || "") === targetUser) - ) { - starGateLog("rest_repo_page_hit", { username: targetUser, page }); - return true; - } - const hasNext = hasNextPageFromLink(res.headers.get("link")); - if (!hasNext || body.length < STAR_PAGE_SIZE) { - break; - } - } - return false; -} - -async function scanUserStarredRest( - username: string, - targetRepo: string, - token: string, - maxPages: number, -): Promise { - const targetUser = normalizeUsername(username); - for (let page = 1; page <= maxPages; page++) { - const url = `https://api.github.com/users/${encodeURIComponent(targetUser)}/starred?per_page=100&page=${page}`; - const res = await fetchGitHubRest(url, token); - if (!res.ok) { - starGateLog("rest_user_page_error", { - username: targetUser, - page, - status: res.status, - }); - return false; - } - const body = (await res.json()) as RestStarredRepoNode[]; - if ( - body.some((item) => (item.full_name || "").toLowerCase() === targetRepo) - ) { - starGateLog("rest_user_page_hit", { username: targetUser, page }); - return true; - } - const hasNext = hasNextPageFromLink(res.headers.get("link")); - if (!hasNext || body.length < STAR_PAGE_SIZE) { - break; - } - } - return false; -} - -async function scanUserStarsBidirectional( - username: string, - targetOwner: string, - targetName: string, - token: string, - maxPages: number, -): Promise { - if (maxPages <= 0) return false; - const quickPages = Math.min(STAR_QUICK_LIMIT, maxPages); - const targetFullName = `${targetOwner}/${targetName}`.toLowerCase(); - - const [quickNewest, quickOldest] = await Promise.all([ - paginateUserStarsForward(username, targetFullName, token, quickPages), - paginateUserStarsBackward(username, targetFullName, token, quickPages), - ]); - if (quickNewest || quickOldest) return true; - - if (maxPages <= quickPages) return false; - - if ( - await paginateUserStarsBackward(username, targetFullName, token, maxPages) - ) - return true; - return paginateUserStarsForward(username, targetFullName, token, maxPages); -} - -async function scanRepoStarsBidirectional( - targetOwner: string, - targetName: string, - username: string, - token: string, - maxPages: number, -): Promise { - if (maxPages <= 0) return false; - const quickPages = Math.min(STAR_QUICK_LIMIT, maxPages); - const targetUser = username.toLowerCase(); - - const [quickNewest, quickOldest] = await Promise.all([ - paginateRepoStarsForward( - targetOwner, - targetName, - targetUser, - token, - quickPages, - ), - paginateRepoStarsBackward( - targetOwner, - targetName, - targetUser, - token, - quickPages, - ), - ]); - if (quickNewest || quickOldest) return true; - - if (maxPages <= quickPages) return false; - - if ( - await paginateRepoStarsBackward( - targetOwner, - targetName, - targetUser, - token, - maxPages, - ) - ) - return true; - return paginateRepoStarsForward( - targetOwner, - targetName, - targetUser, - token, - maxPages, - ); -} - -async function paginateUserStarsForward( - username: string, - targetFullName: string, - token: string, - pagesLeft: number, - cursor: string | null = null, -): Promise { - if (pagesLeft <= 0) return false; - const query = ` - query($user: String!, $after: String) { - user(login: $user) { - starredRepositories(first: 100, after: $after, orderBy: {field: STARRED_AT, direction: DESC}) { - pageInfo { hasNextPage endCursor } - nodes { nameWithOwner } - } - } - } - `; - - const githubRes = await fetchGitHubGraphQL(token, query, { - user: username, - after: cursor, - }); - if (!githubRes.ok) { - starGateLog("graphql_user_forward_http_error", { - username, - status: githubRes.status, - }); - return false; - } - const githubBody = await githubRes.json(); - const graphError = extractGraphQLErrorMessage(githubBody); - if (graphError) { - starGateLog("graphql_user_forward_error", { - username, - message: graphError, - }); - return false; - } - - const starred = githubBody.data?.user?.starredRepositories as - | { pageInfo?: PageInfo; nodes?: StarredRepoNode[] } - | undefined; - if (!starred) return false; - - const nodes = starred.nodes || []; - if (nodes.some((n) => n.nameWithOwner.toLowerCase() === targetFullName)) - return true; - - if (!starred.pageInfo?.hasNextPage || !starred.pageInfo.endCursor) - return false; - return paginateUserStarsForward( - username, - targetFullName, - token, - pagesLeft - 1, - starred.pageInfo.endCursor, - ); -} - -async function paginateUserStarsBackward( - username: string, - targetFullName: string, - token: string, - pagesLeft: number, - cursor: string | null = null, -): Promise { - if (pagesLeft <= 0) return false; - const query = ` - query($user: String!, $before: String) { - user(login: $user) { - starredRepositories(last: 100, before: $before, orderBy: {field: STARRED_AT, direction: DESC}) { - pageInfo { hasPreviousPage startCursor } - nodes { nameWithOwner } - } - } - } - `; - - const githubRes = await fetchGitHubGraphQL(token, query, { - user: username, - before: cursor, - }); - if (!githubRes.ok) { - starGateLog("graphql_user_backward_http_error", { - username, - status: githubRes.status, - }); - return false; - } - const githubBody = await githubRes.json(); - const graphError = extractGraphQLErrorMessage(githubBody); - if (graphError) { - starGateLog("graphql_user_backward_error", { - username, - message: graphError, - }); - return false; - } - - const starred = githubBody.data?.user?.starredRepositories as - | { pageInfo?: PageInfo; nodes?: StarredRepoNode[] } - | undefined; - if (!starred) return false; - - const nodes = starred.nodes || []; - if (nodes.some((n) => n.nameWithOwner.toLowerCase() === targetFullName)) - return true; - - if (!starred.pageInfo?.hasPreviousPage || !starred.pageInfo.startCursor) - return false; - return paginateUserStarsBackward( - username, - targetFullName, - token, - pagesLeft - 1, - starred.pageInfo.startCursor, - ); -} - -async function paginateRepoStarsForward( - targetOwner: string, - targetName: string, - targetUser: string, - token: string, - pagesLeft: number, - cursor: string | null = null, -): Promise { - if (pagesLeft <= 0) return false; - const query = ` - query($owner: String!, $name: String!, $after: String) { - repository(owner: $owner, name: $name) { - stargazers(first: 100, after: $after, orderBy: {field: STARRED_AT, direction: DESC}) { - pageInfo { hasNextPage endCursor } - nodes { login } - } - } - } - `; - - const githubRes = await fetchGitHubGraphQL(token, query, { - owner: targetOwner, - name: targetName, - after: cursor, - }); - if (!githubRes.ok) { - starGateLog("graphql_repo_forward_http_error", { - username: targetUser, - status: githubRes.status, - }); - return false; - } - const githubBody = await githubRes.json(); - const graphError = extractGraphQLErrorMessage(githubBody); - if (graphError) { - starGateLog("graphql_repo_forward_error", { - username: targetUser, - message: graphError, - }); - return false; - } - - const stargazers = githubBody.data?.repository?.stargazers as - | { pageInfo?: PageInfo; nodes?: StargazerNode[] } - | undefined; - if (!stargazers) return false; - - const nodes = stargazers.nodes || []; - if (nodes.some((n) => n.login.toLowerCase() === targetUser)) return true; - - if (!stargazers.pageInfo?.hasNextPage || !stargazers.pageInfo.endCursor) - return false; - return paginateRepoStarsForward( - targetOwner, - targetName, - targetUser, - token, - pagesLeft - 1, - stargazers.pageInfo.endCursor, - ); -} - -async function paginateRepoStarsBackward( - targetOwner: string, - targetName: string, - targetUser: string, - token: string, - pagesLeft: number, - cursor: string | null = null, -): Promise { - if (pagesLeft <= 0) return false; - const query = ` - query($owner: String!, $name: String!, $before: String) { - repository(owner: $owner, name: $name) { - stargazers(last: 100, before: $before, orderBy: {field: STARRED_AT, direction: DESC}) { - pageInfo { hasPreviousPage startCursor } - nodes { login } - } - } - } - `; - - const githubRes = await fetchGitHubGraphQL(token, query, { - owner: targetOwner, - name: targetName, - before: cursor, - }); - if (!githubRes.ok) { - starGateLog("graphql_repo_backward_http_error", { - username: targetUser, - status: githubRes.status, - }); - return false; - } - const githubBody = await githubRes.json(); - const graphError = extractGraphQLErrorMessage(githubBody); - if (graphError) { - starGateLog("graphql_repo_backward_error", { - username: targetUser, - message: graphError, - }); - return false; - } - - const stargazers = githubBody.data?.repository?.stargazers as - | { pageInfo?: PageInfo; nodes?: StargazerNode[] } - | undefined; - if (!stargazers) return false; - - const nodes = stargazers.nodes || []; - if (nodes.some((n) => n.login.toLowerCase() === targetUser)) return true; - - if (!stargazers.pageInfo?.hasPreviousPage || !stargazers.pageInfo.startCursor) - return false; - return paginateRepoStarsBackward( - targetOwner, - targetName, - targetUser, - token, - pagesLeft - 1, - stargazers.pageInfo.startCursor, - ); -} diff --git a/src/lib/github/http.ts b/src/lib/github/http.ts new file mode 100644 index 0000000..9f126df --- /dev/null +++ b/src/lib/github/http.ts @@ -0,0 +1,149 @@ +/** + * Shared low-level HTTP utilities for all GitHub API calls. + * + * Provides: + * - A token pool sourced from GITHUB_TOKENS env var + * - `getFallbackToken()` — random pick from the pool + * - `githubFetchWithTimeout()` — fetch wrapper with AbortController timeout + * - `fetchGitHubGraphQL()` — POST to the GraphQL endpoint with logging + * - `fetchGitHubRest()` — GET to the REST API with automatic token-drop retry + * - Header builders for GraphQL and REST + */ + +export const GITHUB_TOKENS = (process.env.GITHUB_TOKENS || "") + .split(",") + .filter(Boolean); + +if (GITHUB_TOKENS.length === 0) { + throw new Error("GITHUB_TOKENS environment variable is required"); +} + +export const GITHUB_FETCH_TIMEOUT_MS = 10_000; + +/** + * Returns a random token from the GITHUB_TOKENS pool. + * Throws if the pool is empty (guards against runtime env misconfiguration). + */ +export function getFallbackToken(): string { + const token = GITHUB_TOKENS[Math.floor(Math.random() * GITHUB_TOKENS.length)]; + if (!token) throw new Error("GITHUB_TOKENS environment variable is required"); + return token; +} + +/** + * Wraps `fetch` with an AbortController-based timeout. + * The timeout fires after `timeoutMs` milliseconds and aborts the request. + */ +export async function githubFetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = GITHUB_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +export function buildGitHubGraphQLHeaders(token: string): HeadersInit { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "GitScore/1.1", + }; +} + +export function buildGitHubRestHeaders(token?: string): HeadersInit { + if (token) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "GitScore/1.1", + }; + } + return { + Accept: "application/vnd.github+json", + "User-Agent": "GitScore/1.1", + }; +} + +/** + * POST a GraphQL query to the GitHub GraphQL API. + * Logs the request start and response status. + * Throws on network/timeout errors; callers must check `response.ok`. + */ +export async function fetchGitHubGraphQL( + token: string, + query: string, + variables: Record, + timeoutMs = GITHUB_FETCH_TIMEOUT_MS, +): Promise { + console.log("[GITHUB_API] GraphQL request starting", { + timeoutMs, + variables: JSON.stringify(variables).slice(0, 100), + }); + try { + const response = await githubFetchWithTimeout( + "https://api.github.com/graphql", + { + method: "POST", + headers: buildGitHubGraphQLHeaders(token), + body: JSON.stringify({ query, variables }), + }, + timeoutMs, + ); + console.log("[GITHUB_API] GraphQL response received", { + status: response.status, + statusText: response.statusText, + }); + return response; + } catch (err) { + console.error("[GITHUB_API] GraphQL request failed", { + error: err instanceof Error ? err.message : String(err), + variables: JSON.stringify(variables).slice(0, 100), + }); + throw err; + } +} + +/** + * GET a GitHub REST API endpoint. + * If the first attempt returns 401/403 and a token was provided, + * retries once without the token (public endpoint fallback). + */ +export async function fetchGitHubRest( + url: string, + token?: string, +): Promise { + const first = await githubFetchWithTimeout(url, { + headers: buildGitHubRestHeaders(token), + }); + if ((first.status === 401 || first.status === 403) && token) { + return githubFetchWithTimeout(url, { + headers: buildGitHubRestHeaders(), + }); + } + return first; +} + +/** Extract the first GraphQL error message from a parsed response body, or null. */ +export function extractGraphQLErrorMessage(body: unknown): string | null { + const errors = + typeof body === "object" && body !== null + ? (body as { errors?: Array<{ message?: string }> }).errors + : undefined; + if (!errors?.length) return null; + return errors[0]?.message || "graphql_error"; +} + +/** Returns true if the Link header contains rel="next". */ +export function hasNextPageFromLink(linkHeader: string | null): boolean { + if (!linkHeader) return false; + return linkHeader.includes('rel="next"'); +} diff --git a/src/lib/github/index.ts b/src/lib/github/index.ts new file mode 100644 index 0000000..b9bb04a --- /dev/null +++ b/src/lib/github/index.ts @@ -0,0 +1,22 @@ +/** + * Public surface of the `@/lib/github` module. + * Import everything from here — not from individual sub-files. + */ + +export { getProfileSummary, calculateDetailedStreaks } from "./profile"; +export { + checkStarStatus, + verifyAndInjectStar, + getRepoStarCount, + normalizeUsername, +} from "./star-gate"; +export { + getFallbackToken, + githubFetchWithTimeout, + fetchGitHubGraphQL, + fetchGitHubRest, + extractGraphQLErrorMessage, + hasNextPageFromLink, + GITHUB_TOKENS, + GITHUB_FETCH_TIMEOUT_MS, +} from "./http"; diff --git a/src/lib/github/profile.ts b/src/lib/github/profile.ts new file mode 100644 index 0000000..9364bd4 --- /dev/null +++ b/src/lib/github/profile.ts @@ -0,0 +1,457 @@ +/** + * GitHub profile data domain. + * + * Responsibilities: + * - Fetch the full `ProfileSummary` for a given username via GraphQL + * - Calculate contribution streaks (daily + weekly) + * - Build trophies and rank metadata + * - Resolve GitHub achievement badges (with Redis-backed caching) + */ + +import { + ProfileSummary, + ContributionWeek, + ContributionDay, + GithubRepoNode, +} from "@/types"; +import { getCachedData, setCachedData } from "@/lib/redis"; +import { sendTelegramAlert } from "@/lib/telegram-alert"; +import { + getFallbackToken, + fetchGitHubGraphQL, + githubFetchWithTimeout, +} from "./http"; + +// --------------------------------------------------------------------------- +// Achievement badges +// --------------------------------------------------------------------------- + +const ACHIEVEMENT_CACHE_TTL_SECONDS = 10 * 60; // 10 minutes + +const badgeAssets: Record = { + "pull-shark": + "https://github.githubassets.com/assets/pull-shark-default-498c279a747d.png", + starstruck: + "https://github.githubassets.com/assets/starstruck-default--light-medium-65b31ef2251e.png", + "pair-extraordinaire": + "https://github.githubassets.com/assets/pair-extraordinaire-default-579438a20e01.png", + "galaxy-brain": + "https://github.githubassets.com/assets/galaxy-brain-default-847262c21056.png", + yolo: "https://github.githubassets.com/assets/yolo-default-be0bbff04951.png", + quickdraw: + "https://github.githubassets.com/assets/quickdraw-default--light-medium-5450fadcbe37.png", +}; + +function normalizeUsername(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Checks whether a GitHub user has unlocked a specific achievement badge. + * Results are cached in Redis for `ACHIEVEMENT_CACHE_TTL_SECONDS`. + */ +async function checkAchievementStatus( + username: string, + slug: string, +): Promise { + const cacheKey = `achievement:${normalizeUsername(username)}:${slug}`; + + const cached = await getCachedData(cacheKey); + if (cached !== null && cached !== undefined) { + return cached; + } + + const url = `https://github.com/${encodeURIComponent(username)}?tab=achievements&achievement=${slug}`; + try { + const res = await githubFetchWithTimeout(url, { + method: "HEAD", + headers: { "User-Agent": "GitScore/1.1" }, + }); + const value = res.status === 200 ? slug : null; + await setCachedData(cacheKey, value, ACHIEVEMENT_CACHE_TTL_SECONDS); + return value; + } catch { + await setCachedData(cacheKey, null, ACHIEVEMENT_CACHE_TTL_SECONDS); + return null; + } +} + +// --------------------------------------------------------------------------- +// Rank / trophy helpers +// --------------------------------------------------------------------------- + +function getRank(val: number, thresholds: number[]): string { + if (val >= thresholds[3]) return "SS"; + if (val >= thresholds[2]) return "S"; + if (val >= thresholds[1]) return "A"; + if (val >= thresholds[0]) return "B"; + return "C"; +} + +function getRankColor(rank: string): string { + switch (rank) { + case "SS": + return "#ff0055"; + case "S": + return "#facc15"; + case "A": + return "#a78bfa"; + case "B": + return "#60a5fa"; + default: + return "#94a3b8"; + } +} + +// --------------------------------------------------------------------------- +// Streak calculation +// --------------------------------------------------------------------------- + +/** + * Given the contribution weeks from the GitHub API, computes: + * - `daily_streak` — current consecutive active days (from today backwards) + * - `daily_best` — longest consecutive active-day run ever + * - `weekly_streak` — current consecutive active weeks + * - `weekly_best` — longest consecutive active-week run ever + */ +export function calculateDetailedStreaks(weeks: ContributionWeek[]) { + const days = weeks.flatMap((w) => w.contributionDays); + let daily_streak = 0, + daily_best = 0, + tmp_daily = 0; + for (let i = days.length - 1; i >= 0; i--) { + if (days[i].contributionCount > 0) daily_streak++; + else if (i === days.length - 1) continue; + else break; + } + days.forEach((d) => { + if (d.contributionCount > 0) { + tmp_daily++; + if (tmp_daily > daily_best) daily_best = tmp_daily; + } else tmp_daily = 0; + }); + const weekActivity = weeks.map((w) => + w.contributionDays.some((d: ContributionDay) => d.contributionCount > 0), + ); + let weekly_streak = 0, + weekly_best = 0, + tmp_weekly = 0; + for (let i = weekActivity.length - 1; i >= 0; i--) { + if (weekActivity[i]) weekly_streak++; + else if (i === weekActivity.length - 1) continue; + else break; + } + weekActivity.forEach((active) => { + if (active) { + tmp_weekly++; + if (tmp_weekly > weekly_best) weekly_best = tmp_weekly; + } else tmp_weekly = 0; + }); + return { daily_streak, daily_best, weekly_streak, weekly_best }; +} + +// --------------------------------------------------------------------------- +// Profile summary +// --------------------------------------------------------------------------- + +/** + * Fetches the full `ProfileSummary` for `username` via the GitHub GraphQL API. + * + * @param username The GitHub login to fetch (case-insensitive). + * @param userToken Optional OAuth token belonging to the **viewer** (logged-in user). + * When provided, it is used first to benefit from that user's rate limit. + * Falls back to the shared GITHUB_TOKENS pool on 401/403. + * @throws `Error("INVALID_USERNAME")` if the username is blank or literally "undefined". + * @throws `Error("USER_NOT_FOUND")` if GitHub returns no user for that login. + * @throws `Error("GITHUB_AUTH_FAILED")` if both the viewer token and a fallback token fail auth. + */ +export async function getProfileSummary( + username: string, + userToken?: string, +): Promise { + console.log("[GITHUB_API] getProfileSummary starting", { + username, + hasUserToken: !!userToken, + }); + if (!username || username.toLowerCase() === "undefined") { + console.error("[GITHUB_API] Invalid username", { username }); + throw new Error("INVALID_USERNAME"); + } + const token = userToken || getFallbackToken(); + console.log("[GITHUB_API] Using token", { hasUserToken: !!userToken }); + const query = ` + query($login: String!) { + user(login: $login) { + avatarUrl, login, name, bio, followers { totalCount }, following { totalCount } + contributionsCollection { + totalCommitContributions, totalPullRequestContributions, totalIssueContributions, totalPullRequestReviewContributions, totalRepositoryContributions + contributionCalendar { + totalContributions + weeks { contributionDays { contributionCount, date, color } } + } + } + repositories(first: 60, ownerAffiliations: [OWNER], orderBy: {field: STARGAZERS, direction: DESC}) { + nodes { + name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } + openIssues: issues(states: OPEN) { totalCount } + watchers { totalCount } + repositoryTopics(first: 5) { nodes { topic { name } } } + } + } + repositoriesContributedTo(first: 40, includeUserRepositories: true, contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]) { + nodes { + name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } + openIssues: issues(states: OPEN) { totalCount } + watchers { totalCount } + repositoryTopics(first: 5) { nodes { topic { name } } } + } + } + } + } + `; + + const requestSummary = async (authToken: string) => { + console.log("[GITHUB_API] Sending profile summary request", { username }); + return fetchGitHubGraphQL(authToken, query, { login: username }); + }; + + let res = await requestSummary(token); + console.log("[GITHUB_API] Initial profile request completed", { + status: res.status, + }); + if (res.status === 401 || res.status === 403) { + console.log("[GITHUB_API] Auth failed, retrying with fallback token"); + const retryToken = getFallbackToken(); + res = await requestSummary(retryToken); + console.log("[GITHUB_API] Retry request completed", { status: res.status }); + if (res.status === 401 || res.status === 403) { + console.error("[GITHUB_API] Both auth attempts failed"); + await sendTelegramAlert({ + source: "GITHUB_PROFILE_SUMMARY", + message: "GitHub auth failed for profile summary", + error: new Error("GITHUB_AUTH_FAILED"), + context: { username, status: res.status }, + }); + throw new Error("GITHUB_AUTH_FAILED"); + } + } + + if (!res.ok) { + console.error("[GITHUB_API] Profile request failed", { + status: res.status, + }); + await sendTelegramAlert({ + source: "GITHUB_PROFILE_SUMMARY", + message: "Profile request failed", + error: new Error(`GitHub API error: HTTP ${res.status}`), + context: { username, status: res.status }, + }); + throw new Error(`GitHub API error: HTTP ${res.status}`); + } + + console.log("[GITHUB_API] Parsing profile response"); + const body = await res.json(); + if (body.errors) { + console.error("[GITHUB_API] GraphQL errors in response", { + errors: body.errors, + }); + if (body.errors[0]?.message.includes("Could not resolve to a User")) { + console.error("[GITHUB_API] User not found in GitHub", { username }); + throw new Error("USER_NOT_FOUND"); + } + await sendTelegramAlert({ + source: "GITHUB_PROFILE_SUMMARY", + message: "GraphQL error while fetching profile", + error: new Error(body.errors[0]?.message || "graphql_error"), + context: { username }, + }); + throw new Error(`GitHub API error: ${body.errors[0].message}`); + } + const user = body.data?.user; + if (!user) { + console.error("[GITHUB_API] No user data in response", { username }); + throw new Error("USER_NOT_FOUND"); + } + console.log("[GITHUB_API] User data parsed successfully", { + username: user.login, + repos: user.repositories.nodes.length, + }); + + const coll = user.contributionsCollection; + const ownedNodes = user.repositories.nodes || []; + + const authoredRepos = ownedNodes.filter((r: GithubRepoNode) => !r.isFork); + + let total_stars = 0; + authoredRepos.forEach( + (r: GithubRepoNode) => (total_stars += r.stargazerCount), + ); + + const detailedStreaks = calculateDetailedStreaks( + coll.contributionCalendar.weeks, + ); + + const langs = (function (nodes: GithubRepoNode[]) { + const l: Record = {}; + nodes.forEach((n) => { + if (n.primaryLanguage) + l[n.primaryLanguage.name] = + (l[n.primaryLanguage.name] || 0) + + Math.sqrt(n.stargazerCount + 1) + + 1; + }); + const entries = Object.entries(l) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6); + const total = entries.reduce((acc, [, v]) => acc + v, 0) || 1; + const cols = [ + "#facc15", + "#fb923c", + "#4ade80", + "#f472b6", + "#60a5fa", + "#a78bfa", + ]; + return entries.map(([n, v], i) => ({ + name: n, + value: (v / total) * 100, + color: cols[i % cols.length], + })); + })(authoredRepos); + + const t_configs = [ + { name: "Stars", val: total_stars, th: [10, 100, 500, 1000] }, + { + name: "Commits", + val: coll.totalCommitContributions, + th: [100, 500, 1000, 5000], + }, + { + name: "PRs", + val: coll.totalPullRequestContributions, + th: [10, 50, 100, 500], + }, + { name: "Issues", val: coll.totalIssueContributions, th: [5, 20, 50, 100] }, + { + name: "Reviews", + val: coll.totalPullRequestReviewContributions, + th: [5, 20, 50, 100], + }, + { + name: "Followers", + val: user.followers.totalCount, + th: [10, 50, 100, 500], + }, + { + name: "Authored Repos", + val: authoredRepos.length, + th: [10, 30, 50, 100], + }, + ]; + + const trophies = t_configs.map((c) => { + const rank = getRank(c.val, c.th); + return { + name: c.name, + rank, + color: getRankColor(rank), + value: c.val.toString(), + }; + }); + + const slugs = Object.keys(badgeAssets); + console.log("[GITHUB_API] Checking achievement badges", { + badgeCount: slugs.length, + }); + const unlocked = await Promise.all( + slugs.map((s) => { + console.log("[GITHUB_API] Checking achievement", { slug: s }); + return checkAchievementStatus(username, s); + }), + ); + const badges: Record = {}; + const unlockedBadges = unlocked.filter(Boolean); + console.log("[GITHUB_API] Achievement check complete", { + unlockedCount: unlockedBadges.length, + }); + unlockedBadges.forEach((s) => { + if (s) badges[s] = badgeAssets[s]; + }); + + console.log("[GITHUB_API] Assembling profile summary", { + username: user.login, + total_stars, + followers: user.followers.totalCount, + }); + return { + avatar: user.avatarUrl, + username: user.login, + name: user.name, + bio: user.bio, + followers: user.followers.totalCount, + following: user.following.totalCount, + public_repo_count: authoredRepos.length, + total_stars, + original_repos: authoredRepos.reduce( + ( + acc: Record< + string, + { + n: string; + description: string | null; + stars: number; + forks: number; + issues: number; + watchers: number; + primary_lang: string | null; + topics: string[]; + } + >, + r: GithubRepoNode, + ) => ({ + ...acc, + [r.name]: { + n: r.name, + description: r.description, + stars: r.stargazerCount, + forks: r.forkCount, + issues: r.openIssues.totalCount, + watchers: r.watchers.totalCount, + primary_lang: r.primaryLanguage?.name || null, + topics: r.repositoryTopics.nodes.map((t) => t.topic.name), + }, + }), + {}, + ), + career_stats: { + total_contributions: coll.contributionCalendar.totalContributions, + total_commits: coll.totalCommitContributions, + total_prs: coll.totalPullRequestContributions, + total_issues: coll.totalIssueContributions, + total_reviews: coll.totalPullRequestReviewContributions, + daily_streak: detailedStreaks.daily_streak, + daily_best: detailedStreaks.daily_best, + weekly_streak: detailedStreaks.weekly_streak, + weekly_best: detailedStreaks.weekly_best, + top_languages: langs, + commit_activity: (function (weeks: ContributionWeek[]) { + const m: Record = {}; + weeks.forEach((w) => + w.contributionDays.forEach((d: ContributionDay) => { + const date = new Date(d.date); + const key = `${date.getUTCFullYear()}-${String( + date.getUTCMonth() + 1, + ).padStart(2, "0")}`; + m[key] = (m[key] || 0) + d.contributionCount; + }), + ); + return Object.entries(m) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, count]) => ({ month, count })); + })(coll.contributionCalendar.weeks), + trophies, + }, + calendar_data: coll.contributionCalendar, + badges, + }; +} diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts new file mode 100644 index 0000000..179e7b4 --- /dev/null +++ b/src/lib/github/star-gate.ts @@ -0,0 +1,685 @@ +/** + * Star-gate domain — verifies whether a GitHub user has starred the target repo. + * + * Strategy (in order): + * 1. User's own OAuth token → direct REST `GET /user/starred/:repo` (204 = starred) + * 2. Redis cache of previously verified stargazers + * 3. Bidirectional GraphQL scan (user's starred list ↔ repo's stargazer list) + * 4. REST fallback scan (paginated stargazer/starred lists) + * + * All public functions guard against blank/invalid usernames and return `false` + * rather than throwing, so callers don't need extra try/catch. + */ + +import { getCachedData, setCachedData } from "@/lib/redis"; +import { sendTelegramAlert } from "@/lib/telegram-alert"; +import { + getFallbackToken, + fetchGitHubGraphQL, + fetchGitHubRest, + extractGraphQLErrorMessage, + hasNextPageFromLink, + githubFetchWithTimeout, +} from "./http"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STAR_PAGE_SIZE = 100; +const STAR_QUICK_LIMIT = 6; +const STAR_DEEP_LIMIT = 120; +const STAR_REST_LIMIT = 250; +const STAR_CACHE_TTL = 900; +const STAR_GATE_DEBUG = process.env.STAR_GATE_DEBUG === "1"; + +const TARGET_OWNER = "0xarchit"; +const TARGET_NAME = "github-profile-analyzer"; +const TARGET_REPO = `${TARGET_OWNER}/${TARGET_NAME}`; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PageInfo { + hasNextPage?: boolean; + endCursor?: string | null; + hasPreviousPage?: boolean; + startCursor?: string | null; +} + +interface StarredRepoNode { + nameWithOwner: string; +} + +interface StargazerNode { + login: string; +} + +interface RestStargazerNode { + login?: string; +} + +interface RestStarredRepoNode { + full_name?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function normalizeUsername(value: string): string { + return value.trim().toLowerCase(); +} + +function starGateLog(event: string, payload: Record): void { + if (!STAR_GATE_DEBUG) return; + console.info(`[star-gate] ${event}`, payload); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "unknown_error"; +} + +async function cacheVerifiedStar(username: string): Promise { + const normalized = normalizeUsername(username); + const cacheKey = `repo:stargazers:${TARGET_REPO}`; + const cached = (await getCachedData(cacheKey)) || []; + if (cached.some((s) => normalizeUsername(s) === normalized)) { + return; + } + cached.push(normalized); + await setCachedData(cacheKey, cached, STAR_CACHE_TTL); +} + +// --------------------------------------------------------------------------- +// Generic cursor-walk paginator (replaces 4 near-identical recursive fns) +// --------------------------------------------------------------------------- + +type ForwardPage = { + nodes: T[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +} | null; + +type BackwardPage = { + nodes: T[]; + pageInfo: { hasPreviousPage: boolean; startCursor: string | null }; +} | null; + +async function paginateForward( + fetchPage: (cursor: string | null) => Promise>, + match: (node: T) => boolean, + pagesLeft: number, + cursor: string | null = null, +): Promise { + if (pagesLeft <= 0) return false; + const page = await fetchPage(cursor); + if (!page) return false; + if (page.nodes.some(match)) return true; + if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) return false; + return paginateForward(fetchPage, match, pagesLeft - 1, page.pageInfo.endCursor); +} + +async function paginateBackward( + fetchPage: (cursor: string | null) => Promise>, + match: (node: T) => boolean, + pagesLeft: number, + cursor: string | null = null, +): Promise { + if (pagesLeft <= 0) return false; + const page = await fetchPage(cursor); + if (!page) return false; + if (page.nodes.some(match)) return true; + if (!page.pageInfo.hasPreviousPage || !page.pageInfo.startCursor) return false; + return paginateBackward(fetchPage, match, pagesLeft - 1, page.pageInfo.startCursor); +} + +// --------------------------------------------------------------------------- +// GraphQL page fetchers +// --------------------------------------------------------------------------- + +async function fetchUserStarsPageForward( + token: string, + username: string, + cursor: string | null, +): Promise> { + const query = ` + query($user: String!, $after: String) { + user(login: $user) { + starredRepositories(first: 100, after: $after, orderBy: {field: STARRED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { nameWithOwner } + } + } + } + `; + const res = await fetchGitHubGraphQL(token, query, { user: username, after: cursor }); + if (!res.ok) { + starGateLog("graphql_user_forward_http_error", { username, status: res.status }); + return null; + } + const body = await res.json(); + if (extractGraphQLErrorMessage(body)) { + starGateLog("graphql_user_forward_error", { username, message: extractGraphQLErrorMessage(body) }); + return null; + } + const sr = body.data?.user?.starredRepositories as + | { pageInfo?: PageInfo; nodes?: StarredRepoNode[] } + | undefined; + if (!sr) return null; + return { + nodes: sr.nodes || [], + pageInfo: { + hasNextPage: sr.pageInfo?.hasNextPage ?? false, + endCursor: sr.pageInfo?.endCursor ?? null, + }, + }; +} + +async function fetchUserStarsPageBackward( + token: string, + username: string, + cursor: string | null, +): Promise> { + const query = ` + query($user: String!, $before: String) { + user(login: $user) { + starredRepositories(last: 100, before: $before, orderBy: {field: STARRED_AT, direction: DESC}) { + pageInfo { hasPreviousPage startCursor } + nodes { nameWithOwner } + } + } + } + `; + const res = await fetchGitHubGraphQL(token, query, { user: username, before: cursor }); + if (!res.ok) { + starGateLog("graphql_user_backward_http_error", { username, status: res.status }); + return null; + } + const body = await res.json(); + if (extractGraphQLErrorMessage(body)) { + starGateLog("graphql_user_backward_error", { username, message: extractGraphQLErrorMessage(body) }); + return null; + } + const sr = body.data?.user?.starredRepositories as + | { pageInfo?: PageInfo; nodes?: StarredRepoNode[] } + | undefined; + if (!sr) return null; + return { + nodes: sr.nodes || [], + pageInfo: { + hasPreviousPage: sr.pageInfo?.hasPreviousPage ?? false, + startCursor: sr.pageInfo?.startCursor ?? null, + }, + }; +} + +async function fetchRepoStarsPageForward( + token: string, + cursor: string | null, +): Promise> { + const query = ` + query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + stargazers(first: 100, after: $after, orderBy: {field: STARRED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { login } + } + } + } + `; + const res = await fetchGitHubGraphQL(token, query, { + owner: TARGET_OWNER, + name: TARGET_NAME, + after: cursor, + }); + if (!res.ok) { + starGateLog("graphql_repo_forward_http_error", { status: res.status }); + return null; + } + const body = await res.json(); + if (extractGraphQLErrorMessage(body)) { + starGateLog("graphql_repo_forward_error", { message: extractGraphQLErrorMessage(body) }); + return null; + } + const sg = body.data?.repository?.stargazers as + | { pageInfo?: PageInfo; nodes?: StargazerNode[] } + | undefined; + if (!sg) return null; + return { + nodes: sg.nodes || [], + pageInfo: { + hasNextPage: sg.pageInfo?.hasNextPage ?? false, + endCursor: sg.pageInfo?.endCursor ?? null, + }, + }; +} + +async function fetchRepoStarsPageBackward( + token: string, + cursor: string | null, +): Promise> { + const query = ` + query($owner: String!, $name: String!, $before: String) { + repository(owner: $owner, name: $name) { + stargazers(last: 100, before: $before, orderBy: {field: STARRED_AT, direction: DESC}) { + pageInfo { hasPreviousPage startCursor } + nodes { login } + } + } + } + `; + const res = await fetchGitHubGraphQL(token, query, { + owner: TARGET_OWNER, + name: TARGET_NAME, + before: cursor, + }); + if (!res.ok) { + starGateLog("graphql_repo_backward_http_error", { status: res.status }); + return null; + } + const body = await res.json(); + if (extractGraphQLErrorMessage(body)) { + starGateLog("graphql_repo_backward_error", { message: extractGraphQLErrorMessage(body) }); + return null; + } + const sg = body.data?.repository?.stargazers as + | { pageInfo?: PageInfo; nodes?: StargazerNode[] } + | undefined; + if (!sg) return null; + return { + nodes: sg.nodes || [], + pageInfo: { + hasPreviousPage: sg.pageInfo?.hasPreviousPage ?? false, + startCursor: sg.pageInfo?.startCursor ?? null, + }, + }; +} + +// --------------------------------------------------------------------------- +// Bidirectional GraphQL scans +// --------------------------------------------------------------------------- + +async function scanUserStarsBidirectional( + username: string, + token: string, + maxPages: number, +): Promise { + if (maxPages <= 0) return false; + const quickPages = Math.min(STAR_QUICK_LIMIT, maxPages); + const targetFullName = TARGET_REPO.toLowerCase(); + const matchRepo = (n: StarredRepoNode) => + n.nameWithOwner.toLowerCase() === targetFullName; + + const [quickNewest, quickOldest] = await Promise.all([ + paginateForward( + (cursor) => fetchUserStarsPageForward(token, username, cursor), + matchRepo, + quickPages, + ), + paginateBackward( + (cursor) => fetchUserStarsPageBackward(token, username, cursor), + matchRepo, + quickPages, + ), + ]); + if (quickNewest || quickOldest) return true; + if (maxPages <= quickPages) return false; + + if ( + await paginateBackward( + (cursor) => fetchUserStarsPageBackward(token, username, cursor), + matchRepo, + maxPages, + ) + ) + return true; + return paginateForward( + (cursor) => fetchUserStarsPageForward(token, username, cursor), + matchRepo, + maxPages, + ); +} + +async function scanRepoStarsBidirectional( + username: string, + token: string, + maxPages: number, +): Promise { + if (maxPages <= 0) return false; + const quickPages = Math.min(STAR_QUICK_LIMIT, maxPages); + const targetUser = username.toLowerCase(); + const matchUser = (n: StargazerNode) => n.login.toLowerCase() === targetUser; + + const [quickNewest, quickOldest] = await Promise.all([ + paginateForward( + (cursor) => fetchRepoStarsPageForward(token, cursor), + matchUser, + quickPages, + ), + paginateBackward( + (cursor) => fetchRepoStarsPageBackward(token, cursor), + matchUser, + quickPages, + ), + ]); + if (quickNewest || quickOldest) return true; + if (maxPages <= quickPages) return false; + + if ( + await paginateBackward( + (cursor) => fetchRepoStarsPageBackward(token, cursor), + matchUser, + maxPages, + ) + ) + return true; + return paginateForward( + (cursor) => fetchRepoStarsPageForward(token, cursor), + matchUser, + maxPages, + ); +} + +// --------------------------------------------------------------------------- +// REST fallback scans +// --------------------------------------------------------------------------- + +async function scanRepoStargazersRest( + username: string, + token: string, + maxPages: number, +): Promise { + const targetUser = normalizeUsername(username); + for (let page = 1; page <= maxPages; page++) { + const url = `https://api.github.com/repos/${encodeURIComponent(TARGET_OWNER)}/${encodeURIComponent(TARGET_NAME)}/stargazers?per_page=100&page=${page}`; + const res = await fetchGitHubRest(url, token); + if (!res.ok) { + starGateLog("rest_repo_page_error", { username: targetUser, page, status: res.status }); + return false; + } + const body = (await res.json()) as RestStargazerNode[]; + if (body.some((item) => normalizeUsername(item.login || "") === targetUser)) { + starGateLog("rest_repo_page_hit", { username: targetUser, page }); + return true; + } + const hasNext = hasNextPageFromLink(res.headers.get("link")); + if (!hasNext || body.length < STAR_PAGE_SIZE) break; + } + return false; +} + +async function scanUserStarredRest( + username: string, + token: string, + maxPages: number, +): Promise { + const targetUser = normalizeUsername(username); + const targetRepo = TARGET_REPO.toLowerCase(); + for (let page = 1; page <= maxPages; page++) { + const url = `https://api.github.com/users/${encodeURIComponent(targetUser)}/starred?per_page=100&page=${page}`; + const res = await fetchGitHubRest(url, token); + if (!res.ok) { + starGateLog("rest_user_page_error", { username: targetUser, page, status: res.status }); + return false; + } + const body = (await res.json()) as RestStarredRepoNode[]; + if (body.some((item) => (item.full_name || "").toLowerCase() === targetRepo)) { + starGateLog("rest_user_page_hit", { username: targetUser, page }); + return true; + } + const hasNext = hasNextPageFromLink(res.headers.get("link")); + if (!hasNext || body.length < STAR_PAGE_SIZE) break; + } + return false; +} + +async function executeRestFallbackStarCheck( + username: string, + token: string, + repoStars = 0, + userStars = 0, +): Promise { + const repoPages = Math.max( + 1, + Math.min( + Math.ceil((repoStars > 0 ? repoStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), + STAR_REST_LIMIT, + ), + ); + const userPages = Math.max( + 1, + Math.min( + Math.ceil((userStars > 0 ? userStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), + STAR_REST_LIMIT, + ), + ); + + if (await scanRepoStargazersRest(username, token, repoPages)) return true; + return scanUserStarredRest(username, token, userPages); +} + +// --------------------------------------------------------------------------- +// Core bidirectional check +// --------------------------------------------------------------------------- + +async function executeBidirectionalStarCheck(username: string): Promise { + const token = getFallbackToken(); + if (!token) return false; + + const countQuery = ` + query($owner: String!, $name: String!, $user: String!) { + repository(owner: $owner, name: $name) { stargazerCount } + user(login: $user) { starredRepositories { totalCount } } + } + `; + + try { + const resCount = await fetchGitHubGraphQL(token, countQuery, { + owner: TARGET_OWNER, + name: TARGET_NAME, + user: username, + }); + if (!resCount.ok) { + starGateLog("graphql_count_http_error", { username, status: resCount.status }); + return executeRestFallbackStarCheck(username, token); + } + const countBody = await resCount.json(); + const countError = extractGraphQLErrorMessage(countBody); + if (countError) { + starGateLog("graphql_count_error", { username, message: countError }); + } + const repoStars = Number(countBody.data?.repository?.stargazerCount || 0); + const userStars = Number( + countBody.data?.user?.starredRepositories?.totalCount || 0, + ); + + const userPages = Math.min(Math.ceil(userStars / STAR_PAGE_SIZE), STAR_DEEP_LIMIT); + const repoPages = Math.min(Math.ceil(repoStars / STAR_PAGE_SIZE), STAR_DEEP_LIMIT); + + starGateLog("graphql_count", { username, repoStars, userStars, repoPages, userPages }); + + let graphVerified = false; + + if (userStars <= repoStars) { + graphVerified = await scanUserStarsBidirectional(username, token, userPages); + if (!graphVerified) { + graphVerified = await scanRepoStarsBidirectional(username, token, repoPages); + } + } else { + graphVerified = await scanRepoStarsBidirectional(username, token, repoPages); + if (!graphVerified) { + graphVerified = await scanUserStarsBidirectional(username, token, userPages); + } + } + + if (graphVerified) { + starGateLog("graphql_scan_pass", { username }); + return true; + } + + starGateLog("graphql_scan_miss", { username }); + const restVerified = await executeRestFallbackStarCheck( + username, + token, + repoStars, + userStars, + ); + if (restVerified) { + starGateLog("rest_scan_pass", { username }); + } else { + starGateLog("rest_scan_miss", { username }); + } + return restVerified; + } catch (e) { + console.error("Bidirectional check failed", e); + starGateLog("graphql_scan_exception", { username, message: toErrorMessage(e) }); + return executeRestFallbackStarCheck(username, token); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Checks whether `username` has starred the `0xarchit/github-profile-analyzer` repo. + * + * @param username The GitHub login to check. + * @param userToken Optional OAuth token for the viewer (used for a direct, authoritative REST check). + * @returns `true` if the user has starred the repo, `false` otherwise (never throws). + */ +export async function checkStarStatus( + username: string, + userToken?: string, +): Promise { + console.log("[GITHUB_API] checkStarStatus starting", { + username, + hasUserToken: !!userToken, + }); + const normalizedUsername = normalizeUsername(username || ""); + if ( + !normalizedUsername || + normalizedUsername === "undefined" || + normalizedUsername === "null" + ) { + console.log("[GITHUB_API] Invalid username for star check", { username }); + return false; + } + + starGateLog("check_start", { + username: normalizedUsername, + hasUserToken: Boolean(userToken), + }); + + // Strategy 1: viewer's own token (direct, most reliable) + if (userToken) { + try { + const res = await githubFetchWithTimeout( + `https://api.github.com/user/starred/${TARGET_REPO}`, + { + headers: { + Authorization: `Bearer ${userToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "GitScore/1.1", + }, + }, + ); + if (res.status === 204) { + await cacheVerifiedStar(normalizedUsername); + starGateLog("check_pass_user_token", { username: normalizedUsername }); + return true; + } + starGateLog("check_user_token_miss", { + username: normalizedUsername, + status: res.status, + }); + } catch (e) { + console.error("REST star check failed:", e); + } + } + + // Strategy 2: Redis cache + const cacheKey = `repo:stargazers:${TARGET_REPO}`; + try { + const cachedStargazers = await getCachedData(cacheKey); + if ( + cachedStargazers?.some((u) => normalizeUsername(u) === normalizedUsername) + ) { + starGateLog("check_pass_cache", { username: normalizedUsername }); + return true; + } + + // Strategy 3 & 4: bidirectional GraphQL + REST fallback + const isVerified = await executeBidirectionalStarCheck(normalizedUsername); + if (isVerified) { + await cacheVerifiedStar(normalizedUsername); + starGateLog("check_pass_deep", { username: normalizedUsername }); + } else { + starGateLog("check_fail_deep", { username: normalizedUsername }); + } + return isVerified; + } catch (err) { + console.error("Optimized star check error:", err); + await sendTelegramAlert({ + source: "STAR_CHECK", + message: "Optimized star check error", + error: err, + context: { username: normalizedUsername }, + }); + starGateLog("check_error", { + username: normalizedUsername, + message: toErrorMessage(err), + }); + return false; + } +} + +/** + * Verifies a guest user's star and injects them into the Redis stargazer cache. + * Used by the `/api/auth/verify-guest` route. + * + * @returns `true` if the star was verified, `false` otherwise. + */ +export async function verifyAndInjectStar(username: string): Promise { + const normalizedUsername = normalizeUsername(username || ""); + if ( + !normalizedUsername || + normalizedUsername === "undefined" || + normalizedUsername === "null" + ) + return false; + starGateLog("verify_guest_start", { username: normalizedUsername }); + const isVerified = await executeBidirectionalStarCheck(normalizedUsername); + if (isVerified) { + await cacheVerifiedStar(normalizedUsername); + starGateLog("verify_guest_pass", { username: normalizedUsername }); + } else { + starGateLog("verify_guest_fail", { username: normalizedUsername }); + } + return isVerified; +} + +/** + * Returns the current stargazer count for the `0xarchit/github-profile-analyzer` repo. + * Returns `null` on any error. + */ +export async function getRepoStarCount(): Promise { + const url = `https://api.github.com/repos/${TARGET_REPO}`; + const token = getFallbackToken(); + const headers: HeadersInit = { + Accept: "application/vnd.github+json", + "User-Agent": "GitScore/1.1", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + try { + const response = await githubFetchWithTimeout(url, { headers }); + if (!response.ok) return null; + const payload = (await response.json()) as { stargazers_count?: number }; + return typeof payload.stargazers_count === "number" + ? payload.stargazers_count + : null; + } catch { + return null; + } +} From 624032fe9b9fe49cc4a145ac033f76fbdaed2ab8 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:17:43 +0530 Subject: [PATCH 02/14] fix(contributions): replace duplicated GitHub client with shared fetchGitHubGraphQL - Remove local GITHUB_TOKENS re-declaration - Replace raw fetch() (no timeout, no retry) with fetchGitHubGraphQL from @/lib/github - Use getFallbackToken() for consistent token selection - Route now inherits 10s timeout and structured error logging automatically --- src/app/api/contributions/route.ts | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/app/api/contributions/route.ts b/src/app/api/contributions/route.ts index cbe20f5..ec8a2ec 100644 --- a/src/app/api/contributions/route.ts +++ b/src/app/api/contributions/route.ts @@ -1,14 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { UsernameSchema } from "@/lib/validation"; import { getSession, getGuestSession } from "@/lib/auth"; -import { checkStarStatus } from "@/lib/github"; +import { checkStarStatus, fetchGitHubGraphQL, getFallbackToken } from "@/lib/github"; import { getCachedData, setCachedData } from "@/lib/redis"; export const runtime = "edge"; -const GITHUB_TOKENS = (process.env.GITHUB_TOKENS || "") - .split(",") - .filter(Boolean); export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); @@ -81,13 +78,7 @@ export async function GET(req: NextRequest) { }); } - const token = GITHUB_TOKENS[Math.floor(Math.random() * GITHUB_TOKENS.length)]; - if (!token) { - return NextResponse.json( - { error: "GITHUB_TOKENS environment variable is required" }, - { status: 500 }, - ); - } + const token = getFallbackToken(); const query = ` query($login: String!) { user(login: $login) { @@ -106,18 +97,7 @@ export async function GET(req: NextRequest) { `; try { - const res = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": "GitScore-Retro/1.0", - }, - body: JSON.stringify({ - query, - variables: { login: targetUser }, - }), - }); + const res = await fetchGitHubGraphQL(token, query, { login: targetUser }); if (!res.ok) { return new Response( From 3653c6ab3049e10ac3ed52ed4b9a2c405abce66c Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:19:08 +0530 Subject: [PATCH 03/14] fix(type-safety): add runtime guards for JWT payload and DB row casts auth.ts: - Add isValidSession(p: JWTPayload) type predicate with runtime field checks (githubId: number, username: string, accessToken: string, avatarUrl: string) - Replace 'payload as unknown as Session' double-cast with predicate guard - Replace 'payload.username as string' cast with typeof guard db.ts: - Validate id, github_id (number/bigint) and username (string) in materializeUser() before casting raw DB row to User - Returns null on schema mismatch instead of silently producing undefined fields --- src/lib/auth.ts | 25 +++++++++++++++++++++---- src/lib/db.ts | 6 ++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f5e7197..9b8ba7f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { SignJWT, jwtVerify } from "jose"; +import { SignJWT, jwtVerify, JWTPayload } from "jose"; import { cookies } from "next/headers"; import { sendTelegramAlert } from "./telegram-alert"; @@ -27,6 +27,20 @@ function isExpectedJwtFailure(error: unknown): boolean { ); } +/** + * Runtime type predicate that validates a JWT payload contains all required + * Session fields with the correct types. Prevents silent undefined values + * from propagating when field names change or tokens are malformed. + */ +function isValidSession(p: JWTPayload): p is JWTPayload & Session { + return ( + typeof (p as Record).githubId === "number" && + typeof (p as Record).username === "string" && + typeof (p as Record).accessToken === "string" && + typeof (p as Record).avatarUrl === "string" + ); +} + export async function createSession(sessionData: Session) { const token = await new SignJWT({ ...sessionData }) .setProtectedHeader({ alg: "HS256" }) @@ -66,8 +80,8 @@ export async function getGuestSession(): Promise { if (!token) return null; try { const { payload } = await jwtVerify(token, JWT_SECRET); - if (payload.verified && payload.username) { - return payload.username as string; + if (payload.verified && typeof payload.username === "string") { + return payload.username; } return null; } catch (error) { @@ -85,7 +99,10 @@ export async function getGuestSession(): Promise { export async function verifySession(token: string): Promise { try { const { payload } = await jwtVerify(token, JWT_SECRET); - return payload as unknown as Session; + if (!isValidSession(payload)) { + return null; + } + return payload; } catch (error) { if (!isExpectedJwtFailure(error)) { void sendTelegramAlert({ diff --git a/src/lib/db.ts b/src/lib/db.ts index 757fca2..257877f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -101,6 +101,12 @@ async function materializeUser( includeAccessToken = false, ): Promise { if (!row) return null; + const r = row as Record; + // Validate required fields to guard against schema drift silently producing + // undefined values behind the typed interface. + if (typeof r.id !== "number" && typeof r.id !== "bigint") return null; + if (typeof r.github_id !== "number" && typeof r.github_id !== "bigint") return null; + if (typeof r.username !== "string") return null; const user = row as User; user.settings = normalizeSettings(user.settings); if ( From f8c20988bf0f6586036a51c225651532db5686f0 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:22:23 +0530 Subject: [PATCH 04/14] refactor(analyze): deduplicate force/non-force save+return branches Before: populateAnalysis() + saveScan() + return were copy-pasted in both the force=true and force=false branches (~40 duplicate lines). After: force flag only controls whether the Redis cache read is skipped. A single populateAnalysis() call, single saveScan() guard, and single NextResponse.json(finalData) return cover both paths. Also: standardise indentation to spaces throughout the file. --- src/app/api/analyze/route.ts | 175 ++++++++++++++++------------------- 1 file changed, 78 insertions(+), 97 deletions(-) diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index 9976f57..b3f67a0 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -202,86 +202,87 @@ export async function GET(request: NextRequest) { } console.log("[ANALYZE] Star status resolved", { hasTargetStarred }); - if (!hasTargetStarred) { - console.log("[ANALYZE] Star required - denying access"); - return NextResponse.json( - { - error: "Star required", - showPopup: true, - isStarred: false, - message: "Support the analyzer to unlock high-fidelity shards.", - }, - { status: 403 }, - ); - } - - // Enforce visibility settings for registered users only - if (targetUser) { - const hasPrincipal = Boolean(targetUser.settings?.primary_scan_id); - const publicScans = targetUser.settings?.public_scans ?? false; - if (!isOwnerOfTarget && !publicScans && !hasPrincipal) { - console.log("[ANALYZE] Access denied - profile private, no principal"); - return NextResponse.json( - { error: "ACCESS_DENIED", message: "This profile is private." }, - { status: 403 }, - ); - } - } - - if (!force && targetUser) { - if (targetUser.settings?.primary_scan_id) { - const isLocked = targetUser.settings?.profile_locked ?? false; - console.log("[ANALYZE] Attempting to load principal scan", { - scanId: targetUser.settings.primary_scan_id, - isLocked, - }); - const principalScan = await getScanById( - targetUser.settings.primary_scan_id, - ); - if (principalScan) { - console.log("[ANALYZE] Returning principal scan"); - return NextResponse.json({ - ...principalScan.data, - isLocked, - snapshotId: principalScan.id, - isHistorical: true, - }); - } - } - - if (isOwnerOfTarget || targetUser.settings?.public_scans) { - console.log("[ANALYZE] Attempting to load latest self scan", { - userId: targetUser.id, - username, - }); - const latestScan = await getLatestSelfScan(targetUser.id, username); - if (latestScan) { - console.log("[ANALYZE] Returning latest self scan"); - return NextResponse.json({ - ...latestScan.data, - isLocked: false, - snapshotId: latestScan.id, - isHistorical: true, - }); - } - } - - // If no principal scan and viewer is not owner and public_scans is disabled, deny access - if (!isOwnerOfTarget && !targetUser.settings?.public_scans) { - console.log("[ANALYZE] Access denied - profile is private"); - return NextResponse.json( - { - error: "ACCESS_DENIED", - message: "This profile is private.", - }, - { status: 403 }, - ); - } - } + if (!hasTargetStarred) { + console.log("[ANALYZE] Star required - denying access"); + return NextResponse.json( + { + error: "Star required", + showPopup: true, + isStarred: false, + message: "Support the analyzer to unlock high-fidelity shards.", + }, + { status: 403 }, + ); + } + + // Enforce visibility settings for registered users only + if (targetUser) { + const hasPrincipal = Boolean(targetUser.settings?.primary_scan_id); + const publicScans = targetUser.settings?.public_scans ?? false; + if (!isOwnerOfTarget && !publicScans && !hasPrincipal) { + console.log("[ANALYZE] Access denied - profile private, no principal"); + return NextResponse.json( + { error: "ACCESS_DENIED", message: "This profile is private." }, + { status: 403 }, + ); + } + } + + if (!force && targetUser) { + if (targetUser.settings?.primary_scan_id) { + const isLocked = targetUser.settings?.profile_locked ?? false; + console.log("[ANALYZE] Attempting to load principal scan", { + scanId: targetUser.settings.primary_scan_id, + isLocked, + }); + const principalScan = await getScanById( + targetUser.settings.primary_scan_id, + ); + if (principalScan) { + console.log("[ANALYZE] Returning principal scan"); + return NextResponse.json({ + ...principalScan.data, + isLocked, + snapshotId: principalScan.id, + isHistorical: true, + }); + } + } + + if (isOwnerOfTarget || targetUser.settings?.public_scans) { + console.log("[ANALYZE] Attempting to load latest self scan", { + userId: targetUser.id, + username, + }); + const latestScan = await getLatestSelfScan(targetUser.id, username); + if (latestScan) { + console.log("[ANALYZE] Returning latest self scan"); + return NextResponse.json({ + ...latestScan.data, + isLocked: false, + snapshotId: latestScan.id, + isHistorical: true, + }); + } + } + + // If no principal scan and viewer is not owner and public_scans is disabled, deny access + if (!isOwnerOfTarget && !targetUser.settings?.public_scans) { + console.log("[ANALYZE] Access denied - profile is private"); + return NextResponse.json( + { + error: "ACCESS_DENIED", + message: "This profile is private.", + }, + { status: 403 }, + ); + } + } const cacheKey = `analysed:${username.toLowerCase()}`; try { + // Only read from cache on non-forced requests if (!force) { console.log("[ANALYZE] Checking cache", { cacheKey }); const cachedResult = await getCachedData(cacheKey); @@ -325,27 +326,7 @@ export async function GET(request: NextRequest) { return finalData; }; - if (!force) { - const finalData = await populateAnalysis(); - - if ( - viewerUser && - !nosave && - isOwnerOfTarget && - viewerUser.settings.keep_history - ) { - console.log("[ANALYZE] Saving scan to database", { - userId: viewerUser.id, - username, - }); - await saveScan(viewerUser.id, username, finalData); - console.log("[ANALYZE] Scan saved to database"); - } - - console.log("[ANALYZE] Analysis complete - returning result"); - return NextResponse.json(finalData); - } - + // Single execution path — force only controls cache-read skipping above const finalData = await populateAnalysis(); if ( From 40109bd8fd47379c1dee1199a34313d05821a59e Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:27:04 +0530 Subject: [PATCH 05/14] test: add Vitest test suite for streak calc, AI analysis, and auth Setup: - vitest.config.ts: node environment, @/ path alias, v8 coverage - src/test-setup.ts: stubs required env vars (GITHUB_TOKENS, JWT_SECRET) - package.json: add test / test:watch / test:coverage scripts Tests (10 passing): - src/lib/github/__tests__/profile.test.ts 10 cases for calculateDetailedStreaks: empty input, no contributions, single day, consecutive days, gap mid-sequence, grace period (today=0), two-zero-day reset, weekly streak counts, weekly grace period, weekly two-zero-week reset - src/lib/__tests__/auth.test.ts (pending env fixes for jose import) - src/lib/__tests__/ai.test.ts (pending fetch mock wiring) --- package.json | 8 +- src/lib/__tests__/ai.test.ts | 137 +++++++++++++++++++++++ src/lib/__tests__/auth.test.ts | 81 ++++++++++++++ src/lib/github/__tests__/profile.test.ts | 104 +++++++++++++++++ src/test-setup.ts | 6 + vitest.config.ts | 28 +++++ 6 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/ai.test.ts create mode 100644 src/lib/__tests__/auth.test.ts create mode 100644 src/lib/github/__tests__/profile.test.ts create mode 100644 src/test-setup.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index ac8a610..4a5b81f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@neondatabase/serverless": "^1.0.2", @@ -37,7 +40,8 @@ "eslint-config-next": "16.2.3", "shadcn": "^4.2.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" }, "ignoreScripts": [ "sharp", diff --git a/src/lib/__tests__/ai.test.ts b/src/lib/__tests__/ai.test.ts new file mode 100644 index 0000000..405665d --- /dev/null +++ b/src/lib/__tests__/ai.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock all external dependencies before any import of the module under test +vi.mock("@/lib/telegram-alert", () => ({ + sendTelegramAlert: vi.fn().mockResolvedValue(undefined), + TelegramAlertCollector: vi.fn().mockImplementation(() => ({ + add: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + size: 0, + })), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { getAIAnalysis } from "../ai"; +import type { ProfileSummary } from "@/types"; + +const MINIMAL_PROFILE: ProfileSummary = { + username: "testuser", + name: "Test User", + bio: null, + followers: 10, + following: 5, + avatar: null, + total_stars: 50, + public_repo_count: 5, + original_repos: {}, + career_stats: { + total_contributions: 200, + total_commits: 150, + total_prs: 20, + total_issues: 10, + total_reviews: 5, + daily_streak: 3, + daily_best: 10, + weekly_streak: 2, + weekly_best: 5, + top_languages: [{ name: "TypeScript", value: 80, color: "#3178c6" }], + commit_activity: [{ month: "2024-01", count: 30 }], + trophies: [], + }, + calendar_data: { weeks: [] }, + badges: {}, +}; + +const VALID_AI_RESPONSE = { + score: 75, + segments: { + roast: "You write code like a poet with no readers.", + technical_analysis: "Solid TypeScript usage across the board.", + strategic_advice: "Focus on open source contributions.", + }, + improvement_areas: ["Add more tests", "Write READMEs"], + diagnostics: ["No CI/CD pipeline detected"], + project_ideas: { + "1": { title: "CLI tool", description: "A terminal helper", "tech stack": ["Node.js"] }, + "2": { title: "API wrapper", description: "Wrap a public API", "tech stack": ["TypeScript"] }, + "3": { title: "Dashboard", description: "Visualize your stats", "tech stack": ["React"] }, + }, + tag: { tag_name: "The Builder", description: "Always shipping something" }, + developer_type: "Full-Stack Developer", +}; + +function makeAIResponse(body: unknown, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve({ + choices: [{ message: { content: JSON.stringify(body) } }], + }), + } as unknown as Response); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: valid AI response + mockFetch.mockImplementation(() => makeAIResponse(VALID_AI_RESPONSE)); +}); + +describe("getAIAnalysis", () => { + it("returns a validated AIAnalysis for a well-formed AI response", async () => { + const result = await getAIAnalysis(MINIMAL_PROFILE, "test-token"); + expect(result.score).toBe(75); + expect(result.segments.roast).toContain("poet"); + expect(result.developer_type).toBe("Full-Stack Developer"); + }); + + it("returns default developer_type when field is missing from AI response", async () => { + const withoutDevType = { ...VALID_AI_RESPONSE, developer_type: undefined }; + mockFetch.mockImplementation(() => makeAIResponse(withoutDevType)); + const result = await getAIAnalysis(MINIMAL_PROFILE, "test-token"); + expect(result.developer_type).toBe("Developer"); // default applied + }); + + it("throws CORRUPT_INTELLIGENCE when AI response content is malformed JSON", async () => { + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [{ message: { content: "this is not json {{{" } }], + }), + } as unknown as Response), + ); + await expect(getAIAnalysis(MINIMAL_PROFILE, "test-token")).rejects.toThrow( + "CORRUPT_INTELLIGENCE", + ); + }); + + it("throws CORRUPT_INTELLIGENCE when AI response content is empty", async () => { + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ choices: [{ message: { content: "" } }] }), + } as unknown as Response), + ); + await expect(getAIAnalysis(MINIMAL_PROFILE, "test-token")).rejects.toThrow( + "CORRUPT_INTELLIGENCE", + ); + }); + + it("throws on HTTP 429 after all retries", async () => { + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: false, + status: 429, + text: () => Promise.resolve("rate limited"), + } as unknown as Response), + ); + await expect(getAIAnalysis(MINIMAL_PROFILE, "test-token")).rejects.toThrow(); + }); +}); diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts new file mode 100644 index 0000000..5ced7ec --- /dev/null +++ b/src/lib/__tests__/auth.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the dependencies before importing the module under test +vi.mock("next/headers", () => ({ + cookies: vi.fn().mockResolvedValue({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }), +})); + +vi.mock("@/lib/telegram-alert", () => ({ + sendTelegramAlert: vi.fn().mockResolvedValue(undefined), +})); + +// We test the isValidSession predicate indirectly through verifySession. +// Import after mocks are set up. +import { createSession, verifySession } from "../auth"; + +// A helper to build a minimal valid JWT token for tests +async function buildValidToken(): Promise { + // Access the JWT_SECRET from the module + const { JWT_SECRET, SESSION_COOKIE } = await import("../auth"); + const { SignJWT } = await import("jose"); + return new SignJWT({ + githubId: 12345, + username: "testuser", + accessToken: "gho_token", + avatarUrl: "https://avatars.githubusercontent.com/u/12345", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(JWT_SECRET); +} + +describe("verifySession", () => { + it("returns Session for a valid JWT with all required fields", async () => { + const token = await buildValidToken(); + const session = await verifySession(token); + expect(session).not.toBeNull(); + expect(session?.githubId).toBe(12345); + expect(session?.username).toBe("testuser"); + expect(session?.accessToken).toBe("gho_token"); + }); + + it("returns null for an invalid/garbage JWT", async () => { + const session = await verifySession("not.a.valid.jwt"); + expect(session).toBeNull(); + }); + + it("returns null when githubId is a string instead of number", async () => { + const { JWT_SECRET } = await import("../auth"); + const { SignJWT } = await import("jose"); + const badToken = await new SignJWT({ + githubId: "string-id", // wrong type + username: "testuser", + accessToken: "gho_token", + avatarUrl: "https://avatars.githubusercontent.com/u/12345", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(JWT_SECRET); + const session = await verifySession(badToken); + expect(session).toBeNull(); + }); + + it("returns null when a required field (accessToken) is missing", async () => { + const { JWT_SECRET } = await import("../auth"); + const { SignJWT } = await import("jose"); + const badToken = await new SignJWT({ + githubId: 12345, + username: "testuser", + // accessToken missing + avatarUrl: "https://avatars.githubusercontent.com/u/12345", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(JWT_SECRET); + const session = await verifySession(badToken); + expect(session).toBeNull(); + }); +}); diff --git a/src/lib/github/__tests__/profile.test.ts b/src/lib/github/__tests__/profile.test.ts new file mode 100644 index 0000000..3611ee7 --- /dev/null +++ b/src/lib/github/__tests__/profile.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { calculateDetailedStreaks } from "../profile"; +import type { ContributionWeek } from "@/types"; + +function makeWeek(counts: number[]): ContributionWeek { + return { + contributionDays: counts.map((contributionCount, i) => ({ + contributionCount, + date: `2024-01-${String(i + 1).padStart(2, "0")}`, + })), + }; +} + +describe("calculateDetailedStreaks", () => { + it("returns all zeros for empty input", () => { + const result = calculateDetailedStreaks([]); + expect(result).toEqual({ + daily_streak: 0, + daily_best: 0, + weekly_streak: 0, + weekly_best: 0, + }); + }); + + it("returns all zeros when no contributions exist", () => { + const result = calculateDetailedStreaks([makeWeek([0, 0, 0, 0, 0, 0, 0])]); + expect(result).toEqual({ + daily_streak: 0, + daily_best: 0, + weekly_streak: 0, + weekly_best: 0, + }); + }); + + it("counts a single active day as daily_streak=1 and daily_best=1", () => { + const result = calculateDetailedStreaks([makeWeek([0, 0, 0, 0, 0, 0, 1])]); + expect(result.daily_streak).toBe(1); + expect(result.daily_best).toBe(1); + }); + + it("counts consecutive active days correctly", () => { + // All 7 days active → streak = 7 + const result = calculateDetailedStreaks([makeWeek([1, 2, 3, 1, 2, 3, 1])]); + expect(result.daily_streak).toBe(7); + expect(result.daily_best).toBe(7); + }); + + it("resets current streak on a gap but preserves best", () => { + // Week 1: [3,3,3,0,0,0,0] → best=3, current at that week=0 + // Week 2: [0,0,0,0,2,2,2] → current ends at 3, final streak=3 + const result = calculateDetailedStreaks([ + makeWeek([3, 3, 3, 0, 0, 0, 0]), + makeWeek([0, 0, 0, 0, 2, 2, 2]), + ]); + expect(result.daily_best).toBe(3); + expect(result.daily_streak).toBe(3); + }); + + it("preserves streak when only the last (today) day has no contributions (grace period)", () => { + // The algorithm skips the last day if it's 0 — today may not have contributions yet. + // So [1,1,1,1,1,1,0] has a streak of 6 (yesterday and earlier all active). + const result = calculateDetailedStreaks([makeWeek([1, 1, 1, 1, 1, 1, 0])]); + expect(result.daily_streak).toBe(6); + expect(result.daily_best).toBe(6); + }); + + it("streak is 0 when the last two days both have no contributions", () => { + // Grace period only applies to the very last day; a second empty day breaks the streak. + const result = calculateDetailedStreaks([makeWeek([1, 1, 1, 1, 1, 0, 0])]); + expect(result.daily_streak).toBe(0); + expect(result.daily_best).toBe(5); + }); + + it("weekly streak counts consecutive active weeks", () => { + const activeWeek = makeWeek([0, 0, 0, 0, 0, 0, 1]); + const emptyWeek = makeWeek([0, 0, 0, 0, 0, 0, 0]); + // 2 active, 1 empty, 1 active → best=2, current=1 + const result = calculateDetailedStreaks([ + activeWeek, + activeWeek, + emptyWeek, + activeWeek, + ]); + expect(result.weekly_best).toBe(2); + expect(result.weekly_streak).toBe(1); + }); + + it("weekly streak is preserved when only the last (this) week has no contributions (grace period)", () => { + // Same grace-period logic applies at the weekly level. + const activeWeek = makeWeek([0, 0, 0, 0, 0, 0, 1]); + const emptyWeek = makeWeek([0, 0, 0, 0, 0, 0, 0]); + const result = calculateDetailedStreaks([activeWeek, activeWeek, emptyWeek]); + expect(result.weekly_streak).toBe(2); + expect(result.weekly_best).toBe(2); + }); + + it("weekly streak is 0 when the last two weeks both have no contributions", () => { + const activeWeek = makeWeek([0, 0, 0, 0, 0, 0, 1]); + const emptyWeek = makeWeek([0, 0, 0, 0, 0, 0, 0]); + const result = calculateDetailedStreaks([activeWeek, activeWeek, emptyWeek, emptyWeek]); + expect(result.weekly_streak).toBe(0); + expect(result.weekly_best).toBe(2); + }); +}); diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..a055492 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,6 @@ +// Vitest global test setup — runs before each test file +// Stub required environment variables so module-level guards don't throw during tests. + +process.env.GITHUB_TOKENS = "test-token-1,test-token-2"; +process.env.JWT_SECRET = "test-jwt-secret-at-least-32-characters-long"; +process.env.ENCRYPTION_KEY = "test-encryption-key-32-characters!"; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5004382 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["src/**/__tests__/**/*.test.ts"], + exclude: ["node_modules", ".next"], + setupFiles: ["./src/test-setup.ts"], + + coverage: { + provider: "v8", + include: [ + "src/lib/github/profile.ts", + "src/lib/github/star-gate.ts", + "src/lib/auth.ts", + "src/lib/ai.ts", + "src/lib/db.ts", + ], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From 5c65cb224bbf4378fb8c243d72a0dc66badda7bd Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:29:12 +0530 Subject: [PATCH 06/14] docs: add JSDoc to all public API exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai.ts: getAIAnalysis — documents userToken, alertCollector, CORRUPT_INTELLIGENCE error db.ts: updateUserSettings — documents normalizeSettingsPatch whitelist + INVALID_PRIMARY_SCAN saveScan — documents 10-scan rolling window retention auth.ts: createSession — documents cookie name (gitscore_session) and 7-day expiry verifySession — documents isValidSession predicate and return contract getSession — documents delegation to verifySession Also: remove unused imports from auth.test.ts (flagged by tsc) --- package-lock.json | 379 +++++++++++++++++++++++++++++++-- src/lib/__tests__/auth.test.ts | 6 +- src/lib/ai.ts | 15 ++ src/lib/auth.ts | 19 ++ src/lib/db.ts | 20 ++ 5 files changed, 414 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62148ed..f6ed359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ "eslint-config-next": "16.2.3", "shadcn": "^4.2.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -1838,7 +1839,6 @@ "version": "0.124.0", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -2043,7 +2043,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2061,7 +2060,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2079,7 +2077,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2097,7 +2094,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2115,7 +2111,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2136,7 +2131,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2157,7 +2151,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2178,7 +2171,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2199,7 +2191,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2220,7 +2211,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2241,7 +2231,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2259,7 +2248,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2274,7 +2262,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", @@ -2297,7 +2284,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2313,7 +2299,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2691,6 +2676,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "license": "MIT" @@ -2736,6 +2732,13 @@ "version": "3.0.2", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -3431,6 +3434,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "license": "MIT" @@ -3708,6 +3824,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "dev": true, @@ -3997,6 +4123,16 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "license": "MIT", @@ -4930,6 +5066,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "dev": true, @@ -5395,6 +5538,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "dev": true, @@ -5466,6 +5619,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "dev": true, @@ -5802,7 +5965,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -7919,6 +8081,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "dev": true, @@ -8165,6 +8341,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "license": "ISC", @@ -8688,7 +8871,6 @@ "version": "1.0.0-rc.15", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" @@ -8720,8 +8902,7 @@ "node_modules/rolldown/node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.15", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/router": { "version": "2.2.0", @@ -9153,6 +9334,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "dev": true, @@ -9215,6 +9403,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "dev": true, @@ -9223,6 +9418,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "dev": true, @@ -9541,6 +9743,23 @@ "version": "1.3.3", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "dev": true, @@ -9556,6 +9775,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "dev": true, @@ -10011,7 +10240,6 @@ "version": "8.0.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -10096,6 +10324,96 @@ "node": ">= 6" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "dev": true, @@ -10237,6 +10555,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index 5ced7ec..6de4309 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; // Mock the dependencies before importing the module under test vi.mock("next/headers", () => ({ @@ -11,12 +11,12 @@ vi.mock("@/lib/telegram-alert", () => ({ // We test the isValidSession predicate indirectly through verifySession. // Import after mocks are set up. -import { createSession, verifySession } from "../auth"; +import { verifySession } from "../auth"; // A helper to build a minimal valid JWT token for tests async function buildValidToken(): Promise { // Access the JWT_SECRET from the module - const { JWT_SECRET, SESSION_COOKIE } = await import("../auth"); + const { JWT_SECRET } = await import("../auth"); const { SignJWT } = await import("jose"); return new SignJWT({ githubId: 12345, diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b43a805..c255d87 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -112,6 +112,21 @@ async function callAIWithTimeout( } } +/** + * Calls the GitHub Models inference API to generate an AI analysis of a GitHub profile. + * + * @param profile The full `ProfileSummary` to analyse. + * @param userToken Optional OAuth token belonging to the **viewer** (logged-in user). + * When provided, it is used as the inference API key first, + * benefiting from that user's GitHub Models quota. + * Falls back to the shared GITHUB_TOKENS pool on failure. + * @param alertCollector Optional `TelegramAlertCollector` for deferred, batched alerts. + * When provided, errors are collected and flushed together with the + * caller's other alerts. When omitted, alerts fire immediately. + * @throws `Error("CORRUPT_INTELLIGENCE")` if the AI response cannot be parsed or fails + * Zod validation after all retry attempts. + * @throws On persistent HTTP errors (e.g. 429, 500) after `AI_RETRY_ATTEMPTS` retries. + */ export async function getAIAnalysis( profile: ProfileSummary, userToken?: string, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9b8ba7f..b26cea9 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -41,6 +41,12 @@ function isValidSession(p: JWTPayload): p is JWTPayload & Session { ); } +/** + * Creates a signed JWT session and sets it as an HTTP-only cookie (`gitscore_session`). + * The token is signed with HS256 and expires in 7 days. + * + * @param sessionData The session payload to sign. + */ export async function createSession(sessionData: Session) { const token = await new SignJWT({ ...sessionData }) .setProtectedHeader({ alg: "HS256" }) @@ -96,6 +102,15 @@ export async function getGuestSession(): Promise { } } +/** + * Verifies a raw JWT string and returns the typed `Session` if valid. + * Uses `isValidSession()` to perform runtime field validation — returns `null` + * if any required field (`githubId`, `username`, `accessToken`, `avatarUrl`) is + * missing or has the wrong type, preventing silent undefined propagation. + * + * @param token The raw JWT string to verify. + * @returns `Session` on success, `null` on invalid/expired/malformed token. + */ export async function verifySession(token: string): Promise { try { const { payload } = await jwtVerify(token, JWT_SECRET); @@ -115,6 +130,10 @@ export async function verifySession(token: string): Promise { } } +/** + * Reads the `gitscore_session` cookie from the current request and verifies it. + * Delegates to `verifySession()` — returns `null` if the cookie is absent or invalid. + */ export async function getSession(): Promise { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; diff --git a/src/lib/db.ts b/src/lib/db.ts index 257877f..fa01eb2 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -203,6 +203,16 @@ export async function upsertUser(user: { return result; } +/** + * Updates a subset of the authenticated user's settings. + * Only fields allowed by `normalizeSettingsPatch` can be modified — unknown fields are + * silently dropped, preventing unintended field injection. + * + * @param userId The internal DB ID of the user performing the update. + * @param settings Partial settings to apply (merged into the existing JSONB column). + * @throws `Error("INVALID_PRIMARY_SCAN")` if `primary_scan_id` is set to a scan that + * does not belong to this user. + */ export async function updateUserSettings( userId: number, settings: Partial, @@ -230,6 +240,16 @@ export async function updateUserSettings( `; } +/** + * Persists a new analysis scan to the database and enforces a 10-scan rolling window. + * After inserting, any scans beyond the 10 most recent for this user are deleted atomically + * in the same CTE transaction. + * + * @param userId The internal DB ID of the user who owns this scan. + * @param username The GitHub login the scan was performed for. + * @param data The full validated analysis result to store. + * @returns The newly inserted `Scan` record. + */ export async function saveScan( userId: number, username: string, From dd2326ffa0c10a245f96de902a228e949b9527d9 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 12:33:40 +0530 Subject: [PATCH 07/14] test: add analyze api access control tests --- .../analyze/__tests__/access-control.test.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/app/api/analyze/__tests__/access-control.test.ts diff --git a/src/app/api/analyze/__tests__/access-control.test.ts b/src/app/api/analyze/__tests__/access-control.test.ts new file mode 100644 index 0000000..bbbec5e --- /dev/null +++ b/src/app/api/analyze/__tests__/access-control.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET } from "../route"; +import { NextRequest } from "next/server"; +import { getProfileSummary, checkStarStatus } from "@/lib/github"; +import { getAIAnalysis } from "@/lib/ai"; +import { getSession } from "@/lib/auth"; +import { getCachedData, setCachedData } from "@/lib/redis"; +import { + getUserByUsername, + saveScan, + getScanById, + getUserByGithubId, + getLatestSelfScan, +} from "@/lib/db"; + +// Mock all external modules to avoid actual network/DB calls +vi.mock("@/lib/github", () => ({ + getProfileSummary: vi.fn(), + checkStarStatus: vi.fn(), +})); + +vi.mock("@/lib/ai", () => ({ + getAIAnalysis: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/redis", () => ({ + getCachedData: vi.fn(), + setCachedData: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + getUserByUsername: vi.fn(), + saveScan: vi.fn(), + getScanById: vi.fn(), + getUserByGithubId: vi.fn(), + getLatestSelfScan: vi.fn(), +})); + +vi.mock("@/lib/telegram-alert", () => ({ + TelegramAlertCollector: vi.fn().mockImplementation(() => ({ + add: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + })), +})); + +describe("analyze route access-control & routing logic", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("1. Unstarred guest -> 403 Star required", async () => { + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(getUserByUsername).mockResolvedValue(null); + vi.mocked(checkStarStatus).mockResolvedValue(false); + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser"); + const res = await GET(req); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body).toEqual({ + error: "Star required", + showPopup: true, + isStarred: false, + message: "Support the analyzer to unlock high-fidelity shards.", + }); + }); + + it("2. Owner force-refreshing own profile -> allowed & calls saveScan", async () => { + const mockUser = { id: "user-123", username: "targetuser", settings: { keep_history: true } }; + vi.mocked(getSession).mockResolvedValue({ + githubId: 111, + username: "targetuser", + accessToken: "gho_owner_token", + avatarUrl: "", + }); + vi.mocked(getUserByGithubId).mockResolvedValue(mockUser); + vi.mocked(getUserByUsername).mockResolvedValue(mockUser); + + vi.mocked(getProfileSummary).mockResolvedValue({ + original_repos: {}, + username: "targetuser", + } as any); + + vi.mocked(getAIAnalysis).mockResolvedValue({ + score: 85, + developer_type: "Backend Engineer", + segments: [], + } as any); + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser&force=true"); + const res = await GET(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.score).toBe(85); + expect(body.username).toBe("targetuser"); + + // Owner should trigger database save + expect(saveScan).toHaveBeenCalledWith("user-123", "targetuser", expect.any(Object)); + }); + + it("3. Non-owner force-refreshing registered user -> 403 ACCESS_DENIED", async () => { + const mockViewer = { id: "user-viewer", username: "vieweruser" }; + const mockTarget = { id: "user-target", username: "targetuser" }; + + vi.mocked(getSession).mockResolvedValue({ + githubId: 222, + username: "vieweruser", + accessToken: "gho_viewer_token", + avatarUrl: "", + }); + vi.mocked(getUserByGithubId).mockResolvedValue(mockViewer); + vi.mocked(getUserByUsername).mockResolvedValue(mockTarget); + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser&force=true"); + const res = await GET(req); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body).toEqual({ + error: "ACCESS_DENIED", + message: "You are not allowed to override or force-update another registered user's profile.", + }); + }); + + it("4. Private profile, no principal scan, non-owner -> 403 ACCESS_DENIED", async () => { + const mockViewer = { id: "user-viewer", username: "vieweruser" }; + const mockTarget = { + id: "user-target", + username: "targetuser", + settings: { public_scans: false, primary_scan_id: null }, + }; + + vi.mocked(getSession).mockResolvedValue({ + githubId: 222, + username: "vieweruser", + accessToken: "gho_viewer_token", + avatarUrl: "", + }); + vi.mocked(getUserByGithubId).mockResolvedValue(mockViewer); + vi.mocked(getUserByUsername).mockResolvedValue(mockTarget); + vi.mocked(checkStarStatus).mockResolvedValue(true); // starred but private + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser"); + const res = await GET(req); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body).toEqual({ + error: "ACCESS_DENIED", + message: "This profile is private.", + }); + }); + + it("5. Public profile, non-owner -> proceeds to analysis but does not saveScan", async () => { + const mockViewer = { id: "user-viewer", username: "vieweruser" }; + const mockTarget = { + id: "user-target", + username: "targetuser", + settings: { public_scans: true, primary_scan_id: null }, + }; + + vi.mocked(getSession).mockResolvedValue({ + githubId: 222, + username: "vieweruser", + accessToken: "gho_viewer_token", + avatarUrl: "", + }); + vi.mocked(getUserByGithubId).mockResolvedValue(mockViewer); + vi.mocked(getUserByUsername).mockResolvedValue(mockTarget); + vi.mocked(checkStarStatus).mockResolvedValue(true); + + vi.mocked(getProfileSummary).mockResolvedValue({ + original_repos: {}, + username: "targetuser", + } as any); + + vi.mocked(getAIAnalysis).mockResolvedValue({ + score: 92, + developer_type: "Frontend Specialist", + segments: [], + } as any); + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser"); + const res = await GET(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.score).toBe(92); + + // Should not save scan to DB since viewer is not the owner + expect(saveScan).not.toHaveBeenCalled(); + }); + + it("6. Cache hit -> returns cached data without calling GitHub/AI APIs", async () => { + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(getUserByUsername).mockResolvedValue(null); + vi.mocked(checkStarStatus).mockResolvedValue(true); + + const cachedData = { + username: "targetuser", + score: 95, + developer_type: "Architect", + isStarred: true, + cachedAt: "2026-06-25T12:00:00Z", + }; + vi.mocked(getCachedData).mockResolvedValue(cachedData); + + const req = new NextRequest("http://localhost/api/analyze?username=targetuser"); + const res = await GET(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual(cachedData); + + // APIs should not be called + expect(getProfileSummary).not.toHaveBeenCalled(); + expect(getAIAnalysis).not.toHaveBeenCalled(); + }); +}); From c2627c76c9a36ed333e4181284c731ab96725899 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 13:58:39 +0530 Subject: [PATCH 08/14] fix: address PR reviews and align code logic with requirements --- package-lock.json | 163 +++++++++++++++++- package.json | 1 + .../analyze/__tests__/access-control.test.ts | 135 +++++++++++---- src/app/api/analyze/route.ts | 3 +- src/app/api/contributions/route.ts | 2 +- src/lib/__tests__/ai.test.ts | 8 +- src/lib/ai.ts | 3 + src/lib/auth.ts | 2 +- src/lib/github/http.ts | 8 +- src/lib/github/profile.ts | 67 +++++-- src/lib/github/star-gate.ts | 21 +-- 11 files changed, 332 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6ed359..295e833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9", "eslint-config-next": "16.2.3", "shadcn": "^4.2.0", @@ -266,7 +267,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -274,7 +277,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -302,11 +307,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -432,17 +439,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -3434,6 +3453,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -3850,6 +3900,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -7008,6 +7077,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "dev": true, @@ -7551,6 +7659,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "dev": true, diff --git a/package.json b/package.json index 4a5b81f..df24ff7 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9", "eslint-config-next": "16.2.3", "shadcn": "^4.2.0", diff --git a/src/app/api/analyze/__tests__/access-control.test.ts b/src/app/api/analyze/__tests__/access-control.test.ts index bbbec5e..24c121a 100644 --- a/src/app/api/analyze/__tests__/access-control.test.ts +++ b/src/app/api/analyze/__tests__/access-control.test.ts @@ -4,14 +4,13 @@ import { NextRequest } from "next/server"; import { getProfileSummary, checkStarStatus } from "@/lib/github"; import { getAIAnalysis } from "@/lib/ai"; import { getSession } from "@/lib/auth"; -import { getCachedData, setCachedData } from "@/lib/redis"; +import { getCachedData } from "@/lib/redis"; import { getUserByUsername, saveScan, - getScanById, getUserByGithubId, - getLatestSelfScan, } from "@/lib/db"; +import type { User } from "@/lib/db"; // Mock all external modules to avoid actual network/DB calls vi.mock("@/lib/github", () => ({ @@ -47,6 +46,72 @@ vi.mock("@/lib/telegram-alert", () => ({ })), })); +// Shared Mock Builders to satisfy type checking without "any" +const createMockUser = (overrides: Partial = {}): User => ({ + id: 123, + github_id: 111, + username: "targetuser", + avatar_url: "", + access_token: "encrypted_token", + settings: { + profile_locked: false, + keep_history: true, + public_scans: true, + primary_scan_id: null, + }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, +}); + +type ProfileSummaryResult = Awaited>; +type AIAnalysisResult = Awaited>; + +const createMockProfileSummary = (overrides: Partial = {}): ProfileSummaryResult => ({ + username: "targetuser", + name: "Target User", + bio: null, + followers: 10, + following: 5, + avatar: null, + total_stars: 50, + public_repo_count: 5, + original_repos: {}, + career_stats: { + total_contributions: 200, + total_commits: 150, + total_prs: 20, + total_issues: 10, + total_reviews: 5, + daily_streak: 3, + daily_best: 10, + weekly_streak: 2, + weekly_best: 5, + top_languages: [], + commit_activity: [], + trophies: [], + }, + calendar_data: { + weeks: [], + }, + badges: {}, + ...overrides, +}); + +const createMockAIAnalysis = (overrides: Partial = {}): AIAnalysisResult => ({ + score: 85, + developer_type: "Backend Engineer", + segments: { + roast: "Brutal roast", + technical_analysis: "Technical details", + strategic_advice: "Strategic advice", + }, + improvement_areas: [], + diagnostics: [], + timestamp: new Date().toISOString(), + ...overrides, +}); + describe("analyze route access-control & routing logic", () => { beforeEach(() => { vi.resetAllMocks(); @@ -71,7 +136,7 @@ describe("analyze route access-control & routing logic", () => { }); it("2. Owner force-refreshing own profile -> allowed & calls saveScan", async () => { - const mockUser = { id: "user-123", username: "targetuser", settings: { keep_history: true } }; + const mockUser = createMockUser({ id: 123, username: "targetuser" }); vi.mocked(getSession).mockResolvedValue({ githubId: 111, username: "targetuser", @@ -81,16 +146,8 @@ describe("analyze route access-control & routing logic", () => { vi.mocked(getUserByGithubId).mockResolvedValue(mockUser); vi.mocked(getUserByUsername).mockResolvedValue(mockUser); - vi.mocked(getProfileSummary).mockResolvedValue({ - original_repos: {}, - username: "targetuser", - } as any); - - vi.mocked(getAIAnalysis).mockResolvedValue({ - score: 85, - developer_type: "Backend Engineer", - segments: [], - } as any); + vi.mocked(getProfileSummary).mockResolvedValue(createMockProfileSummary({ username: "targetuser" })); + vi.mocked(getAIAnalysis).mockResolvedValue(createMockAIAnalysis({ score: 85 })); const req = new NextRequest("http://localhost/api/analyze?username=targetuser&force=true"); const res = await GET(req); @@ -101,12 +158,12 @@ describe("analyze route access-control & routing logic", () => { expect(body.username).toBe("targetuser"); // Owner should trigger database save - expect(saveScan).toHaveBeenCalledWith("user-123", "targetuser", expect.any(Object)); + expect(saveScan).toHaveBeenCalledWith(123, "targetuser", expect.any(Object)); }); it("3. Non-owner force-refreshing registered user -> 403 ACCESS_DENIED", async () => { - const mockViewer = { id: "user-viewer", username: "vieweruser" }; - const mockTarget = { id: "user-target", username: "targetuser" }; + const mockViewer = createMockUser({ id: 999, username: "vieweruser" }); + const mockTarget = createMockUser({ id: 123, username: "targetuser" }); vi.mocked(getSession).mockResolvedValue({ githubId: 222, @@ -129,12 +186,17 @@ describe("analyze route access-control & routing logic", () => { }); it("4. Private profile, no principal scan, non-owner -> 403 ACCESS_DENIED", async () => { - const mockViewer = { id: "user-viewer", username: "vieweruser" }; - const mockTarget = { - id: "user-target", + const mockViewer = createMockUser({ id: 999, username: "vieweruser" }); + const mockTarget = createMockUser({ + id: 123, username: "targetuser", - settings: { public_scans: false, primary_scan_id: null }, - }; + settings: { + profile_locked: false, + keep_history: true, + public_scans: false, + primary_scan_id: null, + }, + }); vi.mocked(getSession).mockResolvedValue({ githubId: 222, @@ -150,6 +212,7 @@ describe("analyze route access-control & routing logic", () => { const res = await GET(req); expect(res.status).toBe(403); + expect(checkStarStatus).toHaveBeenCalledWith("vieweruser", "gho_viewer_token"); const body = await res.json(); expect(body).toEqual({ error: "ACCESS_DENIED", @@ -158,12 +221,17 @@ describe("analyze route access-control & routing logic", () => { }); it("5. Public profile, non-owner -> proceeds to analysis but does not saveScan", async () => { - const mockViewer = { id: "user-viewer", username: "vieweruser" }; - const mockTarget = { - id: "user-target", + const mockViewer = createMockUser({ id: 999, username: "vieweruser" }); + const mockTarget = createMockUser({ + id: 123, username: "targetuser", - settings: { public_scans: true, primary_scan_id: null }, - }; + settings: { + profile_locked: false, + keep_history: true, + public_scans: true, + primary_scan_id: null, + }, + }); vi.mocked(getSession).mockResolvedValue({ githubId: 222, @@ -175,21 +243,14 @@ describe("analyze route access-control & routing logic", () => { vi.mocked(getUserByUsername).mockResolvedValue(mockTarget); vi.mocked(checkStarStatus).mockResolvedValue(true); - vi.mocked(getProfileSummary).mockResolvedValue({ - original_repos: {}, - username: "targetuser", - } as any); - - vi.mocked(getAIAnalysis).mockResolvedValue({ - score: 92, - developer_type: "Frontend Specialist", - segments: [], - } as any); + vi.mocked(getProfileSummary).mockResolvedValue(createMockProfileSummary({ username: "targetuser" })); + vi.mocked(getAIAnalysis).mockResolvedValue(createMockAIAnalysis({ score: 92, developer_type: "Frontend Specialist" })); const req = new NextRequest("http://localhost/api/analyze?username=targetuser"); const res = await GET(req); expect(res.status).toBe(200); + expect(checkStarStatus).toHaveBeenCalledWith("vieweruser", "gho_viewer_token"); const body = await res.json(); expect(body.score).toBe(92); diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index b3f67a0..a183839 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -196,7 +196,8 @@ export async function GET(request: NextRequest) { }); let hasTargetStarred = false; if (!isOwnerOfTarget) { - hasTargetStarred = await checkStarStatus(username, scannerToken); + const viewerUsername = session?.username || username; + hasTargetStarred = await checkStarStatus(viewerUsername, scannerToken); } else { hasTargetStarred = true; } diff --git a/src/app/api/contributions/route.ts b/src/app/api/contributions/route.ts index ec8a2ec..a526bb2 100644 --- a/src/app/api/contributions/route.ts +++ b/src/app/api/contributions/route.ts @@ -78,7 +78,6 @@ export async function GET(req: NextRequest) { }); } - const token = getFallbackToken(); const query = ` query($login: String!) { user(login: $login) { @@ -97,6 +96,7 @@ export async function GET(req: NextRequest) { `; try { + const token = getFallbackToken(); const res = await fetchGitHubGraphQL(token, query, { login: targetUser }); if (!res.ok) { diff --git a/src/lib/__tests__/ai.test.ts b/src/lib/__tests__/ai.test.ts index 405665d..505e385 100644 --- a/src/lib/__tests__/ai.test.ts +++ b/src/lib/__tests__/ai.test.ts @@ -125,6 +125,7 @@ describe("getAIAnalysis", () => { }); it("throws on HTTP 429 after all retries", async () => { + vi.useFakeTimers(); mockFetch.mockImplementation(() => Promise.resolve({ ok: false, @@ -132,6 +133,11 @@ describe("getAIAnalysis", () => { text: () => Promise.resolve("rate limited"), } as unknown as Response), ); - await expect(getAIAnalysis(MINIMAL_PROFILE, "test-token")).rejects.toThrow(); + const promise = getAIAnalysis(MINIMAL_PROFILE, "test-token"); + promise.catch(() => {}); + await vi.runAllTimersAsync(); + await expect(promise).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(3); + vi.useRealTimers(); }); }); diff --git a/src/lib/ai.ts b/src/lib/ai.ts index c255d87..40ad163 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -196,6 +196,9 @@ IMPORTANT: Always return 'developer_type' as a direct child of the root object. status: response.status, statusText: response.statusText, }); + if (!response.ok) { + throw new Error(`AI API error: ${response.status}`); + } break; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b26cea9..a13a836 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -86,7 +86,7 @@ export async function getGuestSession(): Promise { if (!token) return null; try { const { payload } = await jwtVerify(token, JWT_SECRET); - if (payload.verified && typeof payload.username === "string") { + if (payload.verified === true && typeof payload.username === "string") { return payload.username; } return null; diff --git a/src/lib/github/http.ts b/src/lib/github/http.ts index 9f126df..748f97c 100644 --- a/src/lib/github/http.ts +++ b/src/lib/github/http.ts @@ -12,12 +12,9 @@ export const GITHUB_TOKENS = (process.env.GITHUB_TOKENS || "") .split(",") + .map((t) => t.trim()) .filter(Boolean); -if (GITHUB_TOKENS.length === 0) { - throw new Error("GITHUB_TOKENS environment variable is required"); -} - export const GITHUB_FETCH_TIMEOUT_MS = 10_000; /** @@ -25,6 +22,9 @@ export const GITHUB_FETCH_TIMEOUT_MS = 10_000; * Throws if the pool is empty (guards against runtime env misconfiguration). */ export function getFallbackToken(): string { + if (GITHUB_TOKENS.length === 0) { + throw new Error("GITHUB_TOKENS environment variable is required"); + } const token = GITHUB_TOKENS[Math.floor(Math.random() * GITHUB_TOKENS.length)]; if (!token) throw new Error("GITHUB_TOKENS environment variable is required"); return token; diff --git a/src/lib/github/profile.ts b/src/lib/github/profile.ts index 9364bd4..bd21bbb 100644 --- a/src/lib/github/profile.ts +++ b/src/lib/github/profile.ts @@ -56,9 +56,9 @@ async function checkAchievementStatus( ): Promise { const cacheKey = `achievement:${normalizeUsername(username)}:${slug}`; - const cached = await getCachedData(cacheKey); + const cached = await getCachedData(cacheKey); if (cached !== null && cached !== undefined) { - return cached; + return cached === "__NONE__" ? null : cached; } const url = `https://github.com/${encodeURIComponent(username)}?tab=achievements&achievement=${slug}`; @@ -68,14 +68,15 @@ async function checkAchievementStatus( headers: { "User-Agent": "GitScore/1.1" }, }); const value = res.status === 200 ? slug : null; - await setCachedData(cacheKey, value, ACHIEVEMENT_CACHE_TTL_SECONDS); + await setCachedData(cacheKey, value === null ? "__NONE__" : value, ACHIEVEMENT_CACHE_TTL_SECONDS); return value; } catch { - await setCachedData(cacheKey, null, ACHIEVEMENT_CACHE_TTL_SECONDS); + await setCachedData(cacheKey, "__NONE__", ACHIEVEMENT_CACHE_TTL_SECONDS); return null; } } + // --------------------------------------------------------------------------- // Rank / trophy helpers // --------------------------------------------------------------------------- @@ -180,7 +181,7 @@ export async function getProfileSummary( const token = userToken || getFallbackToken(); console.log("[GITHUB_API] Using token", { hasUserToken: !!userToken }); const query = ` - query($login: String!) { + query($login: String!, $after: String) { user(login: $login) { avatarUrl, login, name, bio, followers { totalCount }, following { totalCount } contributionsCollection { @@ -190,7 +191,9 @@ export async function getProfileSummary( weeks { contributionDays { contributionCount, date, color } } } } - repositories(first: 60, ownerAffiliations: [OWNER], orderBy: {field: STARGAZERS, direction: DESC}) { + repositories(first: 100, after: $after, ownerAffiliations: [OWNER], orderBy: {field: STARGAZERS, direction: DESC}) { + totalCount + pageInfo { hasNextPage, endCursor } nodes { name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } openIssues: issues(states: OPEN) { totalCount } @@ -198,7 +201,15 @@ export async function getProfileSummary( repositoryTopics(first: 5) { nodes { topic { name } } } } } - repositoriesContributedTo(first: 40, includeUserRepositories: true, contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]) { + } + } + `; + + const pageQuery = ` + query($login: String!, $after: String) { + user(login: $login) { + repositories(first: 100, after: $after, ownerAffiliations: [OWNER], orderBy: {field: STARGAZERS, direction: DESC}) { + pageInfo { hasNextPage, endCursor } nodes { name, description, stargazerCount, forkCount, isFork, primaryLanguage { name } openIssues: issues(states: OPEN) { totalCount } @@ -212,9 +223,10 @@ export async function getProfileSummary( const requestSummary = async (authToken: string) => { console.log("[GITHUB_API] Sending profile summary request", { username }); - return fetchGitHubGraphQL(authToken, query, { login: username }); + return fetchGitHubGraphQL(authToken, query, { login: username, after: null }); }; + let successfulToken = token; let res = await requestSummary(token); console.log("[GITHUB_API] Initial profile request completed", { status: res.status, @@ -223,6 +235,7 @@ export async function getProfileSummary( console.log("[GITHUB_API] Auth failed, retrying with fallback token"); const retryToken = getFallbackToken(); res = await requestSummary(retryToken); + successfulToken = retryToken; console.log("[GITHUB_API] Retry request completed", { status: res.status }); if (res.status === 401 || res.status === 403) { console.error("[GITHUB_API] Both auth attempts failed"); @@ -278,12 +291,36 @@ export async function getProfileSummary( }); const coll = user.contributionsCollection; - const ownedNodes = user.repositories.nodes || []; + const initialRepos = user.repositories.nodes || []; + let allRepos: GithubRepoNode[] = [...initialRepos]; + + let hasNext = user.repositories.pageInfo.hasNextPage; + let cursor = user.repositories.pageInfo.endCursor; + let pageCount = 1; + + while (hasNext && cursor && pageCount < 10) { + console.log("[GITHUB_API] Fetching next page of repositories", { cursor, pageCount }); + const pageRes = await fetchGitHubGraphQL(successfulToken, pageQuery, { login: username, after: cursor }); + if (!pageRes.ok) { + console.warn("[GITHUB_API] Failed to fetch next page of repositories, continuing with collected repos"); + break; + } + const pageBody = await pageRes.json(); + if (pageBody.errors || !pageBody.data?.user?.repositories) { + console.warn("[GITHUB_API] GraphQL errors in page, continuing with collected repos", pageBody.errors); + break; + } + const repoData = pageBody.data.user.repositories; + allRepos.push(...(repoData.nodes || [])); + hasNext = repoData.pageInfo.hasNextPage; + cursor = repoData.pageInfo.endCursor; + pageCount++; + } - const authoredRepos = ownedNodes.filter((r: GithubRepoNode) => !r.isFork); + const allAuthoredRepos = allRepos.filter((r: GithubRepoNode) => !r.isFork); let total_stars = 0; - authoredRepos.forEach( + allAuthoredRepos.forEach( (r: GithubRepoNode) => (total_stars += r.stargazerCount), ); @@ -317,7 +354,7 @@ export async function getProfileSummary( value: (v / total) * 100, color: cols[i % cols.length], })); - })(authoredRepos); + })(allAuthoredRepos); const t_configs = [ { name: "Stars", val: total_stars, th: [10, 100, 500, 1000] }, @@ -344,7 +381,7 @@ export async function getProfileSummary( }, { name: "Authored Repos", - val: authoredRepos.length, + val: allAuthoredRepos.length, th: [10, 30, 50, 100], }, ]; @@ -390,9 +427,9 @@ export async function getProfileSummary( bio: user.bio, followers: user.followers.totalCount, following: user.following.totalCount, - public_repo_count: authoredRepos.length, + public_repo_count: user.repositories.totalCount, total_stars, - original_repos: authoredRepos.reduce( + original_repos: allAuthoredRepos.slice(0, 60).reduce( ( acc: Record< string, diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts index 179e7b4..ebd73c0 100644 --- a/src/lib/github/star-gate.ts +++ b/src/lib/github/star-gate.ts @@ -440,20 +440,13 @@ async function executeRestFallbackStarCheck( repoStars = 0, userStars = 0, ): Promise { - const repoPages = Math.max( - 1, - Math.min( - Math.ceil((repoStars > 0 ? repoStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), - STAR_REST_LIMIT, - ), - ); - const userPages = Math.max( - 1, - Math.min( - Math.ceil((userStars > 0 ? userStars : STAR_PAGE_SIZE) / STAR_PAGE_SIZE), - STAR_REST_LIMIT, - ), - ); + const limitPages = Math.ceil(STAR_REST_LIMIT / STAR_PAGE_SIZE); + const repoPages = repoStars > 0 + ? Math.max(1, Math.min(Math.ceil(repoStars / STAR_PAGE_SIZE), limitPages)) + : limitPages; + const userPages = userStars > 0 + ? Math.max(1, Math.min(Math.ceil(userStars / STAR_PAGE_SIZE), limitPages)) + : limitPages; if (await scanRepoStargazersRest(username, token, repoPages)) return true; return scanUserStarredRest(username, token, userPages); From d25f1afd10a2125dd261ef8c58fac2e40b0a8e89 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 17:56:58 +0530 Subject: [PATCH 09/14] fix: enforce pagination errors and reset per-attempt response state in retry loop --- src/lib/ai.ts | 1 + src/lib/github/profile.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 40ad163..621028a 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -179,6 +179,7 @@ IMPORTANT: Always return 'developer_type' as a direct child of the root object. let response: Response | null = null; for (let attempt = 1; attempt <= AI_RETRY_ATTEMPTS; attempt++) { + response = null; try { console.log("[AI_ANALYSIS] Sending request to GitHub AI API", { attempt, diff --git a/src/lib/github/profile.ts b/src/lib/github/profile.ts index bd21bbb..50f0faf 100644 --- a/src/lib/github/profile.ts +++ b/src/lib/github/profile.ts @@ -292,7 +292,7 @@ export async function getProfileSummary( const coll = user.contributionsCollection; const initialRepos = user.repositories.nodes || []; - let allRepos: GithubRepoNode[] = [...initialRepos]; + const allRepos: GithubRepoNode[] = [...initialRepos]; let hasNext = user.repositories.pageInfo.hasNextPage; let cursor = user.repositories.pageInfo.endCursor; @@ -302,13 +302,14 @@ export async function getProfileSummary( console.log("[GITHUB_API] Fetching next page of repositories", { cursor, pageCount }); const pageRes = await fetchGitHubGraphQL(successfulToken, pageQuery, { login: username, after: cursor }); if (!pageRes.ok) { - console.warn("[GITHUB_API] Failed to fetch next page of repositories, continuing with collected repos"); - break; + throw new Error(`Failed to fetch next page of repositories: HTTP ${pageRes.status}`); } const pageBody = await pageRes.json(); - if (pageBody.errors || !pageBody.data?.user?.repositories) { - console.warn("[GITHUB_API] GraphQL errors in page, continuing with collected repos", pageBody.errors); - break; + if (pageBody.errors) { + throw new Error(`GraphQL error on repositories page: ${pageBody.errors[0]?.message || "graphql_error"}`); + } + if (!pageBody.data?.user?.repositories) { + throw new Error("Invalid response format on repositories page"); } const repoData = pageBody.data.user.repositories; allRepos.push(...(repoData.nodes || [])); From caec5b7a170ec1ced92bce595ad97aabb3764e42 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 23:18:39 +0530 Subject: [PATCH 10/14] feat: integrate Cloudflare D1 stargazers table, webhook, and daily sync workflow --- .github/workflows/sync.yml | 29 +++++ scripts/sync-stargazers.mjs | 132 ++++++++++++++++++++++ src/app/api/webhooks/github-star/route.ts | 119 +++++++++++++++++++ src/lib/github/star-gate.ts | 57 ++++++++++ src/types/cloudflare.d.ts | 9 ++ 5 files changed, 346 insertions(+) create mode 100644 .github/workflows/sync.yml create mode 100644 scripts/sync-stargazers.mjs create mode 100644 src/app/api/webhooks/github-star/route.ts create mode 100644 src/types/cloudflare.d.ts diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..e292301 --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,29 @@ +name: Daily Stargazer Sync + +on: + schedule: + - cron: '0 0 * * *' # Run every day at midnight + workflow_dispatch: # Enable manual run from GitHub UI + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Run sync script + run: node scripts/sync-stargazers.mjs + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_DATABASE_NAME: ${{ secrets.CLOUDFLARE_DATABASE_NAME }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/sync-stargazers.mjs b/scripts/sync-stargazers.mjs new file mode 100644 index 0000000..894036b --- /dev/null +++ b/scripts/sync-stargazers.mjs @@ -0,0 +1,132 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +const GITHUB_REPO = '0xarchit/github-profile-analyzer'; +const DB_NAME = process.env.CLOUDFLARE_DATABASE_NAME; + +if (!DB_NAME) { + console.error("Error: CLOUDFLARE_DATABASE_NAME environment variable is required."); + process.exit(1); +} + +function runWrangler(command) { + try { + const output = execSync(`npx wrangler d1 execute ${DB_NAME} --remote --command "${command.replace(/"/g, '\\"')}" --json`, { + env: { ...process.env }, + encoding: 'utf-8' + }); + return JSON.parse(output); + } catch (err) { + console.error(`Wrangler execution failed for query: ${command}`, err.message); + throw err; + } +} + +async function fetchGitHubStarCount() { + const url = `https://api.github.com/repos/${GITHUB_REPO}`; + const headers = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'GitScore-Sync-Script', + }; + if (process.env.GITHUB_TOKEN) { + headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; + } + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return data.stargazers_count; +} + +async function fetchAllStargazers() { + let stargazers = []; + let page = 1; + while (true) { + const url = `https://api.github.com/repos/${GITHUB_REPO}/stargazers?per_page=100&page=${page}`; + const headers = { + 'Accept': 'application/vnd.github.v3.star+json', + 'User-Agent': 'GitScore-Sync-Script', + }; + if (process.env.GITHUB_TOKEN) { + headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; + } + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`GitHub API error at page ${page}: ${await res.text()}`); + } + const data = await res.json(); + if (data.length === 0) break; + + stargazers.push(...data.map(item => ({ + username: item.user.login.toLowerCase(), + starred_at: item.starred_at + }))); + + const link = res.headers.get('link'); + if (!link || !link.includes('rel="next"')) { + break; + } + page++; + } + return stargazers; +} + +async function main() { + try { + console.log("Checking star counts..."); + const githubCount = await fetchGitHubStarCount(); + console.log(`GitHub repository star count: ${githubCount}`); + + const d1Result = runWrangler("SELECT COUNT(*) as count FROM stargazers"); + const d1Count = d1Result[0]?.results?.[0]?.count ?? 0; + console.log(`D1 database stargazers count: ${d1Count}`); + + if (githubCount === d1Count) { + console.log("Database count matches GitHub count. Sync is not required."); + process.exit(0); + } + + console.log("Counts mismatch! Initiating full synchronization..."); + const stargazers = await fetchAllStargazers(); + console.log(`Fetched ${stargazers.length} stargazers from GitHub API.`); + + if (stargazers.length === 0) { + console.log("No stargazers found. Clearing D1 stargazers table..."); + runWrangler("DELETE FROM stargazers"); + console.log("Sync complete."); + process.exit(0); + } + + const sqlFile = path.join(process.cwd(), 'sync_temp.sql'); + let sqlContent = 'BEGIN TRANSACTION;\nDELETE FROM stargazers;\n'; + + for (const sg of stargazers) { + const escapedUser = sg.username.replace(/'/g, "''"); + sqlContent += `INSERT INTO stargazers (username, created_at) VALUES ('${escapedUser}', '${sg.starred_at}');\n`; + } + + sqlContent += 'COMMIT;\n'; + fs.writeFileSync(sqlFile, sqlContent); + + console.log("Applying batch SQL file to Cloudflare D1..."); + try { + execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=sync_temp.sql`, { + env: { ...process.env }, + stdio: 'inherit' + }); + console.log("Cloudflare D1 sync completed successfully!"); + } finally { + if (fs.existsSync(sqlFile)) { + fs.unlinkSync(sqlFile); + } + } + + } catch (err) { + console.error("Synchronization failed:", err); + process.exit(1); + } +} + +main(); diff --git a/src/app/api/webhooks/github-star/route.ts b/src/app/api/webhooks/github-star/route.ts new file mode 100644 index 0000000..389809d --- /dev/null +++ b/src/app/api/webhooks/github-star/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { normalizeUsername } from "@/lib/github"; +import { sendTelegramAlert } from "@/lib/telegram-alert"; + +export const runtime = "edge"; + +const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || ""; + +function hexToBuffer(hex: string): ArrayBuffer { + const len = hex.length / 2; + const view = new Uint8Array(len); + for (let i = 0; i < len; i++) { + view[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return view.buffer; +} + +async function verifySignature(secret: string, bodyText: string, signatureHeader: string): Promise { + if (!signatureHeader || !signatureHeader.startsWith("sha256=")) { + return false; + } + const signatureHex = signatureHeader.substring(7); + const signatureBuffer = hexToBuffer(signatureHex); + + const encoder = new TextEncoder(); + const secretKeyData = encoder.encode(secret); + const bodyData = encoder.encode(bodyText); + + const key = await crypto.subtle.importKey( + "raw", + secretKeyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + + return await crypto.subtle.verify( + "HMAC", + key, + signatureBuffer, + bodyData + ); +} + +export async function POST(request: Request) { + const signatureHeader = request.headers.get("x-hub-signature-256") || ""; + const eventHeader = request.headers.get("x-github-event") || ""; + + let bodyText = ""; + try { + bodyText = await request.text(); + } catch (err) { + return NextResponse.json({ error: "Failed to read request body" }, { status: 400 }); + } + + // 1. Verify HMAC Signature + if (WEBHOOK_SECRET) { + const isValid = await verifySignature(WEBHOOK_SECRET, bodyText, signatureHeader); + if (!isValid) { + console.warn("[Webhook] Invalid signature received"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + } else { + console.warn("[Webhook] GITHUB_WEBHOOK_SECRET is not configured. Skipping signature verification."); + } + + // 2. Parse payload + let payload: any; + try { + payload = JSON.parse(bodyText); + } catch (err) { + return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + console.log(`[Webhook] Event: ${eventHeader}, Action: ${payload.action}`); + + // 3. Handle 'watch' event with 'started' action + if (eventHeader === "watch" && payload.action === "started") { + const sender = payload.sender?.login; + if (!sender) { + return NextResponse.json({ error: "Missing sender login" }, { status: 400 }); + } + + const normalized = normalizeUsername(sender); + + if (process.env.DB) { + try { + await process.env.DB.prepare( + "INSERT OR IGNORE INTO stargazers (username) VALUES (?)" + ) + .bind(normalized) + .run(); + + console.log(`[Webhook] Successfully saved stargazer: ${normalized}`); + + void sendTelegramAlert({ + source: "WEBHOOK_STAR", + message: `⭐️ User starred the repo and synced to D1: ${normalized}`, + context: { username: normalized }, + }).catch(() => null); + + } catch (dbErr) { + console.error("[Webhook] Failed to insert stargazer in D1:", dbErr); + void sendTelegramAlert({ + source: "WEBHOOK_STAR_ERROR", + message: "Failed to save stargazer in D1", + error: dbErr, + context: { username: normalized }, + }).catch(() => null); + return NextResponse.json({ error: "Database insertion failed" }, { status: 500 }); + } + } else { + console.error("[Webhook] D1 Database binding DB is not available in environment"); + return NextResponse.json({ error: "Database binding missing" }, { status: 500 }); + } + } + + return NextResponse.json({ success: true }); +} diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts index ebd73c0..5556099 100644 --- a/src/lib/github/star-gate.ts +++ b/src/lib/github/star-gate.ts @@ -81,9 +81,26 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "unknown_error"; } +async function saveStarToD1(username: string): Promise { + if (!process.env.DB) return; + try { + await process.env.DB.prepare( + "INSERT OR IGNORE INTO stargazers (username) VALUES (?)" + ) + .bind(username) + .run(); + } catch (err) { + console.error("[D1] Failed to save stargazer to D1:", err); + } +} + async function cacheVerifiedStar(username: string): Promise { const normalized = normalizeUsername(username); const cacheKey = `repo:stargazers:${TARGET_REPO}`; + + // Save to Cloudflare D1 if available + await saveStarToD1(normalized); + const cached = (await getCachedData(cacheKey)) || []; if (cached.some((s) => normalizeUsername(s) === normalized)) { return; @@ -565,6 +582,29 @@ export async function checkStarStatus( hasUserToken: Boolean(userToken), }); + // Strategy 0: Cloudflare D1 local database (O(1) lookups) + if (process.env.DB) { + try { + const stmt = process.env.DB.prepare( + "SELECT 1 FROM stargazers WHERE username = ? LIMIT 1" + ); + const res = await stmt.bind(normalizedUsername).first(); + if (res) { + starGateLog("check_pass_d1", { username: normalizedUsername }); + return true; + } + starGateLog("check_d1_miss", { username: normalizedUsername }); + } catch (err) { + console.error("[D1] Database query failed, falling back:", err); + void sendTelegramAlert({ + source: "STAR_CHECK_D1", + message: "D1 database query failed, falling back", + error: err, + context: { username: normalizedUsername }, + }).catch(() => null); + } + } + // Strategy 1: viewer's own token (direct, most reliable) if (userToken) { try { @@ -643,6 +683,23 @@ export async function verifyAndInjectStar(username: string): Promise { ) return false; starGateLog("verify_guest_start", { username: normalizedUsername }); + + // Strategy 0: Cloudflare D1 local database (O(1) lookups) + if (process.env.DB) { + try { + const stmt = process.env.DB.prepare( + "SELECT 1 FROM stargazers WHERE username = ? LIMIT 1" + ); + const res = await stmt.bind(normalizedUsername).first(); + if (res) { + starGateLog("verify_guest_pass_d1", { username: normalizedUsername }); + return true; + } + } catch (err) { + console.error("[D1] Database query failed in verifyAndInjectStar:", err); + } + } + const isVerified = await executeBidirectionalStarCheck(normalizedUsername); if (isVerified) { await cacheVerifiedStar(normalizedUsername); diff --git a/src/types/cloudflare.d.ts b/src/types/cloudflare.d.ts new file mode 100644 index 0000000..d9498a9 --- /dev/null +++ b/src/types/cloudflare.d.ts @@ -0,0 +1,9 @@ +import { D1Database } from "@cloudflare/workers-types"; + +declare global { + namespace NodeJS { + interface ProcessEnv { + DB?: D1Database; + } + } +} From b4ac11a90b0a1c2a472aeb5b5d615fea6731a8c2 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 23:35:43 +0530 Subject: [PATCH 11/14] fix(sync): remove transactions and add --yes flag to wrangler execute --- scripts/sync-stargazers.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/sync-stargazers.mjs b/scripts/sync-stargazers.mjs index 894036b..5cdea13 100644 --- a/scripts/sync-stargazers.mjs +++ b/scripts/sync-stargazers.mjs @@ -12,7 +12,7 @@ if (!DB_NAME) { function runWrangler(command) { try { - const output = execSync(`npx wrangler d1 execute ${DB_NAME} --remote --command "${command.replace(/"/g, '\\"')}" --json`, { + const output = execSync(`npx wrangler d1 execute ${DB_NAME} --remote --command "${command.replace(/"/g, '\\"')}" --json --yes`, { env: { ...process.env }, encoding: 'utf-8' }); @@ -100,19 +100,18 @@ async function main() { } const sqlFile = path.join(process.cwd(), 'sync_temp.sql'); - let sqlContent = 'BEGIN TRANSACTION;\nDELETE FROM stargazers;\n'; + let sqlContent = 'DELETE FROM stargazers;\n'; for (const sg of stargazers) { const escapedUser = sg.username.replace(/'/g, "''"); sqlContent += `INSERT INTO stargazers (username, created_at) VALUES ('${escapedUser}', '${sg.starred_at}');\n`; } - sqlContent += 'COMMIT;\n'; fs.writeFileSync(sqlFile, sqlContent); console.log("Applying batch SQL file to Cloudflare D1..."); try { - execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=sync_temp.sql`, { + execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=sync_temp.sql --yes`, { env: { ...process.env }, stdio: 'inherit' }); From b9b08eaf545e658adf709690c2c49660c11f8431 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Thu, 25 Jun 2026 23:50:16 +0530 Subject: [PATCH 12/14] perf: shrink duplicate D1 checks and remove redundant Redis deletion --- src/lib/github/star-gate.ts | 53 +++++++++++++++++-------------------- src/lib/redis.ts | 1 - 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts index 5556099..f83ded4 100644 --- a/src/lib/github/star-gate.ts +++ b/src/lib/github/star-gate.ts @@ -94,6 +94,21 @@ async function saveStarToD1(username: string): Promise { } } +async function isStarredInD1(username: string): Promise { + if (!process.env.DB) return false; + try { + const res = await process.env.DB.prepare( + "SELECT 1 FROM stargazers WHERE username = ? LIMIT 1" + ) + .bind(username) + .first(); + return !!res; + } catch (err) { + console.error("[D1] Database query failed:", err); + return false; + } +} + async function cacheVerifiedStar(username: string): Promise { const normalized = normalizeUsername(username); const cacheKey = `repo:stargazers:${TARGET_REPO}`; @@ -584,25 +599,12 @@ export async function checkStarStatus( // Strategy 0: Cloudflare D1 local database (O(1) lookups) if (process.env.DB) { - try { - const stmt = process.env.DB.prepare( - "SELECT 1 FROM stargazers WHERE username = ? LIMIT 1" - ); - const res = await stmt.bind(normalizedUsername).first(); - if (res) { - starGateLog("check_pass_d1", { username: normalizedUsername }); - return true; - } - starGateLog("check_d1_miss", { username: normalizedUsername }); - } catch (err) { - console.error("[D1] Database query failed, falling back:", err); - void sendTelegramAlert({ - source: "STAR_CHECK_D1", - message: "D1 database query failed, falling back", - error: err, - context: { username: normalizedUsername }, - }).catch(() => null); + const isStarred = await isStarredInD1(normalizedUsername); + if (isStarred) { + starGateLog("check_pass_d1", { username: normalizedUsername }); + return true; } + starGateLog("check_d1_miss", { username: normalizedUsername }); } // Strategy 1: viewer's own token (direct, most reliable) @@ -686,17 +688,10 @@ export async function verifyAndInjectStar(username: string): Promise { // Strategy 0: Cloudflare D1 local database (O(1) lookups) if (process.env.DB) { - try { - const stmt = process.env.DB.prepare( - "SELECT 1 FROM stargazers WHERE username = ? LIMIT 1" - ); - const res = await stmt.bind(normalizedUsername).first(); - if (res) { - starGateLog("verify_guest_pass_d1", { username: normalizedUsername }); - return true; - } - } catch (err) { - console.error("[D1] Database query failed in verifyAndInjectStar:", err); + const isStarred = await isStarredInD1(normalizedUsername); + if (isStarred) { + starGateLog("verify_guest_pass_d1", { username: normalizedUsername }); + return true; } } diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 396f593..57a0554 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -111,7 +111,6 @@ export async function deleteCachedData(key: string): Promise { try { console.log("[CACHE] Deleting cache key", { key: normalizedKey }); await redis.del(normalizedKey); - await redis.del(key); console.log("[CACHE] Cache key deleted", { key: normalizedKey }); } catch (err) { console.error("[CACHE] Redis delete failure", { From a87401475aea4fed7977ff94170135b7edbfa274 Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Fri, 26 Jun 2026 00:31:08 +0530 Subject: [PATCH 13/14] feat: pin sync workflow actions, remove count exit, implement atomic D1 table swap, and harden webhook --- .github/workflows/sync.yml | 10 ++++--- scripts/sync-stargazers.mjs | 21 ++++++++----- src/app/api/webhooks/github-star/route.ts | 36 +++++++++++++++-------- src/lib/github/star-gate.ts | 22 +++++++------- 4 files changed, 54 insertions(+), 35 deletions(-) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index e292301..7edefaa 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -2,18 +2,20 @@ name: Daily Stargazer Sync on: schedule: - - cron: '0 0 * * *' # Run every day at midnight - workflow_dispatch: # Enable manual run from GitHub UI + - cron: "0 0 * * *" + workflow_dispatch: jobs: sync: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20 diff --git a/scripts/sync-stargazers.mjs b/scripts/sync-stargazers.mjs index 5cdea13..cd3be26 100644 --- a/scripts/sync-stargazers.mjs +++ b/scripts/sync-stargazers.mjs @@ -83,12 +83,7 @@ async function main() { const d1Count = d1Result[0]?.results?.[0]?.count ?? 0; console.log(`D1 database stargazers count: ${d1Count}`); - if (githubCount === d1Count) { - console.log("Database count matches GitHub count. Sync is not required."); - process.exit(0); - } - - console.log("Counts mismatch! Initiating full synchronization..."); + console.log("Initiating full synchronization..."); const stargazers = await fetchAllStargazers(); console.log(`Fetched ${stargazers.length} stargazers from GitHub API.`); @@ -100,13 +95,23 @@ async function main() { } const sqlFile = path.join(process.cwd(), 'sync_temp.sql'); - let sqlContent = 'DELETE FROM stargazers;\n'; + let sqlContent = ` +DROP TABLE IF EXISTS stargazers_new; +CREATE TABLE stargazers_new ( + username TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +`; for (const sg of stargazers) { const escapedUser = sg.username.replace(/'/g, "''"); - sqlContent += `INSERT INTO stargazers (username, created_at) VALUES ('${escapedUser}', '${sg.starred_at}');\n`; + sqlContent += `INSERT INTO stargazers_new (username, created_at) VALUES ('${escapedUser}', '${sg.starred_at}');\n`; } + sqlContent += ` +DROP TABLE IF EXISTS stargazers; +ALTER TABLE stargazers_new RENAME TO stargazers; +`; fs.writeFileSync(sqlFile, sqlContent); console.log("Applying batch SQL file to Cloudflare D1..."); diff --git a/src/app/api/webhooks/github-star/route.ts b/src/app/api/webhooks/github-star/route.ts index 389809d..b65502c 100644 --- a/src/app/api/webhooks/github-star/route.ts +++ b/src/app/api/webhooks/github-star/route.ts @@ -54,29 +54,41 @@ export async function POST(request: Request) { } // 1. Verify HMAC Signature - if (WEBHOOK_SECRET) { - const isValid = await verifySignature(WEBHOOK_SECRET, bodyText, signatureHeader); - if (!isValid) { - console.warn("[Webhook] Invalid signature received"); - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); - } - } else { - console.warn("[Webhook] GITHUB_WEBHOOK_SECRET is not configured. Skipping signature verification."); + if (!WEBHOOK_SECRET) { + console.error("[Webhook] GITHUB_WEBHOOK_SECRET is not configured. Failing closed."); + return NextResponse.json({ error: "Webhook configuration error" }, { status: 500 }); + } + + const isValid = await verifySignature(WEBHOOK_SECRET, bodyText, signatureHeader); + if (!isValid) { + console.warn("[Webhook] Invalid signature received"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } // 2. Parse payload - let payload: any; + let payload: unknown; try { payload = JSON.parse(bodyText); } catch (err) { return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); } - console.log(`[Webhook] Event: ${eventHeader}, Action: ${payload.action}`); + if (!payload || typeof payload !== "object") { + return NextResponse.json({ error: "Invalid payload shape" }, { status: 400 }); + } + + const typedPayload = payload as { + action?: string; + sender?: { + login?: string; + }; + }; + + console.log(`[Webhook] Event: ${eventHeader}, Action: ${typedPayload.action}`); // 3. Handle 'watch' event with 'started' action - if (eventHeader === "watch" && payload.action === "started") { - const sender = payload.sender?.login; + if (eventHeader === "watch" && typedPayload.action === "started") { + const sender = typedPayload.sender?.login; if (!sender) { return NextResponse.json({ error: "Missing sender login" }, { status: 400 }); } diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts index f83ded4..98c025f 100644 --- a/src/lib/github/star-gate.ts +++ b/src/lib/github/star-gate.ts @@ -597,17 +597,7 @@ export async function checkStarStatus( hasUserToken: Boolean(userToken), }); - // Strategy 0: Cloudflare D1 local database (O(1) lookups) - if (process.env.DB) { - const isStarred = await isStarredInD1(normalizedUsername); - if (isStarred) { - starGateLog("check_pass_d1", { username: normalizedUsername }); - return true; - } - starGateLog("check_d1_miss", { username: normalizedUsername }); - } - - // Strategy 1: viewer's own token (direct, most reliable) + // Strategy 1: viewer's own token (direct, most reliable, check first to ensure freshness) if (userToken) { try { const res = await githubFetchWithTimeout( @@ -634,6 +624,16 @@ export async function checkStarStatus( } } + // Strategy 0: Cloudflare D1 local database (O(1) lookups) + if (process.env.DB) { + const isStarred = await isStarredInD1(normalizedUsername); + if (isStarred) { + starGateLog("check_pass_d1", { username: normalizedUsername }); + return true; + } + starGateLog("check_d1_miss", { username: normalizedUsername }); + } + // Strategy 2: Redis cache const cacheKey = `repo:stargazers:${TARGET_REPO}`; try { From 961c4b39cc312c48859a3dda252f16a70ff9cb3b Mon Sep 17 00:00:00 2001 From: 0xarchit <0xarchit@gmail.com> Date: Fri, 26 Jun 2026 00:49:59 +0530 Subject: [PATCH 14/14] feat: validate target repository matching in webhook, and export constants --- src/app/api/webhooks/github-star/route.ts | 64 ++++++++++++++++------- src/lib/github/index.ts | 1 + src/lib/github/star-gate.ts | 2 +- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/app/api/webhooks/github-star/route.ts b/src/app/api/webhooks/github-star/route.ts index b65502c..2c4f924 100644 --- a/src/app/api/webhooks/github-star/route.ts +++ b/src/app/api/webhooks/github-star/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { normalizeUsername } from "@/lib/github"; +import { normalizeUsername, TARGET_REPO } from "@/lib/github"; import { sendTelegramAlert } from "@/lib/telegram-alert"; export const runtime = "edge"; @@ -82,12 +82,21 @@ export async function POST(request: Request) { sender?: { login?: string; }; + repository?: { + full_name?: string; + }; }; console.log(`[Webhook] Event: ${eventHeader}, Action: ${typedPayload.action}`); - // 3. Handle 'watch' event with 'started' action - if (eventHeader === "watch" && typedPayload.action === "started") { + // 3. Handle 'star' event (created / deleted actions) + if (eventHeader === "star") { + // Verify repository matches TARGET_REPO + if (typedPayload.repository?.full_name?.toLowerCase() !== TARGET_REPO.toLowerCase()) { + console.warn(`[Webhook] Ignored event for unrelated repository: ${typedPayload.repository?.full_name}`); + return NextResponse.json({ error: "Unrelated repository" }, { status: 400 }); + } + const sender = typedPayload.sender?.login; if (!sender) { return NextResponse.json({ error: "Missing sender login" }, { status: 400 }); @@ -97,29 +106,44 @@ export async function POST(request: Request) { if (process.env.DB) { try { - await process.env.DB.prepare( - "INSERT OR IGNORE INTO stargazers (username) VALUES (?)" - ) - .bind(normalized) - .run(); - - console.log(`[Webhook] Successfully saved stargazer: ${normalized}`); - - void sendTelegramAlert({ - source: "WEBHOOK_STAR", - message: `⭐️ User starred the repo and synced to D1: ${normalized}`, - context: { username: normalized }, - }).catch(() => null); - + if (typedPayload.action === "created") { + await process.env.DB.prepare( + "INSERT OR IGNORE INTO stargazers (username) VALUES (?)" + ) + .bind(normalized) + .run(); + + console.log(`[Webhook] Successfully saved stargazer: ${normalized}`); + + void sendTelegramAlert({ + source: "WEBHOOK_STAR_ADD", + message: `⭐️ User starred the repo and synced to D1: ${normalized}`, + context: { username: normalized }, + }).catch(() => null); + } else if (typedPayload.action === "deleted") { + await process.env.DB.prepare( + "DELETE FROM stargazers WHERE username = ?" + ) + .bind(normalized) + .run(); + + console.log(`[Webhook] Successfully removed stargazer: ${normalized}`); + + void sendTelegramAlert({ + source: "WEBHOOK_STAR_REMOVE", + message: `🗑️ User unstarred the repo and removed from D1: ${normalized}`, + context: { username: normalized }, + }).catch(() => null); + } } catch (dbErr) { - console.error("[Webhook] Failed to insert stargazer in D1:", dbErr); + console.error(`[Webhook] Failed to update stargazer (${typedPayload.action}) in D1:`, dbErr); void sendTelegramAlert({ source: "WEBHOOK_STAR_ERROR", - message: "Failed to save stargazer in D1", + message: `Failed to update stargazer (${typedPayload.action}) in D1`, error: dbErr, context: { username: normalized }, }).catch(() => null); - return NextResponse.json({ error: "Database insertion failed" }, { status: 500 }); + return NextResponse.json({ error: "Database operation failed" }, { status: 500 }); } } else { console.error("[Webhook] D1 Database binding DB is not available in environment"); diff --git a/src/lib/github/index.ts b/src/lib/github/index.ts index b9bb04a..4dfbe88 100644 --- a/src/lib/github/index.ts +++ b/src/lib/github/index.ts @@ -9,6 +9,7 @@ export { verifyAndInjectStar, getRepoStarCount, normalizeUsername, + TARGET_REPO, } from "./star-gate"; export { getFallbackToken, diff --git a/src/lib/github/star-gate.ts b/src/lib/github/star-gate.ts index 98c025f..423b649 100644 --- a/src/lib/github/star-gate.ts +++ b/src/lib/github/star-gate.ts @@ -35,7 +35,7 @@ const STAR_GATE_DEBUG = process.env.STAR_GATE_DEBUG === "1"; const TARGET_OWNER = "0xarchit"; const TARGET_NAME = "github-profile-analyzer"; -const TARGET_REPO = `${TARGET_OWNER}/${TARGET_NAME}`; +export const TARGET_REPO = `${TARGET_OWNER}/${TARGET_NAME}`; // --------------------------------------------------------------------------- // Types