diff --git a/apps/mesh/src/shared/github-clone-info.test.ts b/apps/mesh/src/shared/github-clone-info.test.ts new file mode 100644 index 0000000000..66ce08714f --- /dev/null +++ b/apps/mesh/src/shared/github-clone-info.test.ts @@ -0,0 +1,199 @@ +import { + describe, + it, + expect, + vi, + mock, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from "bun:test"; +import { + createTestDatabase, + closeTestDatabase, + type TestDatabase, +} from "../database/test-db"; +import { + createTestSchema, + seedCommonTestFixtures, +} from "../storage/test-helpers"; +import { CredentialVault } from "../encryption/credential-vault"; +import { DownstreamTokenStorage } from "../storage/downstream-token"; +import { ConnectionStorage } from "../storage/connection"; +import type { TokenRefreshResult } from "@/oauth/refresh-access-token"; + +const mockRefreshAccessToken = + vi.fn< + ( + ...args: Parameters< + typeof import("@/oauth/refresh-access-token").refreshAccessToken + > + ) => Promise + >(); +mock.module("@/oauth/refresh-access-token", () => ({ + refreshAccessToken: mockRefreshAccessToken, +})); + +const { buildCloneInfo } = await import("./github-clone-info"); + +describe("buildCloneInfo", () => { + let database: TestDatabase; + let vault: CredentialVault; + let tokenStorage: DownstreamTokenStorage; + const connectionId = "conn_github_clone_test"; + const originalFetch = globalThis.fetch; + let fetchCalls: string[] = []; + + beforeAll(async () => { + database = await createTestDatabase(); + await createTestSchema(database.db); + await seedCommonTestFixtures(database.db); + + vault = new CredentialVault(CredentialVault.generateKey()); + tokenStorage = new DownstreamTokenStorage(database.db, vault); + + const connectionStorage = new ConnectionStorage(database.db, vault); + await connectionStorage.create({ + id: connectionId, + organization_id: "org_123", + created_by: "user_1", + title: "GitHub", + connection_type: "HTTP", + connection_url: "https://mcp.example.com/github", + connection_token: null, + tools: null, + }); + }); + + afterAll(async () => { + await closeTestDatabase(database); + globalThis.fetch = originalFetch; + }); + + beforeEach(async () => { + fetchCalls = []; + mockRefreshAccessToken.mockReset(); + await tokenStorage.delete(connectionId); + globalThis.fetch = (async ( + input: RequestInfo | URL, + _init?: RequestInit, + ) => { + const url = typeof input === "string" ? input : input.toString(); + fetchCalls.push(url); + throw new Error(`buildCloneInfo must not fetch — got ${url}`); + }) as unknown as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns clone URL + bot identity without making any GitHub API call", async () => { + await tokenStorage.upsert({ + connectionId, + accessToken: "install-token-abc", + refreshToken: "rt", + scope: "repo", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + clientId: "cid", + clientSecret: "csecret", + tokenEndpoint: "https://github.com/login/oauth/access_token", + }); + + const info = await buildCloneInfo( + connectionId, + "octocat", + "hello-world", + database.db, + vault, + ); + + expect(info.cloneUrl).toBe( + "https://x-access-token:install-token-abc@github.com/octocat/hello-world.git", + ); + expect(info.gitUserName).toBe("mcp-github[bot]"); + expect(info.gitUserEmail).toBe("mcp-github[bot]@users.noreply.github.com"); + expect(fetchCalls).toEqual([]); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it("proactively refreshes an expired token and embeds the fresh one", async () => { + await tokenStorage.upsert({ + connectionId, + accessToken: "stale-token", + refreshToken: "rt", + scope: "repo", + expiresAt: new Date(Date.now() - 60 * 1000), + clientId: "cid", + clientSecret: "csecret", + tokenEndpoint: "https://github.com/login/oauth/access_token", + }); + + mockRefreshAccessToken.mockResolvedValueOnce({ + success: true, + accessToken: "fresh-token", + refreshToken: "rt2", + expiresIn: 3600, + scope: "repo", + }); + + const info = await buildCloneInfo( + connectionId, + "octocat", + "hello-world", + database.db, + vault, + ); + + expect(info.cloneUrl).toContain("x-access-token:fresh-token@"); + expect(info.gitUserName).toBe("mcp-github[bot]"); + expect(info.gitUserEmail).toBe("mcp-github[bot]@users.noreply.github.com"); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(fetchCalls).toEqual([]); + }); + + it("throws RECONNECT_ERROR when proactive refresh fails", async () => { + await tokenStorage.upsert({ + connectionId, + accessToken: "stale-token", + refreshToken: "rt", + scope: "repo", + expiresAt: new Date(Date.now() - 60 * 1000), + clientId: "cid", + clientSecret: "csecret", + tokenEndpoint: "https://github.com/login/oauth/access_token", + }); + + mockRefreshAccessToken.mockResolvedValueOnce({ + success: false, + error: "invalid_grant", + }); + + await expect( + buildCloneInfo( + connectionId, + "octocat", + "hello-world", + database.db, + vault, + ), + ).rejects.toThrow(/reconnect/i); + + expect(fetchCalls).toEqual([]); + }); + + it("throws when no token is stored for the connection", async () => { + await expect( + buildCloneInfo( + connectionId, + "octocat", + "hello-world", + database.db, + vault, + ), + ).rejects.toThrow(/No GitHub token found/i); + + expect(fetchCalls).toEqual([]); + }); +}); diff --git a/apps/mesh/src/shared/github-clone-info.ts b/apps/mesh/src/shared/github-clone-info.ts index 1352a6902e..4bb2769319 100644 --- a/apps/mesh/src/shared/github-clone-info.ts +++ b/apps/mesh/src/shared/github-clone-info.ts @@ -1,7 +1,7 @@ /** - * Authenticated clone URL + git identity from a connection's OAuth token. - * Falls back to generic defaults on /user failure so callers never block - * on a flaky upstream. + * Authenticated clone URL + bot git identity from a connection's downstream + * App installation token. Makes no GitHub API call — the committer is the + * Mesh GitHub App bot. */ import type { Kysely } from "kysely"; @@ -15,6 +15,9 @@ import { refreshAndStore, } from "../oauth/token-refresh"; +const MCP_GITHUB_BOT_NAME = "mcp-github[bot]"; +const MCP_GITHUB_BOT_EMAIL = "mcp-github[bot]@users.noreply.github.com"; + export interface GitHubCloneInfo { cloneUrl: string; gitUserName: string; @@ -38,7 +41,6 @@ export async function buildCloneInfo( let accessToken = token.accessToken; - // Proactive refresh before baking into the clone URL. Mirrors GITHUB_LIST_USER_ORGS. if ( canRefresh(token) && tokenStorage.isExpired(token, PROACTIVE_REFRESH_BUFFER_MS) @@ -52,27 +54,9 @@ export async function buildCloneInfo( const cloneUrl = `https://x-access-token:${accessToken}@github.com/${owner}/${name}.git`; - let gitUserName = "Deco Studio"; - let gitUserEmail = "studio@deco.cx"; - try { - const res = await fetch("https://api.github.com/user", { - headers: { - Authorization: `token ${accessToken}`, - Accept: "application/vnd.github+json", - }, - }); - if (res.ok) { - const user = (await res.json()) as { - name?: string | null; - login: string; - email?: string | null; - }; - gitUserName = user.name || user.login; - gitUserEmail = user.email || `${user.login}@users.noreply.github.com`; - } - } catch { - // Fallback to defaults — don't block the caller. - } - - return { cloneUrl, gitUserName, gitUserEmail }; + return { + cloneUrl, + gitUserName: MCP_GITHUB_BOT_NAME, + gitUserEmail: MCP_GITHUB_BOT_EMAIL, + }; } diff --git a/apps/mesh/src/tools/github/list-user-orgs.test.ts b/apps/mesh/src/tools/github/list-user-orgs.test.ts index b737f48aa2..6e90b18bc1 100644 --- a/apps/mesh/src/tools/github/list-user-orgs.test.ts +++ b/apps/mesh/src/tools/github/list-user-orgs.test.ts @@ -94,9 +94,13 @@ describe("GITHUB_LIST_USER_ORGS", () => { return [() => (globalThis.fetch = originalFetch)]; }; - const githubOkResponse = (installations: unknown[]) => + const installationReposResponse = ( + repositories: Array<{ + owner: { login: string; avatar_url: string; type: string }; + }>, + ) => new Response( - JSON.stringify({ installations, total_count: installations.length }), + JSON.stringify({ repositories, total_count: repositories.length }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -212,15 +216,13 @@ describe("GITHUB_LIST_USER_ORGS", () => { }); installHandler(() => - githubOkResponse([ + installationReposResponse([ { - id: 42, - account: { + owner: { login: "octocat", avatar_url: "https://example.com/a.png", type: "User", }, - app_slug: "mcp-github", }, ]), ); @@ -229,7 +231,7 @@ describe("GITHUB_LIST_USER_ORGS", () => { expect(result.installations).toEqual([ { - installationId: 42, + installationId: 0, login: "octocat", avatarUrl: "https://example.com/a.png", type: "User", @@ -237,6 +239,7 @@ describe("GITHUB_LIST_USER_ORGS", () => { ]); expect(result.appSlug).toBe("mcp-github"); expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.url).toContain("/installation/repositories"); expect(fetchCalls[0]?.headers["authorization"]).toBe("Bearer valid-token"); expect(mockRefreshAccessToken).not.toHaveBeenCalled(); }); @@ -261,7 +264,7 @@ describe("GITHUB_LIST_USER_ORGS", () => { scope: "repo", }); - installHandler(() => githubOkResponse([])); + installHandler(() => installationReposResponse([])); const result = await GITHUB_LIST_USER_ORGS.execute({ connectionId }, ctx); @@ -325,15 +328,13 @@ describe("GITHUB_LIST_USER_ORGS", () => { installHandler( () => github401(), () => - githubOkResponse([ + installationReposResponse([ { - id: 7, - account: { + owner: { login: "acme", avatar_url: "https://example.com/b.png", type: "Organization", }, - app_slug: "mcp-github", }, ]), ); @@ -445,71 +446,8 @@ describe("GITHUB_LIST_USER_ORGS", () => { await expect( GITHUB_LIST_USER_ORGS.execute({ connectionId }, ctx), - ).rejects.toThrow(/500/); + ).rejects.toThrow(/installation\/repositories.*500/i); expect(mockRefreshAccessToken).not.toHaveBeenCalled(); }); - - it("reactively refreshes on 401 that surfaces on a later page", async () => { - await tokenStorage.upsert({ - connectionId, - accessToken: "seemingly-valid-token", - refreshToken: "rt", - scope: "repo", - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - clientId: "cid", - clientSecret: "csecret", - tokenEndpoint: "https://github.com/login/oauth/access_token", - }); - - mockRefreshAccessToken.mockResolvedValueOnce({ - success: true, - accessToken: "fresh-token", - refreshToken: "rt2", - expiresIn: 3600, - scope: "repo", - }); - - const fullPage = Array.from({ length: 100 }, (_, i) => ({ - id: i + 1, - account: { - login: `org-${i + 1}`, - avatar_url: `https://example.com/${i + 1}.png`, - type: "Organization", - }, - app_slug: "mcp-github", - })); - - installHandler( - () => githubOkResponse(fullPage), - () => github401(), - () => - githubOkResponse([ - { - id: 101, - account: { - login: "late", - avatar_url: "https://example.com/late.png", - type: "Organization", - }, - app_slug: "mcp-github", - }, - ]), - ); - - const result = await GITHUB_LIST_USER_ORGS.execute({ connectionId }, ctx); - - expect(result.installations).toHaveLength(101); - expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); - expect(fetchCalls).toHaveLength(3); - expect(fetchCalls[0]?.headers["authorization"]).toBe( - "Bearer seemingly-valid-token", - ); - expect(fetchCalls[1]?.headers["authorization"]).toBe( - "Bearer seemingly-valid-token", - ); - expect(fetchCalls[2]?.headers["authorization"]).toBe("Bearer fresh-token"); - expect(fetchCalls[1]?.url).toContain("page=2"); - expect(fetchCalls[2]?.url).toContain("page=2"); - }); }); diff --git a/apps/mesh/src/tools/github/list-user-orgs.ts b/apps/mesh/src/tools/github/list-user-orgs.ts index ecedd74f09..05bbd1423a 100644 --- a/apps/mesh/src/tools/github/list-user-orgs.ts +++ b/apps/mesh/src/tools/github/list-user-orgs.ts @@ -9,11 +9,12 @@ import { import { DownstreamTokenStorage } from "../../storage/downstream-token"; const GITHUB_API = "https://api.github.com"; +const MCP_GITHUB_APP_SLUG = "mcp-github"; export const GITHUB_LIST_USER_ORGS = defineTool({ name: "GITHUB_LIST_USER_ORGS", description: - "List GitHub App installations (orgs/accounts) accessible to the authenticated user.", + "Return the GitHub App installation summary (account login + avatar) for the connected mcp-github installation. Output shape is preserved for backwards compatibility with the repo-picker UI.", annotations: { title: "List GitHub User Orgs", readOnlyHint: true, @@ -49,8 +50,6 @@ export const GITHUB_LIST_USER_ORGS = defineTool({ let accessToken = token.accessToken; - // Proactive refresh: if the cached token is (about to be) expired and we - // have refresh credentials, swap it for a fresh one before hitting GitHub. if ( canRefresh(token) && tokenStorage.isExpired(token, PROACTIVE_REFRESH_BUFFER_MS) @@ -63,84 +62,61 @@ export const GITHUB_LIST_USER_ORGS = defineTool({ token = (await tokenStorage.get(input.connectionId)) ?? token; } - const installations: Array<{ - installationId: number; - login: string; - avatarUrl: string; - type: string; - }> = []; - - let appSlug: string | undefined; - let page = 1; - const perPage = 100; - - const fetchPage = async (token: string) => - fetch( - `${GITHUB_API}/user/installations?per_page=${perPage}&page=${page}`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, + const fetchRepos = async (token: string) => + fetch(`${GITHUB_API}/installation/repositories?per_page=1`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", }, - ); + }); - while (true) { - let res = await fetchPage(accessToken); + let res = await fetchRepos(accessToken); - // Reactive refresh: GitHub rejected the token (revoked, rotated, or - // expired before our clock said so). Try one refresh + retry before - // giving up. Applies to any page — a token can be invalidated - // between pages of a long installations listing. + if (res.status === 401) { + const current = await tokenStorage.get(input.connectionId); + if (!current || !canRefresh(current)) { + await tokenStorage.delete(input.connectionId); + throw new Error(RECONNECT_ERROR); + } + const refreshed = await refreshAndStore(current, tokenStorage); + if (!refreshed) { + throw new Error(RECONNECT_ERROR); + } + accessToken = refreshed; + res = await fetchRepos(accessToken); if (res.status === 401) { - const current = await tokenStorage.get(input.connectionId); - if (!current || !canRefresh(current)) { - await tokenStorage.delete(input.connectionId); - throw new Error(RECONNECT_ERROR); - } - const refreshed = await refreshAndStore(current, tokenStorage); - if (!refreshed) { - throw new Error(RECONNECT_ERROR); - } - accessToken = refreshed; - res = await fetchPage(accessToken); - if (res.status === 401) { - await tokenStorage.delete(input.connectionId); - throw new Error(RECONNECT_ERROR); - } + await tokenStorage.delete(input.connectionId); + throw new Error(RECONNECT_ERROR); } + } - if (!res.ok) { - throw new Error(`GitHub /user/installations failed: ${res.status}`); - } + if (!res.ok) { + throw new Error( + `GitHub /installation/repositories failed: ${res.status}`, + ); + } - const data = (await res.json()) as { - installations: Array<{ - id: number; - account: { login: string; avatar_url: string; type: string }; - app_slug?: string; - app?: { slug?: string }; - }>; - total_count: number; - }; + const data = (await res.json()) as { + repositories: Array<{ + owner: { login: string; avatar_url: string; type: string }; + }>; + total_count: number; + }; - for (const inst of data.installations) { - if (!appSlug) { - appSlug = inst.app_slug ?? inst.app?.slug; - } - installations.push({ - installationId: inst.id, - login: inst.account.login, - avatarUrl: inst.account.avatar_url, - type: inst.account.type, - }); - } + const owner = data.repositories[0]?.owner; - if (data.installations.length < perPage) break; - page++; - } + const installations = owner + ? [ + { + installationId: 0, + login: owner.login, + avatarUrl: owner.avatar_url, + type: owner.type, + }, + ] + : []; - return { installations, ...(appSlug ? { appSlug } : {}) }; + return { installations, appSlug: MCP_GITHUB_APP_SLUG }; }, });