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
26 changes: 21 additions & 5 deletions src/github/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,30 @@ export async function fetchPublicContributorProfile(login: string, env?: Pick<En
]);
if (!userResponse.ok) throw new Error(`GitHub user lookup failed (${userResponse.status})`);
const user = (await userResponse.json()) as GitHubUserResponse;
const repos: GitHubRepoResponse[] = reposResponse.ok ? ((await reposResponse.json()) as GitHubRepoResponse[]) : [];
let linkHeader = reposResponse.ok ? reposResponse.headers.get("link") : null;
// Isolate repos-list fetch/parse from the user profile (#8891): an HTTP failure already degraded to
// `repos = []` while keeping `user`; a truncated/invalid JSON body previously escaped to the outer catch
// and discarded the successfully-parsed user as `source: "unavailable"`.
let repos: GitHubRepoResponse[] = [];
let linkHeader: string | null = null;
if (reposResponse.ok) {
try {
repos = (await reposResponse.json()) as GitHubRepoResponse[];
linkHeader = reposResponse.headers.get("link");
} catch {
repos = [];
linkHeader = null;
}
}
for (let page = 2; page <= MAX_REPO_PAGES && linkHeader?.includes('rel="next"'); page += 1) {
const nextResponse = await fetchWithTimeout(`https://github.com/ghapi/users/${safeLogin}/repos?per_page=100&sort=updated&page=${page}`);
if (!nextResponse.ok) break;
const batch = (await nextResponse.json()) as GitHubRepoResponse[];
repos.push(...batch);
linkHeader = nextResponse.headers.get("link");
try {
const batch = (await nextResponse.json()) as GitHubRepoResponse[];
repos.push(...batch);
linkHeader = nextResponse.headers.get("link");
} catch {
break;
}
}
const languageCounts = new Map<string, number>();
for (const repo of repos) {
Expand Down
66 changes: 66 additions & 0 deletions test/unit/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,72 @@ describe("small adapters and normalizers", () => {
expect(profile.topLanguages).toContain("Go");
});

it("REGRESSION (#8891): a repos-list JSON-parse failure keeps the fetched user and only clears topLanguages", async () => {
// HTTP non-ok already degraded this way; a truncated/invalid JSON body after reposResponse.ok previously
// discarded the whole profile as source: "unavailable".
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.endsWith("/users/parsefail")) {
return Response.json({
login: "parsefail",
name: "Parse Fail",
bio: "still here",
company: "Acme",
public_repos: 4,
followers: 2,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-02-01T00:00:00Z",
});
}
if (url.includes("/users/parsefail/repos?")) {
return new Response("{not-json", {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
});

const profile = await fetchPublicContributorProfile("parsefail");
expect(profile).toMatchObject({
login: "parsefail",
name: "Parse Fail",
bio: "still here",
company: "Acme",
publicRepos: 4,
followers: 2,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
topLanguages: [],
source: "github",
});
expect(profile.source).not.toBe("unavailable");
});

it("stops paginating when a later repos page returns unparseable JSON (#8891)", async () => {
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.endsWith("/users/pageparse")) return Response.json({ login: "pageparse", public_repos: 200 });
if (url.includes("/pageparse/repos?") && !url.includes("page=2")) {
return Response.json(
Array.from({ length: 100 }, () => ({ language: "Go" })),
{ headers: { link: '<https://github.com/ghapi/users/pageparse/repos?page=2>; rel="next"' } },
);
}
if (url.includes("/pageparse/repos?") && url.includes("page=2")) {
return new Response("{truncated", {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
});

const profile = await fetchPublicContributorProfile("pageparse");
expect(profile.source).toBe("github");
expect(profile.topLanguages).toContain("Go");
});

it("authenticates public profile requests with GITHUB_PUBLIC_TOKEN to lift the rate ceiling (#790)", async () => {
const authHeaders: Array<string | null> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
Expand Down