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..e6691eaf11 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/connect.test.ts @@ -0,0 +1,376 @@ +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'; +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', + slug: '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', + githubAccountType: GitHubAppInstallationAccountType.Organization, + 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( + '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('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( + '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( + '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..fd6247e633 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/__tests__/status.test.ts @@ -0,0 +1,65 @@ +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', + slug: '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..aa3c7d04e0 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/github/connect.ts @@ -0,0 +1,179 @@ +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 { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { printGitHubRepository } from '../../../commandUtils/github'; +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'; +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); + const projectGitHubSettingsUrl = getProjectGitHubSettingsUrl(app.ownerAccount.name, app.slug); + 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 before connecting another repository: ${projectGitHubSettingsUrl}` + ); + } + } 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}. 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( + graphqlClient, + installation.installationIdentifier, + 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( + `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, { + 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'); + 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/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..48911709cf 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, 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, githubAccountType?: GitHubAppInstallationAccountType | 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..c97de6e64c --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/GitHubRepositoryQuery.ts @@ -0,0 +1,165 @@ +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 + slug + 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 + githubAccountType + 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); + }); +});