Skip to content
Open
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
56 changes: 54 additions & 2 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export interface RawRepo {
pushedAt: string;
}

// A repo reduced to just its primary language — the unit the language-diversity
// signal counts. The scored list is the UNION of a user's owned repos and the
// public repos they've recently committed to (deduped by repo, built in
// normalize), so a developer whose work lives in orgs they don't *own* still gets
// a real language profile instead of a "0 languages" card.
export interface RawRepoLanguage {
language: string | null;
}

// Flat, normalized profile — all fields below are real GitHub data.
export interface RawPayload {
login: string;
Expand All @@ -40,7 +49,8 @@ export interface RawPayload {
createdAt: string;
followers: number;
publicRepos: number;
repos: RawRepo[]; // owned, non-fork, top 100 by stars
repos: RawRepo[]; // owned, non-fork, top 100 by stars (drives the star signals)
languageRepos: RawRepoLanguage[]; // owned ∪ recently-contributed public repos, deduped — the language signal
recentCommits: number; // the "recent" fields cover the last 365 days
recentPRs: number;
recentReviews: number;
Expand All @@ -59,6 +69,10 @@ const ENDPOINT = "https://api.github.com/graphql";
const VALID = /^(?=.*[a-z\d])[a-z\d-]{1,39}$/i;
const GITHUB_EPOCH_YEAR = 2008; // GitHub launched Feb 2008; no account predates it.
const LIFETIME_BATCH = 4; // contribution windows per request — stays well under GitHub's timeout.
// A repo only feeds the language signal once the user has really worked in it —
// this many commits in the last year — so a single drive-by typo fix to a large
// polyglot repo can't inflate their language diversity.
const MIN_CONTRIBUTED_LANG_COMMITS = 3;
// Abort a GitHub request that hangs at the socket level, instead of letting it
// hang the whole scout (and, under load, starve other requests). Kept BELOW
// Vercel's ~10s serverless function cap: at 8s we still get to return a clean
Expand All @@ -82,6 +96,7 @@ interface UserNode {
repositories: {
totalCount: number;
nodes: {
nameWithOwner: string;
stargazerCount: number;
primaryLanguage: { name: string } | null;
createdAt: string;
Expand All @@ -94,6 +109,17 @@ interface UserNode {
totalPullRequestReviewContributions: number;
totalIssueContributions: number;
restrictedContributionsCount: number; // private contributions, when the user shows them
// Public repos the user committed to in the last year, with each repo's
// primary language — the source for counting languages beyond owned repos.
commitContributionsByRepository: {
contributions: { totalCount: number };
repository: {
nameWithOwner: string;
isFork: boolean;
isPrivate: boolean;
primaryLanguage: { name: string } | null;
};
}[];
contributionCalendar: { weeks: { contributionDays: { contributionCount: number }[] }[] };
};
}
Expand Down Expand Up @@ -184,14 +210,18 @@ function profileQuery(): string {
followers { totalCount }
repositories(ownerAffiliations: OWNER, isFork: false, first: 100, orderBy: { field: STARGAZERS, direction: DESC }) {
totalCount
nodes { stargazerCount primaryLanguage { name } createdAt pushedAt }
nodes { nameWithOwner stargazerCount primaryLanguage { name } createdAt pushedAt }
}
recent: contributionsCollection {
totalCommitContributions
totalPullRequestContributions
totalPullRequestReviewContributions
totalIssueContributions
restrictedContributionsCount
commitContributionsByRepository(maxRepositories: 100) {
contributions { totalCount }
repository { nameWithOwner isFork isPrivate primaryLanguage { name } }
}
contributionCalendar { weeks { contributionDays { contributionCount } } }
}
}
Expand Down Expand Up @@ -299,6 +329,27 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
pushedAt: n.pushedAt,
}));

// Language diversity is scored over the UNION of the repos a user *owns* and the
// public repos they've recently *committed to*, deduped by repo. Owning is only
// one way to work in a language — a professional whose real code lives in orgs
// they don't own would otherwise score "0 languages". Seed with owned repos
// (authoritative, language may be null), then fold in each qualifying contributed
// repo not already counted: public, non-fork, with a real commit footprint and a
// language GitHub could classify. Private repos stay a bare contribution count in
// GitHub's API (no repo/language), so they can't be counted here either.
const languageByRepo = new Map<string, string | null>();
for (const n of user.repositories.nodes) {
languageByRepo.set(n.nameWithOwner, n.primaryLanguage?.name ?? null);
}
for (const c of user.recent.commitContributionsByRepository ?? []) {
const r = c.repository;
if (r.isFork || r.isPrivate || !r.primaryLanguage) continue;
if (c.contributions.totalCount < MIN_CONTRIBUTED_LANG_COMMITS) continue;
if (languageByRepo.has(r.nameWithOwner)) continue;
languageByRepo.set(r.nameWithOwner, r.primaryLanguage.name);
}
const languageRepos: RawRepoLanguage[] = [...languageByRepo.values()].map((language) => ({ language }));

const recentActiveDays = user.recent.contributionCalendar.weeks.reduce(
(days, w) => days + w.contributionDays.filter((d) => d.contributionCount > 0).length,
0,
Expand All @@ -313,6 +364,7 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
followers: user.followers.totalCount,
publicRepos: user.repositories.totalCount,
repos,
languageRepos,
recentCommits: user.recent.totalCommitContributions,
recentPRs: user.recent.totalPullRequestContributions,
recentReviews: user.recent.totalPullRequestReviewContributions,
Expand Down
7 changes: 5 additions & 2 deletions lib/github/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ export function signalsFromPayload(p: RawPayload, now = Date.now()): Signals {

const total_stars_owned = p.repos.reduce((s, r) => s + r.stars, 0);
const max_repo_stars = p.repos.reduce((m, r) => Math.max(m, r.stars), 0);
const langs = new Set(p.repos.map((r) => r.language).filter(Boolean) as string[]);
// Counted over the UNION of owned repos and public repos the user has recently
// committed to (deduped in the client) — so work in orgs a developer doesn't
// *own* still counts, instead of a misleading "0 languages" for org/team devs.
const langs = new Set(p.languageRepos.map((r) => r.language).filter(Boolean) as string[]);
const languages = langs.size;
// Primary languages ranked by repo count, programming languages floated above
// styling/markup (CSS/HTML) — the #1 drives the card's language + logo.
const rankedLanguages = rankLanguages(p.repos);
const rankedLanguages = rankLanguages(p.languageRepos);

const years = new Set<number>();
for (const r of p.repos) {
Expand Down
79 changes: 79 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,82 @@ describe("fetchProfile request timeout", () => {
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
});
});

describe("fetchProfile language diversity (owned ∪ contributed)", () => {
// A profile whose OWNED public repos are thin (one TS repo + one empty repo),
// but who commits to several repos they don't own — the org/team-dev shape that
// used to score "0 languages".
const ownedNode = (nameWithOwner: string, language: string | null) => ({
nameWithOwner,
stargazerCount: 0,
primaryLanguage: language ? { name: language } : null,
createdAt: "2022-01-01T00:00:00Z",
pushedAt: "2024-01-01T00:00:00Z",
});
const contrib = (
nameWithOwner: string,
totalCount: number,
language: string | null,
opts: { isFork?: boolean; isPrivate?: boolean } = {},
) => ({
contributions: { totalCount },
repository: {
nameWithOwner,
isFork: opts.isFork ?? false,
isPrivate: opts.isPrivate ?? false,
primaryLanguage: language ? { name: language } : null,
},
});

const USER_LANGS = {
...USER,
repositories: {
totalCount: 2,
nodes: [ownedNode("someuser/own-ts", "TypeScript"), ownedNode("someuser/own-empty", null)],
},
recent: {
...USER.recent,
commitContributionsByRepository: [
contrib("acme/api", 10, "Go"), // qualifies -> counts
contrib("acme/cli", 3, "Rust"), // exactly at the threshold -> counts
contrib("acme/driveby", 2, "Elixir"), // below threshold -> excluded
contrib("acme/forked", 50, "Java", { isFork: true }), // fork -> excluded
contrib("acme/secret", 99, "Kotlin", { isPrivate: true }), // private -> excluded
contrib("acme/docs", 8, null), // no classified language -> excluded
contrib("someuser/own-ts", 20, "TypeScript"), // dup of an owned repo -> not double-counted
],
},
};

const langsOf = (payload: Awaited<ReturnType<typeof fetchProfile>>) =>
new Set(payload.languageRepos.map((r) => r.language).filter(Boolean));

it("requests commit contributions by repository in the profile query", async () => {
scriptFetch((_t, body) => (body.includes("query Profile") ? ok({ data: { user: USER_LANGS } }) : ok({ data: { user: {} } })));
await fetchProfile(LOGIN, NOW);
expect(calls[0].body).toContain("commitContributionsByRepository");
});

it("folds contributed public repos into the language set, deduped against owned repos", async () => {
scriptFetch((_t, body) => (body.includes("query Profile") ? ok({ data: { user: USER_LANGS } }) : ok({ data: { user: {} } })));
const payload = await fetchProfile(LOGIN, NOW);

// TypeScript (owned) + Go + Rust (contributed). One entry per repo, deduped:
// own-ts, own-empty(null), acme/api, acme/cli = 4 rows; 3 distinct languages.
expect(payload.languageRepos).toHaveLength(4);
expect(langsOf(payload)).toEqual(new Set(["TypeScript", "Go", "Rust"]));
});

it("excludes forks, private repos, drive-by (< 3 commits) and unclassified repos", async () => {
scriptFetch((_t, body) => (body.includes("query Profile") ? ok({ data: { user: USER_LANGS } }) : ok({ data: { user: {} } })));
const langs = langsOf(await fetchProfile(LOGIN, NOW));
for (const excluded of ["Java", "Kotlin", "Elixir"]) expect(langs.has(excluded)).toBe(false);
});

it("still yields no languages when there are neither owned nor qualifying contributed repos", async () => {
// The baseline USER: empty owned repos and no commit-by-repo contributions.
scriptFetch((_t, body) => okFor(body));
const payload = await fetchProfile(LOGIN, NOW);
expect(payload.languageRepos).toEqual([]);
});
});
88 changes: 88 additions & 0 deletions tests/signals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { signalsFromPayload } from "@/lib/github/signals";
import type { RawPayload, RawRepo } from "@/lib/github/client";

// The language signal is scored over `languageRepos` (owned ∪ recently-contributed
// public repos, deduped in the client) — NOT the owned-only `repos`, which now
// drives only the star signals. These tests pin that decoupling: a dev with no
// owned public repos but real contributions still gets a language profile.

const repo = (over: Partial<RawRepo> = {}): RawRepo => ({
stars: 0,
language: null,
createdAt: "2021-01-01T00:00:00Z",
pushedAt: "2021-01-01T00:00:00Z",
...over,
});

const payload = (over: Partial<RawPayload> = {}): RawPayload => ({
login: "someuser",
name: null,
avatarUrl: "",
location: null,
createdAt: "2020-01-01T00:00:00Z",
followers: 0,
publicRepos: 0,
repos: [],
languageRepos: [],
recentCommits: 0,
recentPRs: 0,
recentReviews: 0,
recentIssues: 0,
recentRestricted: 0,
recentActiveDays: 0,
lifetimeContributions: 0,
...over,
});

const NOW = Date.parse("2026-07-03T12:00:00Z");

describe("signalsFromPayload — language diversity", () => {
it("counts distinct languages from languageRepos and ranks by repo count", () => {
const s = signalsFromPayload(
payload({ languageRepos: [{ language: "Go" }, { language: "Go" }, { language: "Rust" }, { language: null }] }),
NOW,
);
expect(s.languages).toBe(2); // Go, Rust — nulls ignored
expect(s.rankedLanguages).toEqual(["Go", "Rust"]); // Go leads 2:1
expect(s.topLanguage).toBe("Go");
});

it("scores languages from contributions even with NO owned public repos (the org-dev case)", () => {
// repos is empty (nothing owned/public), but the user commits to org repos.
const s = signalsFromPayload(
payload({ repos: [], languageRepos: [{ language: "JavaScript" }, { language: "TypeScript" }] }),
NOW,
);
expect(s.languages).toBe(2);
expect(s.topLanguage).toBe("JavaScript"); // count tie broken by name asc
});

it("does NOT count languages from `repos` — that list only feeds the star signals now", () => {
const s = signalsFromPayload(
payload({ repos: [repo({ stars: 12, language: "Python" })], languageRepos: [] }),
NOW,
);
expect(s.total_stars_owned).toBe(12); // stars still come from repos
expect(s.max_repo_stars).toBe(12);
expect(s.languages).toBe(0); // but languages come only from languageRepos
expect(s.topLanguage).toBeNull();
expect(s.rankedLanguages).toEqual([]);
});

it("keeps the markup/styling demotion when ranking the union", () => {
const s = signalsFromPayload(
payload({ languageRepos: [{ language: "CSS" }, { language: "CSS" }, { language: "Python" }] }),
NOW,
);
expect(s.languages).toBe(2); // CSS still *counts*…
expect(s.topLanguage).toBe("Python"); // …but a real language headlines
});

it("reports 0 languages and no top language for an empty union", () => {
const s = signalsFromPayload(payload({ languageRepos: [] }), NOW);
expect(s.languages).toBe(0);
expect(s.rankedLanguages).toEqual([]);
expect(s.topLanguage).toBeNull();
});
});