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
256 changes: 254 additions & 2 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { tokenPool, pickToken, pickFailover, recordTokenHealth, benchToken, type
// the years in small parallel batches with a retry — and tolerate a dropped
// batch (the figure is only used log-scaled, so a missing year barely moves it).

export type GithubErrorType = "invalid" | "notfound" | "ratelimit" | "network" | "config";
export type GithubErrorType = "invalid" | "notfound" | "ratelimit" | "resource" | "network" | "config";

export interface GithubError {
type: GithubErrorType;
Expand Down Expand Up @@ -69,6 +69,9 @@ 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.
const DAY_MS = 86_400_000;
const RESOURCE_RECENT_WINDOW_DAYS = 21;
const RESOURCE_WINDOW_CONCURRENCY = 4;
// 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.
Expand Down Expand Up @@ -124,6 +127,24 @@ interface UserNode {
};
}

type ProfileBasicsNode = Omit<UserNode, "recent">;

type RecentNode = UserNode["recent"];
type RepoContribution = RecentNode["commitContributionsByRepository"][number];

interface RecentWindowNode {
c: RecentNode;
}

interface ContributionTotalsNode {
c: YearContrib;
}

interface ContributionWindow {
from: string;
to: string;
}

interface YearContrib {
totalCommitContributions: number;
totalIssueContributions: number;
Expand Down Expand Up @@ -193,6 +214,15 @@ async function gql<T>(query: string, login: string, tok: PoolToken, retries = 1)
benchToken(tok.idx, res.headers);
return fail("ratelimit", "GitHub rate limit hit. Try again shortly.");
}
if (body.errors?.length && body.errors.every((e) => e.type === "NOT_FOUND")) {
return { user: null };
}
if (body.errors?.some((e) => e.type === "RESOURCE_LIMITS_EXCEEDED") && !body.data?.user) {
return fail("resource", "GitHub query was too expensive for this profile.");
}
if (body.errors?.length && !body.data?.user) {
return fail("network", body.errors[0]?.message ?? "GitHub returned a GraphQL error.");
}
return { user: body.data?.user ?? null };
}
return fail("network", "GitHub request failed."); // unreachable; satisfies the type checker
Expand Down Expand Up @@ -228,6 +258,59 @@ function profileQuery(): string {
}`;
}

function profileBasicsQuery(): string {
return `
query ProfileBasics($login: String!) {
user(login: $login) {
login
name
avatarUrl(size: 480)
location
createdAt
followers { totalCount }
repositories(ownerAffiliations: OWNER, isFork: false, first: 100, orderBy: { field: STARGAZERS, direction: DESC }) {
totalCount
nodes { nameWithOwner stargazerCount primaryLanguage { name } createdAt pushedAt }
}
}
}`;
}

function recentWindowQuery(from: string, to: string): string {
return `
query ResourceRecentWindow($login: String!) {
user(login: $login) {
c: contributionsCollection(from: "${from}", to: "${to}") {
totalCommitContributions
totalPullRequestContributions
totalPullRequestReviewContributions
totalIssueContributions
restrictedContributionsCount
commitContributionsByRepository(maxRepositories: 100) {
contributions { totalCount }
repository { nameWithOwner isFork isPrivate primaryLanguage { name } }
}
contributionCalendar { weeks { contributionDays { contributionCount } } }
}
}
}`;
}

function contributionTotalQuery(from: string, to: string): string {
return `
query LifetimeContributionTotals($login: String!) {
user(login: $login) {
c: contributionsCollection(from: "${from}", to: "${to}") {
totalCommitContributions
totalIssueContributions
totalPullRequestContributions
totalPullRequestReviewContributions
restrictedContributionsCount
}
}
}`;
}

function lifetimeQuery(years: number[], currentYear: number, nowIso: string): string {
const aliases = years
.map((y) => {
Expand All @@ -249,6 +332,100 @@ function chunk<T>(arr: T[], size: number): T[][] {
return out;
}

async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}),
);
return results;
}

function startOfUtcDay(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}

function endOfUtcDay(date: Date): Date {
const d = new Date(date);
d.setUTCHours(23, 59, 59, 999);
return d;
}

function addUtcDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * DAY_MS);
}

function contributionWindows(from: Date, to: Date, days: number): ContributionWindow[] {
const windows: ContributionWindow[] = [];
let start = new Date(from);
while (start <= to) {
const end = new Date(Math.min(endOfUtcDay(addUtcDays(start, days - 1)).getTime(), to.getTime()));
windows.push({ from: start.toISOString(), to: end.toISOString() });
start = new Date(end.getTime() + 1);
}
return windows;
}

function splitWindow(w: ContributionWindow): [ContributionWindow, ContributionWindow] | null {
const from = new Date(w.from);
const to = new Date(w.to);
if (to.getTime() - from.getTime() < DAY_MS) return null;
const mid = endOfUtcDay(new Date(from.getTime() + Math.floor((to.getTime() - from.getTime()) / 2)));
return [
{ from: from.toISOString(), to: mid.toISOString() },
{ from: addUtcDays(startOfUtcDay(mid), 1).toISOString(), to: to.toISOString() },
];
}

function emptyRecent(): RecentNode {
return {
totalCommitContributions: 0,
totalPullRequestContributions: 0,
totalPullRequestReviewContributions: 0,
totalIssueContributions: 0,
restrictedContributionsCount: 0,
commitContributionsByRepository: [],
contributionCalendar: { weeks: [] },
};
}

function combineRecent(parts: RecentNode[]): RecentNode {
const out = emptyRecent();
const repos = new Map<string, RepoContribution>();

for (const p of parts) {
out.totalCommitContributions += p.totalCommitContributions;
out.totalPullRequestContributions += p.totalPullRequestContributions;
out.totalPullRequestReviewContributions += p.totalPullRequestReviewContributions;
out.totalIssueContributions += p.totalIssueContributions;
out.restrictedContributionsCount += p.restrictedContributionsCount;
out.contributionCalendar.weeks.push(...p.contributionCalendar.weeks);

for (const c of p.commitContributionsByRepository ?? []) {
const key = c.repository.nameWithOwner;
const existing = repos.get(key);
if (existing) {
existing.contributions.totalCount += c.contributions.totalCount;
} else {
repos.set(key, {
contributions: { totalCount: c.contributions.totalCount },
repository: c.repository,
});
}
}
}

out.commitContributionsByRepository = [...repos.values()];
return out;
}

// Sum of every year's contributions (commits + issues + PRs + reviews + private).
// Each batch is best-effort: a batch that fails after its retry contributes 0
// rather than failing the scout.
Expand Down Expand Up @@ -290,6 +467,75 @@ async function fetchLifetime(
return sums.reduce((a, b) => a + b, 0);
}

async function fetchRecentWindow(login: string, tok: PoolToken, w: ContributionWindow): Promise<RecentNode> {
try {
const { user } = await gql<RecentWindowNode>(recentWindowQuery(w.from, w.to), login, tok);
return user?.c ?? emptyRecent();
} catch (e) {
const err = e as GithubError;
if (err.type === "resource") {
const split = splitWindow(w);
if (split) return combineRecent([await fetchRecentWindow(login, tok, split[0]), await fetchRecentWindow(login, tok, split[1])]);
return emptyRecent();
}
if (err.type === "network") return emptyRecent();
throw e;
}
}

async function fetchWindowedRecent(login: string, tok: PoolToken, now: Date): Promise<RecentNode> {
const from = new Date(now.getTime() - 365 * DAY_MS);
const windows = contributionWindows(from, now, RESOURCE_RECENT_WINDOW_DAYS);
return combineRecent(await mapLimit(windows, RESOURCE_WINDOW_CONCURRENCY, (w) => fetchRecentWindow(login, tok, w)));
}

async function fetchContributionTotal(login: string, tok: PoolToken, w: ContributionWindow): Promise<number> {
try {
const { user } = await gql<ContributionTotalsNode>(contributionTotalQuery(w.from, w.to), login, tok);
return user?.c
? user.c.totalCommitContributions +
user.c.totalIssueContributions +
user.c.totalPullRequestContributions +
user.c.totalPullRequestReviewContributions +
user.c.restrictedContributionsCount
: 0;
} catch (e) {
const err = e as GithubError;
if (err.type === "resource") {
const split = splitWindow(w);
if (split) return (await fetchContributionTotal(login, tok, split[0])) + (await fetchContributionTotal(login, tok, split[1]));
return 0;
}
if (err.type === "network") return 0;
throw e;
}
}

async function fetchLifetimeTotals(login: string, tok: PoolToken, createdYear: number, currentYear: number, now: Date): Promise<number> {
const years: ContributionWindow[] = [];
for (let y = Math.max(createdYear, GITHUB_EPOCH_YEAR); y <= currentYear; y++) {
years.push({
from: `${y}-01-01T00:00:00.000Z`,
to: y === currentYear ? now.toISOString() : `${y}-12-31T23:59:59.999Z`,
});
}
const totals = await mapLimit(years, RESOURCE_WINDOW_CONCURRENCY, (w) => fetchContributionTotal(login, tok, w));
return totals.reduce((a, b) => a + b, 0);
}

async function fetchResourceLimitedProfile(login: string, tok: PoolToken, now: Date): Promise<RawPayload> {
const { user } = await gql<ProfileBasicsNode>(profileBasicsQuery(), login, tok);
if (!user) return fail("notfound", "No GitHub user by that name.");

const createdYear = new Date(user.createdAt).getUTCFullYear();
const [recent, lifetimeContributions] = await Promise.all([
fetchWindowedRecent(login, tok, now),
fetchLifetimeTotals(login, tok, createdYear, now.getUTCFullYear(), now),
]);

return normalize({ ...user, recent }, lifetimeContributions);
}

export async function fetchProfile(username: string, now = new Date()): Promise<RawPayload> {
const login = username.trim().replace(/^@/, "");
if (!VALID.test(login)) return fail("invalid", "That doesn't look like a GitHub username.");
Expand All @@ -305,13 +551,19 @@ export async function fetchProfile(username: string, now = new Date()): Promise<
try {
({ user } = await gql<UserNode>(profileQuery(), login, tok));
} catch (e) {
if ((e as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok, now);
// Only a rate limit is cured by another token (a timeout or 5xx would just
// fail again) — retry once on the healthiest token, if the pool has one.
if ((e as GithubError).type !== "ratelimit" || pool.length < 2) throw e;
const fallback = await pickFailover(tok.idx, pool);
if (!fallback) throw e; // every other token is benched too
tok = fallback;
({ user } = await gql<UserNode>(profileQuery(), login, tok));
try {
({ user } = await gql<UserNode>(profileQuery(), login, tok));
} catch (retryErr) {
if ((retryErr as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok, now);
throw retryErr;
}
}
if (!user) return fail("notfound", "No GitHub user by that name.");

Expand Down
Loading