Compare commits

..
Author SHA1 Message Date
Quinlan Jung 7c779b3dc0 v3.18.1 2023-08-02 19:04:22 -07:00
Quinlan Jung 50474ec5e9 [eas-cli] fix update:republish group id (#1971)
* [eas-cli] fix update:republish group id

* update CHANGELOG.md
2023-08-02 15:20:33 -07:00
Quinlan Jung 46ff97f173 [ENG-8616][eas-cli] change update default runtime policy (#1968)
* [eas-cli] change update default runtime policy

* update CHANGELOG.md
2023-08-02 14:59:09 -07:00
Szymon Dziedzic b263dffbde update and fix changelog 2023-08-02 23:53:24 +02:00
Quinlan Jung 8d57828f7e [ENG-9441][eas-cli] channel:rollout-unstable (#1966)
* [eas-cli] eas channel:rollout-unstable

* json unsupport

* update CHANGELOG.md

* fix tsc

* fix test

* fix lint

* new selectors using filter pagination

* replace selectors

* remove unused stuff

* branch mapping tests

* add tests for relay utilities and selectors

* at is not shipped in node14

* pr feedback
2023-08-02 14:40:40 -07:00
Szymon Dziedzic fd9561cfc3 v3.18.0 2023-08-02 23:33:31 +02:00
Szymon Dziedzic 93ce451bc7 upgrade eas-cli-local-build-plugin to 1.0.31 2023-08-02 23:33:14 +02:00
Szymon Dziedzic 554e433548 update @expo/steps and @expo/eas-build-job (#1970) 2023-08-02 23:24:35 +02:00
Szymon Dziedzic 12a1cb7011 [ENG-9482][eas-cli] provide credentials for custom Android builds (#1969)
* [eas-cli] provide credentials for custom Android builds

* update CHANGELOG.md
2023-08-02 18:06:41 +02:00
Patryk FrydaandPatryk Fryda 360de90eea [eas-cli] fix 'manged' typo in ios target (#1961)
Co-authored-by: Patryk Fryda <[email protected]>
2023-07-31 17:08:12 +02:00
Quinlan Jung ea02fcebda [eas-cli] select existing rollouts and channels (#1958)
* [eas-cli] select existing rollouts and channels

* update CHANGELOG.md
2023-07-28 09:27:17 -07:00
Piotr Szeremeta 0b5194b1a5 [ENG-9422][eas-cli] Use .nvmrc to set node version (#1954)
* [ENG-9422][eas-cli] Use .nvmrc to set node version

* update CHANGELOG.md
2023-07-28 12:26:03 +02:00
Quinlan Jung 05799aba9c [eas-cli] more branch mapping utility (#1957)
* [eas-cli] more branch mapping utility

* update CHANGELOG.md
2023-07-27 17:05:00 -07:00
Expo CI 2cece65dbf update CHANGELOG.md 2023-07-27 14:32:42 +00:00
Szymon Dziedzic 17b6b4ec45 v3.17.1 2023-07-27 15:37:26 +02:00
Szymon Dziedzic 4f892c66e7 [eas-cli] update @expo/steps version (#1956) 2023-07-27 15:15:09 +02:00
Quinlan Jung fd99365655 [eas-cli] fetch entire relay compliant dataset (#1955)
* [eas-cli] fetch entire relay compliant dataset

* update CHANGELOG.md
2023-07-26 20:48:21 -07:00
Quinlan Jung 7dca2c8021 [eas-cli] revert UpdateBranchWithCurrentGroupFragment (#1952)
* [eas-ci] revert UpdateBranchWithCurrentGroupFragment

* update CHANGELOG.md
2023-07-26 10:32:11 -07:00
Quinlan Jung 55bce8d82a [eas-cli] update channel basic tests and fixtures (#1950) 2023-07-25 09:37:47 -07:00
Quinlan Jung 1f5533e5e3 [eas-cli][1/2] unify channel graphql query types (#1949)
* [eas-cli] unify channel graphql query types

* update CHANGELOG.md
2023-07-25 09:34:12 -07:00
62 changed files with 6374 additions and 679 deletions

No files matched your search

+27
View File
@@ -12,6 +12,33 @@ This is the log of notable changes to EAS CLI and related packages.
### 🧹 Chores
- Make new rollouts version available for internal dogfooding. ([#1966](https://github.com/expo/eas-cli/pull/1966) by [@quinlanj](https://github.com/quinlanj))
## [3.18.0](https://github.com/expo/eas-cli/releases/tag/v3.18.0) - 2023-08-02
### 🎉 New features
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
- Provide credentials for custom Android builds. ([#1969](https://github.com/expo/eas-cli/pull/1969) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Pass correct group into `update:republish`. ([#1971](https://github.com/expo/eas-cli/pull/1971) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- More branch mapping utility. ([#1957](https://github.com/expo/eas-cli/pull/1957) by [@quinlanj](https://github.com/quinlanj))
- Utility classes to select existing rollouts and channels. ([#1958](https://github.com/expo/eas-cli/pull/1958) by [@quinlanj](https://github.com/quinlanj))
- Change default runtime version policy for EAS Update to appVersion. ([#1968](https://github.com/expo/eas-cli/pull/1968) by [@quinlanj](https://github.com/quinlanj))
## [3.17.1](https://github.com/expo/eas-cli/releases/tag/v3.17.1) - 2023-07-27
### 🧹 Chores
- Unify channel graphql query types. ([#1949](https://github.com/expo/eas-cli/pull/1949) by [@quinlanj](https://github.com/quinlanj))
- Revert UpdateBranchWithCurrentGroupFragment. ([#1952](https://github.com/expo/eas-cli/pull/1952) by [@quinlanj](https://github.com/quinlanj))
- Fetch entire relay compliant dataset. ([#1953](https://github.com/expo/eas-cli/pull/1953) by [@quinlanj](https://github.com/quinlanj))
## [3.17.0](https://github.com/expo/eas-cli/releases/tag/v3.17.0) - 2023-07-24
### 🎉 New features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.17.0",
"version": "3.18.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+55 -55
View File
@@ -133,7 +133,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -150,7 +150,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -167,7 +167,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -181,7 +181,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -231,7 +231,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -252,7 +252,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -272,7 +272,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -292,7 +292,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -315,7 +315,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -345,7 +345,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -362,7 +362,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -379,7 +379,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -414,7 +414,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -452,7 +452,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -478,7 +478,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -502,7 +502,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -551,7 +551,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -570,7 +570,7 @@ DESCRIPTION
update version of an app
```
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -589,7 +589,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -606,7 +606,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -627,7 +627,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -649,7 +649,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -669,7 +669,7 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -693,7 +693,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -716,7 +716,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -737,7 +737,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -754,7 +754,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -768,7 +768,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -788,7 +788,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -809,7 +809,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -830,7 +830,7 @@ DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -844,7 +844,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -858,7 +858,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -946,7 +946,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -963,7 +963,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -980,7 +980,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -994,7 +994,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1008,7 +1008,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1030,7 +1030,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -1053,7 +1053,7 @@ DESCRIPTION
create an environment secret on the current project or owner account
```
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1071,7 +1071,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1085,7 +1085,7 @@ DESCRIPTION
list environment secrets available for your current app
```
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1105,7 +1105,7 @@ DESCRIPTION
read environment secrets from env file and store on the server
```
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/secret/push.ts)_
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1135,7 +1135,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1168,7 +1168,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1186,7 +1186,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1207,7 +1207,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -1229,7 +1229,7 @@ DESCRIPTION
view the recent updates
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1253,7 +1253,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:view GROUPID`
@@ -1273,7 +1273,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -1294,7 +1294,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1314,7 +1314,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1332,7 +1332,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1354,7 +1354,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1371,7 +1371,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.17.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.18.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+1360 -99
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "3.17.0",
"version": "3.18.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -13,8 +13,8 @@
"@expo/config": "8.1.2",
"@expo/config-plugins": "7.2.4",
"@expo/config-types": "49.0.0",
"@expo/eas-build-job": "1.0.13",
"@expo/eas-json": "3.17.0",
"@expo/eas-build-job": "1.0.30",
"@expo/eas-json": "3.18.0",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -27,7 +27,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.25",
"@expo/steps": "1.0.31",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
@@ -0,0 +1,12 @@
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
export const testBasicBranchInfo: UpdateBranchBasicInfoFragment = {
id: 'c4fec95d-7ab1-4d35-bfe6-fe7ff05beda9',
name: 'development',
__typename: 'UpdateBranch',
};
export const testBasicBranchInfo2: UpdateBranchBasicInfoFragment = {
id: '43ccb8a4-8aaa-47f0-9790-dcc80933441b',
name: 'preview',
__typename: 'UpdateBranch',
};
@@ -0,0 +1,107 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a branch for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the branches that are shown to the user. It takes a branchInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of branch printed to the user. Defaults to 'branch'.
* @param {number} options.pageSize - The number of branches to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectBranch implements EASUpdateAction<UpdateBranchBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (branchInfo: UpdateBranchBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) => boolean
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateBranchBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'branch';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (branchInfo: UpdateBranchBasicInfoFragment): Promise<string> =>
branchInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -0,0 +1,88 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../../graphql/generated';
import { BranchQuery } from '../../../graphql/queries/BranchQuery';
import { promptAsync } from '../../../prompts';
import { testBasicBranchInfo, testBasicBranchInfo2 } from '../../__tests__/fixtures';
import { SelectBranch } from '../SelectBranch';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/BranchQuery');
describe(SelectBranch, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no branchs', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBeNull();
});
it('returns the branch without prompting if project only one branch', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('returns the selected branch', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const ctx = createCtxMock();
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('returns the only available branch with a filter', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectBranch = new SelectBranch({
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo === testBasicBranchInfo,
});
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectBranch = new SelectBranch();
// dont fail if users are running in non-interactive mode
await expect(selectBranch.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
+1 -2
View File
@@ -124,8 +124,7 @@ export async function prepareAndroidBuildAsync(
}
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
const isCustomBuild = ctx.buildProfile.config !== undefined;
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
return !ctx.buildProfile.withoutCredentials;
}
async function ensureAndroidCredentialsAsync(
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.27';
const PLUGIN_PACKAGE_VERSION = '1.0.31';
export enum LocalBuildMode {
/**
@@ -117,6 +117,7 @@ export async function runBuildAndSubmitAsync(
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
Log.log(
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
@@ -183,6 +184,7 @@ export async function runBuildAndSubmitAsync(
platforms,
profileName: flags.submitProfile,
type: 'submit',
projectDir,
});
for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows(
@@ -0,0 +1,35 @@
import { rolloutBranchMapping, standardBranchMapping } from '../../rollout/__tests__/fixtures';
import {
BranchMappingValidationError,
getAlwaysTrueBranchMapping,
getStandardBranchId,
isAlwaysTrueBranchMapping,
} from '../branch-mapping';
import { testChannelObject } from './fixtures';
describe(isAlwaysTrueBranchMapping, () => {
it('detects always true branch mappings', () => {
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
});
});
describe(getAlwaysTrueBranchMapping, () => {
it('gets an always true branch mapping', () => {
const alwaysTrueBranchMapping = getAlwaysTrueBranchMapping('test-id');
expect(isAlwaysTrueBranchMapping(alwaysTrueBranchMapping)).toBe(true);
});
});
describe(getStandardBranchId, () => {
it('throws if the branch mapping is not a standard mapping', () => {
expect(() => getStandardBranchId(testChannelObject)).toThrowError(BranchMappingValidationError);
});
it('gets a standard branch id', () => {
const channelObjectWithStandardMapping = { ...testChannelObject };
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
standardBranchMapping.data[0].branchId
);
});
});
@@ -0,0 +1,147 @@
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
export const testUpdateBranch1: UpdateBranchObject = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
updateGroups: [
[
{
id: 'bbe3e63a-f620-4603-8c4d-258f3e51590d',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'db22401b-d321-4629-a068-21d6d1f19c02',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testUpdateBranch2: UpdateBranchObject = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
updateGroups: [
[
{
id: 'f04aafc1-0bf5-4548-8c35-726b467e7c40',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'd8d15786-2727-407f-bf3e-f4bda7e397d9',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testChannelObject: UpdateChannelObject = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
createdAt: '2023-05-16T21:26:16.946Z',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
id: '9d5c7bf0-d52d-474c-9140-c1bad7f0de9d',
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
@@ -0,0 +1,11 @@
import { getUpdateBranch } from '../utils';
import { testChannelObject, testUpdateBranch1 } from './fixtures';
describe(getUpdateBranch, () => {
it('gets the update branch', () => {
expect(getUpdateBranch(testChannelObject, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
testUpdateBranch1
);
expect(() => getUpdateBranch(testChannelObject, 'not-a-branch-ID')).toThrow();
});
});
@@ -0,0 +1,107 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a channel for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the channels that are shown to the user. It takes a channelInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of channel printed to the user. Defaults to 'channel'.
* @param {number} options.pageSize - The number of channels to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectChannel implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'channel';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
channelInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -0,0 +1,88 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../__tests__/fixtures';
import { SelectChannel } from '../SelectChannel';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectChannel, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no channels', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBeNull();
});
it('returns the channel without prompting if project only one channel', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the selected channel', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the only available channel with a filter', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectChannel = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) =>
channelInfo === testBasicChannelInfo,
});
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectChannel = new SelectChannel();
// dont fail if users are running in non-interactive mode
await expect(selectChannel.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
+99 -6
View File
@@ -1,4 +1,4 @@
import assert from 'assert';
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
// TODO(quin): move this into a common package with www
export type BranchMappingOperator =
@@ -35,6 +35,66 @@ export type BranchMapping = {
}[];
};
export type AlwaysTrueBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
return {
version: 0,
data: [
{
branchId,
branchMappingLogic: 'true',
},
],
};
}
export function hasStandardBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isAlwaysTrueBranchMapping(branchMapping);
}
export function getStandardBranchId(channelInfo: UpdateChannelBasicInfoFragment): string {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
assertAlwaysTrueBranchMapping(branchMapping);
return getBranchIdFromStandardMapping(branchMapping);
}
export function isAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): branchMapping is AlwaysTrueBranchMapping {
const numBranches = branchMapping.data.length;
if (numBranches !== 1) {
return false;
}
const branchMappingLogic = branchMapping.data[0].branchMappingLogic;
return isAlwaysTrue(branchMappingLogic);
}
function getBranchIdFromStandardMapping(branchMapping: AlwaysTrueBranchMapping): string {
return branchMapping.data[0].branchId;
}
export function getBranchIds(branchMapping: BranchMapping): string[] {
return branchMapping.data.map(data => data.branchId);
}
export function getBranchMapping(branchMappingString: string): BranchMapping {
try {
return JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
}
export function getNodesFromStatement(statement: BranchMappingStatement): BranchMappingNode[] {
return statement.slice(1) as BranchMappingNode[];
}
@@ -73,13 +133,46 @@ export function hashLtOperator(): BranchMappingOperator {
return 'hash_lt';
}
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
if (!isStatement(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
);
}
}
export function assertNodeObject(node: BranchMappingNode): asserts node is BranchMappingObject {
assert(
isNodeObject(node),
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
if (!isNodeObject(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
}
}
export function assertNumber(operand: string | number | string[]): asserts operand is number {
assert(typeof operand === 'number', 'Expected a number. Received: ' + JSON.stringify(operand));
if (typeof operand !== 'number') {
throw new BranchMappingValidationError(
'Expected a number. Received: ' + JSON.stringify(operand)
);
}
}
export function assertString(operand: string | number | string[]): asserts operand is string {
if (typeof operand !== 'string') {
throw new BranchMappingValidationError(
'Expected a string. Received: ' + JSON.stringify(operand)
);
}
}
function assertAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is AlwaysTrueBranchMapping {
if (!isAlwaysTrueBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Expected standard branch mapping. Received: ' + JSON.stringify(branchMapping)
);
}
}
export class BranchMappingValidationError extends Error {}
+4 -2
View File
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
@@ -12,6 +13,7 @@ import {
} from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
import Log from '../log';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
@@ -227,11 +229,11 @@ export async function createChannelOnAppAsync(
updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
name
branchMapping
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{
appId,
@@ -68,6 +68,7 @@ export default class BuildVersionGetView extends EasCommand {
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
const results: { [key in Platform]?: string } = {};
for (const { profile, platform } of buildProfiles) {
@@ -80,6 +80,7 @@ export default class BuildVersionSyncView extends EasCommand {
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
for (const profileInfo of buildProfiles) {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
@@ -1,5 +1,6 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { selectBranchOnAppAsync } from '../../branch/queries';
@@ -9,18 +10,20 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelBasicInfo } from '../../graphql/queries/ChannelQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function updateChannelBranchMappingAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
): Promise<UpdateChannelBasicInfo> {
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
@@ -29,11 +32,11 @@ export async function updateChannelBranchMappingAsync(
updateChannel {
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
id
name
branchMapping
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId, branchMapping }
)
@@ -0,0 +1,147 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
EndOutcome,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from '../../rollout/actions/EndRollout';
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
import {
MainMenuActions,
RolloutActions,
RolloutMainMenu,
} from '../../rollout/actions/RolloutMainMenu';
enum ActionRawFlagValue {
CREATE = 'create',
EDIT = 'edit',
END = 'end',
}
type ChannelRolloutRawArgsAndFlags = {
channel?: string;
action?: ActionRawFlagValue;
percent?: number;
outcome?: EndOutcome;
'non-interactive': boolean;
branch?: string;
'runtime-version'?: string;
json?: boolean;
};
type ChannelRolloutArgsAndFlags = {
channelName?: string;
action?: RolloutActions;
nonInteractive: boolean;
json?: boolean;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRolloutPreview extends EasCommand {
static override hidden = true;
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = [
{
name: 'channel',
description: 'channel on which the rollout should be done',
},
];
static override flags = {
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
}),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
outcome: Flags.enum({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
}),
branch: Flags.string({
description: 'Branch to rollout. Use with --action=create',
required: false,
}),
'runtime-version': Flags.string({
description: 'Runtime version to target. Use with --action=create',
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelRolloutPreview);
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
const {
privateProjectConfig: { exp, projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRolloutPreview, {
nonInteractive: argsAndFlags.nonInteractive,
});
if (argsAndFlags.json) {
// TODO(quin): implement json output
throw new Error('Developer Preview doesnt support JSON output yet');
}
const app = { projectId, exp };
const ctx = {
projectId,
nonInteractive: argsAndFlags.nonInteractive,
graphqlClient,
app,
};
if (argsAndFlags.nonInteractive) {
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
} else {
Log.addNewLineIfNone();
Log.warn(
`✨ This command is in Developer Preview and has not been released to production yet`
);
Log.addNewLineIfNone();
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
}
}
private getAction(action: ActionRawFlagValue): RolloutActions {
switch (action) {
case ActionRawFlagValue.CREATE:
return MainMenuActions.CREATE_NEW;
case ActionRawFlagValue.EDIT:
return ManageRolloutActions.EDIT;
case ActionRawFlagValue.END:
return ManageRolloutActions.END;
}
}
private sanitizeArgsAndFlags(
rawFlags: ChannelRolloutRawArgsAndFlags
): ChannelRolloutArgsAndFlags {
const action = rawFlags.action;
return {
channelName: rawFlags.channel,
percent: rawFlags.percent,
outcome: rawFlags.outcome,
branchNameToRollout: rawFlags.branch,
runtimeVersion: rawFlags['runtime-version'],
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
};
}
}
@@ -47,6 +47,7 @@ export default class MetadataLint extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -49,6 +49,7 @@ export default class MetadataPull extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -47,6 +47,7 @@ export default class MetadataPush extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
+1
View File
@@ -118,6 +118,7 @@ export default class Submit extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms,
profileName: flagsWithPlatform.profile,
projectDir,
});
const submissions: SubmissionFragment[] = [];
+24 -116
View File
@@ -1,23 +1,18 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { Update } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFromChannelNameAsync';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { enableJsonOutput } from '../../utils/json';
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
@@ -41,20 +36,6 @@ type UpdateRepublishFlags = {
json: boolean;
};
type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
export default class UpdateRepublish extends EasCommand {
static override description = 'roll back to an existing update';
@@ -138,89 +119,16 @@ export default class UpdateRepublish extends EasCommand {
);
}
// This command only republishes a single update group
// The update group properties are the same for all updates
const { branchId, branchName, runtimeVersion } = updatesToPublish[0];
const updateMessage = await getOrAskUpdateMessageAsync(updatesToPublish, flags);
// If codesigning was created for the original update, we need to add it to the republish
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
if (shouldRepublishWithCodesigning) {
Log.withTick(
`The republished update will be signed with the same codesigning as the original update.`
);
}
const publishIndicator = ora('Republishing...').start();
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
try {
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
{
branchId,
runtimeVersion,
message: updateMessage,
updateInfoGroup: Object.fromEntries(
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
),
gitCommitHash: updatesToPublish[0].gitCommitHash,
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
},
]);
if (shouldRepublishWithCodesigning) {
const codeSigningByPlatform = Object.fromEntries(
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
);
await Promise.all(
updatesRepublished.map(async update => {
const codeSigning = codeSigningByPlatform[update.platform];
if (codeSigning) {
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
}
})
);
}
publishIndicator.succeed('Republished update');
} catch (error: any) {
publishIndicator.fail('Failed to republish update');
throw error;
}
if (flags.json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name,
exp.slug,
updatesRepublished[0].group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: updatesRepublished[0].runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update Group ID', value: updatesRepublished[0].id },
...(updatesRepublishedByPlatform.android
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
: []),
...(updatesRepublishedByPlatform.ios
? [{ label: 'iOS update ID', value: updatesRepublishedByPlatform.ios.id }]
: []),
{ label: 'Message', value: updateMessage },
{ label: 'Website link', value: link(updateGroupUrl, { dim: false }) },
])
);
const arbitraryUpdate = updatesToPublish[0];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish,
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
updateMessage,
json: flags.json,
});
}
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
@@ -258,15 +166,15 @@ async function getOrAskUpdatesAsync(
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroups = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroups.map(group => ({
...group,
groupId: group.group,
branchId: group.branch.id,
branchName: group.branch.name,
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
@@ -303,17 +211,17 @@ async function askUpdatesFromBranchNameAsync(
throw new Error('Must supply --group when in non-interactive mode');
}
const updateGroups = await selectUpdateGroupOnBranchAsync(graphqlClient, {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroups.map(group => ({
...group,
groupId: group.id,
branchId: group.branch.id,
branchName: group.branch.name,
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
@@ -0,0 +1,10 @@
import { testAppJson } from '../../credentials/__tests__/fixtures-constants';
import { EASUpdateContext } from '../utils';
export function createCtxMock(options: { nonInteractive?: boolean } = {}): EASUpdateContext {
return {
graphqlClient: jest.fn() as any,
nonInteractive: options.nonInteractive ?? false,
app: { exp: testAppJson, projectId: 'test-project-id' },
};
}
+15
View File
@@ -0,0 +1,15 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
export type EASUpdateContext = {
graphqlClient: ExpoGraphqlClient;
nonInteractive: boolean;
app: { exp: ExpoConfig; projectId: string };
};
export interface EASUpdateAction<T = any> {
runAsync(ctx: EASUpdateContext): Promise<T>;
}
export class NonInteractiveError extends Error {}
+204 -26
View File
@@ -1009,6 +1009,7 @@ export type App = Project & {
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
fullName: Scalars['String'];
githubBuildTriggers: Array<GitHubBuildTrigger>;
githubRepository?: Maybe<GitHubRepository>;
githubRepositorySettings?: Maybe<GitHubRepositorySettings>;
/** githubUrl field from most recent classic update manifest */
@@ -1365,16 +1366,19 @@ export type AppInsights = {
export type AppInsightsTotalUniqueUsersArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByAppVersionOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByPlatformOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppMutation = {
@@ -2236,6 +2240,17 @@ export enum BuildJobStatus {
Started = 'STARTED'
}
export type BuildLimitThresholdExceededMetadata = {
__typename?: 'BuildLimitThresholdExceededMetadata';
account: Account;
thresholdsExceeded: Array<NotificationThresholdExceeded>;
};
export enum BuildLimitThresholdExceededMetadataType {
Ios = 'IOS',
Total = 'TOTAL'
}
export type BuildLogs = {
__typename?: 'BuildLogs';
format?: Maybe<BuildJobLogsFormat>;
@@ -2381,6 +2396,14 @@ export type BuildParamsInput = {
sdkVersion?: InputMaybe<Scalars['String']>;
};
export type BuildPlanCreditThresholdExceededMetadata = {
__typename?: 'BuildPlanCreditThresholdExceededMetadata';
account: Account;
buildCreditUsage: Scalars['Int'];
planLimit: Scalars['Int'];
threshold: Scalars['Int'];
};
export enum BuildPriority {
High = 'HIGH',
Normal = 'NORMAL',
@@ -2586,6 +2609,19 @@ export type CreateGitHubAppInstallationInput = {
installationIdentifier: Scalars['Int'];
};
export type CreateGitHubBuildTriggerInput = {
appId: Scalars['ID'];
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
/** A branch or tag name, or a wildcard pattern where the code change originates from. For example, `main` or `release/*`. */
sourcePattern: Scalars['String'];
/** A branch name or a wildcard pattern that the pull request targets. For example, `main` or `release/*`. */
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type CreateGitHubRepositoryInput = {
appId: Scalars['ID'];
githubAppInstallationId: Scalars['ID'];
@@ -3091,6 +3127,54 @@ export type GitHubBuildInput = {
platform: AppPlatform;
};
export type GitHubBuildTrigger = {
__typename?: 'GitHubBuildTrigger';
app: App;
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
isActive: Scalars['Boolean'];
lastRunAt?: Maybe<Scalars['DateTime']>;
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: Maybe<Scalars['String']>;
type: GitHubBuildTriggerType;
updatedAt: Scalars['DateTime'];
};
export type GitHubBuildTriggerMutation = {
__typename?: 'GitHubBuildTriggerMutation';
/** Create GitHub build trigger for an App */
createGitHubBuildTrigger: GitHubBuildTrigger;
/** Delete GitHub build trigger by ID */
deleteGitHubBuildTrigger: GitHubBuildTrigger;
/** Update a GitHub build trigger by ID */
updateGitHubBuildTrigger: GitHubBuildTrigger;
};
export type GitHubBuildTriggerMutationCreateGitHubBuildTriggerArgs = {
githubBuildTriggerData: CreateGitHubBuildTriggerInput;
};
export type GitHubBuildTriggerMutationDeleteGitHubBuildTriggerArgs = {
githubBuildTriggerId: Scalars['ID'];
};
export type GitHubBuildTriggerMutationUpdateGitHubBuildTriggerArgs = {
githubBuildTriggerData: UpdateGitHubBuildTriggerInput;
githubBuildTriggerId: Scalars['ID'];
};
export enum GitHubBuildTriggerType {
PullRequestUpdated = 'PULL_REQUEST_UPDATED',
PushToBranch = 'PUSH_TO_BRANCH',
TagUpdated = 'TAG_UPDATED'
}
export type GitHubRepository = {
__typename?: 'GitHubRepository';
app: App;
@@ -3758,13 +3842,27 @@ export type MeteredBillingStatus = {
EAS_UPDATE: Scalars['Boolean'];
};
export type Notification = {
__typename?: 'Notification';
createdAt: Scalars['DateTime'];
event: NotificationEvent;
id: Scalars['ID'];
isRead: Scalars['Boolean'];
metadata?: Maybe<NotificationMetadata>;
type: NotificationType;
updatedAt: Scalars['DateTime'];
};
export enum NotificationEvent {
BuildComplete = 'BUILD_COMPLETE',
BuildLimitThresholdExceeded = 'BUILD_LIMIT_THRESHOLD_EXCEEDED',
BuildPlanCreditThresholdExceeded = 'BUILD_PLAN_CREDIT_THRESHOLD_EXCEEDED',
SubmissionComplete = 'SUBMISSION_COMPLETE'
SubmissionComplete = 'SUBMISSION_COMPLETE',
Test = 'TEST'
}
export type NotificationMetadata = BuildLimitThresholdExceededMetadata | BuildPlanCreditThresholdExceededMetadata | TestNotificationMetadata;
export type NotificationSubscription = {
__typename?: 'NotificationSubscription';
account?: Maybe<Account>;
@@ -3805,8 +3903,17 @@ export type NotificationSubscriptionMutationUnsubscribeArgs = {
id: Scalars['ID'];
};
export type NotificationThresholdExceeded = {
__typename?: 'NotificationThresholdExceeded';
count: Scalars['Int'];
limit: Scalars['Int'];
threshold: Scalars['Int'];
type: BuildLimitThresholdExceededMetadataType;
};
export enum NotificationType {
Email = 'EMAIL'
Email = 'EMAIL',
Web = 'WEB'
}
export type Offer = {
@@ -4090,6 +4197,8 @@ export type RootMutation = {
githubApp: GitHubAppMutation;
/** Mutations for GitHub App installations */
githubAppInstallation: GitHubAppInstallationMutation;
/** Mutations for GitHub build triggers */
githubBuildTrigger: GitHubBuildTriggerMutation;
/** Mutations for GitHub repositories */
githubRepository: GitHubRepositoryMutation;
/** Mutations for GitHub repository settings */
@@ -4200,14 +4309,15 @@ export type RootQuery = {
meUserActor?: Maybe<UserActor>;
project: ProjectQuery;
snack: SnackQuery;
/** Top-level query object for querying SSO Users. */
ssoUser: SsoUserQuery;
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
submissions: SubmissionQuery;
/** fetch all updates in a group */
updatesByGroup: Array<Update>;
/** Top-level query object for querying Users. */
/**
* Top-level query object for querying Users.
* @deprecated Public user queries are no longer supported
*/
user: UserQuery;
/** Top-level query object for querying UserActors. */
userActor: UserActorQuery;
@@ -4264,6 +4374,23 @@ export type Runtime = {
version: Scalars['String'];
};
export type RuntimeEdge = {
__typename?: 'RuntimeEdge';
cursor: Scalars['String'];
node: Runtime;
};
export type RuntimeFilterInput = {
/** Only return runtimes shared with this branch */
branchId?: InputMaybe<Scalars['String']>;
};
export type RuntimesConnection = {
__typename?: 'RuntimesConnection';
edges: Array<RuntimeEdge>;
pageInfo: PageInfo;
};
/** Represents a human SSO (not robot) actor. */
export type SsoUser = Actor & UserActor & {
__typename?: 'SSOUser';
@@ -4309,6 +4436,8 @@ export type SsoUser = Actor & UserActor & {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
/** Web notifications linked to a user */
webNotifications: Array<Notification>;
};
@@ -4351,24 +4480,6 @@ export type SsoUserDataInput = {
lastName?: InputMaybe<Scalars['String']>;
};
export type SsoUserQuery = {
__typename?: 'SSOUserQuery';
/** Query an SSOUser by ID */
byId: SsoUser;
/** Query an SSOUser by username */
byUsername: SsoUser;
};
export type SsoUserQueryByIdArgs = {
userId: Scalars['ID'];
};
export type SsoUserQueryByUsernameArgs = {
username: Scalars['String'];
};
export type SecondFactorBooleanResult = {
__typename?: 'SecondFactorBooleanResult';
success: Scalars['Boolean'];
@@ -4745,6 +4856,11 @@ export type SubscriptionDetailsPlanEnablementArgs = {
serviceMetric: EasServiceMetric;
};
export type TestNotificationMetadata = {
__typename?: 'TestNotificationMetadata';
message: Scalars['String'];
};
export type TimelineActivityConnection = {
__typename?: 'TimelineActivityConnection';
edges: Array<TimelineActivityEdge>;
@@ -4806,12 +4922,22 @@ export type UpdateBranch = {
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
name: Scalars['String'];
runtimes: RuntimesConnection;
updateGroups: Array<Array<Update>>;
updatedAt: Scalars['DateTime'];
updates: Array<Update>;
};
export type UpdateBranchRuntimesArgs = {
after?: InputMaybe<Scalars['String']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
first?: InputMaybe<Scalars['Int']>;
last?: InputMaybe<Scalars['Int']>;
};
export type UpdateBranchUpdateGroupsArgs = {
filter?: InputMaybe<UpdatesFilter>;
limit: Scalars['Int'];
@@ -4917,6 +5043,16 @@ export type UpdateChannelMutationEditUpdateChannelArgs = {
channelId: Scalars['ID'];
};
export type UpdateGitHubBuildTriggerInput = {
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type UpdateGitHubRepositorySettingsInput = {
baseDirectory: Scalars['String'];
};
@@ -5062,6 +5198,7 @@ export type User = Actor & UserActor & {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
webNotifications: Array<Notification>;
};
@@ -5150,6 +5287,8 @@ export type UserActor = {
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']>;
username: Scalars['String'];
/** Web notifications linked to a user */
webNotifications: Array<Notification>;
};
@@ -5368,9 +5507,15 @@ export type UserPermission = {
export type UserQuery = {
__typename?: 'UserQuery';
/** Query a User by ID */
/**
* Query a User by ID
* @deprecated Public user queries are no longer supported
*/
byId: User;
/** Query a User by username */
/**
* Query a User by username
* @deprecated Public user queries are no longer supported
*/
byUsername: User;
};
@@ -6136,6 +6281,17 @@ export type BranchesByAppQueryVariables = Exact<{
export type BranchesByAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> }> } } };
export type BranchesBasicPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type BranchesBasicPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, branchesPaginated: { __typename?: 'AppBranchesConnection', edges: Array<{ __typename?: 'AppBranchEdge', cursor: string, node: { __typename?: 'UpdateBranch', id: string, name: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewBranchesOnUpdateChannelQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
@@ -6173,6 +6329,7 @@ export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename
export type ViewUpdateChannelOnAppQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
filter?: InputMaybe<UpdatesFilter>;
}>;
@@ -6191,10 +6348,12 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String'];
@@ -6217,6 +6376,19 @@ export type GetAssetLimitPerUpdateGroupForAppQueryVariables = Exact<{
export type GetAssetLimitPerUpdateGroupForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, assetLimitPerUpdateGroup: number } } };
export type ViewRuntimesOnBranchQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
}>;
export type ViewRuntimesOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, runtimes: { __typename?: 'RuntimesConnection', edges: Array<{ __typename?: 'RuntimeEdge', cursor: string, node: { __typename?: 'Runtime', id: string, version: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } | null } } };
export type StatuspageServiceByServiceNamesQueryVariables = Exact<{
serviceNames: Array<StatuspageServiceName> | StatuspageServiceName;
}>;
@@ -6300,6 +6472,8 @@ export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, s
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any };
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
export type SubmissionFragment = { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null };
@@ -6308,6 +6482,10 @@ export type UpdateFragment = { __typename?: 'Update', id: string, group: string,
export type UpdateBranchFragment = { __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> };
export type UpdateBranchBasicInfoFragment = { __typename?: 'UpdateBranch', id: string, name: string };
export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string };
export type WebhookFragment = { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any };
export type AndroidAppBuildCredentialsFragment = { __typename?: 'AndroidAppBuildCredentials', id: string, isDefault: boolean, isLegacy: boolean, name: string, androidKeystore?: { __typename?: 'AndroidKeystore', id: string, type: AndroidKeystoreType, keystore: string, keystorePassword: string, keyAlias: string, keyPassword?: string | null, md5CertificateFingerprint?: string | null, sha1CertificateFingerprint?: string | null, sha256CertificateFingerprint?: string | null, createdAt: any, updatedAt: any } | null };
@@ -5,8 +5,11 @@ import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
BranchesBasicPaginatedOnAppQuery,
BranchesBasicPaginatedOnAppQueryVariables,
BranchesByAppQuery,
BranchesByAppQueryVariables,
UpdateBranchBasicInfoFragment,
UpdateBranchFragment,
ViewBranchQuery,
ViewBranchQueryVariables,
@@ -15,6 +18,7 @@ import {
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateBranchFragmentNode } from '../types/UpdateBranch';
import { UpdateBranchBasicInfoFragmentNode } from '../types/UpdateBranchBasicInfo';
export type UpdateBranchOnChannelObject = NonNullable<
ViewBranchesOnUpdateChannelQuery['app']['byId']['updateChannelByName']
@@ -24,7 +28,7 @@ export const BranchQuery = {
async getBranchByNameAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: ViewBranchQueryVariables
): Promise<NonNullable<ViewBranchQuery['app']['byId']['updateBranchByName']>> {
): Promise<UpdateBranchBasicInfoFragment> {
const response = await withErrorHandlingAsync<ViewBranchQuery>(
graphqlClient
.query<ViewBranchQuery, ViewBranchQueryVariables>(
@@ -35,11 +39,12 @@ export const BranchQuery = {
id
updateBranchByName(name: $name) {
id
name
...UpdateBranchBasicInfoFragment
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{
appId,
@@ -88,6 +93,56 @@ export const BranchQuery = {
return data?.app?.byId.updateBranches ?? [];
},
async listBranchesBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after, last, before }: BranchesBasicPaginatedOnAppQueryVariables
): Promise<BranchesBasicPaginatedOnAppQuery['app']['byId']['branchesPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<BranchesBasicPaginatedOnAppQuery, BranchesBasicPaginatedOnAppQueryVariables>(
gql`
query BranchesBasicPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
branchesPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateBranchBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateBranch'] }
)
.toPromise()
);
const { branchesPaginated } = response.app.byId;
if (!branchesPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return branchesPaginated;
},
async listBranchesOnChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName, offset, limit }: ViewBranchesOnUpdateChannelQueryVariables
@@ -5,7 +5,8 @@ import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
PageInfo,
UpdateBranchBasicInfoFragment,
UpdateFragment,
ViewUpdateChannelOnAppQuery,
ViewUpdateChannelOnAppQueryVariables,
ViewUpdateChannelsOnAppQuery,
@@ -14,6 +15,7 @@ import {
ViewUpdateChannelsPaginatedOnAppQueryVariables,
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateChannelBasicInfoFragmentNode } from '../types/UpdateChannelBasicInfo';
type ViewUpdateChannelsOnAppObject = NonNullable<
ViewUpdateChannelsOnAppQuery['app']['byId']['updateChannels']
@@ -23,25 +25,35 @@ type UpdateChannelByNameObject = NonNullable<
ViewUpdateChannelOnAppQuery['app']['byId']['updateChannelByName']
>;
// this asserts they are the same thing
// these types should have the same fields
export type UpdateChannelObject = ViewUpdateChannelsOnAppObject & UpdateChannelByNameObject;
export type UpdateBranchObject = UpdateChannelObject['updateBranches'][number];
export type UpdateChannelBasicInfo = NonNullable<
ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']['edges'][0]['node']
>;
export function composeUpdateBranchObject(
branchInfo: UpdateBranchBasicInfoFragment,
updateGroups: UpdateFragment[][]
): UpdateBranchObject {
return {
...branchInfo,
updateGroups,
};
}
export const ChannelQuery = {
async viewUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName }: ViewUpdateChannelOnAppQueryVariables
{ appId, channelName, filter }: ViewUpdateChannelOnAppQueryVariables
): Promise<UpdateChannelByNameObject> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<ViewUpdateChannelOnAppQuery, ViewUpdateChannelOnAppQueryVariables>(
gql`
query ViewUpdateChannelOnApp($appId: String!, $channelName: String!) {
query ViewUpdateChannelOnApp(
$appId: String!
$channelName: String!
$filter: UpdatesFilter
) {
app {
byId(appId: $appId) {
id
@@ -53,7 +65,7 @@ export const ChannelQuery = {
updateBranches(offset: 0, limit: 5) {
id
name
updateGroups(offset: 0, limit: 1) {
updateGroups(offset: 0, limit: 1, filter: $filter) {
id
...UpdateFragment
}
@@ -64,7 +76,7 @@ export const ChannelQuery = {
}
${print(UpdateFragmentNode)}
`,
{ appId, channelName },
{ appId, channelName, filter },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -123,8 +135,8 @@ export const ChannelQuery = {
},
async viewUpdateChannelsBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after }: ViewUpdateChannelsPaginatedOnAppQueryVariables
): Promise<[UpdateChannelBasicInfo[], PageInfo]> {
{ appId, first, after, last, before }: ViewUpdateChannelsPaginatedOnAppQueryVariables
): Promise<ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<
@@ -132,17 +144,23 @@ export const ChannelQuery = {
ViewUpdateChannelsPaginatedOnAppQueryVariables
>(
gql`
query ViewUpdateChannelsPaginatedOnApp($appId: String!, $first: Int, $after: String) {
query ViewUpdateChannelsPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
channelsPaginated(first: $first, after: $after) {
channelsPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
name
branchMapping
...UpdateChannelBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
@@ -154,8 +172,9 @@ export const ChannelQuery = {
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ appId, first, after },
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -165,7 +184,6 @@ export const ChannelQuery = {
if (!channelsPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return [channelsPaginated.edges.map(edge => edge.node) ?? [], channelsPaginated.pageInfo];
return channelsPaginated;
},
};
@@ -0,0 +1,85 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import { withErrorHandlingAsync } from '../client';
import {
RuntimeFragment,
ViewRuntimesOnBranchQuery,
ViewRuntimesOnBranchQueryVariables,
} from '../generated';
import { RuntimeFragmentNode } from '../types/Runtime';
export const RuntimeQuery = {
async getRuntimesOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name, first, after, last, before, filter }: ViewRuntimesOnBranchQueryVariables
): Promise<Connection<RuntimeFragment>> {
const response = await withErrorHandlingAsync<ViewRuntimesOnBranchQuery>(
graphqlClient
.query<ViewRuntimesOnBranchQuery, ViewRuntimesOnBranchQueryVariables>(
gql`
query ViewRuntimesOnBranch(
$appId: String!
$name: String!
$first: Int
$after: String
$last: Int
$before: String
$filter: RuntimeFilterInput
) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
runtimes(
first: $first
after: $after
before: $before
last: $last
filter: $filter
) {
edges {
node {
id
...RuntimeFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
}
${print(RuntimeFragmentNode)}
`,
{
appId,
name,
first,
after,
last,
before,
filter,
},
{ additionalTypenames: ['UpdateBranch', 'Runtime'] }
)
.toPromise()
);
const { updateBranchByName } = response.app.byId;
if (!updateBranchByName) {
throw new BranchNotFoundError(`Could not find a branch named "${name}".`);
}
return updateBranchByName.runtimes;
},
};
@@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export const RuntimeFragmentNode = gql`
fragment RuntimeFragment on Runtime {
id
version
}
`;
@@ -2,15 +2,17 @@ import { print } from 'graphql';
import gql from 'graphql-tag';
import { UpdateFragmentNode } from './Update';
import { UpdateBranchBasicInfoFragmentNode } from './UpdateBranchBasicInfo';
export const UpdateBranchFragmentNode = gql`
fragment UpdateBranchFragment on UpdateBranch {
id
name
...UpdateBranchBasicInfoFragment
updates(offset: 0, limit: 10) {
id
...UpdateFragment
}
}
${print(UpdateFragmentNode)}
${print(UpdateBranchBasicInfoFragmentNode)}
`;
@@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export const UpdateBranchBasicInfoFragmentNode = gql`
fragment UpdateBranchBasicInfoFragment on UpdateBranch {
id
name
}
`;
@@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const UpdateChannelBasicInfoFragmentNode = gql`
fragment UpdateChannelBasicInfoFragment on UpdateChannel {
id
name
branchMapping
}
`;
+2 -2
View File
@@ -38,7 +38,7 @@ const AppExtensionsConfigSchema = Joi.array().items(
})
);
export async function resolveMangedProjectTargetsAsync({
export async function resolveManagedProjectTargetsAsync({
exp,
projectDir,
xcodeBuildContext,
@@ -138,7 +138,7 @@ export async function resolveTargetsAsync(opts: ResolveTargetOptions): Promise<T
if (workflow === Workflow.GENERIC) {
return await resolveBareProjectTargetsAsync(opts);
} else if (workflow === Workflow.MANAGED) {
return await resolveMangedProjectTargetsAsync(opts);
return await resolveManagedProjectTargetsAsync(opts);
} else {
throw new Error(`Unknown workflow: ${workflow}`);
}
@@ -0,0 +1,339 @@
import { v4 as uuidv4 } from 'uuid';
import {
testChannelObject,
testUpdateBranch1,
testUpdateBranch2,
} from '../../channel/__tests__/fixtures';
import {
BranchMappingOperator,
BranchMappingValidationError,
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import {
assertRolloutBranchMapping,
composeRollout,
createRolloutBranchMapping,
editRolloutBranchMapping,
getRollout,
getRolloutInfo,
getRolloutInfoFromBranchMapping,
isConstrainedRollout,
isConstrainedRolloutInfo,
isLegacyRolloutInfo,
isRolloutBranchMapping,
} from '../branch-mapping';
import {
rolloutBranchMapping,
rolloutBranchMappingLegacy,
standardBranchMapping,
} from './fixtures';
describe(composeRollout, () => {
it('composes a rollout', () => {
const rollout = getRollout(testChannelObject);
const rolloutInfo = getRolloutInfo(testChannelObject);
const composedRollout = composeRollout(rolloutInfo, testUpdateBranch2, testUpdateBranch1);
expect(composedRollout).toEqual(rollout);
});
it('throws if the branches to not match the rollout info', () => {
const rolloutInfo = getRolloutInfo(testChannelObject);
expect(() => composeRollout(rolloutInfo, testUpdateBranch1, testUpdateBranch2)).toThrowError(
BranchMappingValidationError
);
});
});
describe(createRolloutBranchMapping, () => {
it('creates a rollout branch mapping', () => {
const branchMapping = createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 10,
runtimeVersion: '1.0.0',
});
expect(isRolloutBranchMapping(branchMapping)).toBe(true);
});
it('throws if an invalid number is passed in', () => {
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 0.1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: -1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 1000,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
});
});
describe(assertRolloutBranchMapping, () => {
it('asserts a rollout branch mapping', () => {
expect(() => assertRolloutBranchMapping(standardBranchMapping)).toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMapping)).not.toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMappingLegacy)).not.toThrowError(
BranchMappingValidationError
);
});
});
describe(isLegacyRolloutInfo, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isLegacyRolloutInfo(constrainedRollout)).toBe(false);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isLegacyRolloutInfo(legacyRollout)).toBe(true);
});
});
describe(isConstrainedRollout, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isConstrainedRolloutInfo(constrainedRollout)).toBe(true);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isConstrainedRolloutInfo(legacyRollout)).toBe(false);
});
});
describe(getRollout, () => {
it('doesnt get the mapping if it isnt a rollout', () => {
const notRollout = { ...testChannelObject };
notRollout.branchMapping = JSON.stringify(standardBranchMapping);
expect(() => getRollout(notRollout)).toThrowError();
});
it('gets a constrained rollout', () => {
const constrainedRollout = { ...testChannelObject };
const rolloutBranchMappingWithCorrectBranch = { ...rolloutBranchMapping };
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testUpdateBranch1.id;
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testUpdateBranch2.id;
constrainedRollout.branchMapping = JSON.stringify(rolloutBranchMapping);
const rollout = getRollout(constrainedRollout);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
});
it('gets a legacy rollout', () => {
const rollout = getRollout(testChannelObject);
expect(rollout.percentRolledOut).toEqual(15);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testUpdateBranch2);
expect(rollout.rolledOutBranch).toEqual(testUpdateBranch1);
expect(rollout.defaultBranchId).toEqual(testUpdateBranch2.id);
expect(rollout.rolledOutBranchId).toEqual(testUpdateBranch1.id);
});
});
describe(getRolloutInfoFromBranchMapping, () => {
it('gets a constrained rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMapping.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMapping.data[0].branchId);
});
it('gets a legacy rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(rollout.percentRolledOut).toEqual(10);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMappingLegacy.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMappingLegacy.data[0].branchId);
});
});
describe(editRolloutBranchMapping, () => {
it('doesnt edit the branch mapping if the input is out of range', () => {
expect(() => editRolloutBranchMapping(rolloutBranchMapping, -1)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 101)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 0.1)).toThrowError();
});
it('returns a new instance of a branch mapping', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
expect(rolloutBranchMapping).not.toBe(editedBranchMapping);
});
it('edits the branch mapping for a constrained rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
});
it('edits the branch mapping for a legacy rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMappingLegacy, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
});
});
describe(isRolloutBranchMapping, () => {
it('detects a rollout made by the cli', () => {
expect(isRolloutBranchMapping(rolloutBranchMappingLegacy)).toBe(true);
expect(isRolloutBranchMapping(rolloutBranchMapping)).toBe(true);
});
it('detects custom mappings equivalent to rollouts made by the cli', () => {
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(true);
});
it('correctly classifies branchMappings that arent rollouts', () => {
expect(isRolloutBranchMapping(standardBranchMapping)).toBe(false);
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_gt' as BranchMappingOperator,
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(false);
const customMapping2 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping2)).toBe(false);
const customMapping3 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping3)).toBe(false);
const customMapping4 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping4)).toBe(false);
const customMapping5 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '2.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping5)).toBe(false);
const customMapping6 = {
version: 0,
data: [
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping6)).toBe(false);
});
});
@@ -0,0 +1,70 @@
import { v4 as uuidv4 } from 'uuid';
import { BranchMapping, alwaysTrue } from '../../channel/branch-mapping';
import { ConstrainedRolloutBranchMapping, LegacyRolloutBranchMapping } from '../branch-mapping';
export const rolloutBranchMapping: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMapping2: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMappingLegacy: LegacyRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const standardBranchMapping: BranchMapping = {
version: 0,
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
};
@@ -1,200 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import {
BranchMappingOperator,
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import { isRollout } from '../utils';
const rolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
const rolloutBranchMappingLegacy = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
const standardBranchMapping = {
version: 0,
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
};
describe(isRollout, () => {
it('detects a rollout made by the cli', () => {
expect(isRollout(rolloutBranchMappingLegacy)).toBe(true);
expect(isRollout(rolloutBranchMapping)).toBe(true);
});
it('detects custom mappings equivalent to rollouts made by the cli', () => {
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping1)).toBe(true);
});
it('correctly classifies branchMappings that arent rollouts', () => {
expect(isRollout(standardBranchMapping)).toBe(false);
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_gt' as BranchMappingOperator,
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping1)).toBe(false);
const customMapping2 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping2)).toBe(false);
const customMapping3 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping3)).toBe(false);
const customMapping4 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRollout(customMapping4)).toBe(false);
const customMapping5 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '2.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping5)).toBe(false);
const customMapping6 = {
version: 0,
data: [
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRollout(customMapping6)).toBe(false);
});
});
@@ -0,0 +1,232 @@
import assert from 'assert';
import { SelectBranch } from '../../branch/actions/SelectBranch';
import { getStandardBranchId, hasStandardBranchMap } from '../../channel/branch-mapping';
import { getUpdateBranch } from '../../channel/utils';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import {
UpdateBranchBasicInfoFragment,
UpdateChannelBasicInfoFragment,
UpdateFragment,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
ChannelQuery,
UpdateChannelObject,
composeUpdateBranchObject,
} from '../../graphql/queries/ChannelQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
composeRollout,
createRolloutBranchMapping,
getRolloutInfoFromBranchMapping,
isRollout,
} from '../branch-mapping';
import {
displayRolloutDetails,
formatBranchWithUpdateGroup,
promptForRolloutPercentAsync,
} from '../utils';
import { SelectRuntime } from './SelectRuntime';
export type NonInteractiveOptions = {
branchNameToRollout: string;
percent: number;
runtimeVersion: string;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.branchNameToRollout && !!options.percent && !!options.runtimeVersion;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--branch, --percent and --runtime-version are required for creating a rollout in non-interactive mode.'
);
}
/**
* Create a rollout for the project.
*/
export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { branchNameToRollout } = this.options;
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
if (isRollout(this.channelInfo)) {
throw new Error(`A rollout is already in progress for channel ${this.channelInfo.name}`);
}
if (!hasStandardBranchMap(this.channelInfo)) {
throw new Error(
`You have a custom branch mapping. Map your channel to a single branch before creating a rollout. Received: ${this.channelInfo.branchMapping}`
);
}
const defaultBranchId = getStandardBranchId(this.channelInfo);
const branchInfoToRollout = branchNameToRollout
? await this.resolveBranchNameAsync(ctx, branchNameToRollout)
: await this.selectBranchAsync(ctx, defaultBranchId);
if (branchInfoToRollout.id === defaultBranchId) {
throw new Error(
`Channel ${this.channelInfo.name} is already mapped to branch ${branchInfoToRollout.name}.`
);
}
const runtimeVersion =
this.options.runtimeVersion ??
(await this.selectRuntimeVersionAsync(ctx, branchInfoToRollout, defaultBranchId));
Log.newLine();
const promptMessage = `What percent of users should be routed to branch ${branchInfoToRollout.name}?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
const rolloutBranchMapping = createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId: branchInfoToRollout.id,
percent,
runtimeVersion,
});
const channelObject = await this.getChannelObjectAsync(ctx, runtimeVersion);
const defaultBranch = getUpdateBranch(channelObject, defaultBranchId);
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const rolloutInfo = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
const rolledOutBranchUpdateGroup = await this.getLatestUpdateGroupOnBranchAsync(
ctx,
branchInfoToRollout,
runtimeVersion
);
const rolledOutBranch = composeUpdateBranchObject(
branchInfoToRollout,
rolledOutBranchUpdateGroup ? [rolledOutBranchUpdateGroup] : []
);
const rollout = composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
displayRolloutDetails(this.channelInfo.name, rollout);
Log.log(
formatBranchWithUpdateGroup(defaultUpdateGroup, defaultBranch, 100 - rollout.percentRolledOut)
);
Log.addNewLineIfNone();
Log.log(
formatBranchWithUpdateGroup(
rolledOutBranchUpdateGroup,
rolledOutBranch,
rollout.percentRolledOut
)
);
Log.addNewLineIfNone();
const didConfirm = await this.confirmCreationAsync(ctx);
if (!didConfirm) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(rolloutBranchMapping),
});
Log.succeed('✅ Successfully created rollout');
return newChannelInfo;
}
async confirmCreationAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Create rollout?`,
});
}
async getChannelObjectAsync(
ctx: EASUpdateContext,
runtimeVersion: string
): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
filter: { runtimeVersions: [runtimeVersion] },
});
}
async getLatestUpdateGroupOnBranchAsync(
ctx: EASUpdateContext,
branchInfo: UpdateBranchBasicInfoFragment,
runtimeVersion: string
): Promise<UpdateFragment[] | null> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId: projectId,
branchName: branchInfo.name,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtimeVersion],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return updateGroups[0] ?? null;
}
async selectRuntimeVersionAsync(
ctx: EASUpdateContext,
branchToRollout: UpdateBranchBasicInfoFragment,
defaultBranchId: string
): Promise<string> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
// we just want the branch name, not the update group
const channelObjectRtvAgnostic = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
});
const defaultBranchRtvAgnostic = getUpdateBranch(channelObjectRtvAgnostic, defaultBranchId);
const selectRuntimeAction = new SelectRuntime(branchToRollout, {
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
});
return await selectRuntimeAction.runAsync(ctx);
}
async selectBranchAsync(
ctx: EASUpdateContext,
defaultBranchId: string
): Promise<UpdateBranchBasicInfoFragment> {
const selectBranchAction = new SelectBranch({
printedType: 'branch to rollout',
// we don't want to show the default branch as an option
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo.id !== defaultBranchId,
});
const branchInfo = await selectBranchAction.runAsync(ctx);
if (!branchInfo) {
throw new Error(`You dont have any branches. Create one with 'eas branch:create'`);
}
return branchInfo;
}
async resolveBranchNameAsync(
ctx: EASUpdateContext,
branchName: string
): Promise<UpdateBranchBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: app.projectId,
name: branchName,
});
}
}
@@ -0,0 +1,110 @@
import assert from 'assert';
import chalk from 'chalk';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
editRolloutBranchMapping,
getRollout,
getRolloutBranchMapping,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { promptForRolloutPercentAsync } from '../utils';
export type NonInteractiveOptions = {
percent: number;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.percent;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--percent is required for editing a rollout in non-interactive mode.'
);
}
/**
* Edit an existing rollout for the project.
*/
export class EditRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch, defaultBranch } = rollout;
const promptMessage = `What percent of users should be directed to the ${rolledOutBranch.name} branch ?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
Log.newLine();
Log.log(
`📝 The updated rollout will send ${chalk.bold(percent)}% of users to the ${chalk.bold(
rolledOutBranch.name
)} branch and ${chalk.bold(100 - percent)}% to the ${chalk.bold(defaultBranch.name)} branch.`
);
const confirmEdit = await this.confirmEditAsync(ctx);
if (!confirmEdit) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channelObject.id,
branchMapping: JSON.stringify(newBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`✅ Successfuly updated rollout`);
return newChannelInfo;
}
async confirmEditAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Confirm changes?`,
});
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
@@ -0,0 +1,212 @@
import assert from 'assert';
import chalk from 'chalk';
import { getAlwaysTrueBranchMapping } from '../../channel/branch-mapping';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { republishAsync } from '../../update/republish';
import formatFields from '../../utils/formatFields';
import {
Rollout,
getRollout,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { formatBranchWithUpdateGroup } from '../utils';
export enum EndOutcome {
REPUBLISH_AND_ROUTE_BACK = 'republish-and-route-back',
ROUTE_BACK = 'route-back',
}
export type NonInteractiveOptions = {
outcome: EndOutcome;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.outcome;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--outcome is required for ending a rollout in non-interactive mode.'
);
}
/**
* End an existing rollout for the project.
*/
export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
let outcome: EndOutcome;
if (!rolledOutUpdateGroup) {
Log.log(`⚠️ There is no Update Group being served on ${rolledOutBranch.name}.`);
assert(
this.options.outcome !== EndOutcome.REPUBLISH_AND_ROUTE_BACK,
'The only valid outcome for this rollout is to route back to the default branch. '
);
outcome = EndOutcome.ROUTE_BACK;
} else {
outcome = this.options.outcome ?? (await this.selectOutcomeAsync(rollout));
}
const didConfirm = await this.confirmOutcomeAsync(ctx, outcome, rollout);
if (!didConfirm) {
if (!rolledOutUpdateGroup) {
Log.log(
`If you wish to stop serving updates to your users, you can edit your rollout to 100% on ${chalk.bold(
rolledOutBranch.name
)} instead`
);
}
throw new Error('Aborting...');
}
return await this.performOutcomeAsync(ctx, rollout, outcome);
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
async selectOutcomeAsync(rollout: Rollout): Promise<EndOutcome> {
const { rolledOutBranch, percentRolledOut, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const outcomes = [
{
value: EndOutcome.REPUBLISH_AND_ROUTE_BACK,
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
},
{
value: EndOutcome.ROUTE_BACK,
title: formatBranchWithUpdateGroup(
defaultUpdateGroup,
defaultBranch,
100 - percentRolledOut
),
},
];
const { outcome: selectedOutcome } = await promptAsync({
type: 'select',
name: 'outcome',
message: `Which Update Group would you like to serve?`,
choices: outcomes,
});
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
Log.log(
`🍽️ The Update Group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
);
} else {
Log.log(
`🍽️ The Update Group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
);
}
return selectedOutcome;
}
async performOutcomeAsync(
ctx: EASUpdateContext,
rollout: Rollout,
outcome: EndOutcome
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
const { rolledOutBranch, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
if (outcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
const arbitraryUpdate = rolledOutUpdateGroup[0];
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
await republishAsync({
graphqlClient,
app,
updatesToPublish: rolledOutUpdateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
})),
targetBranch: { branchId: defaultBranch.id, branchName: defaultBranch.name },
updateMessage: newUpdateMessage,
});
}
const alwaysTrueDefaultBranchMapping = getAlwaysTrueBranchMapping(defaultBranch.id);
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(alwaysTrueDefaultBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`🚗 Routed all traffic back to branch ${chalk.bold(defaultBranch.name)}`);
Log.log(`✅ Successfully ended rollout`);
return newChannelInfo;
}
async confirmOutcomeAsync(
ctx: EASUpdateContext,
selectedOutcome: EndOutcome,
rollout: Rollout
): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
const { rolledOutBranch, defaultBranch } = rollout;
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
Log.log(`Ending the rollout will do the following:`);
const actions = formatFields([
{
label: '1.',
value: `🔁 Republish the Update Group from ${chalk.bold(
rolledOutBranch.name
)} onto ${chalk.bold(defaultBranch.name)}`,
},
{ label: '2.', value: `⬅️ Route all users back to ${chalk.bold(defaultBranch.name)}` },
]);
Log.log(actions);
} else {
Log.log(
`⬅️ Ending the rollout will route all users back to ${chalk.bold(defaultBranch.name)}`
);
}
return await confirmAsync({
message: `Continue?`,
});
}
}
@@ -0,0 +1,91 @@
import assert from 'assert';
import chalk from 'chalk';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../prompts';
import { getRolloutInfo, isConstrainedRolloutInfo, isRollout } from '../branch-mapping';
import { printRollout } from '../utils';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
export enum ManageRolloutActions {
EDIT = 'Edit',
END = 'End',
GO_BACK = 'Go back',
}
/**
* Manage a rollout for the project.
*/
export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: {
callingAction?: EASUpdateAction;
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<EASUpdateAction> {
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout selection cannot be run in non-interactive mode.`);
}
const channelObject = await this.getChannelObjectAsync(ctx);
printRollout(channelObject);
const action = this.options.action ?? (await this.selectActionAsync());
switch (action as ManageRolloutActions) {
case ManageRolloutActions.EDIT:
return new EditRollout(this.channelInfo, this.options);
case ManageRolloutActions.END:
return new EndRollout(this.channelInfo, this.options);
case ManageRolloutActions.GO_BACK:
assert(this.options.callingAction, 'calling action must be defined');
return this.options.callingAction;
}
}
async selectActionAsync(): Promise<ManageRolloutActions> {
const manageOptions = [ManageRolloutActions.EDIT, ManageRolloutActions.END];
if (this.options.callingAction) {
manageOptions.push(ManageRolloutActions.GO_BACK);
}
const { manageOption: selectedManageOption } = await promptAsync({
type: 'select',
name: 'manageOption',
message: `What would you like to do?`,
choices: manageOptions.map(manageOption => ({
value: manageOption,
title: manageOption,
})),
});
return selectedManageOption;
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To manage a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
@@ -0,0 +1,69 @@
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
import { ManageRolloutActions } from './ManageRollout';
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
/**
* Control a rollout in non interactive mode.
*/
export class NonInteractiveRollout implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { channelName, action } = this.options;
const { nonInteractive, app, graphqlClient } = ctx;
if (!nonInteractive) {
throw new Error(`This action is meant for non-interactive mode.`);
}
if (!channelName) {
throw new Error(
`The channel argument is required in non-interactive mode. Run eas channel:rollout [channel-name]`
);
}
const channelInfo = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
if (!action) {
throw new Error(`--action is required in non-interactive mode.`);
}
await this.runActionAsync(ctx, action, channelInfo, this.options);
}
async runActionAsync(
ctx: EASUpdateContext,
action: RolloutActions,
channelInfo: UpdateChannelBasicInfoFragment,
options: Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions>
): Promise<UpdateChannelBasicInfoFragment> {
switch (action) {
case MainMenuActions.CREATE_NEW:
return await new CreateRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.EDIT:
return await new EditRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.END:
return await new EndRollout(channelInfo, options).runAsync(ctx);
}
}
}
@@ -0,0 +1,137 @@
import assert from 'assert';
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { isRollout } from '../branch-mapping';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from './EditRollout';
import { NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
import { ManageRollout, ManageRolloutActions } from './ManageRollout';
import { SelectRollout } from './SelectRollout';
export enum MainMenuActions {
CREATE_NEW = 'Create a new rollout',
MANAGE_EXISTING = 'Manage an existing rollout',
}
export type RolloutActions =
| MainMenuActions.CREATE_NEW
| ManageRolloutActions.EDIT
| ManageRolloutActions.END;
/**
* Manage a rollout for the project.
*/
export class RolloutMainMenu implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { action } = this.options;
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout main menu cannot be run in non-interactive mode.`);
}
const menuOption = action ? this.toMainMenuAction(action) : await this.promptMenuActionAsync();
await this.runActionAsync(ctx, menuOption);
}
async runActionAsync(ctx: EASUpdateContext, menuAction: MainMenuActions): Promise<null> {
const { channelName } = this.options;
switch (menuAction) {
case MainMenuActions.CREATE_NEW: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectChannelAsync(ctx, channelInfo => !isRollout(channelInfo));
await new CreateRollout(channelInfo, this.options).runAsync(ctx);
return null;
}
case MainMenuActions.MANAGE_EXISTING: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectRolloutAsync(ctx);
if (!channelInfo) {
Log.log('You dont have any rollouts.');
} else {
assert(
this.options.action !== MainMenuActions.CREATE_NEW,
'Invalid route for create action'
);
const manageAction = await new ManageRollout(channelInfo, {
...this.options,
action: this.options.action,
callingAction: this,
}).runAsync(ctx);
await manageAction.runAsync(ctx);
}
return null;
}
}
}
async selectRolloutAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const selectRollout = new SelectRollout();
const channelInfo = await selectRollout.runAsync(ctx);
return channelInfo;
}
async selectChannelAsync(
ctx: EASUpdateContext,
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<UpdateChannelBasicInfoFragment> {
const selectChannelAction = new SelectChannel({ filterPredicate });
const channelInfo = await selectChannelAction.runAsync(ctx);
if (!channelInfo) {
throw new Error(`You dont have any channels. Create one with <TODO>`);
}
return channelInfo;
}
async resolveChannelNameAsync(
ctx: EASUpdateContext,
channelName: string
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
}
toMainMenuAction(action: RolloutActions): MainMenuActions {
if (action === MainMenuActions.CREATE_NEW) {
return MainMenuActions.CREATE_NEW;
} else if (action === ManageRolloutActions.EDIT || action === ManageRolloutActions.END) {
return MainMenuActions.MANAGE_EXISTING;
} else {
throw new Error(`Action not supported yet: ` + action);
}
}
async promptMenuActionAsync(): Promise<MainMenuActions> {
const menuOptions = [MainMenuActions.CREATE_NEW, MainMenuActions.MANAGE_EXISTING];
const { menuOption: selectedMenuOption } = await promptAsync({
type: 'select',
name: 'menuOption',
message: `What would you like to do?`,
choices: menuOptions.map(menuOption => ({
value: menuOption,
title: menuOption,
})),
});
return selectedMenuOption;
}
}
@@ -0,0 +1,38 @@
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { Ora, ora } from '../../ora';
import { Edge, QueryParams } from '../../utils/relay';
import { isRollout } from '../branch-mapping';
/**
* Select an existing rollout for the project.
*/
export class SelectRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
let assetSpinner: Ora | null = null;
const afterEachFilterQuery = (
_externalQueryParams: QueryParams,
totalNodesFetched: number,
_dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
): void => {
if (willFetchAgain && !assetSpinner) {
assetSpinner = ora().start('Fetching channels...');
}
if (assetSpinner) {
assetSpinner.text = `Fetched ${totalNodesFetched} channels`;
}
};
const selectChannelAction = new SelectChannel({
printedType: 'rollout',
filterPredicate: isRollout,
afterEachFilterQuery,
});
const channelInfo = await selectChannelAction.runAsync(ctx);
if (assetSpinner) {
(assetSpinner as Ora).succeed(`Fetched all channels`);
}
return channelInfo;
}
}
@@ -0,0 +1,219 @@
import assert from 'assert';
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { RuntimeFragment, UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { learnMore } from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { formatRuntimeWithUpdateGroup } from '../utils';
function beginSentence(phrase: string): string {
if (typeof phrase !== 'string' || phrase.length === 0) {
return phrase; // Return the input without any modification if it's not a string or empty
}
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
}
/**
* Select a runtime from a branch
*/
export class SelectRuntime implements EASUpdateAction<string> {
private printedType;
constructor(
private branchInfo: UpdateBranchBasicInfoFragment,
private options: {
anotherBranchToIntersectRuntimesBy?: UpdateBranchBasicInfoFragment;
} = {}
) {
this.printedType = options.anotherBranchToIntersectRuntimesBy
? `compatible runtime`
: `runtime`;
}
warnNoRuntime(): void {
if (this.options.anotherBranchToIntersectRuntimesBy) {
const intersectBranchName = this.options.anotherBranchToIntersectRuntimesBy.name;
Log.warn(
`⚠️ Branches ${this.branchInfo.name} and ${intersectBranchName} dont have any updates with the same runtime.`
);
// TODO(quin): write a learn more
Log.warn(`Your updates could be misconfigured. ${learnMore('https://expo.fyi/todo')}`);
} else {
// no runtime on branch means no updates published on branch
Log.warn(`⚠️ There are no updates published on branch ${this.branchInfo.name}.`);
}
}
formatCantFindRuntime(): string {
return `🕵️ Not finding the update you were looking for? ${learnMore('https://expo.fyi/todo')}`;
}
public async runAsync(ctx: EASUpdateContext): Promise<string> {
const { nonInteractive, graphqlClient, app } = ctx;
const { projectId } = app;
if (nonInteractive) {
throw new NonInteractiveError(`runtime selection cannot be run in non-interactive mode.`);
}
const newestRuntimeConnection = await this.getNewestRuntimeAsync(graphqlClient, {
appId: projectId,
branchName: this.branchInfo.name,
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
});
if (!newestRuntimeConnection) {
Log.addNewLineIfNone();
this.warnNoRuntime();
return await this.promptForRuntimeAsync();
}
const moreThanOneRuntime = newestRuntimeConnection.edges.length > 1;
Log.log(`✅ ${beginSentence(this.printedType)}${moreThanOneRuntime ? 's' : ''} detected`);
if (!moreThanOneRuntime) {
const runtime = newestRuntimeConnection.edges[0].node;
const formattedRuntimeWithGroup = await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId: projectId,
branchName: this.branchInfo.name,
runtime,
});
Log.addNewLineIfNone();
Log.log(formattedRuntimeWithGroup);
Log.addNewLineIfNone();
const useRuntime = await confirmAsync({
message: `Target ${this.printedType} ${chalk.bold(runtime.version)}?`,
});
if (useRuntime) {
return runtime.version;
} else {
Log.newLine();
Log.warn(this.formatCantFindRuntime());
return await this.promptForRuntimeAsync();
}
}
Log.log(this.formatCantFindRuntime());
const selectedRuntime = await this.selectRuntimesAsync(graphqlClient, {
appId: projectId,
branchName: this.branchInfo.name,
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
});
if (!selectedRuntime) {
throw new Error(`No ${this.printedType} selected`);
}
return selectedRuntime.version;
}
async getNewestRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
anotherBranchIdToIntersectRuntimesBy,
}: {
appId: string;
branchName: string;
anotherBranchIdToIntersectRuntimesBy?: string;
}
): Promise<Connection<RuntimeFragment> | null> {
const connection = await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: 1,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
const { edges } = connection;
if (edges.length === 0) {
return null;
}
return connection;
}
async displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName,
runtime,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
branchName: string;
runtime: RuntimeFragment;
}): Promise<string> {
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId,
branchName,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtime.version],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime);
}
async selectRuntimesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
anotherBranchIdToIntersectRuntimesBy,
batchSize = 5,
}: {
appId: string;
branchName: string;
anotherBranchIdToIntersectRuntimesBy?: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName,
runtime,
});
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
async promptForRuntimeAsync(): Promise<string> {
Log.log(`You can input a runtime manually then publish an update later.`);
const { runtimeVersion } = await promptAsync({
type: 'text',
name: 'runtimeVersion',
message: 'Input a runtime version:',
initial: '1.0.0',
});
return runtimeVersion;
}
}
@@ -0,0 +1,84 @@
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../../channel/__tests__/fixtures';
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { standardBranchMapping } from '../../__tests__/fixtures';
import { SelectRollout } from '../SelectRollout';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectRollout, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no rollouts', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
});
it('returns the rollout without prompting if project only one rollout', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBe(testBasicChannelInfo);
});
it('returns the selected rollout', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBe(testBasicChannelInfo);
});
it('filters the channels for rollouts', async () => {
const standardBranchMappingChannel = { ...testBasicChannelInfo };
standardBranchMappingChannel.branchMapping = JSON.stringify(standardBranchMapping);
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: standardBranchMappingChannel, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectRollout = new SelectRollout();
// dont fail if users are running in non-interactive mode
await expect(selectRollout.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -0,0 +1,393 @@
import assert from 'assert';
import {
BranchMapping,
BranchMappingAlwaysTrue,
BranchMappingNode,
BranchMappingValidationError,
alwaysTrue,
assertNodeObject,
assertNumber,
assertStatement,
assertString,
getBranchMapping,
getNodesFromStatement,
isAlwaysTrue,
isAndStatement,
isStatement,
} from '../channel/branch-mapping';
import { getUpdateBranch } from '../channel/utils';
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
export type Rollout = LegacyRollout | ConstrainedRollout;
export type RolloutInfo = LegacyRolloutInfo | ConstrainedRolloutInfo;
type ConstrainedRollout = LegacyRollout & {
runtimeVersion: string;
};
type LegacyRollout = {
rolledOutBranch: UpdateBranchObject;
defaultBranch: UpdateBranchObject;
} & LegacyRolloutInfo;
type ConstrainedRolloutInfo = LegacyRolloutInfo & {
runtimeVersion: string;
};
type LegacyRolloutInfo = {
rolledOutBranchId: string;
percentRolledOut: number;
defaultBranchId: string;
};
export type RolloutBranchMapping = LegacyRolloutBranchMapping | ConstrainedRolloutBranchMapping;
type RolloutNode = {
clientKey: 'rolloutToken';
branchMappingOperator: 'hash_lt';
operand: number;
};
type RuntimeVersionNode = {
clientKey: 'runtimeVersion';
branchMappingOperator: '==';
operand: string;
};
type RtvConstrainedRolloutNode =
| ['and', RolloutNode, RuntimeVersionNode]
| ['and', RuntimeVersionNode, RolloutNode];
export type LegacyRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export type ConstrainedRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RtvConstrainedRolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export function isLegacyRolloutInfo(rollout: RolloutInfo): rollout is LegacyRolloutInfo {
return !isConstrainedRolloutInfo(rollout);
}
export function isConstrainedRolloutInfo(rollout: RolloutInfo): rollout is ConstrainedRolloutInfo {
return 'runtimeVersion' in rollout;
}
export function isConstrainedRollout(rollout: Rollout): rollout is ConstrainedRollout {
return isConstrainedRolloutInfo(rollout);
}
export function getRolloutInfo(basicChannelInfo: UpdateChannelBasicInfoFragment): RolloutInfo {
const rolloutBranchMapping = getRolloutBranchMapping(basicChannelInfo.branchMapping);
return getRolloutInfoFromBranchMapping(rolloutBranchMapping);
}
export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapping): RolloutInfo {
const rolledOutBranchId = branchMapping.data[0].branchId;
const defaultBranchId = branchMapping.data[1].branchId;
if (isRtvConstrainedRollout(branchMapping)) {
const statementNode = branchMapping.data[0].branchMappingLogic;
assertStatement(statementNode);
const nodesFromStatement = getNodesFromStatement(statementNode);
const runtimeVersionNode = nodesFromStatement.find(isRuntimeVersionNode);
assert(runtimeVersionNode, 'Runtime version node must be defined.');
assertNodeObject(runtimeVersionNode);
const runtimeVersion = runtimeVersionNode.operand;
assertString(runtimeVersion);
const rolloutNode = nodesFromStatement.find(isRolloutNode);
assert(rolloutNode, 'Rollout node must be defined.');
assertNodeObject(rolloutNode);
const operand = rolloutNode.operand;
assertNumber(operand);
return {
rolledOutBranchId,
percentRolledOut: operand * 100,
runtimeVersion,
defaultBranchId,
};
} else {
const rolloutNode = branchMapping.data[0].branchMappingLogic;
assertNodeObject(rolloutNode);
const operand = rolloutNode.operand;
assertNumber(operand);
return {
rolledOutBranchId,
percentRolledOut: operand * 100,
defaultBranchId,
};
}
}
export function getRollout(channel: UpdateChannelObject): Rollout {
const rolloutBranchMapping = getRolloutBranchMapping(channel.branchMapping);
const rolledOutBranchId = rolloutBranchMapping.data[0].branchId;
const rolledOutBranch = getUpdateBranch(channel, rolledOutBranchId);
const defaultBranchId = rolloutBranchMapping.data[1].branchId;
const defaultBranch = getUpdateBranch(channel, defaultBranchId);
const rolloutInfo = getRolloutInfo(channel);
return composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
}
export function composeRollout(
rolloutInfo: RolloutInfo,
defaultBranch: UpdateBranchObject,
rolledOutBranch: UpdateBranchObject
): Rollout {
if (rolloutInfo.defaultBranchId !== defaultBranch.id) {
throw new BranchMappingValidationError(
`Default branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${defaultBranch.id}`
);
}
if (rolloutInfo.rolledOutBranchId !== rolledOutBranch.id) {
throw new BranchMappingValidationError(
`Rolled out branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${
rolledOutBranch.id
}`
);
}
return {
...rolloutInfo,
rolledOutBranch,
defaultBranch,
};
}
export function getRolloutBranchMapping(branchMappingString: string): RolloutBranchMapping {
const branchMapping = getBranchMapping(branchMappingString);
assertRolloutBranchMapping(branchMapping);
return branchMapping;
}
/**
* Detect if a branch mapping is a rollout.
*
* Types of rollout:
* 1. Legacy unconstrained rollout:
* Maps to a rollout branch via a rollout token
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*
* 2. RTV constrained rollout:
* Maps to a rollout branch via a rollout token, constrained by runtime version
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*/
export function isRolloutBranchMapping(
branchMapping: BranchMapping
): branchMapping is RolloutBranchMapping {
return isUnconstrainedRollout(branchMapping) || isRtvConstrainedRollout(branchMapping);
}
export function isRollout(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isRolloutBranchMapping(branchMapping);
}
export function createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId,
percent,
runtimeVersion,
}: {
defaultBranchId: string;
rolloutBranchId: string;
percent: number;
runtimeVersion: string;
}): ConstrainedRolloutBranchMapping {
assertPercent(percent);
return {
version: 0,
data: [
{
branchId: rolloutBranchId,
branchMappingLogic: [
'and',
{
operand: runtimeVersion,
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: defaultBranchId, branchMappingLogic: alwaysTrue() },
],
};
}
export function editRolloutBranchMapping(
branchMapping: RolloutBranchMapping,
percent: number
): RolloutBranchMapping {
assertPercent(percent);
if (isRtvConstrainedRollout(branchMapping)) {
return editRtvConstrainedRollout(branchMapping, percent);
} else {
return editLegacyRollout(branchMapping, percent);
}
}
function editRtvConstrainedRollout(
branchMapping: ConstrainedRolloutBranchMapping,
percent: number
): ConstrainedRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const statementNode = newBranchMapping.data[0].branchMappingLogic;
const nodesFromStatement = getNodesFromStatement(statementNode);
const rolloutNode = nodesFromStatement.find(isRolloutNode);
assert(rolloutNode, 'Rollout node must be defined.');
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function editLegacyRollout(
branchMapping: LegacyRolloutBranchMapping,
percent: number
): LegacyRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const rolloutNode = newBranchMapping.data[0].branchMappingLogic;
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function isRtvConstrainedRollout(
branchMapping: BranchMapping
): branchMapping is ConstrainedRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRtvRolloutNode = isRtvConstrainedRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRtvRolloutNode && defaultsToAlwaysTrueNode;
}
function isRtvConstrainedRolloutNode(node: BranchMappingNode): node is RtvConstrainedRolloutNode {
if (!isStatement(node) || !isAndStatement(node)) {
return false;
}
const statementNodes = getNodesFromStatement(node);
if (statementNodes.length !== 2) {
return false;
}
const hasRuntimeVersionNode = statementNodes.some(isRuntimeVersionNode);
const hasRolloutNode = statementNodes.some(isRolloutNode);
return hasRuntimeVersionNode && hasRolloutNode;
}
function isUnconstrainedRollout(
branchMapping: BranchMapping
): branchMapping is LegacyRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRolloutNode = isRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRolloutNode && defaultsToAlwaysTrueNode;
}
function isRuntimeVersionNode(node: BranchMappingNode): node is RuntimeVersionNode {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'runtimeVersion' && node.branchMappingOperator === '==';
}
function isRolloutNode(node: BranchMappingNode): node is RolloutNode {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'rolloutToken' && node.branchMappingOperator === 'hash_lt';
}
export function assertRolloutBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is RolloutBranchMapping {
if (!isRolloutBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a rollout. Received: ' + JSON.stringify(branchMapping)
);
}
}
function assertPercent(percent: number): void {
const isPercent = Number.isInteger(percent) && percent >= 0 && percent <= 100;
if (!isPercent) {
throw new BranchMappingValidationError(
`The percentage must be an integer between 0 and 100 inclusive. Received: ${percent}`
);
}
}
+87 -102
View File
@@ -1,116 +1,101 @@
import {
BranchMapping,
BranchMappingNode,
getNodesFromStatement,
isAlwaysTrue,
isAndStatement,
isStatement,
} from '../channel/branch-mapping';
import chalk from 'chalk';
/**
* Detect if a branch mapping is a rollout.
*
* Types of rollout:
* 1. Legacy unconstrained rollout:
* Maps to a rollout branch via a rollout token
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
import { RuntimeFragment, UpdateFragment } from '../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { promptAsync } from '../prompts';
import { FormattedUpdateGroupDescription, getUpdateGroupDescriptions } from '../update/utils';
import formatFields from '../utils/formatFields';
import { Rollout, getRollout, isConstrainedRollout } from './branch-mapping';
export function printRollout(channel: UpdateChannelObject): void {
const rollout = getRollout(channel);
displayRolloutDetails(channel.name, rollout);
}
export function displayRolloutDetails(channelName: string, rollout: Rollout): void {
const rolledOutPercent = rollout.percentRolledOut;
Log.newLine();
Log.log(chalk.bold('🚀 Rollout:'));
Log.log(
formatFields([
{ label: 'Channel', value: channelName },
...(isConstrainedRollout(rollout)
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
: []),
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
label: 'Branches',
value: `${rollout.rolledOutBranch.name} (${rolledOutPercent}%), ${
rollout.defaultBranch.name
} (${100 - rolledOutPercent}%)`,
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*
* 2. RTV constrained rollout:
* Maps to a rollout branch via a rollout token, constrained by runtime version
* Falls back to a default branch
*
* Example:
* {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
}
*/
export function isRollout(branchMapping: BranchMapping): boolean {
return isUnconstrainedRollout(branchMapping) || isRtvConstrainedRollout(branchMapping);
])
);
Log.addNewLineIfNone();
}
function isRtvConstrainedRollout(branchMapping: BranchMapping): boolean {
if (branchMapping.data.length !== 2) {
return false;
export function formatBranchWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
branch: UpdateBranchObject,
percentRolledOut: number
): string {
const lines: string[] = [];
lines.push(chalk.bold(`🍽️ Served by branch ${chalk.bold(branch.name)} (${percentRolledOut}%)`));
if (!maybeUpdateGroup) {
lines.push(`No updates for target runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
const hasRtvRolloutNode = isRtvConstrainedRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRtvRolloutNode && defaultsToAlwaysTrueNode;
return lines.join('\n ');
}
function isRtvConstrainedRolloutNode(node: BranchMappingNode): boolean {
if (!isStatement(node) || !isAndStatement(node)) {
return false;
export function formatRuntimeWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
runtime: RuntimeFragment
): string {
const lines: string[] = [];
lines.push(chalk.bold(`🍽️ Served by runtime ${chalk.bold(runtime.version)}:`));
if (!maybeUpdateGroup) {
lines.push(`No updates published for this runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
const statementNodes = getNodesFromStatement(node);
if (statementNodes.length !== 2) {
return false;
}
const hasRuntimeVersionNode = statementNodes.some(isRuntimeVersionNode);
const hasRolloutNode = statementNodes.some(isRolloutNode);
return hasRuntimeVersionNode && hasRolloutNode;
return lines.join('\n ');
}
function isUnconstrainedRollout(branchMapping: BranchMapping): boolean {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRolloutNode = isRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRolloutNode && defaultsToAlwaysTrueNode;
function formatUpdateGroup(updateGroup: FormattedUpdateGroupDescription): string[] {
const lines: string[] = [];
const formattedLines = formatFields([
{ label: 'Message', value: updateGroup.message ?? 'N/A' },
{ label: 'Runtime Version', value: updateGroup.runtimeVersion ?? 'N/A' },
{ label: 'Platforms', value: updateGroup.platforms ?? 'N/A' },
{ label: 'Group ID', value: updateGroup.group ?? 'N/A' },
]).split('\n');
lines.push(...formattedLines);
return lines;
}
function isRuntimeVersionNode(node: BranchMappingNode): boolean {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'runtimeVersion' && node.branchMappingOperator === '==';
}
function isRolloutNode(node: BranchMappingNode): boolean {
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
return false;
}
return node.clientKey === 'rolloutToken' && node.branchMappingOperator === 'hash_lt';
export async function promptForRolloutPercentAsync({
promptMessage,
}: {
promptMessage: string;
}): Promise<number> {
const { name: rolloutPercent } = await promptAsync({
type: 'text',
name: 'name',
format: value => {
return parseInt(value, 10);
},
message: promptMessage,
initial: 0,
validate: (rolloutPercent: string): true | string => {
const floatValue = parseFloat(rolloutPercent);
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
? true
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
},
});
return rolloutPercent;
}
@@ -0,0 +1,52 @@
import { Workflow } from '@expo/eas-build-job';
import {
DEFAULT_BARE_RUNTIME_VERSION,
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49,
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48,
getDefaultRuntimeVersion,
} from '../configure';
describe(getDefaultRuntimeVersion, () => {
it('gets the right rtv version/policy', () => {
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '48.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '49.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '50.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, 'sdf')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, undefined)).toBe(
DEFAULT_BARE_RUNTIME_VERSION
);
});
});
@@ -115,6 +115,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
createdAt: '2022-12-07T02:24:29.786Z',
updatedAt: '2022-12-07T02:24:29.786Z',
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
updateGroups: [
[
{
@@ -148,6 +149,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
updatedAt: '2022-12-07T02:24:29.786Z',
updateGroups: [],
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
__typename: 'UpdateBranch' as const,
},
codeSigningInfo: null,
+19 -7
View File
@@ -3,6 +3,7 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import semver from 'semver';
import { getEASUpdateURL } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
@@ -20,13 +21,22 @@ import { confirmAsync } from '../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from './android/UpdatesModule';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from './ios/UpdatesModule';
export const DEFAULT_MANAGED_RUNTIME_VERSION = { policy: 'sdkVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49 = { policy: 'appVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48 = { policy: 'sdkVersion' } as const;
export const DEFAULT_BARE_RUNTIME_VERSION = '1.0.0' as const;
function getDefaultRuntimeVersion(workflow: Workflow): NonNullable<ExpoConfig['runtimeVersion']> {
return workflow === Workflow.GENERIC
? DEFAULT_BARE_RUNTIME_VERSION
: DEFAULT_MANAGED_RUNTIME_VERSION;
export function getDefaultRuntimeVersion(
workflow: Workflow,
sdkVersion: string | undefined
): NonNullable<ExpoConfig['runtimeVersion']> {
if (workflow === Workflow.GENERIC) {
return DEFAULT_BARE_RUNTIME_VERSION;
}
// Expo Go supports loading appVersion SDK 49 and above
const hasSupportedSdk = sdkVersion && semver.satisfies(sdkVersion, '>= 49.0.0');
return hasSupportedSdk
? DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
: DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48;
}
function isRuntimeEqual(
@@ -108,8 +118,10 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({
(['all', 'android'].includes(platform) && !androidRuntimeVersion) ||
(['all', 'ios'].includes(platform) && !iosRuntimeVersion)
) {
androidRuntimeVersion = androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android);
iosRuntimeVersion = iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios);
androidRuntimeVersion =
androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android, exp.sdkVersion);
iosRuntimeVersion =
iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios, exp.sdkVersion);
if (platform === 'all' && isRuntimeEqual(androidRuntimeVersion, iosRuntimeVersion)) {
modifyConfig.runtimeVersion = androidRuntimeVersion;
+138
View File
@@ -0,0 +1,138 @@
import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import { getUpdateGroupUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Update } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
export type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
/**
* @param updatesToPublish The update group to republish
* @param targetBranch The branch to repubish the update group on
*/
export async function republishAsync({
graphqlClient,
app,
updatesToPublish,
targetBranch,
updateMessage,
json,
}: {
graphqlClient: ExpoGraphqlClient;
app: { exp: ExpoConfig; projectId: string };
updatesToPublish: UpdateToRepublish[];
targetBranch: { branchName: string; branchId: string };
updateMessage: string;
json?: boolean;
}): Promise<void> {
const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch;
// The update group properties are the same for all updates
assert(updatesToPublish.length > 0, 'Updates to republish must be provided');
const arbitraryUpdate = updatesToPublish[0];
const isSameGroup = (update: UpdateToRepublish): boolean =>
update.groupId === arbitraryUpdate.groupId &&
update.branchId === arbitraryUpdate.branchId &&
update.branchName === arbitraryUpdate.branchName &&
update.runtimeVersion === arbitraryUpdate.runtimeVersion;
assert(updatesToPublish.every(isSameGroup), 'All updates must belong to the same update group');
const { runtimeVersion } = arbitraryUpdate;
// If codesigning was created for the original update, we need to add it to the republish
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
if (shouldRepublishWithCodesigning) {
Log.withTick(
`The republished update will be signed with the same codesigning as the original update.`
);
}
const publishIndicator = ora('Republishing...').start();
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
try {
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
{
branchId: targetBranchId,
runtimeVersion,
message: updateMessage,
updateInfoGroup: Object.fromEntries(
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
),
gitCommitHash: updatesToPublish[0].gitCommitHash,
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
},
]);
if (shouldRepublishWithCodesigning) {
const codeSigningByPlatform = Object.fromEntries(
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
);
await Promise.all(
updatesRepublished.map(async update => {
const codeSigning = codeSigningByPlatform[update.platform];
if (codeSigning) {
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
}
})
);
}
publishIndicator.succeed('Republished update');
} catch (error: any) {
publishIndicator.fail('Failed to republish update');
throw error;
}
if (json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const arbitraryRepublishedUpdate = updatesRepublished[0];
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, app.projectId)).name,
app.exp.slug,
arbitraryRepublishedUpdate.group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: targetBranchName },
{ label: 'Runtime version', value: arbitraryRepublishedUpdate.runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update Group ID', value: arbitraryRepublishedUpdate.group },
...(updatesRepublishedByPlatform.android
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
: []),
...(updatesRepublishedByPlatform.ios
? [{ label: 'iOS update ID', value: updatesRepublishedByPlatform.ios.id }]
: []),
{ label: 'Message', value: updateMessage },
{ label: 'Website link', value: link(updateGroupUrl, { dim: false }) },
])
);
}
@@ -1,5 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, errors } from '@expo/eas-json';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import Log from '../../log';
import { selectAsync } from '../../prompts';
@@ -9,6 +12,7 @@ import {
maybePrintBuildProfileDeprecationWarningsAsync,
} from '../profiles';
jest.mock('fs');
jest.mock('../../prompts');
jest.mock('@expo/eas-json', () => {
const actual = jest.requireActual('@expo/eas-json');
@@ -32,6 +36,7 @@ const getBuildProfileDeprecationWarningsAsync = jest.spyOn(
);
const newLineSpy = jest.spyOn(Log, 'newLine');
const warnSpy = jest.spyOn(Log, 'warn');
const projectDir = '/app';
describe(getProfilesAsync, () => {
afterEach(() => {
@@ -51,6 +56,7 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
projectDir,
});
expect(result[0].profileName).toBe('production');
@@ -71,6 +77,7 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID],
profileName: undefined,
type: 'build',
projectDir,
})
).rejects.toThrowError(errors.MissingProfileError);
});
@@ -86,6 +93,7 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profileName).toBe('custom-profile');
@@ -98,6 +106,52 @@ describe(getProfilesAsync, () => {
expect(getBuildProfileAsync).toBeCalledWith(easJsonAccessor, Platform.IOS, 'custom-profile');
});
describe('node version', () => {
const nodeVersion = '14.17.1';
it('is read from profile', async () => {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
getBuildProfileAsync.mockImplementation(async () => {
return { node: nodeVersion } as BuildProfile<Platform.ANDROID>;
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profile.node).toBe(nodeVersion);
});
describe('with .nvmrc', () => {
beforeEach(async () => {
vol.reset();
await fs.mkdirp(os.tmpdir());
vol.fromJSON({ '.nvmrc': nodeVersion }, projectDir);
});
it('is read from .nvmrc', async () => {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
getBuildProfileAsync.mockImplementation(async () => {
return {} as BuildProfile<Platform.ANDROID>;
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profile.node).toBe(nodeVersion);
});
});
});
it('throws validation error if eas.json is invalid', async () => {
getBuildProfileAsync.mockImplementation(() => {
throw new errors.InvalidEasJsonError('eas.json is not valid');
@@ -109,6 +163,7 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
projectDir,
})
).rejects.toThrowError(/eas.json is not valid/);
});
@@ -0,0 +1,795 @@
import { promptAsync } from '../../prompts';
import {
Connection,
FilterPagination,
NEXT_PAGE_OPTION,
PREV_PAGE_OPTION,
QueryParams,
selectPaginatedAsync,
} from '../relay';
jest.mock('../../prompts');
describe(FilterPagination, () => {
describe(FilterPagination.getPageAsync, () => {
let queryParams: QueryParams;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let filterPredicate: () => boolean;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryParams = {};
queryAsync = jest.fn();
filterPredicate = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
test('should call getFirstItemsAsync when isFirstAfter is true', async () => {
const originalFirstAfter = FilterPagination.isFirstAfter;
const originalGetFirstItemsAsync = FilterPagination.getFirstItemsAsync;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.getFirstItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isFirstAfter).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getFirstItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isFirstAfter = originalFirstAfter;
FilterPagination.getFirstItemsAsync = originalGetFirstItemsAsync;
});
test('should call getLastItemsAsync when isLastBefore is true', async () => {
const originalIsLastBefore = FilterPagination.isLastBefore;
const originalGetLastItemsAsync = FilterPagination.getLastItemsAsync;
FilterPagination.isLastBefore = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
FilterPagination.getLastItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isLastBefore).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getLastItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isLastBefore = originalIsLastBefore;
FilterPagination.getLastItemsAsync = originalGetLastItemsAsync;
});
test('should throw an error for invalid query params', async () => {
const originalIsFirstAfter = FilterPagination.isFirstAfter;
const originalIsLastBefore = FilterPagination.isLastBefore;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.isLastBefore = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
await expect(
FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError('Invalid query params');
FilterPagination.isFirstAfter = originalIsFirstAfter;
FilterPagination.isLastBefore = originalIsLastBefore;
});
});
describe(FilterPagination.isFirstAfter, () => {
test('should return true for object with first property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(true);
});
test('should return false for object without first property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.isLastBefore, () => {
test('should return true for object with last property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(true);
});
test('should return false for object without last property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.getFirstItemsAsync, () => {
let queryArgs: {
first: number;
after?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { first: 10, after: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasNextPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({ first: internalBatchSize, after: queryArgs.after });
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasNextPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getFirstItemsAsync(
{ first: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[0]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasNextPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
describe(FilterPagination.getLastItemsAsync, () => {
let queryArgs: {
last: number;
before?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { last: 10, before: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasPreviousPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({
last: internalBatchSize,
before: queryArgs.before,
});
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getLastItemsAsync(
{ last: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[2]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasPreviousPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
});
describe(selectPaginatedAsync, () => {
const queryAsync = jest.fn();
const getTitleAsync = jest.fn();
const printedType = 'Node';
const pageSize = 10;
afterEach(() => {
jest.clearAllMocks();
});
test('returns null when there are no items', async () => {
queryAsync.mockResolvedValueOnce({
edges: [],
});
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toBeNull();
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
});
test('returns the only item when there is only one', async () => {
const node = { id: '1', name: 'Node 1' };
queryAsync.mockResolvedValueOnce({
edges: [{ node }],
});
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node);
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
});
});
test('prompts for selection when there are multiple items', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
queryAsync.mockResolvedValue({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: { hasNextPage: false },
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
jest.mocked(promptAsync).mockImplementation(async () => ({
item: node1,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
});
test('selects the previous page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
// for preflight
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: PREV_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node1,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
last: pageSize,
before: 'startCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
test('selects the next page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
// for preflight
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node3,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node3);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
after: 'endCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
});
+47 -2
View File
@@ -6,6 +6,9 @@ import {
ProfileType,
SubmitProfile,
} from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import Log, { learnMore } from '../log';
@@ -24,19 +27,23 @@ export async function getProfilesAsync<T extends ProfileType>({
platforms,
profileName,
type,
projectDir,
}: {
easJsonAccessor: EasJsonAccessor;
platforms: Platform[];
profileName?: string;
projectDir: string;
type: T;
}): Promise<ProfileData<T>[]> {
const results = platforms.map(async function (platform) {
const profile = await readProfileAsync({
const profile = await readProfileWithOverridesAsync({
easJsonAccessor,
platform,
type,
profileName,
projectDir,
});
return {
profile,
profileName: profileName ?? 'production',
@@ -47,16 +54,53 @@ export async function getProfilesAsync<T extends ProfileType>({
return await Promise.all(results);
}
async function readProfileAsync<T extends ProfileType>({
async function setNodeVersionFromFileAsync(
projectDir: string,
profile: BuildProfile<Platform>
): Promise<void> {
if (profile?.node) {
return;
}
const nodeVersion = await getNodeVersionFromFileAsync(projectDir);
if (nodeVersion) {
Log.log(
`The EAS build profile does not specify a Node.js version. Using the version specified in .nvmrc: ${nodeVersion} `
);
profile.node = nodeVersion;
}
}
async function getNodeVersionFromFileAsync(projectDir: string): Promise<string | undefined> {
const nvmrcPath = path.join(projectDir, '.nvmrc');
if (!(await fs.pathExists(nvmrcPath))) {
return;
}
let nodeVersion: string;
try {
nodeVersion = (await fs.readFile(nvmrcPath, 'utf8')).toString().trim();
} catch {
return undefined;
}
if (!semver.valid(nodeVersion)) {
throw new Error(`Invalid node version in .nvmrc: ${nodeVersion}`);
}
return nodeVersion;
}
async function readProfileWithOverridesAsync<T extends ProfileType>({
easJsonAccessor,
platform,
type,
profileName,
projectDir,
}: {
easJsonAccessor: EasJsonAccessor;
platform: Platform;
type: T;
profileName?: string;
projectDir: string;
}): Promise<EasProfile<T>> {
if (type === 'build') {
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
@@ -66,6 +110,7 @@ async function readProfileAsync<T extends ProfileType>({
);
await maybePrintBuildProfileDeprecationWarningsAsync(easJsonAccessor, platform, profileName);
await setNodeVersionFromFileAsync(projectDir, buildProfile);
return buildProfile as EasProfile<T>;
} else {
+363
View File
@@ -0,0 +1,363 @@
import assert from 'assert';
import { PageInfo } from '../graphql/generated';
import { promptAsync } from '../prompts';
export type Connection<T> = {
edges: Edge<T>[];
pageInfo: PageInfo;
};
export type Edge<T> = {
cursor: string;
node: T;
};
export type QueryParams = {
first?: number;
after?: string;
last?: number;
before?: string;
};
/**
*
* Pagination that performs client side filtering on the nodes returned from a relay compliant datasource.
*
* @param queryParams The query params for the pagination.
* @param queryAsync A promise based function for querying.
* @param filterPredicate A predicate function to filter the node.
* @param beforeEachQuery Optional. A callback function to be called before each query
* @param afterEachQuery Optional. A callback function to be called after each query.
* @param internalBatchSize Optional. The batch size of queryAsync. Defaults to 100.
* @param maxNodesFetched Optional. The maximum number of nodes to fetch. Defaults to 10_000.
* @param beforeEachQuery Optional. A callback function to be called before each query
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param afterEachQuery Optional. A callback function to be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*
* @throws {Error} - If an error occurs during execution of the query or pagination.
*/
export class FilterPagination {
static async getPageAsync<T>({
queryParams,
queryAsync,
filterPredicate,
internalBatchSize = 100,
maxNodesFetched = 10_000,
beforeEachQuery,
afterEachQuery,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
filterPredicate: (node: T) => boolean;
internalBatchSize?: number;
maxNodesFetched?: number;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}): Promise<Connection<T>> {
if (this.isFirstAfter(queryParams)) {
return await this.getFirstItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
} else if (this.isLastBefore(queryParams)) {
return await this.getLastItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
}
throw new Error('Invalid query params');
}
static isFirstAfter(connectionArgs: QueryParams): connectionArgs is {
first: number;
after?: string;
} {
return 'first' in connectionArgs;
}
static isLastBefore(connectionArgs: {
first?: number;
after?: string;
last?: number;
before?: string;
}): connectionArgs is {
last: number;
before?: string;
} {
return 'last' in connectionArgs;
}
static async getFirstItemsAsync<T>(
{ first, after }: { first: number; after?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = first + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let afterInternal: string | undefined = after;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ first, after }, totalNodesFetched, dataset);
}
const result = await queryAsync({ first: internalBatchSize, after: afterInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
dataset.push(...batch.slice(0, nodesRemaining));
hasMore = pageInfo.hasNextPage;
afterInternal = pageInfo.endCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ first, after },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
const edges = dataset.slice(0, first);
return {
edges,
pageInfo: {
hasNextPage: dataset.length > first,
hasPreviousPage: false, // cannot be computed efficiently
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
static async getLastItemsAsync<T>(
{ last, before }: { last: number; before?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = last + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let beforeInternal: string | undefined = before;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ last, before }, totalNodesFetched, dataset);
}
const result = await queryAsync({ last: internalBatchSize, before: beforeInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
// relay orders pages from first to last, so we reverse the batch to to choose the last n
const nodesChosen = batch.reverse().slice(0, nodesRemaining);
dataset.push(...nodesChosen);
hasMore = pageInfo.hasPreviousPage;
beforeInternal = pageInfo.startCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ last, before },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
// we reverse our dataset again to restore the original order of first to last to match relay
const edges = dataset.slice(0, last).reverse();
return {
edges,
pageInfo: {
hasNextPage: false, // cannot be computed efficiently,
hasPreviousPage: dataset.length > last,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
}
export async function selectPaginatedAsync<T>({
queryAsync,
getTitleAsync,
printedType,
pageSize,
}: {
pageSize: number;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T | null> {
// Dont bother prompting if there are 0 or 1 items
const connectionPreflight = await queryAsync({ first: pageSize });
const { edges } = connectionPreflight;
if (edges.length === 0) {
return null;
} else if (edges.length === 1) {
return edges[0].node;
}
return await selectPaginatedInternalAsync({
queryAsync,
getTitleAsync,
printedType,
queryParams: { first: pageSize },
});
}
export const PREV_PAGE_OPTION = {
value: Symbol('PREV_PAGE'),
title: '⬆️ Previous page',
};
export const NEXT_PAGE_OPTION = {
value: Symbol('NEXT_PAGE'),
title: '⬇️ Next page',
};
async function selectPaginatedInternalAsync<T>({
queryAsync,
getTitleAsync,
printedType,
queryParams,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T> {
const limit = queryParams.first ?? queryParams.last;
assert(limit, 'queryParams must have either first or last');
const connection = await queryAsync(queryParams);
const { edges, pageInfo } = connection;
/*
* The Relay spec has a weird definition on hasNextPage and hasPreviousPage:
* 'If the client is paginating with last/before, then the server must return true if prior edges
* exist, otherwise false. If the client is paginating with first/after, then the client may
* return true if edges prior to after exist, if it can do so efficiently, otherwise may return false.'
*
* This means if we are paginating with first/after, we can't rely on pageInfo.hasPreviousPage and vice versa.
*/
const {
endCursor,
hasNextPage: serverResponseHasNextPage,
startCursor,
hasPreviousPage: serverResponseHasPreviousPage,
} = pageInfo;
const hasPreviousPage = serverResponseHasPreviousPage || queryParams.after;
const hasNextPage = serverResponseHasNextPage || queryParams.before;
const nodes = edges.map(edge => edge.node);
const options: { value: symbol | T; title: string }[] = [];
if (hasPreviousPage) {
options.push(PREV_PAGE_OPTION);
}
const nodeTitles = await Promise.all(nodes.map(node => getTitleAsync(node)));
options.push(...nodes.map((node, index) => ({ value: node, title: nodeTitles[index] })));
if (hasNextPage) {
options.push(NEXT_PAGE_OPTION);
}
const { item: selectedItem } = await promptAsync({
type: 'select',
name: 'item',
message: `Select a ${printedType}`,
choices: options.map(option => ({
value: option.value,
title: option.title,
})),
});
if (selectedItem === PREV_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
last: limit,
before: startCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else if (selectedItem === NEXT_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
first: limit,
after: endCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else {
return selectedItem as T;
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "3.17.0",
"version": "3.18.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@babel/code-frame": "7.18.6",
"@expo/eas-build-job": "1.0.13",
"@expo/eas-build-job": "1.0.30",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "10.1.0",
Generated
+50 -27
View File
@@ -1395,13 +1395,13 @@
slugify "^1.3.4"
sucrase "^3.20.0"
"@expo/[email protected].13":
version "1.0.13"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.13.tgz#1cd3981e86e1d4235f20a96f7ac768793b4f7c62"
integrity sha512-Q0i3GposBj/b2OPL0CV00HczawtslorNpoJSBQfB4aNR6i7DBIZZ1jWJtrFP8+zHZqlUi4d21aoblNUrKpgBew==
"@expo/[email protected]0":
version "1.0.30"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.30.tgz#f8c57a673a2cc328f4041346996c480c3d910e92"
integrity sha512-KQGn9POn5p3guMNQJ5ykG3wffRlSEpjTCLO87d5lJll9E4u1grpqHJH2chOh5ICxg7tiWcO4iXt5S8tU7Ogpig==
dependencies:
joi "^17.7.0"
semver "^7.3.8"
joi "^17.9.2"
semver "^7.5.4"
"@expo/[email protected]":
version "0.3.22"
@@ -1438,10 +1438,10 @@
json5 "^2.2.2"
write-file-atomic "^2.3.0"
"@expo/[email protected].21":
version "1.0.21"
resolved "https://registry.yarnpkg.com/@expo/logger/-/logger-1.0.21.tgz#9271b52b8e27159cbbe3385b9a01fca3317b37c2"
integrity sha512-Vz+LHDGWUfowAwL10nBKqxrMYvO4qdZ8hLKcqUYc5mXBt+jmv7RP+8fcsMIejJEMWmYC2meO5eeCMLKKhEIgMA==
"@expo/[email protected].30":
version "1.0.30"
resolved "https://registry.yarnpkg.com/@expo/logger/-/logger-1.0.30.tgz#f964cc7b73cbb0c05de44c874a9451f5cfd2ec2d"
integrity sha512-8wEUDZNLAv+Wi4jvo0kFErQ7nc4ZFfArq5GDeSCoIrRfdKK8baot7YJoJVsmpQ9/JXnTEAgwGDP3vYaR5wzcyg==
dependencies:
"@types/bunyan" "^1.8.8"
bunyan "^1.8.15"
@@ -1569,26 +1569,28 @@
dependencies:
cross-spawn "^7.0.3"
"@expo/spawn-async@^1.7.0":
"@expo/spawn-async@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58"
integrity sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==
dependencies:
cross-spawn "^7.0.3"
"@expo/[email protected].25":
version "1.0.25"
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.25.tgz#04f8c18bb12f2445cdca443b48497d3c2be3e133"
integrity sha512-MBtJ/3+T5qvXxmnOVlfapKlKQsxPUyKoEUaA6lLbpmm8yCcXJEblnmQP/2ZokAG78IWjsYl5E02Ei/AnrIxB9g==
"@expo/[email protected].31":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.31.tgz#61e0456e54bc63bd714aa40b9be27503c032caf0"
integrity sha512-TPuaihUQ9MfVmgG5OzFdMKXcBAgv1dfLZWGpk/k5hWBjPZAoe01UXbw8GCt6F09tS2Z256d2G/kOTJzISLNcjQ==
dependencies:
"@expo/logger" "1.0.21"
"@expo/spawn-async" "^1.7.0"
"@expo/logger" "1.0.30"
"@expo/spawn-async" "^1.7.2"
arg "^5.0.2"
joi "^17.7.0"
fs-extra "^11.1.1"
joi "^17.9.2"
lodash.clonedeep "^4.5.0"
lodash.get "^4.4.2"
this-file "^2.0.3"
uuid "^9.0.0"
yaml "^2.2.1"
yaml "^2.3.1"
"@expo/[email protected]":
version "1.0.0"
@@ -7495,6 +7497,15 @@ fs-extra@^11.1.0:
jsonfile "^6.0.1"
universalify "^2.0.0"
fs-extra@^11.1.1:
version "11.1.1"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d"
integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
fs-extra@^8.1:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@@ -9363,10 +9374,10 @@ [email protected]:
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
joi@^17.7.0:
version "17.8.3"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.8.3.tgz#d772fe27a87a5cda21aace5cf11eee8671ca7e6f"
integrity sha512-q5Fn6Tj/jR8PfrLrx4fpGH4v9qM6o+vDUfD4/3vxxyg34OmKcNqYZ1qn2mpLza96S8tL0p0rIw2gOZX+/cTg9w==
joi@^17.9.2:
version "17.9.2"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.9.2.tgz#8b2e4724188369f55451aebd1d0b1d9482470690"
integrity sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==
dependencies:
"@hapi/hoek" "^9.0.0"
"@hapi/topo" "^5.0.0"
@@ -9728,6 +9739,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
@@ -12101,6 +12117,13 @@ semver@^7.3.7:
dependencies:
lru-cache "^6.0.0"
semver@^7.5.4:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
[email protected]:
version "0.18.0"
resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
@@ -13829,10 +13852,10 @@ yaml@^1.10.0, yaml@^1.7.2:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e"
integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==
yaml@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4"
integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==
yaml@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
[email protected], yargs-parser@^20.2.2:
version "20.2.4"