From 5388ca7b419bfee38908f71d2814d953490261d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 25 Jun 2026 16:07:30 +0800 Subject: [PATCH] fix: authenticate GitHub repo discovery fix: authenticate GitHub repo discovery --- convex/githubImport.test.ts | 69 +++++++++++++++++++++++++++++++++++++ convex/githubImport.ts | 5 ++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/convex/githubImport.test.ts b/convex/githubImport.test.ts index ed889acd..2177c79c 100644 --- a/convex/githubImport.test.ts +++ b/convex/githubImport.test.ts @@ -599,6 +599,75 @@ describe("githubImport", () => { expect(fetchMock.mock.calls[3]?.[1]?.headers).not.toHaveProperty("Authorization"); }); + it("uses the GitHub App token for bounded repo discovery", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + process.env.GITHUB_APP_ID = "3536245"; + process.env.GITHUB_APP_INSTALLATION_ID = "987654"; + process.env.GITHUB_APP_PRIVATE_KEY = privateKey; + + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + token: "ghs_app_token", + expires_at: "2027-02-02T13:00:00Z", + }), + ) + .mockResolvedValueOnce( + Response.json({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + ) + .mockResolvedValueOnce( + Response.json([ + { + name: "public-skill", + full_name: "vyctorbrzezowski/public-skill", + html_url: "https://github.com/vyctorbrzezowski/public-skill", + default_branch: "main", + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + ]), + ) + .mockResolvedValueOnce( + Response.json({ + truncated: false, + tree: [{ path: "SKILL.md", type: "blob" }], + }), + ); + + const result = await __test.listOwnedPublicGitHubReposForUser( + ctx as never, + "users:1" as never, + { page: 1, perPage: 30 }, + fetchMock as never, + ); + + expect(result.repos).toEqual([ + expect.objectContaining({ + repoFullName: "vyctorbrzezowski/public-skill", + skillPath: "SKILL.md", + }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[2]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer ghs_app_token" }), + }), + ); + }); + it("falls back to the repo archive when GitHub truncates the discovery tree", async () => { const ctx = { runQuery: vi.fn().mockResolvedValue("123"), diff --git a/convex/githubImport.ts b/convex/githubImport.ts index 421760e1..cdad4129 100644 --- a/convex/githubImport.ts +++ b/convex/githubImport.ts @@ -432,7 +432,10 @@ async function listOwnedPublicGitHubReposForUser( url.searchParams.set("per_page", String(fallbackPerPage)); url.searchParams.set("page", String(page)); - const response = await fetchGitHubApi(url.toString(), fetcher, undefined, false); + // Public repo discovery still benefits from the installation token's + // higher rate limit. If the app cannot access this endpoint, fetchGitHubApi + // retries with the configured token or anonymous public access. + const response = await fetchGitHubApi(url.toString(), fetcher); if (!response.ok) throwGitHubApiError(response.status); const payload = (await response.json()) as unknown;