Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [20, 22, 24]
node-version: [22, 24]
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 52 additions & 28 deletions src/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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);
Expand Down
170 changes: 170 additions & 0 deletions test/github.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading