Skip to content

Commit 401caef

Browse files
andriypolanskiandriy-polanskicursoragent
authored
fix(github): keep public profile when repos JSON parse fails (#8891) (#8932)
Isolate repos-list parse errors from the user fetch so a truncated body degrades topLanguages to [] instead of discarding the whole profile. Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4dfe15d commit 401caef

2 files changed

Lines changed: 87 additions & 5 deletions

File tree

src/github/public.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,30 @@ export async function fetchPublicContributorProfile(login: string, env?: Pick<En
9494
]);
9595
if (!userResponse.ok) throw new Error(`GitHub user lookup failed (${userResponse.status})`);
9696
const user = (await userResponse.json()) as GitHubUserResponse;
97-
const repos: GitHubRepoResponse[] = reposResponse.ok ? ((await reposResponse.json()) as GitHubRepoResponse[]) : [];
98-
let linkHeader = reposResponse.ok ? reposResponse.headers.get("link") : null;
97+
// Isolate repos-list fetch/parse from the user profile (#8891): an HTTP failure already degraded to
98+
// `repos = []` while keeping `user`; a truncated/invalid JSON body previously escaped to the outer catch
99+
// and discarded the successfully-parsed user as `source: "unavailable"`.
100+
let repos: GitHubRepoResponse[] = [];
101+
let linkHeader: string | null = null;
102+
if (reposResponse.ok) {
103+
try {
104+
repos = (await reposResponse.json()) as GitHubRepoResponse[];
105+
linkHeader = reposResponse.headers.get("link");
106+
} catch {
107+
repos = [];
108+
linkHeader = null;
109+
}
110+
}
99111
for (let page = 2; page <= MAX_REPO_PAGES && linkHeader?.includes('rel="next"'); page += 1) {
100112
const nextResponse = await fetchWithTimeout(`https://api.github.com/users/${safeLogin}/repos?per_page=100&sort=updated&page=${page}`);
101113
if (!nextResponse.ok) break;
102-
const batch = (await nextResponse.json()) as GitHubRepoResponse[];
103-
repos.push(...batch);
104-
linkHeader = nextResponse.headers.get("link");
114+
try {
115+
const batch = (await nextResponse.json()) as GitHubRepoResponse[];
116+
repos.push(...batch);
117+
linkHeader = nextResponse.headers.get("link");
118+
} catch {
119+
break;
120+
}
105121
}
106122
const languageCounts = new Map<string, number>();
107123
for (const repo of repos) {

test/unit/adapters.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,72 @@ describe("small adapters and normalizers", () => {
179179
expect(profile.topLanguages).toContain("Go");
180180
});
181181

182+
it("REGRESSION (#8891): a repos-list JSON-parse failure keeps the fetched user and only clears topLanguages", async () => {
183+
// HTTP non-ok already degraded this way; a truncated/invalid JSON body after reposResponse.ok previously
184+
// discarded the whole profile as source: "unavailable".
185+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
186+
const url = input.toString();
187+
if (url.endsWith("/users/parsefail")) {
188+
return Response.json({
189+
login: "parsefail",
190+
name: "Parse Fail",
191+
bio: "still here",
192+
company: "Acme",
193+
public_repos: 4,
194+
followers: 2,
195+
created_at: "2026-01-01T00:00:00Z",
196+
updated_at: "2026-02-01T00:00:00Z",
197+
});
198+
}
199+
if (url.includes("/users/parsefail/repos?")) {
200+
return new Response("{not-json", {
201+
status: 200,
202+
headers: { "content-type": "application/json" },
203+
});
204+
}
205+
return new Response("not found", { status: 404 });
206+
});
207+
208+
const profile = await fetchPublicContributorProfile("parsefail");
209+
expect(profile).toMatchObject({
210+
login: "parsefail",
211+
name: "Parse Fail",
212+
bio: "still here",
213+
company: "Acme",
214+
publicRepos: 4,
215+
followers: 2,
216+
createdAt: "2026-01-01T00:00:00Z",
217+
updatedAt: "2026-02-01T00:00:00Z",
218+
topLanguages: [],
219+
source: "github",
220+
});
221+
expect(profile.source).not.toBe("unavailable");
222+
});
223+
224+
it("stops paginating when a later repos page returns unparseable JSON (#8891)", async () => {
225+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
226+
const url = input.toString();
227+
if (url.endsWith("/users/pageparse")) return Response.json({ login: "pageparse", public_repos: 200 });
228+
if (url.includes("/pageparse/repos?") && !url.includes("page=2")) {
229+
return Response.json(
230+
Array.from({ length: 100 }, () => ({ language: "Go" })),
231+
{ headers: { link: '<https://api.github.com/users/pageparse/repos?page=2>; rel="next"' } },
232+
);
233+
}
234+
if (url.includes("/pageparse/repos?") && url.includes("page=2")) {
235+
return new Response("{truncated", {
236+
status: 200,
237+
headers: { "content-type": "application/json" },
238+
});
239+
}
240+
return new Response("not found", { status: 404 });
241+
});
242+
243+
const profile = await fetchPublicContributorProfile("pageparse");
244+
expect(profile.source).toBe("github");
245+
expect(profile.topLanguages).toContain("Go");
246+
});
247+
182248
it("authenticates public profile requests with GITHUB_PUBLIC_TOKEN to lift the rate ceiling (#790)", async () => {
183249
const authHeaders: Array<string | null> = [];
184250
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {

0 commit comments

Comments
 (0)