From 4e3492dc7703dfe13a91a319cf734c910c689a5a Mon Sep 17 00:00:00 2001 From: Mathbech Date: Sun, 9 Aug 2026 12:09:10 +0200 Subject: [PATCH 1/5] chore: update Node.js version requirement in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5713c5e..a8883bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ Merci de vouloir contribuer ! Ce projet est ouvert : **aucun accès en écriture ## Prérequis -- Node.js >= 20 (la CI teste aussi sur 22). +- Node.js >= 22 (la CI teste sur 22 et 24). - Un _Personal Access Token_ GitHub pour les tests contre l'API réelle. Il va **uniquement** dans `.env.local` (ignoré par git). Ne committe jamais de token. ## Conventions From 96db3b20401afeb0e7eec14a3624675db6db03d8 Mon Sep 17 00:00:00 2001 From: Mathbech Date: Sun, 9 Aug 2026 12:09:38 +0200 Subject: [PATCH 2/5] chore: simplify description of GitHub stats service in README.md --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index f95582d..2048b7c 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,7 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Mathbech_my-git-stats&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Mathbech_my-git-stats) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Mathbech_my-git-stats&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Mathbech_my-git-stats) -Ton propre service de cartes de stats GitHub en SVG, écrit de zéro — **aucun fork**. Node + Express interrogent l'API GraphQL de GitHub et génèrent les images. L'API reproduit les URLs de github-readme-stats, donc tu remplaces juste `...vercel.app` par ton domaine dans tes READMEs. - -``` -Navigateur → (CloudFront plus tard) → Apache HTTPS → Node/Express (127.0.0.1:9000) → API GitHub -``` +Service de carte de stats GitHub SVG, pour les README de profil GitHub ## Galerie & URLs From 306aa355c51d0d8daa4ebf91a285cf9a30bd8c30 Mon Sep 17 00:00:00 2001 From: Mathbech Date: Sun, 9 Aug 2026 12:09:45 +0200 Subject: [PATCH 3/5] chore: update Node.js version in CI configuration and upgrade SonarCloud action --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b778f08..e66636d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20, 22, 24] + node-version: [22, 24] steps: - uses: actions/checkout@v4 @@ -52,7 +52,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - name: Install dependencies @@ -62,7 +62,7 @@ jobs: run: npm run coverage:lcov - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v5 + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarcloud.io From cf77c3c470707df0978398ee8b4d5548ff9c43c1 Mon Sep 17 00:00:00 2001 From: Mathbech Date: Sun, 9 Aug 2026 12:30:20 +0200 Subject: [PATCH 4/5] chore: enhance pagination for user stats and languages queries in GitHub API --- src/github.js | 80 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/src/github.js b/src/github.js index df71965..4c38aa6 100644 --- a/src/github.js +++ b/src/github.js @@ -31,8 +31,12 @@ async function graphql(query, variables) { return json.data; } +// Garde-fou : plafonne le nombre de pages pour éviter une boucle infinie +// ou une explosion du coût de requêtes (100 repos/page). +const MAX_PAGES = 20; + const STATS_QUERY = ` -query userStats($login: String!) { +query userStats($login: String!, $after: String) { user(login: $login) { name login @@ -44,23 +48,35 @@ query userStats($login: String!) { } pullRequests(first: 1) { totalCount } issues(first: 1) { totalCount } - repositories(first: 100, ownerAffiliations: OWNER, orderBy: { field: STARGAZERS, direction: DESC }) { + repositories(first: 100, after: $after, ownerAffiliations: OWNER, orderBy: { field: STARGAZERS, direction: DESC }) { totalCount nodes { stargazers { totalCount } } + pageInfo { hasNextPage endCursor } } } }`; -/** Récupère les stats agrégées d'un utilisateur. */ +/** Récupère les stats agrégées d'un utilisateur (pagine au-delà de 100 repos). */ export async function fetchStats(username, { includeAllCommits = false } = {}) { - const data = await graphql(STATS_QUERY, { login: username }); - const user = data.user; - if (!user) throw new Error(`Utilisateur introuvable : ${username}`); - - const totalStars = user.repositories.nodes.reduce( - (sum, r) => sum + (r.stargazers?.totalCount || 0), - 0, - ); + let after = null; + let user = null; // métadonnées conservées depuis la 1re page + let totalStars = 0; + + for (let page = 0; page < MAX_PAGES; page++) { + const data = await graphql(STATS_QUERY, { login: username, after }); + if (!data.user) throw new Error(`Utilisateur introuvable : ${username}`); + if (!user) user = data.user; + + const repos = data.user.repositories; + totalStars += repos.nodes.reduce( + (sum, r) => sum + (r.stargazers?.totalCount || 0), + 0, + ); + + if (!repos.pageInfo.hasNextPage) break; + after = repos.pageInfo.endCursor; + } + const commits = user.contributionsCollection.totalCommitContributions + (includeAllCommits @@ -79,9 +95,9 @@ export async function fetchStats(username, { includeAllCommits = false } = {}) { } const LANGS_QUERY = ` -query userLangs($login: String!) { +query userLangs($login: String!, $after: String) { user(login: $login) { - repositories(first: 100, ownerAffiliations: OWNER, isFork: false) { + repositories(first: 100, after: $after, ownerAffiliations: OWNER, isFork: false) { nodes { languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { edges { @@ -90,28 +106,36 @@ query userLangs($login: String!) { } } } + pageInfo { hasNextPage endCursor } } } }`; -/** Récupère et agrège les langages (en octets) d'un utilisateur. */ +/** Récupère et agrège les langages (en octets) d'un utilisateur (pagine au-delà de 100 repos). */ export async function fetchTopLangs(username) { - const data = await graphql(LANGS_QUERY, { login: username }); - const user = data.user; - if (!user) throw new Error(`Utilisateur introuvable : ${username}`); - const totals = new Map(); - for (const repo of user.repositories.nodes) { - for (const edge of repo.languages.edges) { - const name = edge.node.name; - const prev = totals.get(name) || { - name, - color: edge.node.color || "#858585", - size: 0, - }; - prev.size += edge.size; - totals.set(name, prev); + let after = null; + + for (let page = 0; page < MAX_PAGES; page++) { + const data = await graphql(LANGS_QUERY, { login: username, after }); + const user = data.user; + if (!user) throw new Error(`Utilisateur introuvable : ${username}`); + + for (const repo of user.repositories.nodes) { + for (const edge of repo.languages.edges) { + const name = edge.node.name; + const prev = totals.get(name) || { + name, + color: edge.node.color || "#858585", + size: 0, + }; + prev.size += edge.size; + totals.set(name, prev); + } } + + if (!user.repositories.pageInfo.hasNextPage) break; + after = user.repositories.pageInfo.endCursor; } const langs = [...totals.values()].sort((a, b) => b.size - a.size); From cbaa2defe8bc31d3a19917887dca5236fa7b5b4b Mon Sep 17 00:00:00 2001 From: Mathbech Date: Sun, 9 Aug 2026 12:41:29 +0200 Subject: [PATCH 5/5] chore: add unit tests for fetchStats and fetchTopLangs functions in github.js --- test/github.test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 test/github.test.js diff --git a/test/github.test.js b/test/github.test.js new file mode 100644 index 0000000..7b39780 --- /dev/null +++ b/test/github.test.js @@ -0,0 +1,170 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { fetchStats, fetchTopLangs } from "../src/github.js"; + +// --- Mock de fetch -------------------------------------------------------- +// On empile des réponses ; chaque appel à fetch en dépile une et enregistre +// les variables GraphQL envoyées (pour vérifier la transmission du curseur). +let realFetch; +let queue; +let calls; + +function ok(data) { + return { ok: true, json: async () => ({ data }) }; +} + +function pushResponses(...responses) { + queue.push(...responses); +} + +beforeEach(() => { + realFetch = globalThis.fetch; + queue = []; + calls = []; + process.env.PAT_1 = "test-token"; + globalThis.fetch = async (_url, opts) => { + calls.push(JSON.parse(opts.body).variables); + if (queue.length === 0) throw new Error("Aucune réponse mockée en file"); + return queue.shift(); + }; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +// --- Fabriques de pages --------------------------------------------------- +function statsPage({ stars, hasNextPage, endCursor = null }) { + return ok({ + user: { + name: "Mathbech", + login: "mathbech", + followers: { totalCount: 389 }, + contributionsCollection: { + totalCommitContributions: 1800, + restrictedContributionsCount: 43, + totalPullRequestReviewContributions: 42, + }, + pullRequests: { totalCount: 214 }, + issues: { totalCount: 57 }, + repositories: { + totalCount: 150, + nodes: stars.map((s) => ({ stargazers: { totalCount: s } })), + pageInfo: { hasNextPage, endCursor }, + }, + }, + }); +} + +function langsPage({ edges, hasNextPage, endCursor = null }) { + return ok({ + user: { + repositories: { + nodes: [{ languages: { edges } }], + pageInfo: { hasNextPage, endCursor }, + }, + }, + }); +} + +// --- fetchStats ----------------------------------------------------------- +test("fetchStats agrège les stars sur une seule page", async () => { + pushResponses(statsPage({ stars: [10, 5, 1], hasNextPage: false })); + const s = await fetchStats("mathbech"); + assert.equal(s.stars, 16); + assert.equal(s.name, "Mathbech"); + assert.equal(s.commits, 1800); + assert.equal(s.prs, 214); + assert.equal(s.issues, 57); + assert.equal(s.reviews, 42); + assert.equal(s.followers, 389); + assert.equal(calls.length, 1); +}); + +test("fetchStats pagine au-delà de 100 repos et transmet le curseur", async () => { + pushResponses( + statsPage({ stars: [3], hasNextPage: true, endCursor: "C1" }), + statsPage({ stars: [4], hasNextPage: false }), + ); + const s = await fetchStats("mathbech"); + assert.equal(s.stars, 7); + assert.equal(calls.length, 2); + assert.equal(calls[0].after, null); + assert.equal(calls[1].after, "C1"); +}); + +test("fetchStats inclut les commits privés avec includeAllCommits", async () => { + pushResponses(statsPage({ stars: [1], hasNextPage: false })); + const s = await fetchStats("mathbech", { includeAllCommits: true }); + assert.equal(s.commits, 1800 + 43); +}); + +test("fetchStats lève une erreur si l'utilisateur est introuvable", async () => { + pushResponses(ok({ user: null })); + await assert.rejects(fetchStats("inconnu"), /introuvable/); +}); + +// --- fetchTopLangs -------------------------------------------------------- +test("fetchTopLangs agrège les langages et calcule les pourcentages", async () => { + pushResponses( + langsPage({ + edges: [ + { size: 300, node: { name: "JavaScript", color: "#f1e05a" } }, + { size: 100, node: { name: "Python", color: "#3572A5" } }, + ], + hasNextPage: false, + }), + ); + const langs = await fetchTopLangs("mathbech"); + assert.equal(langs[0].name, "JavaScript"); + assert.equal(langs[0].size, 300); + assert.equal(Math.round(langs[0].percent), 75); +}); + +test("fetchTopLangs pagine et cumule un même langage sur plusieurs pages", async () => { + pushResponses( + langsPage({ + edges: [{ size: 100, node: { name: "JavaScript", color: "#f1e05a" } }], + hasNextPage: true, + endCursor: "L1", + }), + langsPage({ + edges: [{ size: 300, node: { name: "JavaScript", color: "#f1e05a" } }], + hasNextPage: false, + }), + ); + const langs = await fetchTopLangs("mathbech"); + assert.equal(langs.length, 1); + assert.equal(langs[0].size, 400); + assert.equal(calls[1].after, "L1"); +}); + +test("fetchTopLangs utilise une couleur par défaut si absente", async () => { + pushResponses( + langsPage({ + edges: [{ size: 50, node: { name: "Text", color: null } }], + hasNextPage: false, + }), + ); + const langs = await fetchTopLangs("mathbech"); + assert.equal(langs[0].color, "#858585"); +}); + +test("fetchTopLangs lève une erreur si l'utilisateur est introuvable", async () => { + pushResponses(ok({ user: null })); + await assert.rejects(fetchTopLangs("inconnu"), /introuvable/); +}); + +// --- graphql : gestion des erreurs --------------------------------------- +test("graphql lève une erreur sur réponse HTTP non OK", async () => { + queue.push({ ok: false, status: 502, json: async () => ({}) }); + await assert.rejects(fetchStats("mathbech"), /HTTP 502/); +}); + +test("graphql lève une erreur si GraphQL renvoie des errors", async () => { + queue.push({ + ok: true, + json: async () => ({ errors: [{ message: "Bad credentials" }] }), + }); + await assert.rejects(fetchStats("mathbech"), /Bad credentials/); +});