From f8b57f08a4a6ba673e8eb6d46a51ecb74530a9fc Mon Sep 17 00:00:00 2001 From: Evelin Date: Fri, 20 Mar 2026 01:18:11 +0100 Subject: [PATCH] refactor(github): isolate GitHub domain into dedicated layer Extract all GitHub API concerns into scripts/github/ following a ports-and-adapters approach: shared HTTP client in githubApiClient.ts, GraphQL queries in queries.ts, and renamed fetchers (fetchUserStats, fetchUserLanguages) fixing the fetchUserData export name mismatch. Also fix Object.values() misuse on arrays and add request timeout. Co-Authored-By: Claude Sonnet 4.6 --- api/languages.ts | 4 +- api/stats.ts | 4 +- scripts/fetchers/fetchLanguages.ts | 112 ------------------------- scripts/fetchers/fetchUserData.ts | 117 --------------------------- scripts/github/fetchUserLanguages.ts | 35 ++++++++ scripts/github/fetchUserStats.ts | 29 +++++++ scripts/github/githubApiClient.ts | 62 ++++++++++++++ scripts/github/queries.ts | 41 ++++++++++ 8 files changed, 171 insertions(+), 233 deletions(-) delete mode 100644 scripts/fetchers/fetchLanguages.ts delete mode 100644 scripts/fetchers/fetchUserData.ts create mode 100644 scripts/github/fetchUserLanguages.ts create mode 100644 scripts/github/fetchUserStats.ts create mode 100644 scripts/github/githubApiClient.ts create mode 100644 scripts/github/queries.ts diff --git a/api/languages.ts b/api/languages.ts index 84c7f54..207f637 100644 --- a/api/languages.ts +++ b/api/languages.ts @@ -1,7 +1,7 @@ import { VALID_USERNAME } from "../scripts/utils/validators"; import { CACHE_DURATION_SECONDS } from "../scripts/utils/constants"; import type { VercelRequest, VercelResponse } from "../types/vercel"; -import { fetchUserData } from "../scripts/fetchers/fetchLanguages"; +import { fetchUserLanguages } from "../scripts/github/fetchUserLanguages"; import { renderLanguageCard as renderLangPie } from "../scripts/renderers/renderLangPie"; import { renderLanguageCard as renderLangPercent } from "../scripts/renderers/renderLangPercent"; import { renderErrorCard } from "../scripts/renderers/renderErrorCard"; @@ -26,7 +26,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => } try { - const data = await fetchUserData(username); + const data = await fetchUserLanguages(username); res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", `public, max-age=${CACHE_DURATION_SECONDS}`); if (pie) { diff --git a/api/stats.ts b/api/stats.ts index a49f840..fdacf53 100644 --- a/api/stats.ts +++ b/api/stats.ts @@ -1,7 +1,7 @@ import { VALID_USERNAME } from "../scripts/utils/validators"; import { CACHE_DURATION_SECONDS } from "../scripts/utils/constants"; import type { VercelRequest, VercelResponse } from "../types/vercel"; -import { fetchUserData } from "../scripts/fetchers/fetchUserData"; +import { fetchUserStats } from "../scripts/github/fetchUserStats"; import { renderStatCard } from "../scripts/renderers/renderStatCard"; import { renderErrorCard } from "../scripts/renderers/renderErrorCard"; @@ -25,7 +25,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => } try { - const data = await fetchUserData(username); + const data = await fetchUserStats(username); res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", `public, max-age=${CACHE_DURATION_SECONDS}`); res.send(renderStatCard(data, color ?? "", peng)); diff --git a/scripts/fetchers/fetchLanguages.ts b/scripts/fetchers/fetchLanguages.ts deleted file mode 100644 index de8603d..0000000 --- a/scripts/fetchers/fetchLanguages.ts +++ /dev/null @@ -1,112 +0,0 @@ -import https from "https"; -import dotenv from "dotenv"; -import { - GitHubApiResponse, - GitHubRepositoryEdge, - LanguageData, - UserLanguageStats, -} from "../../types"; - -dotenv.config(); - -const fetchUserData = async (user: string): Promise => { - const headers: Record = { - "user-agent": "Github-Stats", - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - "Content-Type": "application/json", - Accept: "application/json", - }; - - const query = `{ - user(login:"${user}"){ - repositories(first:100,privacy:PUBLIC,ownerAffiliations:OWNER){ - edges{ - node{ - languages (first:10) { - edges{ - node { - name - color - } - } - } - } - } - } - } - }`; - - const options: https.RequestOptions = { - hostname: "api.github.com", - path: "/graphql", - method: "POST", - headers: headers, - }; - - const request = ( - resolve: (value: GitHubApiResponse) => void, - reject: (reason: Error) => void - ): void => { - const req = https.request(options, (res) => { - let body = ""; - - res.on("data", (d: Buffer) => { - body += d; - }); - - res.on("end", () => { - try { - resolve(JSON.parse(body) as GitHubApiResponse); - } catch (e) { - reject( - new Error( - `Failed to parse GitHub API response: ${(e as Error).message}` - ) - ); - } - }); - }); - - req.on("error", (error: Error) => { - console.log(error); - reject(error); - }); - - req.write(JSON.stringify({ query })); - req.end(); - }; - - const countLanguages = (edges: GitHubRepositoryEdge[]): LanguageData[] => { - const languageMap = new Map(); - - edges.forEach(function (repoEdge) { - repoEdge.node.languages?.edges.forEach(function (languageEdge) { - const { name, color } = languageEdge.node; - if (languageMap.has(name)) { - (languageMap.get(name) as LanguageData).count++; - } else { - languageMap.set(name, { name, color, count: 1 }); - } - }); - }); - - return Array.from(languageMap.values()); - }; - - const getDataObj = (json: GitHubApiResponse): UserLanguageStats => { - const dataObj: UserLanguageStats = { - user: user, - languages: countLanguages(json.data.user.repositories.edges), - }; - return dataObj; - }; - - // PROD - const data = await new Promise((resolve, reject) => - request(resolve, reject) - ); - - return getDataObj(data); -}; - -export { fetchUserData }; diff --git a/scripts/fetchers/fetchUserData.ts b/scripts/fetchers/fetchUserData.ts deleted file mode 100644 index 3e6e185..0000000 --- a/scripts/fetchers/fetchUserData.ts +++ /dev/null @@ -1,117 +0,0 @@ -import https from "https"; -import dotenv from "dotenv"; -import { - GitHubApiResponse, - GitHubRepositoryEdge, - UserStats, -} from "../../types"; - -dotenv.config(); - -const fetchUserData = async (user: string): Promise => { - const headers: Record = { - "user-agent": "Github-Stats", - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - "Content-Type": "application/json", - Accept: "application/json", - }; - - const query = `{ -user(login:"${user}"){ -repositories(first:100,privacy:PUBLIC,ownerAffiliations:OWNER){ -edges{ -node{ -forkCount -stargazerCount -} -} -} -contributionsCollection{ -contributionCalendar{ -totalContributions -} -} -followers{ -totalCount -} -} -}`; - - const options: https.RequestOptions = { - hostname: "api.github.com", - path: "/graphql", - method: "POST", - headers: headers, - }; - - const request = ( - resolve: (value: GitHubApiResponse) => void, - reject: (reason: Error) => void - ): void => { - const req = https.request(options, (res) => { - let body = ""; - - res.on("data", (d: Buffer) => { - body += d; - }); - - res.on("end", () => { - try { - resolve(JSON.parse(body) as GitHubApiResponse); - } catch (e) { - reject( - new Error( - `Failed to parse GitHub API response: ${(e as Error).message}` - ) - ); - } - }); - }); - - req.on("error", (error: Error) => { - console.log(error); - reject(error); - }); - - req.write(JSON.stringify({ query })); - req.end(); - }; - - const countProperty = ( - nodes: GitHubRepositoryEdge[], - property: "stargazerCount" | "forkCount" - ): number => { - return Object.values(nodes).reduce( - (t, { node }) => t + (node[property] ?? 0), - 0 - ); - }; - - const getDataObj = (json: GitHubApiResponse): UserStats => { - const dataObj: UserStats = { - user: user, - amountFollowers: json.data.user.followers?.totalCount ?? 0, - amountRepos: json.data.user.repositories.edges.length, - amountStars: countProperty( - json.data.user.repositories.edges, - "stargazerCount" - ), - amountForks: countProperty( - json.data.user.repositories.edges, - "forkCount" - ), - totalContributions: - json.data.user.contributionsCollection?.contributionCalendar - .totalContributions ?? 0, - }; - return dataObj; - }; - - const data = await new Promise((resolve, reject) => - request(resolve, reject) - ); - - return getDataObj(data); -}; - -export { fetchUserData }; diff --git a/scripts/github/fetchUserLanguages.ts b/scripts/github/fetchUserLanguages.ts new file mode 100644 index 0000000..e30630c --- /dev/null +++ b/scripts/github/fetchUserLanguages.ts @@ -0,0 +1,35 @@ +import { + GitHubRepositoryEdge, + LanguageData, + UserLanguageStats, +} from "../../types"; +import { makeGraphQLRequest } from "./githubApiClient"; +import { USER_LANGUAGES_QUERY } from "./queries"; + +const fetchUserLanguages = async (user: string): Promise => { + const countLanguages = (edges: GitHubRepositoryEdge[]): LanguageData[] => { + const languageMap = new Map(); + + edges.forEach((repoEdge) => { + repoEdge.node.languages?.edges.forEach((languageEdge) => { + const { name, color } = languageEdge.node; + if (languageMap.has(name)) { + languageMap.get(name)!.count++; + } else { + languageMap.set(name, { name, color, count: 1 }); + } + }); + }); + + return Array.from(languageMap.values()); + }; + + const data = await makeGraphQLRequest(USER_LANGUAGES_QUERY(user)); + + return { + user, + languages: countLanguages(data.data.user.repositories.edges), + }; +}; + +export { fetchUserLanguages }; diff --git a/scripts/github/fetchUserStats.ts b/scripts/github/fetchUserStats.ts new file mode 100644 index 0000000..2f48ede --- /dev/null +++ b/scripts/github/fetchUserStats.ts @@ -0,0 +1,29 @@ +import { GitHubRepositoryEdge, UserStats } from "../../types"; +import { makeGraphQLRequest } from "./githubApiClient"; +import { USER_STATS_QUERY } from "./queries"; + +const fetchUserStats = async (user: string): Promise => { + const countProperty = ( + nodes: GitHubRepositoryEdge[], + property: "stargazerCount" | "forkCount" + ): number => + nodes.reduce((t, { node }) => t + (node[property] ?? 0), 0); + + const data = await makeGraphQLRequest(USER_STATS_QUERY(user)); + + return { + user, + amountFollowers: data.data.user.followers?.totalCount ?? 0, + amountRepos: data.data.user.repositories.edges.length, + amountStars: countProperty( + data.data.user.repositories.edges, + "stargazerCount" + ), + amountForks: countProperty(data.data.user.repositories.edges, "forkCount"), + totalContributions: + data.data.user.contributionsCollection?.contributionCalendar + .totalContributions ?? 0, + }; +}; + +export { fetchUserStats }; diff --git a/scripts/github/githubApiClient.ts b/scripts/github/githubApiClient.ts new file mode 100644 index 0000000..f4921a3 --- /dev/null +++ b/scripts/github/githubApiClient.ts @@ -0,0 +1,62 @@ +import https from "https"; +import dotenv from "dotenv"; +import { GitHubApiResponse } from "../../types"; + +dotenv.config(); + +const createHeaders = (): Record => ({ + "user-agent": "Github-Stats", + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "Content-Type": "application/json", + Accept: "application/json", +}); + +const makeGraphQLRequest = (query: string): Promise => { + const options: https.RequestOptions = { + hostname: "api.github.com", + path: "/graphql", + method: "POST", + headers: createHeaders(), + }; + + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + const buffers: Buffer[] = []; + + res.on("data", (d: Buffer) => { + buffers.push(d); + }); + + res.on("end", () => { + try { + resolve( + JSON.parse( + Buffer.concat(buffers).toString() + ) as GitHubApiResponse + ); + } catch (e) { + reject( + new Error( + `Failed to parse GitHub API response: ${(e as Error).message}` + ) + ); + } + }); + }); + + req.on("error", (error: Error) => { + console.log(error); + reject(error); + }); + + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error("GitHub API request timed out")); + }); + + req.write(JSON.stringify({ query })); + req.end(); + }); +}; + +export { makeGraphQLRequest }; diff --git a/scripts/github/queries.ts b/scripts/github/queries.ts new file mode 100644 index 0000000..f1ddb96 --- /dev/null +++ b/scripts/github/queries.ts @@ -0,0 +1,41 @@ +const USER_STATS_QUERY = (user: string): string => `{ +user(login:"${user}"){ +repositories(first:100,privacy:PUBLIC,ownerAffiliations:OWNER){ +edges{ +node{ +forkCount +stargazerCount +} +} +} +contributionsCollection{ +contributionCalendar{ +totalContributions +} +} +followers{ +totalCount +} +} +}`; + +const USER_LANGUAGES_QUERY = (user: string): string => `{ + user(login:"${user}"){ + repositories(first:100,privacy:PUBLIC,ownerAffiliations:OWNER){ + edges{ + node{ + languages (first:10) { + edges{ + node { + name + color + } + } + } + } + } + } + } + }`; + +export { USER_STATS_QUERY, USER_LANGUAGES_QUERY };