From ba607d34f10eee520b073bc81173e0d85629d481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Mon, 5 Oct 2026 02:01:01 +0300 Subject: [PATCH 1/3] [eas-cli] Add GitHub repository connection commands --- CHANGELOG.md | 1 + packages/eas-cli/README.md | 49 +++ packages/eas-cli/src/commandUtils/github.ts | 34 ++ .../github/__tests__/connect.test.ts | 301 ++++++++++++++++++ .../github/__tests__/status.test.ts | 64 ++++ .../commands/integrations/github/connect.ts | 151 +++++++++ .../commands/integrations/github/status.ts | 33 ++ packages/eas-cli/src/graphql/generated.ts | 45 +++ .../mutations/GitHubRepositoryMutation.ts | 100 ++++++ .../graphql/queries/GitHubRepositoryQuery.ts | 163 ++++++++++ .../__tests__/GitHubRepositoryQuery-test.ts | 100 ++++++ 11 files changed, 1041 insertions(+) create mode 100644 packages/eas-cli/src/commandUtils/github.ts create mode 100644 packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts create mode 100644 packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts create mode 100644 packages/eas-cli/src/commands/integrations/github/connect.ts create mode 100644 packages/eas-cli/src/commands/integrations/github/status.ts create mode 100644 packages/eas-cli/src/graphql/mutations/GitHubRepositoryMutation.ts create mode 100644 packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/GitHubRepositoryQuery-test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b0001220..590bab2c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Add `integrations:github:connect` and `integrations:github:status` to link GitHub repositories to EAS projects from the CLI. ([#4538](https://github.com/expo/eas-cli/pull/4538) by [@tchayen](https://github.com/tchayen)) - [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot)) diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index 7df77cea11..ad0176d05a 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -195,6 +195,8 @@ If you want to enforce the `eas-cli` version for your project, use the `"cli.ver * [`eas integrations:convex:team`](#eas-integrationsconvexteam) * [`eas integrations:convex:team:delete [CONVEX_TEAM]`](#eas-integrationsconvexteamdelete-convex_team) * [`eas integrations:convex:team:invite [CONVEX_TEAM]`](#eas-integrationsconvexteaminvite-convex_team) +* [`eas integrations:github:connect`](#eas-integrationsgithubconnect) +* [`eas integrations:github:status`](#eas-integrationsgithubstatus) * [`eas integrations:posthog:connect`](#eas-integrationsposthogconnect) * [`eas integrations:posthog:dashboard`](#eas-integrationsposthogdashboard) * [`eas integrations:posthog:disconnect`](#eas-integrationsposthogdisconnect) @@ -2023,6 +2025,53 @@ DESCRIPTION _See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v24.10.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_ +## `eas integrations:github:connect` + +connect a GitHub repository to the current EAS project + +``` +USAGE + $ eas integrations:github:connect [--json] [--non-interactive] [--repo ] [--base-directory ] + +FLAGS + --base-directory= Directory containing the app in the repository (defaults to existing settings or /) + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + --repo= GitHub repository in owner/repo format (github.com) + +DESCRIPTION + connect a GitHub repository to the current EAS project + The Expo GitHub app must already be installed and linked to the Expo account that owns the project. Authorize your + GitHub account in your Expo personal settings before running this command. Supports github.com repositories. + +EXAMPLES + $ eas integrations:github:connect --repo owner/repo + + $ eas integrations:github:connect --repo owner/repo --non-interactive --json + + $ eas integrations:github:connect --repo owner/monorepo --base-directory apps/mobile +``` + +_See code: [packages/eas-cli/src/commands/integrations/github/connect.ts](https://github.com/expo/eas-cli/blob/v24.10.0/packages/eas-cli/src/commands/integrations/github/connect.ts)_ + +## `eas integrations:github:status` + +show the GitHub repository linked to the current EAS project + +``` +USAGE + $ eas integrations:github:status [--json] [--non-interactive] + +FLAGS + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + +DESCRIPTION + show the GitHub repository linked to the current EAS project +``` + +_See code: [packages/eas-cli/src/commands/integrations/github/status.ts](https://github.com/expo/eas-cli/blob/v24.10.0/packages/eas-cli/src/commands/integrations/github/status.ts)_ + ## `eas integrations:posthog:connect` connect PostHog to your Expo project diff --git a/packages/eas-cli/src/commandUtils/github.ts b/packages/eas-cli/src/commandUtils/github.ts new file mode 100644 index 0000000000..a5d0b21cf7 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/github.ts @@ -0,0 +1,34 @@ +import { GitHubRepositoryAppQuery } from '../graphql/generated'; +import Log from '../log'; +import { printJsonOnlyOutput } from '../utils/json'; + +export function printGitHubRepository( + app: GitHubRepositoryAppQuery['app']['byId'], + { json }: { json: boolean } +): void { + const repository = app.githubRepository; + const fullName = repository + ? `${repository.metadata.githubRepoOwnerName}/${repository.metadata.githubRepoName}` + : null; + if (json) { + printJsonOnlyOutput({ + projectId: app.id, + projectFullName: app.fullName, + connected: !!repository, + repository: repository + ? { + id: repository.id, + fullName, + url: repository.metadata.githubRepoUrl, + githubRepositoryIdentifier: repository.githubRepositoryIdentifier, + } + : null, + baseDirectory: app.githubRepositorySettings?.baseDirectory ?? null, + }); + } else if (repository) { + Log.log(`${app.fullName} is connected to ${fullName}.`); + Log.log(`Base directory: ${app.githubRepositorySettings?.baseDirectory ?? '(not configured)'}`); + } else { + Log.log(`${app.fullName} is not connected to a GitHub repository.`); + } +} diff --git a/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts b/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts new file mode 100644 index 0000000000..d12b811442 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts @@ -0,0 +1,301 @@ +import { mockTestCommand } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + GitHubAppInstallationStatus, + GitHubRepositoryAppQuery, +} from '../../../../graphql/generated'; +import { GitHubRepositoryMutation } from '../../../../graphql/mutations/GitHubRepositoryMutation'; +import { GitHubRepositoryQuery } from '../../../../graphql/queries/GitHubRepositoryQuery'; +import { promptAsync } from '../../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../../utils/json'; +import IntegrationsGitHubConnect from '../connect'; + +jest.mock('../../../../graphql/queries/GitHubRepositoryQuery'); +jest.mock('../../../../graphql/mutations/GitHubRepositoryMutation'); +jest.mock('../../../../log'); +jest.mock('../../../../ora'); +jest.mock('../../../../prompts'); +jest.mock('../../../../utils/json'); + +const graphqlClient = {} as ExpoGraphqlClient; +const disconnectedApp: GitHubRepositoryAppQuery['app']['byId'] = { + id: 'project-id', + fullName: '@expo/mobile', + ownerAccount: { id: 'account-id', name: 'expo' }, + githubRepository: null, + githubRepositorySettings: null, +}; +const repository = { id: 123, nodeId: 'R_123', name: 'mobile', owner: { login: 'expo' } }; +const connectedApp: GitHubRepositoryAppQuery['app']['byId'] = { + ...disconnectedApp, + githubRepository: { + id: 'repository-id', + githubRepositoryIdentifier: 123, + metadata: { + id: 'metadata-id', + githubRepoName: 'mobile', + githubRepoOwnerName: 'expo', + githubRepoUrl: 'https://github.com/expo/mobile', + }, + }, + githubRepositorySettings: { id: 'settings-id', baseDirectory: '/' }, +}; +const installation = { + id: 'installation-id', + installationIdentifier: 456, + metadata: { githubAccountName: 'expo', installationStatus: GitHubAppInstallationStatus.Active }, + registration: null, +}; + +function createCommand(args: string[]): IntegrationsGitHubConnect { + return mockTestCommand(IntegrationsGitHubConnect, args, { + projectId: disconnectedApp.id, + loggedIn: { graphqlClient }, + }); +} + +describe(IntegrationsGitHubConnect, () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(GitHubRepositoryMutation.createAsync).mockResolvedValue(undefined); + jest.mocked(GitHubRepositoryMutation.createSettingsAsync).mockResolvedValue(undefined); + jest.mocked(GitHubRepositoryMutation.updateSettingsAsync).mockResolvedValue(undefined); + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockReset().mockResolvedValue(connectedApp); + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockResolvedValueOnce(disconnectedApp); + jest + .mocked(GitHubRepositoryQuery.getAccountInstallationsAsync) + .mockResolvedValue([installation]); + jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockResolvedValue(repository); + }); + + it('links the accessible repository and configures the repository root', async () => { + await createCommand(['--repo', 'expo/mobile', '--non-interactive']).runAsync(); + + expect(GitHubRepositoryQuery.getAccountInstallationsAsync).toHaveBeenCalledWith( + graphqlClient, + 'expo' + ); + expect(GitHubRepositoryQuery.findRepositoryAsync).toHaveBeenCalledWith( + graphqlClient, + 456, + 'expo/mobile' + ); + expect(GitHubRepositoryMutation.createAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'project-id', + githubAppInstallationId: 'installation-id', + githubRepositoryIdentifier: 123, + nodeIdentifier: 'R_123', + }); + expect(GitHubRepositoryMutation.createSettingsAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'project-id', + baseDirectory: '/', + }); + expect(promptAsync).not.toHaveBeenCalled(); + }); + + it('prompts for a repository interactively', async () => { + jest.mocked(promptAsync).mockResolvedValue({ repo: 'expo/mobile' }); + await createCommand([]).runAsync(); + expect(promptAsync).toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createAsync).toHaveBeenCalled(); + }); + + it('prints the verified connection as JSON without prompting', async () => { + await createCommand(['--repo', 'expo/mobile', '--json']).runAsync(); + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + projectId: 'project-id', + projectFullName: '@expo/mobile', + connected: true, + repository: { + id: 'repository-id', + fullName: 'expo/mobile', + url: 'https://github.com/expo/mobile', + githubRepositoryIdentifier: 123, + }, + baseDirectory: '/', + }); + expect(promptAsync).not.toHaveBeenCalled(); + }); + + it.each(['--non-interactive', '--json'])( + 'requires --repo with %s before querying the project', + async flag => { + const command = createCommand([flag]); + await expect(command.runAsync()).rejects.toThrow('--repo is required'); + expect(GitHubRepositoryQuery.getAppAsync).not.toHaveBeenCalled(); + expect(promptAsync).not.toHaveBeenCalled(); + } + ); + + it.each(['mobile', 'https://github.com/expo/mobile', 'expo/mobile/extra', 'expo/'])( + 'rejects invalid repository %s', + async repo => { + await expect(createCommand(['--repo', repo]).runAsync()).rejects.toThrow( + 'Invalid GitHub repository' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + } + ); + + it('requires a nonempty base directory', async () => { + await expect( + createCommand(['--repo', 'expo/mobile', '--base-directory', ' ']).runAsync() + ).rejects.toThrow('--base-directory must not be empty'); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + }); + + it('matches installation and repository names case insensitively', async () => { + await createCommand(['--repo', 'EXPO/Mobile']).runAsync(); + expect(GitHubRepositoryMutation.createAsync).toHaveBeenCalled(); + }); + + it.each( + [ + [], + [ + { + ...installation, + metadata: { ...installation.metadata, githubAccountName: 'another-owner' }, + }, + ], + [ + { + ...installation, + metadata: { + ...installation.metadata, + installationStatus: GitHubAppInstallationStatus.Suspended, + }, + }, + ], + [ + { + ...installation, + registration: { id: 'enterprise-id', origin: 'https://github.example.com' }, + }, + ], + ].map(installations => ({ installations })) + )( + 'fails before linking without a matching active github.com installation (%j)', + async ({ installations }) => { + jest + .mocked(GitHubRepositoryQuery.getAccountInstallationsAsync) + .mockResolvedValue(installations); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'No active Expo GitHub app installation' + ); + expect(GitHubRepositoryQuery.findRepositoryAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + } + ); + + it('fails before linking when the repository is not accessible', async () => { + jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockResolvedValue(null); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'not accessible' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createSettingsAsync).not.toHaveBeenCalled(); + }); + + it('does not overwrite a different repository connection', async () => { + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockReset().mockResolvedValue(connectedApp); + await expect(createCommand(['--repo', 'expo/other']).runAsync()).rejects.toThrow( + 'already connected' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.updateSettingsAsync).not.toHaveBeenCalled(); + }); + + it('does not treat an Enterprise repository with the same name as github.com', async () => { + jest + .mocked(GitHubRepositoryQuery.getAppAsync) + .mockReset() + .mockResolvedValue({ + ...connectedApp, + githubRepository: { + ...connectedApp.githubRepository!, + metadata: { + ...connectedApp.githubRepository!.metadata, + githubRepoUrl: 'https://github.example.com/expo/mobile', + }, + }, + }); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'already connected' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + }); + + it('succeeds without mutations when the repository is already connected', async () => { + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockReset().mockResolvedValue(connectedApp); + await createCommand(['--repo', 'EXPO/Mobile', '--non-interactive']).runAsync(); + expect(GitHubRepositoryQuery.getAccountInstallationsAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createSettingsAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.updateSettingsAsync).not.toHaveBeenCalled(); + }); + + it('preserves existing base directory settings when connecting', async () => { + jest + .mocked(GitHubRepositoryQuery.getAppAsync) + .mockReset() + .mockResolvedValue({ + ...disconnectedApp, + githubRepositorySettings: { id: 'settings-id', baseDirectory: 'apps/mobile' }, + }); + await createCommand(['--repo', 'expo/mobile']).runAsync(); + expect(GitHubRepositoryMutation.createAsync).toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createSettingsAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.updateSettingsAsync).not.toHaveBeenCalled(); + }); + + it('creates monorepo settings with the requested base directory', async () => { + await createCommand(['--repo', 'expo/mobile', '--base-directory', 'apps/mobile']).runAsync(); + expect(GitHubRepositoryMutation.createSettingsAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'project-id', + baseDirectory: 'apps/mobile', + }); + }); + + it('updates an existing base directory only when explicitly requested', async () => { + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockReset().mockResolvedValue(connectedApp); + await createCommand(['--repo', 'expo/mobile', '--base-directory', 'apps/mobile']).runAsync(); + expect(GitHubRepositoryMutation.updateSettingsAsync).toHaveBeenCalledWith( + graphqlClient, + 'settings-id', + 'apps/mobile' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + }); + + it('can finish setting up a connection after creating settings failed', async () => { + jest + .mocked(GitHubRepositoryQuery.getAppAsync) + .mockReset() + .mockResolvedValue({ ...connectedApp, githubRepositorySettings: null }); + await createCommand(['--repo', 'expo/mobile']).runAsync(); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + expect(GitHubRepositoryMutation.createSettingsAsync).toHaveBeenCalled(); + }); + + it('propagates discovery failures without linking anything', async () => { + jest + .mocked(GitHubRepositoryQuery.findRepositoryAsync) + .mockRejectedValue(new Error('Reconnect your GitHub account')); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'Reconnect your GitHub account' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + }); + + it('does not print a successful connection when settings fail', async () => { + jest + .mocked(GitHubRepositoryMutation.createSettingsAsync) + .mockRejectedValue(new Error('Invalid base directory')); + await expect(createCommand(['--repo', 'expo/mobile', '--json']).runAsync()).rejects.toThrow( + 'Invalid base directory' + ); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts b/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts new file mode 100644 index 0000000000..8c1c424186 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts @@ -0,0 +1,64 @@ +import { mockTestCommand } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GitHubRepositoryAppQuery } from '../../../../graphql/generated'; +import { GitHubRepositoryQuery } from '../../../../graphql/queries/GitHubRepositoryQuery'; +import Log from '../../../../log'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../../utils/json'; +import IntegrationsGitHubStatus from '../status'; + +jest.mock('../../../../graphql/queries/GitHubRepositoryQuery'); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); + +describe(IntegrationsGitHubStatus, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const app: GitHubRepositoryAppQuery['app']['byId'] = { + id: 'project-id', + fullName: '@expo/mobile', + ownerAccount: { id: 'account-id', name: 'expo' }, + githubRepository: null, + githubRepositorySettings: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(GitHubRepositoryQuery.getAppAsync).mockResolvedValue(app); + }); + + it('reports a disconnected project', async () => { + await mockTestCommand(IntegrationsGitHubStatus, [], { + projectId: app.id, + loggedIn: { graphqlClient }, + }).runAsync(); + expect(Log.log).toHaveBeenCalledWith('@expo/mobile is not connected to a GitHub repository.'); + }); + + it('prints disconnected status as JSON', async () => { + await mockTestCommand(IntegrationsGitHubStatus, ['--json'], { + projectId: app.id, + loggedIn: { graphqlClient }, + }).runAsync(); + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + projectId: app.id, + projectFullName: app.fullName, + connected: false, + repository: null, + baseDirectory: null, + }); + expect(Log.log).not.toHaveBeenCalled(); + }); + + it('propagates server errors', async () => { + jest + .mocked(GitHubRepositoryQuery.getAppAsync) + .mockRejectedValue(new Error('Permission denied')); + await expect( + mockTestCommand(IntegrationsGitHubStatus, ['--json'], { + projectId: app.id, + loggedIn: { graphqlClient }, + }).runAsync() + ).rejects.toThrow('Permission denied'); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/integrations/github/connect.ts b/packages/eas-cli/src/commands/integrations/github/connect.ts new file mode 100644 index 0000000000..e4b95a433f --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/connect.ts @@ -0,0 +1,151 @@ +import { Flags } from '@oclif/core'; + +import { getExpoWebsiteBaseUrl } from '../../../api'; +import EasCommand from '../../../commandUtils/EasCommand'; +import { EasCommandError } from '../../../commandUtils/errors'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { printGitHubRepository } from '../../../commandUtils/github'; +import { GitHubAppInstallationStatus } from '../../../graphql/generated'; +import { GitHubRepositoryMutation } from '../../../graphql/mutations/GitHubRepositoryMutation'; +import { GitHubRepositoryQuery } from '../../../graphql/queries/GitHubRepositoryQuery'; +import { ora } from '../../../ora'; +import { promptAsync } from '../../../prompts'; +import { enableJsonOutput } from '../../../utils/json'; + +export default class IntegrationsGitHubConnect extends EasCommand { + static override description = + 'connect a GitHub repository to the current EAS project\nThe Expo GitHub app must already be installed and linked to the Expo account that owns the project. Authorize your GitHub account in your Expo personal settings before running this command. Supports github.com repositories.'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> --repo owner/repo', + '<%= config.bin %> <%= command.id %> --repo owner/repo --non-interactive --json', + '<%= config.bin %> <%= command.id %> --repo owner/monorepo --base-directory apps/mobile', + ]; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + repo: Flags.string({ description: 'GitHub repository in owner/repo format (github.com)' }), + 'base-directory': Flags.string({ + description: + 'Directory containing the app in the repository (defaults to existing settings or /)', + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(IntegrationsGitHubConnect); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + if (nonInteractive && !flags.repo) { + throw new EasCommandError( + '--repo is required in non-interactive mode. Use --repo owner/repo.' + ); + } + const repo = ( + flags.repo ?? + ( + await promptAsync({ + type: 'text', + name: 'repo', + message: 'GitHub repository (owner/repo):', + }) + ).repo + ).trim(); + if (!/^[a-zA-Z0-9-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) { + throw new EasCommandError('Invalid GitHub repository. Use --repo owner/repo.'); + } + const baseDirectory = flags['base-directory']; + if (baseDirectory !== undefined && !baseDirectory.trim()) { + throw new EasCommandError( + '--base-directory must not be empty. Use / for the repository root.' + ); + } + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(IntegrationsGitHubConnect, { nonInteractive }); + + const spinner = ora('Connecting GitHub repository').start(); + try { + const app = await GitHubRepositoryQuery.getAppAsync(graphqlClient, projectId); + if (app.githubRepository) { + const metadata = app.githubRepository.metadata; + const linkedRepo = `${metadata.githubRepoOwnerName}/${metadata.githubRepoName}`; + if ( + linkedRepo.toLowerCase() !== repo.toLowerCase() || + new URL(metadata.githubRepoUrl).origin !== 'https://github.com' + ) { + throw new EasCommandError( + `${app.fullName} is already connected to ${linkedRepo}. Disconnect it in the EAS dashboard under Project settings > GitHub before connecting another repository.` + ); + } + } else { + const installations = await GitHubRepositoryQuery.getAccountInstallationsAsync( + graphqlClient, + app.ownerAccount.name + ); + const owner = repo.split('/')[0]; + const installation = installations.find( + installation => + installation.metadata.githubAccountName?.toLowerCase() === owner.toLowerCase() && + installation.metadata.installationStatus === GitHubAppInstallationStatus.Active && + (!installation.registration || + installation.registration.origin === 'https://github.com') + ); + if (!installation) { + throw new EasCommandError( + `No active Expo GitHub app installation for ${owner} is linked to the Expo account ${app.ownerAccount.name}. Install and link the app from ${getExpoWebsiteBaseUrl()}/accounts/${app.ownerAccount.name}/settings before retrying.` + ); + } + const repository = await GitHubRepositoryQuery.findRepositoryAsync( + graphqlClient, + installation.installationIdentifier, + repo + ); + if (!repository) { + throw new EasCommandError( + `GitHub repository ${repo} is not accessible. Check that the Expo GitHub app has access to this repository and your GitHub account is authorized in your Expo personal settings.` + ); + } + await GitHubRepositoryMutation.createAsync(graphqlClient, { + appId: projectId, + githubAppInstallationId: installation.id, + githubRepositoryIdentifier: repository.id, + nodeIdentifier: repository.nodeId, + }); + } + + if (!app.githubRepositorySettings) { + await GitHubRepositoryMutation.createSettingsAsync(graphqlClient, { + appId: projectId, + baseDirectory: baseDirectory ?? '/', + }); + } else if ( + baseDirectory !== undefined && + baseDirectory !== app.githubRepositorySettings.baseDirectory + ) { + await GitHubRepositoryMutation.updateSettingsAsync( + graphqlClient, + app.githubRepositorySettings.id, + baseDirectory + ); + } + const updatedApp = await GitHubRepositoryQuery.getAppAsync(graphqlClient, projectId); + spinner.succeed('Connected GitHub repository'); + printGitHubRepository(updatedApp, { json }); + } catch (error) { + spinner.fail('Failed to connect GitHub repository'); + throw error; + } + } +} diff --git a/packages/eas-cli/src/commands/integrations/github/status.ts b/packages/eas-cli/src/commands/integrations/github/status.ts new file mode 100644 index 0000000000..cafaa7d06a --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/status.ts @@ -0,0 +1,33 @@ +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { printGitHubRepository } from '../../../commandUtils/github'; +import { GitHubRepositoryQuery } from '../../../graphql/queries/GitHubRepositoryQuery'; +import { enableJsonOutput } from '../../../utils/json'; + +export default class IntegrationsGitHubStatus extends EasCommand { + static override description = 'show the GitHub repository linked to the current EAS project'; + + static override flags = { ...EasNonInteractiveAndJsonFlags }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(IntegrationsGitHubStatus); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(IntegrationsGitHubStatus, { nonInteractive }); + const app = await GitHubRepositoryQuery.getAppAsync(graphqlClient, projectId); + printGitHubRepository(app, { json }); + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 5f77202a83..94bcfc6e15 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -16025,6 +16025,28 @@ export type CreateFingeprintMutationVariables = Exact<{ export type CreateFingeprintMutation = { __typename?: 'RootMutation', fingerprint: { __typename?: 'FingerprintMutation', createOrGetExistingFingerprint: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } } }; +export type CreateGitHubRepositoryMutationVariables = Exact<{ + githubRepositoryData: CreateGitHubRepositoryInput; +}>; + + +export type CreateGitHubRepositoryMutation = { __typename?: 'RootMutation', githubRepository: { __typename?: 'GitHubRepositoryMutation', createGitHubRepository: { __typename?: 'GitHubRepository', id: string } } }; + +export type CreateGitHubRepositorySettingsMutationVariables = Exact<{ + githubRepositorySettingsData: CreateGitHubRepositorySettingsInput; +}>; + + +export type CreateGitHubRepositorySettingsMutation = { __typename?: 'RootMutation', githubRepositorySettings: { __typename?: 'GitHubRepositorySettingsMutation', createGitHubRepositorySettings: { __typename?: 'GitHubRepositorySettings', id: string } } }; + +export type UpdateGitHubRepositorySettingsMutationVariables = Exact<{ + githubRepositorySettingsId: Scalars['ID']['input']; + githubRepositorySettingsData: UpdateGitHubRepositorySettingsInput; +}>; + + +export type UpdateGitHubRepositorySettingsMutation = { __typename?: 'RootMutation', githubRepositorySettings: { __typename?: 'GitHubRepositorySettingsMutation', updateGitHubRepositorySettings: { __typename?: 'GitHubRepositorySettings', id: string } } }; + export type CreateKeystoreGenerationUrlMutationVariables = Exact<{ [key: string]: never; }>; @@ -16789,6 +16811,29 @@ export type FingerprintsByAppIdQueryVariables = Exact<{ export type FingerprintsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fingerprintsPaginated: { __typename?: 'AppFingerprintsConnection', edges: Array<{ __typename?: 'AppFingerprintEdge', node: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } }; +export type GitHubRepositoryAppQueryVariables = Exact<{ + appId: Scalars['String']['input']; +}>; + + +export type GitHubRepositoryAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string }, githubRepository?: { __typename?: 'GitHubRepository', id: string, githubRepositoryIdentifier: number, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string, githubRepoUrl: string } } | null, githubRepositorySettings?: { __typename?: 'GitHubRepositorySettings', id: string, baseDirectory: string } | null } } }; + +export type GitHubRepositoryInstallationsQueryVariables = Exact<{ + accountName: Scalars['String']['input']; +}>; + + +export type GitHubRepositoryInstallationsQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, githubAppInstallations: Array<{ __typename?: 'GitHubAppInstallation', id: string, installationIdentifier: number, metadata: { __typename?: 'GitHubAppInstallationMetadata', githubAccountName?: string | null, installationStatus: GitHubAppInstallationStatus }, registration?: { __typename?: 'GitHubAppRegistration', id: string, origin: string } | null }> } } }; + +export type GitHubRepositoryPageQueryVariables = Exact<{ + registrationId: Scalars['ID']['input']; + installationIdentifier: Scalars['Int']['input']; + after?: InputMaybe; +}>; + + +export type GitHubRepositoryPageQuery = { __typename?: 'RootQuery', githubAppRegistrations: { __typename?: 'GitHubAppRegistrationQuery', byId: { __typename?: 'GitHubAppRegistration', id: string, repositoriesForViewer: { __typename?: 'GitHubAppRegistrationRepositoriesConnection', edges: Array<{ __typename?: 'GitHubAppRegistrationRepositoryEdge', node: { __typename?: 'AccessibleGitHubRepository', id: number, nodeId: string, name: string, owner: { __typename?: 'AccessibleGitHubRepositoryOwner', login: string } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null } } } } }; + export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{ ascApiKeyId: Scalars['ID']['input']; }>; diff --git a/packages/eas-cli/src/graphql/mutations/GitHubRepositoryMutation.ts b/packages/eas-cli/src/graphql/mutations/GitHubRepositoryMutation.ts new file mode 100644 index 0000000000..11a3944da9 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/GitHubRepositoryMutation.ts @@ -0,0 +1,100 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + CreateGitHubRepositoryInput, + CreateGitHubRepositoryMutation, + CreateGitHubRepositoryMutationVariables, + CreateGitHubRepositorySettingsInput, + CreateGitHubRepositorySettingsMutation, + CreateGitHubRepositorySettingsMutationVariables, + UpdateGitHubRepositorySettingsMutation, + UpdateGitHubRepositorySettingsMutationVariables, +} from '../generated'; + +export const GitHubRepositoryMutation = { + async createAsync( + graphqlClient: ExpoGraphqlClient, + githubRepositoryData: CreateGitHubRepositoryInput + ): Promise { + await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation CreateGitHubRepository($githubRepositoryData: CreateGitHubRepositoryInput!) { + githubRepository { + createGitHubRepository(githubRepositoryData: $githubRepositoryData) { + id + } + } + } + `, + { githubRepositoryData }, + { noRetry: true } + ) + .toPromise() + ); + }, + + async createSettingsAsync( + graphqlClient: ExpoGraphqlClient, + githubRepositorySettingsData: CreateGitHubRepositorySettingsInput + ): Promise { + await withErrorHandlingAsync( + graphqlClient + .mutation< + CreateGitHubRepositorySettingsMutation, + CreateGitHubRepositorySettingsMutationVariables + >( + gql` + mutation CreateGitHubRepositorySettings( + $githubRepositorySettingsData: CreateGitHubRepositorySettingsInput! + ) { + githubRepositorySettings { + createGitHubRepositorySettings(githubRepositorySettingsData: $githubRepositorySettingsData) { + id + } + } + } + `, + { githubRepositorySettingsData }, + { noRetry: true } + ) + .toPromise() + ); + }, + + async updateSettingsAsync( + graphqlClient: ExpoGraphqlClient, + githubRepositorySettingsId: string, + baseDirectory: string + ): Promise { + await withErrorHandlingAsync( + graphqlClient + .mutation< + UpdateGitHubRepositorySettingsMutation, + UpdateGitHubRepositorySettingsMutationVariables + >( + gql` + mutation UpdateGitHubRepositorySettings( + $githubRepositorySettingsId: ID! + $githubRepositorySettingsData: UpdateGitHubRepositorySettingsInput! + ) { + githubRepositorySettings { + updateGitHubRepositorySettings( + githubRepositorySettingsId: $githubRepositorySettingsId + githubRepositorySettingsData: $githubRepositorySettingsData + ) { + id + } + } + } + `, + { githubRepositorySettingsId, githubRepositorySettingsData: { baseDirectory } }, + { noRetry: true } + ) + .toPromise() + ); + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts b/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts new file mode 100644 index 0000000000..d0251aa3ce --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts @@ -0,0 +1,163 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + GitHubRepositoryAppQuery, + GitHubRepositoryAppQueryVariables, + GitHubRepositoryInstallationsQuery, + GitHubRepositoryInstallationsQueryVariables, + GitHubRepositoryPageQuery, + GitHubRepositoryPageQueryVariables, +} from '../generated'; + +// The default github.com registration also represents legacy installations with no registration. +const DEFAULT_GITHUB_APP_REGISTRATION_ID = '00000000-0000-0000-0000-000000000000'; + +export const GitHubRepositoryQuery = { + async getAppAsync( + graphqlClient: ExpoGraphqlClient, + appId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query GitHubRepositoryApp($appId: String!) { + app { + byId(appId: $appId) { + id + fullName + ownerAccount { + id + name + } + githubRepository { + id + githubRepositoryIdentifier + metadata { + id + githubRepoOwnerName + githubRepoName + githubRepoUrl + } + } + githubRepositorySettings { + id + baseDirectory + } + } + } + } + `, + { appId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + return data.app.byId; + }, + + async getAccountInstallationsAsync( + graphqlClient: ExpoGraphqlClient, + accountName: string + ): Promise< + NonNullable['githubAppInstallations'] + > { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query GitHubRepositoryInstallations($accountName: String!) { + account { + byName(accountName: $accountName) { + id + githubAppInstallations { + id + installationIdentifier + metadata { + githubAccountName + installationStatus + } + registration { + id + origin + } + } + } + } + } + `, + { accountName }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + return data.account.byName?.githubAppInstallations ?? []; + }, + + async findRepositoryAsync( + graphqlClient: ExpoGraphqlClient, + installationIdentifier: number, + fullName: string + ): Promise< + | GitHubRepositoryPageQuery['githubAppRegistrations']['byId']['repositoriesForViewer']['edges'][number]['node'] + | null + > { + let after: string | undefined; + do { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query GitHubRepositoryPage($registrationId: ID!, $installationIdentifier: Int!, $after: String) { + githubAppRegistrations { + byId(githubAppRegistrationId: $registrationId) { + id + repositoriesForViewer( + installationIdentifier: $installationIdentifier + first: 100 + after: $after + ) { + edges { + node { + id + nodeId + name + owner { + login + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `, + { registrationId: DEFAULT_GITHUB_APP_REGISTRATION_ID, installationIdentifier, after }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + const { edges, pageInfo } = data.githubAppRegistrations.byId.repositoriesForViewer; + const repository = edges.find( + ({ node }) => `${node.owner.login}/${node.name}`.toLowerCase() === fullName.toLowerCase() + )?.node; + if (repository) { + return repository; + } + if (!pageInfo.hasNextPage) { + return null; + } + if (!pageInfo.endCursor || pageInfo.endCursor === after) { + throw new Error('Could not read the next page of GitHub repositories. Try again.'); + } + after = pageInfo.endCursor; + } while (after); + return null; + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/__tests__/GitHubRepositoryQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/GitHubRepositoryQuery-test.ts new file mode 100644 index 0000000000..6f9e22becf --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/GitHubRepositoryQuery-test.ts @@ -0,0 +1,100 @@ +import { CombinedError } from '@urql/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GitHubRepositoryQuery } from '../GitHubRepositoryQuery'; + +const repository = { id: 123, nodeId: 'R_123', name: 'mobile', owner: { login: 'expo' } }; + +function page( + nodes: (typeof repository)[], + hasNextPage: boolean, + endCursor: string | null +): object { + return { + data: { + githubAppRegistrations: { + byId: { + id: '00000000-0000-0000-0000-000000000000', + repositoriesForViewer: { + edges: nodes.map(node => ({ node })), + pageInfo: { hasNextPage, endCursor }, + }, + }, + }, + }, + }; +} + +describe(GitHubRepositoryQuery.findRepositoryAsync, () => { + const query = jest.fn(); + const client = { query } as unknown as ExpoGraphqlClient; + const toPromise = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + query.mockReturnValue({ toPromise }); + }); + + it('finds a repository on a later page, passing the cursor and installation ID', async () => { + toPromise + .mockResolvedValueOnce(page([{ ...repository, name: 'other' }], true, 'page-2')) + .mockResolvedValueOnce(page([repository], true, 'page-3')); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'EXPO/Mobile') + ).resolves.toEqual(repository); + expect(query).toHaveBeenNthCalledWith( + 2, + expect.anything(), + { + registrationId: '00000000-0000-0000-0000-000000000000', + installationIdentifier: 456, + after: 'page-2', + }, + { requestPolicy: 'network-only' } + ); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('returns null when all pages are exhausted', async () => { + toPromise + .mockResolvedValueOnce(page([], true, 'page-2')) + .mockResolvedValueOnce(page([], false, null)); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'expo/mobile') + ).resolves.toBeNull(); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('does not match the same repository name from a different owner', async () => { + toPromise.mockResolvedValue( + page([{ ...repository, owner: { login: 'another-owner' } }], false, null) + ); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'expo/mobile') + ).resolves.toBeNull(); + }); + + it('propagates API authentication failures', async () => { + toPromise.mockResolvedValue({ + error: new CombinedError({ graphQLErrors: ['GitHub authorization expired'] }), + }); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'expo/mobile') + ).rejects.toThrow('GitHub authorization expired'); + }); + + it('fails instead of looping when a page is missing its next cursor', async () => { + toPromise.mockResolvedValue(page([], true, null)); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'expo/mobile') + ).rejects.toThrow('next page'); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('fails instead of looping when the next cursor does not advance', async () => { + toPromise.mockResolvedValue(page([], true, 'page-2')); + await expect( + GitHubRepositoryQuery.findRepositoryAsync(client, 456, 'expo/mobile') + ).rejects.toThrow('next page'); + expect(query).toHaveBeenCalledTimes(2); + }); +}); From a439c4c6165ae3739833cdf271ae711b397715e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Mon, 5 Oct 2026 02:13:02 +0300 Subject: [PATCH 2/3] [eas-cli] Link GitHub setup and repository access settings in errors --- .../github/__tests__/connect.test.ts | 83 ++++++++++++++++++- .../github/__tests__/status.test.ts | 1 + .../commands/integrations/github/connect.ts | 36 +++++++- packages/eas-cli/src/graphql/generated.ts | 4 +- .../graphql/queries/GitHubRepositoryQuery.ts | 2 + 5 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts b/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts index d12b811442..e6691eaf11 100644 --- a/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts @@ -1,6 +1,10 @@ +import { CombinedError } from '@urql/core'; +import { GraphQLError } from 'graphql'; + import { mockTestCommand } from '../../../../__tests__/commands/utils'; import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; import { + GitHubAppInstallationAccountType, GitHubAppInstallationStatus, GitHubRepositoryAppQuery, } from '../../../../graphql/generated'; @@ -21,6 +25,7 @@ const graphqlClient = {} as ExpoGraphqlClient; const disconnectedApp: GitHubRepositoryAppQuery['app']['byId'] = { id: 'project-id', fullName: '@expo/mobile', + slug: 'mobile', ownerAccount: { id: 'account-id', name: 'expo' }, githubRepository: null, githubRepositorySettings: null, @@ -43,7 +48,11 @@ const connectedApp: GitHubRepositoryAppQuery['app']['byId'] = { const installation = { id: 'installation-id', installationIdentifier: 456, - metadata: { githubAccountName: 'expo', installationStatus: GitHubAppInstallationStatus.Active }, + metadata: { + githubAccountName: 'expo', + githubAccountType: GitHubAppInstallationAccountType.Organization, + installationStatus: GitHubAppInstallationStatus.Active, + }, registration: null, }; @@ -182,22 +191,88 @@ describe(IntegrationsGitHubConnect, () => { .mocked(GitHubRepositoryQuery.getAccountInstallationsAsync) .mockResolvedValue(installations); await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( - 'No active Expo GitHub app installation' + 'Set up the GitHub connection in the EAS dashboard: https://expo.dev/accounts/expo/projects/mobile/github' ); expect(GitHubRepositoryQuery.findRepositoryAsync).not.toHaveBeenCalled(); expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); } ); - it('fails before linking when the repository is not accessible', async () => { + it('links organization installation settings when the repository is not accessible', async () => { jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockResolvedValue(null); await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( - 'not accessible' + 'If the Expo GitHub app is configured for "Only select repositories", add expo/mobile under Repository access and save: https://github.com/organizations/expo/settings/installations/456' ); expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); expect(GitHubRepositoryMutation.createSettingsAsync).not.toHaveBeenCalled(); }); + it('links personal installation settings when the repository is not accessible', async () => { + jest.mocked(GitHubRepositoryQuery.getAccountInstallationsAsync).mockResolvedValue([ + { + ...installation, + metadata: { + ...installation.metadata, + githubAccountType: GitHubAppInstallationAccountType.User, + }, + }, + ]); + jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockResolvedValue(null); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'https://github.com/settings/installations/456' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + }); + + it.each(['GITHUB_USER_NOT_FOUND_ERROR', 'GITHUB_AUTHENTICATION_ERROR'])( + 'links personal Expo settings for %s', + async errorCode => { + jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockRejectedValue( + new CombinedError({ + graphQLErrors: [ + new GraphQLError('GitHub authorization failed', { + extensions: { errorCode }, + }), + ], + }) + ); + await expect(createCommand(['--repo', 'expo/mobile', '--json']).runAsync()).rejects.toThrow( + 'Connect or reconnect GitHub under Connections in your Expo personal settings: https://expo.dev/settings' + ); + expect(GitHubRepositoryMutation.createAsync).not.toHaveBeenCalled(); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + } + ); + + it('preserves unrelated GraphQL errors', async () => { + const error = new CombinedError({ + graphQLErrors: [ + new GraphQLError('Permission denied', { + extensions: { errorCode: 'FORBIDDEN_ERROR' }, + }), + ], + }); + jest.mocked(GitHubRepositoryQuery.findRepositoryAsync).mockRejectedValue(error); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toBe(error); + }); + + it('uses the staging EAS dashboard for setup when EXPO_STAGING is enabled', async () => { + const previousStaging = process.env.EXPO_STAGING; + process.env.EXPO_STAGING = '1'; + try { + jest.mocked(GitHubRepositoryQuery.getAccountInstallationsAsync).mockResolvedValue([]); + await expect(createCommand(['--repo', 'expo/mobile']).runAsync()).rejects.toThrow( + 'https://staging.expo.dev/accounts/expo/projects/mobile/github' + ); + } finally { + if (previousStaging === undefined) { + delete process.env.EXPO_STAGING; + } else { + process.env.EXPO_STAGING = previousStaging; + } + } + }); + it('does not overwrite a different repository connection', async () => { jest.mocked(GitHubRepositoryQuery.getAppAsync).mockReset().mockResolvedValue(connectedApp); await expect(createCommand(['--repo', 'expo/other']).runAsync()).rejects.toThrow( diff --git a/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts b/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts index 8c1c424186..fd6247e633 100644 --- a/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts @@ -15,6 +15,7 @@ describe(IntegrationsGitHubStatus, () => { const app: GitHubRepositoryAppQuery['app']['byId'] = { id: 'project-id', fullName: '@expo/mobile', + slug: 'mobile', ownerAccount: { id: 'account-id', name: 'expo' }, githubRepository: null, githubRepositorySettings: null, diff --git a/packages/eas-cli/src/commands/integrations/github/connect.ts b/packages/eas-cli/src/commands/integrations/github/connect.ts index e4b95a433f..6c4d5e0b80 100644 --- a/packages/eas-cli/src/commands/integrations/github/connect.ts +++ b/packages/eas-cli/src/commands/integrations/github/connect.ts @@ -1,6 +1,7 @@ import { Flags } from '@oclif/core'; import { getExpoWebsiteBaseUrl } from '../../../api'; +import { getProjectGitHubSettingsUrl } from '../../../build/utils/url'; import EasCommand from '../../../commandUtils/EasCommand'; import { EasCommandError } from '../../../commandUtils/errors'; import { @@ -8,7 +9,11 @@ import { resolveNonInteractiveAndJsonFlags, } from '../../../commandUtils/flags'; import { printGitHubRepository } from '../../../commandUtils/github'; -import { GitHubAppInstallationStatus } from '../../../graphql/generated'; +import { GraphqlError } from '../../../graphql/client'; +import { + GitHubAppInstallationAccountType, + GitHubAppInstallationStatus, +} from '../../../graphql/generated'; import { GitHubRepositoryMutation } from '../../../graphql/mutations/GitHubRepositoryMutation'; import { GitHubRepositoryQuery } from '../../../graphql/queries/GitHubRepositoryQuery'; import { ora } from '../../../ora'; @@ -78,6 +83,7 @@ export default class IntegrationsGitHubConnect extends EasCommand { const spinner = ora('Connecting GitHub repository').start(); try { const app = await GitHubRepositoryQuery.getAppAsync(graphqlClient, projectId); + const projectGitHubSettingsUrl = getProjectGitHubSettingsUrl(app.ownerAccount.name, app.slug); if (app.githubRepository) { const metadata = app.githubRepository.metadata; const linkedRepo = `${metadata.githubRepoOwnerName}/${metadata.githubRepoName}`; @@ -86,7 +92,7 @@ export default class IntegrationsGitHubConnect extends EasCommand { new URL(metadata.githubRepoUrl).origin !== 'https://github.com' ) { throw new EasCommandError( - `${app.fullName} is already connected to ${linkedRepo}. Disconnect it in the EAS dashboard under Project settings > GitHub before connecting another repository.` + `${app.fullName} is already connected to ${linkedRepo}. Disconnect it in the EAS dashboard before connecting another repository: ${projectGitHubSettingsUrl}` ); } } else { @@ -104,7 +110,7 @@ export default class IntegrationsGitHubConnect extends EasCommand { ); if (!installation) { throw new EasCommandError( - `No active Expo GitHub app installation for ${owner} is linked to the Expo account ${app.ownerAccount.name}. Install and link the app from ${getExpoWebsiteBaseUrl()}/accounts/${app.ownerAccount.name}/settings before retrying.` + `No active Expo GitHub app installation for ${owner} is linked to the Expo account ${app.ownerAccount.name}. Set up the GitHub connection in the EAS dashboard: ${projectGitHubSettingsUrl}\nInstall the Expo GitHub app for ${owner} and link the installation to ${app.ownerAccount.name}, then retry this command.` ); } const repository = await GitHubRepositoryQuery.findRepositoryAsync( @@ -113,8 +119,14 @@ export default class IntegrationsGitHubConnect extends EasCommand { repo ); if (!repository) { + const organizationPath = + installation.metadata.githubAccountType === + GitHubAppInstallationAccountType.Organization + ? `/organizations/${encodeURIComponent(owner)}` + : ''; + const installationSettingsUrl = `https://github.com${organizationPath}/settings/installations/${installation.installationIdentifier}`; throw new EasCommandError( - `GitHub repository ${repo} is not accessible. Check that the Expo GitHub app has access to this repository and your GitHub account is authorized in your Expo personal settings.` + `EAS cannot access GitHub repository ${repo}. If the Expo GitHub app is configured for "Only select repositories", add ${repo} under Repository access and save: ${installationSettingsUrl}\nYou may need a GitHub account or organization admin to grant access. Also check the repository name and that your GitHub user has access to it, then retry this command.` ); } await GitHubRepositoryMutation.createAsync(graphqlClient, { @@ -145,6 +157,22 @@ export default class IntegrationsGitHubConnect extends EasCommand { printGitHubRepository(updatedApp, { json }); } catch (error) { spinner.fail('Failed to connect GitHub repository'); + if (error instanceof GraphqlError && !error.networkError) { + const authorizationError = error.graphQLErrors.find( + error => + error.extensions.errorCode === 'GITHUB_USER_NOT_FOUND_ERROR' || + error.extensions.errorCode === 'GITHUB_AUTHENTICATION_ERROR' + ); + if (authorizationError) { + const reason = + authorizationError.extensions.errorCode === 'GITHUB_USER_NOT_FOUND_ERROR' + ? 'Your Expo user account is not connected to GitHub.' + : 'Your GitHub authorization is no longer valid.'; + throw new EasCommandError( + `${reason} Connect or reconnect GitHub under Connections in your Expo personal settings: ${getExpoWebsiteBaseUrl()}/settings\nThen retry this command.` + ); + } + } throw error; } } diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 94bcfc6e15..48911709cf 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -16816,14 +16816,14 @@ export type GitHubRepositoryAppQueryVariables = Exact<{ }>; -export type GitHubRepositoryAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string }, githubRepository?: { __typename?: 'GitHubRepository', id: string, githubRepositoryIdentifier: number, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string, githubRepoUrl: string } } | null, githubRepositorySettings?: { __typename?: 'GitHubRepositorySettings', id: string, baseDirectory: string } | null } } }; +export type GitHubRepositoryAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string }, githubRepository?: { __typename?: 'GitHubRepository', id: string, githubRepositoryIdentifier: number, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string, githubRepoUrl: string } } | null, githubRepositorySettings?: { __typename?: 'GitHubRepositorySettings', id: string, baseDirectory: string } | null } } }; export type GitHubRepositoryInstallationsQueryVariables = Exact<{ accountName: Scalars['String']['input']; }>; -export type GitHubRepositoryInstallationsQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, githubAppInstallations: Array<{ __typename?: 'GitHubAppInstallation', id: string, installationIdentifier: number, metadata: { __typename?: 'GitHubAppInstallationMetadata', githubAccountName?: string | null, installationStatus: GitHubAppInstallationStatus }, registration?: { __typename?: 'GitHubAppRegistration', id: string, origin: string } | null }> } } }; +export type GitHubRepositoryInstallationsQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, githubAppInstallations: Array<{ __typename?: 'GitHubAppInstallation', id: string, installationIdentifier: number, metadata: { __typename?: 'GitHubAppInstallationMetadata', githubAccountName?: string | null, githubAccountType?: GitHubAppInstallationAccountType | null, installationStatus: GitHubAppInstallationStatus }, registration?: { __typename?: 'GitHubAppRegistration', id: string, origin: string } | null }> } } }; export type GitHubRepositoryPageQueryVariables = Exact<{ registrationId: Scalars['ID']['input']; diff --git a/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts b/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts index d0251aa3ce..c97de6e64c 100644 --- a/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts +++ b/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts @@ -28,6 +28,7 @@ export const GitHubRepositoryQuery = { byId(appId: $appId) { id fullName + slug ownerAccount { id name @@ -77,6 +78,7 @@ export const GitHubRepositoryQuery = { installationIdentifier metadata { githubAccountName + githubAccountType installationStatus } registration { From a3b8c939bbd3ed06a4a00c130cab5583f81a7a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Mon, 5 Oct 2026 02:25:42 +0300 Subject: [PATCH 3/3] [eas-cli] Shorten GitHub repository access error --- packages/eas-cli/src/commands/integrations/github/connect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/integrations/github/connect.ts b/packages/eas-cli/src/commands/integrations/github/connect.ts index 6c4d5e0b80..aa3c7d04e0 100644 --- a/packages/eas-cli/src/commands/integrations/github/connect.ts +++ b/packages/eas-cli/src/commands/integrations/github/connect.ts @@ -126,7 +126,7 @@ export default class IntegrationsGitHubConnect extends EasCommand { : ''; const installationSettingsUrl = `https://github.com${organizationPath}/settings/installations/${installation.installationIdentifier}`; throw new EasCommandError( - `EAS cannot access GitHub repository ${repo}. If the Expo GitHub app is configured for "Only select repositories", add ${repo} under Repository access and save: ${installationSettingsUrl}\nYou may need a GitHub account or organization admin to grant access. Also check the repository name and that your GitHub user has access to it, then retry this command.` + `EAS cannot access GitHub repository ${repo}. If the Expo GitHub app is configured for "Only select repositories", add ${repo} under Repository access and save: ${installationSettingsUrl}` ); } await GitHubRepositoryMutation.createAsync(graphqlClient, {