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
77 changes: 70 additions & 7 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface RawPayload {
createdAt: string;
followers: number;
publicRepos: number;
repos: RawRepo[]; // owned, non-fork, top 100 by stars
repos: RawRepo[]; // owned, non-fork, top REPO_PAGE_SIZE * REPO_MAX_PAGES by stars (see fetchRepoPages)
recentCommits: number; // the "recent" fields cover the last 365 days
recentPRs: number;
recentReviews: number;
Expand All @@ -59,6 +59,14 @@ 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 REPO_PAGE_SIZE = 100; // GraphQL's max page size.
// Extra pages beyond the first, fetched only when the profile has more than
// REPO_PAGE_SIZE owned repos. Capped (not "fetch everything") for the same
// reason lifetime contributions are batched: an unbounded loop over a 1,000+
// repo account risks the resolver timeout. 3 total pages (300 repos) covers
// the overwhelming majority of profiles while staying cheap for the common
// case (accounts under 100 repos do zero extra requests).
const REPO_MAX_PAGES = 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 @@ -81,6 +89,7 @@ interface UserNode {
followers: { totalCount: number };
repositories: {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: {
stargazerCount: number;
primaryLanguage: { name: string } | null;
Expand All @@ -98,6 +107,13 @@ interface UserNode {
};
}

type RepoNode = UserNode["repositories"]["nodes"][number];

interface RepoPage {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: RepoNode[];
}

interface YearContrib {
totalCommitContributions: number;
totalIssueContributions: number;
Expand Down Expand Up @@ -182,8 +198,9 @@ function profileQuery(): string {
location
createdAt
followers { totalCount }
repositories(ownerAffiliations: OWNER, isFork: false, first: 100, orderBy: { field: STARGAZERS, direction: DESC }) {
repositories(ownerAffiliations: OWNER, isFork: false, first: ${REPO_PAGE_SIZE}, orderBy: { field: STARGAZERS, direction: DESC }) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { stargazerCount primaryLanguage { name } createdAt pushedAt }
}
recent: contributionsCollection {
Expand All @@ -198,6 +215,45 @@ function profileQuery(): string {
}`;
}

function repoPageQuery(cursor: string): string {
return `
query RepoPage($login: String!) {
user(login: $login) {
repositories(
ownerAffiliations: OWNER
isFork: false
first: ${REPO_PAGE_SIZE}
after: "${cursor}"
orderBy: { field: STARGAZERS, direction: DESC }
) {
pageInfo { hasNextPage endCursor }
nodes { stargazerCount primaryLanguage { name } createdAt pushedAt }
}
}
}`;
}

// Follows the first repo page up to REPO_MAX_PAGES total, for profiles with
// more owned repos than fit on one page. Best-effort like fetchLifetime: a
// failed extra page just means fewer (still-real) repos counted, not a
// failed scout — the profile query already succeeded.
async function fetchExtraRepoPages(login: string, tok: PoolToken, startCursor: string): Promise<RepoNode[]> {
const extra: RepoNode[] = [];
let cursor = startCursor;
for (let page = 1; page < REPO_MAX_PAGES; page++) {
try {
const { user } = await gql<{ repositories: RepoPage }>(repoPageQuery(cursor), login, tok);
if (!user) break;
extra.push(...user.repositories.nodes);
if (!user.repositories.pageInfo.hasNextPage || !user.repositories.pageInfo.endCursor) break;
cursor = user.repositories.pageInfo.endCursor;
} catch {
break;
}
}
return extra;
}

function lifetimeQuery(years: number[], currentYear: number, nowIso: string): string {
const aliases = years
.map((y) => {
Expand Down Expand Up @@ -286,13 +342,20 @@ export async function fetchProfile(username: string, now = new Date()): Promise<
if (!user) return fail("notfound", "No GitHub user by that name.");

const createdYear = new Date(user.createdAt).getUTCFullYear();
const lifetimeContributions = await fetchLifetime(login, tok, createdYear, now.getUTCFullYear(), now.toISOString());
const extraReposPromise =
user.repositories.pageInfo?.hasNextPage && user.repositories.pageInfo.endCursor
? fetchExtraRepoPages(login, tok, user.repositories.pageInfo.endCursor)
: Promise.resolve<RepoNode[]>([]);
const [lifetimeContributions, extraRepos] = await Promise.all([
fetchLifetime(login, tok, createdYear, now.getUTCFullYear(), now.toISOString()),
extraReposPromise,
]);

return normalize(user, lifetimeContributions);
return normalize(user, lifetimeContributions, extraRepos);
}

function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
const repos: RawRepo[] = user.repositories.nodes.map((n) => ({
function normalize(user: UserNode, lifetimeContributions: number, extraRepos: RepoNode[] = []): RawPayload {
const repos: RawRepo[] = [...user.repositories.nodes, ...extraRepos].map((n) => ({
stars: n.stargazerCount ?? 0,
language: n.primaryLanguage?.name ?? null,
createdAt: n.createdAt,
Expand Down Expand Up @@ -321,4 +384,4 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
recentActiveDays,
lifetimeContributions,
};
}
}
57 changes: 55 additions & 2 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const USER = {
location: null,
createdAt: "2023-02-01T00:00:00Z",
followers: { totalCount: 1 },
repositories: { totalCount: 0, nodes: [] },
repositories: { totalCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
recent: {
totalCommitContributions: 1,
totalPullRequestContributions: 0,
Expand Down Expand Up @@ -192,6 +192,59 @@ describe("fetchProfile username validation", () => {
});
});

describe("fetchProfile repo pagination", () => {
const repoNode = (stars: number) => ({
stargazerCount: stars,
primaryLanguage: { name: "TypeScript" },
createdAt: "2023-02-01T00:00:00Z",
pushedAt: "2023-02-01T00:00:00Z",
});

const PAGE1_USER = {
...USER,
repositories: {
totalCount: 150,
pageInfo: { hasNextPage: true, endCursor: "cursor1" },
nodes: [repoNode(100)],
},
};

it("follows an extra page and merges its repos onto the first page's", async () => {
scriptFetch((_t, body) => {
if (body.includes("query Profile")) return ok({ data: { user: PAGE1_USER } });
if (body.includes("query RepoPage")) {
return ok({
data: {
user: {
repositories: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [repoNode(50)],
},
},
},
});
}
return ok({ data: { user: {} } }); // lifetime
});

const profile = await fetchProfile(LOGIN, NOW);
expect(profile.repos.map((r) => r.stars).sort((a, b) => a - b)).toEqual([50, 100]);
const repoPageCall = calls.find((c) => c.body.includes("query RepoPage"));
expect(repoPageCall?.body).toContain("cursor1");
});

it("degrades to just the first page when the extra page fails (best-effort, scout still succeeds)", async () => {
scriptFetch((_t, body) => {
if (body.includes("query Profile")) return ok({ data: { user: PAGE1_USER } });
if (body.includes("query RepoPage")) return rateLimited();
return ok({ data: { user: {} } }); // lifetime
});

const profile = await fetchProfile(LOGIN, NOW);
expect(profile.repos.map((r) => r.stars)).toEqual([100]);
});
});

describe("fetchProfile request timeout", () => {
it("aborts a hung request at 8s (under Vercel's ~10s cap) and fails as a network error", async () => {
vi.useFakeTimers();
Expand Down Expand Up @@ -220,4 +273,4 @@ describe("fetchProfile request timeout", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
});
});
});