mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
601101844c | ||
|
|
7027eaa974 | ||
|
|
b3203345ea | ||
|
|
5ed2d01146 | ||
|
|
6fc64d337f | ||
|
|
f7ed4cfa20 | ||
|
|
47968a1b8b | ||
|
|
7c779b3dc0 | ||
|
|
50474ec5e9 | ||
|
|
46ff97f173 | ||
|
|
b263dffbde | ||
|
|
8d57828f7e |
No files matched your search
+23
-2
@@ -8,11 +8,32 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 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
|
||||
|
||||
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Use just a comma instead of `, ` when concatenating `keywords` in `eas metadata`. ([#1967](https://github.com/expo/eas-cli/pull/1967) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.18.1](https://github.com/expo/eas-cli/releases/tag/v3.18.1) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Pass correct group into `update:republish`. ([#1971](https://github.com/expo/eas-cli/pull/1971) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Make new rollouts version available for internal dogfooding. ([#1966](https://github.com/expo/eas-cli/pull/1966) 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.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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- More branch mapping utility. ([#1957](https://github.com/expo/eas-cli/pull/1957) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "3.18.0",
|
||||
"version": "3.18.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+55
-55
@@ -133,7 +133,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.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.2/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.18.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.2/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.18.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.2/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.18.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/credentials.ts)_
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/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.18.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.2/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
|
||||
Generated
+896
-99
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "3.18.0",
|
||||
"version": "3.18.2",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.31';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.33';
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
|
||||
@@ -117,7 +117,6 @@ export async function runBuildAndSubmitAsync(
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
|
||||
@@ -184,7 +183,6 @@ 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
UpdateChannelBasicInfoFragment,
|
||||
ViewUpdateChannelsPaginatedOnAppQuery,
|
||||
} from '../../graphql/generated';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
|
||||
export const testUpdateBranch1: UpdateBranchObject = {
|
||||
@@ -148,37 +145,3 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
|
||||
'{"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',
|
||||
};
|
||||
|
||||
export const paginatedChannels: ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated'] =
|
||||
{
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
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',
|
||||
},
|
||||
__typename: 'AppChannelEdge',
|
||||
},
|
||||
{
|
||||
node: {
|
||||
id: '95630b3c-5b91-48cb-bd31-a84315a13246',
|
||||
name: 'gh/quinlanj/3/orig',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":"true"}],"version":0}',
|
||||
__typename: 'UpdateChannel',
|
||||
},
|
||||
__typename: 'AppChannelEdge',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'IjlkNWM3YmYwLWQ1MmQtNDc0Yy05MTQwLWMxYmFkN2YwZGU5ZCI',
|
||||
endCursor: 'ImE4Y2M5ZWVmLTFkZjMtNDNmZi05YTZkLTRmYTZjMTFlYmEwYiI',
|
||||
__typename: 'PageInfo',
|
||||
},
|
||||
__typename: 'AppChannelsConnection',
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getChannelsDatasetAsync } from '../queries';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
Connection,
|
||||
Edge,
|
||||
FilterPagination,
|
||||
QueryParams,
|
||||
selectPaginatedAsync,
|
||||
} from '../../utils/relay';
|
||||
|
||||
/**
|
||||
* Select a channel for the project.
|
||||
@@ -9,45 +15,93 @@ import { getChannelsDatasetAsync } from '../queries';
|
||||
* @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;
|
||||
} = {}
|
||||
) {}
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
|
||||
const { nonInteractive, graphqlClient, app } = ctx;
|
||||
|
||||
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 channels = await getChannelsDatasetAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterPredicate,
|
||||
});
|
||||
const queryAsync = async (
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
|
||||
filterPredicate
|
||||
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
|
||||
: this.queryAsync(ctx, queryParams);
|
||||
|
||||
if (channels.length === 0) {
|
||||
return null;
|
||||
} else if (channels.length === 1) {
|
||||
return channels[0];
|
||||
}
|
||||
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
|
||||
channelInfo.name;
|
||||
|
||||
const { channel: selectedChannel } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'channel',
|
||||
message: `Select a ${printedType}`,
|
||||
choices: channels.map(channel => ({
|
||||
value: channel,
|
||||
title: channel.name,
|
||||
})),
|
||||
return await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
return selectedChannel;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,81 @@
|
||||
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 { getChannelsDatasetAsync } from '../../queries';
|
||||
import { SelectChannel } from '../SelectChannel';
|
||||
|
||||
jest.mock('../../queries');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/ChannelQuery');
|
||||
describe(SelectChannel, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('returns null if project has no channels', async () => {
|
||||
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([]);
|
||||
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 () => {
|
||||
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([testBasicChannelInfo]);
|
||||
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(getChannelsDatasetAsync)
|
||||
.mockResolvedValue([testBasicChannelInfo, testBasicChannelInfo2]);
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicChannelInfo, cursor: 'cursor' },
|
||||
{ node: testBasicChannelInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
channel: testBasicChannelInfo,
|
||||
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,
|
||||
|
||||
@@ -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,7 +35,17 @@ export type BranchMapping = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export function getAlwaysTrueBranchMapping(branchId: string): BranchMapping {
|
||||
export type AlwaysTrueBranchMapping = {
|
||||
version: number;
|
||||
data: [
|
||||
{
|
||||
branchId: string;
|
||||
branchMappingLogic: BranchMappingAlwaysTrue;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
data: [
|
||||
@@ -47,6 +57,36 @@ export function getAlwaysTrueBranchMapping(branchId: string): BranchMapping {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -94,23 +134,45 @@ export function hashLtOperator(): BranchMappingOperator {
|
||||
}
|
||||
|
||||
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
|
||||
assert(
|
||||
isStatement(node),
|
||||
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
|
||||
);
|
||||
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 {
|
||||
assert(typeof operand === 'string', 'Expected a string. Received: ' + JSON.stringify(operand));
|
||||
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 {}
|
||||
@@ -8,8 +8,6 @@ import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
CreateUpdateChannelOnAppMutation,
|
||||
CreateUpdateChannelOnAppMutationVariables,
|
||||
PageInfo,
|
||||
UpdateChannelBasicInfoFragment,
|
||||
ViewBranchesOnUpdateChannelQueryVariables,
|
||||
ViewUpdateChannelsOnAppQueryVariables,
|
||||
} from '../graphql/generated';
|
||||
@@ -17,14 +15,12 @@ import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/Bra
|
||||
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { Connection, QueryParams, getPaginatedDatasetAsync } from '../utils/relay';
|
||||
import { logChannelDetails } from './utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
@@ -268,46 +264,3 @@ export async function ensureChannelExistsAsync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChannelsDatasetAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
filterPredicate,
|
||||
batchSize = 100,
|
||||
}: {
|
||||
appId: string;
|
||||
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
|
||||
batchSize?: number;
|
||||
}
|
||||
): Promise<UpdateChannelBasicInfoFragment[]> {
|
||||
const queryAsync = async ({
|
||||
first,
|
||||
after,
|
||||
}: QueryParams): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
|
||||
await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
first,
|
||||
after,
|
||||
});
|
||||
|
||||
const assetSpinner = ora().start('Fetching channels...');
|
||||
const afterEachQuery = (
|
||||
totalNodesFetched: number,
|
||||
_dataset: UpdateChannelBasicInfoFragment[],
|
||||
_batch: UpdateChannelBasicInfoFragment[],
|
||||
pageInfo: PageInfo
|
||||
): void => {
|
||||
if (pageInfo.hasNextPage) {
|
||||
assetSpinner.text = `Fetched ${totalNodesFetched} channels`;
|
||||
}
|
||||
};
|
||||
const dataset = await getPaginatedDatasetAsync({
|
||||
queryAsync,
|
||||
afterEachQuery,
|
||||
filterPredicate,
|
||||
batchSize,
|
||||
});
|
||||
assetSpinner.succeed(`Fetched all channels`);
|
||||
return dataset;
|
||||
}
|
||||
@@ -68,7 +68,6 @@ 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,7 +80,6 @@ export default class BuildVersionSyncView extends EasCommand {
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
for (const profileInfo of buildProfiles) {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
|
||||
@@ -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,7 +47,6 @@ export default class MetadataLint extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default class MetadataPull extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -47,7 +47,6 @@ export default class MetadataPush extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms: [Platform.IOS],
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (submitProfiles.length !== 1) {
|
||||
|
||||
@@ -118,7 +118,6 @@ export default class Submit extends EasCommand {
|
||||
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
|
||||
platforms,
|
||||
profileName: flagsWithPlatform.profile,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
const submissions: SubmissionFragment[] = [];
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+148
-24
@@ -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 = {
|
||||
@@ -2605,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'];
|
||||
@@ -3110,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;
|
||||
@@ -3782,6 +3847,7 @@ export type Notification = {
|
||||
createdAt: Scalars['DateTime'];
|
||||
event: NotificationEvent;
|
||||
id: Scalars['ID'];
|
||||
isRead: Scalars['Boolean'];
|
||||
metadata?: Maybe<NotificationMetadata>;
|
||||
type: NotificationType;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
@@ -4131,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 */
|
||||
@@ -4241,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;
|
||||
@@ -4305,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';
|
||||
@@ -4394,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'];
|
||||
@@ -4854,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'];
|
||||
@@ -4965,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'];
|
||||
};
|
||||
@@ -5419,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;
|
||||
};
|
||||
|
||||
@@ -6187,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'];
|
||||
@@ -6243,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'];
|
||||
@@ -6269,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;
|
||||
}>;
|
||||
@@ -6352,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 };
|
||||
@@ -6360,6 +6482,8 @@ 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 };
|
||||
|
||||
@@ -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,6 +5,8 @@ import { ChannelNotFoundError } from '../../channel/errors';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
UpdateBranchBasicInfoFragment,
|
||||
UpdateFragment,
|
||||
ViewUpdateChannelOnAppQuery,
|
||||
ViewUpdateChannelOnAppQueryVariables,
|
||||
ViewUpdateChannelsOnAppQuery,
|
||||
@@ -28,6 +30,16 @@ export type UpdateChannelObject = ViewUpdateChannelsOnAppObject & UpdateChannelB
|
||||
|
||||
export type UpdateBranchObject = UpdateChannelObject['updateBranches'][number];
|
||||
|
||||
export function composeUpdateBranchObject(
|
||||
branchInfo: UpdateBranchBasicInfoFragment,
|
||||
updateGroups: UpdateFragment[][]
|
||||
): UpdateBranchObject {
|
||||
return {
|
||||
...branchInfo,
|
||||
updateGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export const ChannelQuery = {
|
||||
async viewUpdateChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -123,7 +135,7 @@ export const ChannelQuery = {
|
||||
},
|
||||
async viewUpdateChannelsBasicInfoPaginatedOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, first, after }: ViewUpdateChannelsPaginatedOnAppQueryVariables
|
||||
{ appId, first, after, last, before }: ViewUpdateChannelsPaginatedOnAppQueryVariables
|
||||
): Promise<ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']> {
|
||||
const response = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
@@ -132,16 +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
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
@@ -155,7 +174,7 @@ export const ChannelQuery = {
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ appId, first, after },
|
||||
{ appId, first, after, last, before },
|
||||
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
|
||||
)
|
||||
.toPromise()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
@@ -180,7 +180,7 @@ export class AppleConfigReader {
|
||||
return {
|
||||
locale,
|
||||
description: info.description,
|
||||
keywords: info.keywords?.join(', '),
|
||||
keywords: info.keywords?.join(','),
|
||||
// TODO: maybe move this to task logic, it's more an exception than data handling
|
||||
whatsNew: context.versionIsFirst ? undefined : info.releaseNotes || DEFAULT_WHATSNEW,
|
||||
marketingUrl: info.marketingUrl,
|
||||
|
||||
@@ -7,19 +7,24 @@ import {
|
||||
} 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,
|
||||
isRollout,
|
||||
isRolloutBranchMapping,
|
||||
} from '../branch-mapping';
|
||||
import {
|
||||
rolloutBranchMapping,
|
||||
@@ -27,6 +32,73 @@ import {
|
||||
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);
|
||||
@@ -78,9 +150,6 @@ describe(getRollout, () => {
|
||||
});
|
||||
|
||||
describe(getRolloutInfoFromBranchMapping, () => {
|
||||
it('doesnt get the mapping if it isnt a rollout', () => {
|
||||
expect(() => getRolloutInfoFromBranchMapping(standardBranchMapping)).toThrowError();
|
||||
});
|
||||
it('gets a constrained rollout', () => {
|
||||
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
|
||||
expect(rollout.percentRolledOut).toEqual(10);
|
||||
@@ -98,13 +167,14 @@ describe(getRolloutInfoFromBranchMapping, () => {
|
||||
});
|
||||
|
||||
describe(editRolloutBranchMapping, () => {
|
||||
it('doesnt edit the branch mapping if it isnt a rollout', () => {
|
||||
expect(() => editRolloutBranchMapping(standardBranchMapping, 50)).toThrowError();
|
||||
});
|
||||
it('doesnt edit the branch mapping if the input is out of range', () => {
|
||||
expect(() => editRolloutBranchMapping(standardBranchMapping, -1)).toThrowError();
|
||||
expect(() => editRolloutBranchMapping(standardBranchMapping, 101)).toThrowError();
|
||||
expect(() => editRolloutBranchMapping(standardBranchMapping, 0.1)).toThrowError();
|
||||
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);
|
||||
@@ -120,10 +190,10 @@ describe(editRolloutBranchMapping, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isRollout, () => {
|
||||
describe(isRolloutBranchMapping, () => {
|
||||
it('detects a rollout made by the cli', () => {
|
||||
expect(isRollout(rolloutBranchMappingLegacy)).toBe(true);
|
||||
expect(isRollout(rolloutBranchMapping)).toBe(true);
|
||||
expect(isRolloutBranchMapping(rolloutBranchMappingLegacy)).toBe(true);
|
||||
expect(isRolloutBranchMapping(rolloutBranchMapping)).toBe(true);
|
||||
});
|
||||
it('detects custom mappings equivalent to rollouts made by the cli', () => {
|
||||
const customMapping1 = {
|
||||
@@ -147,10 +217,10 @@ describe(isRollout, () => {
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping1)).toBe(true);
|
||||
expect(isRolloutBranchMapping(customMapping1)).toBe(true);
|
||||
});
|
||||
it('correctly classifies branchMappings that arent rollouts', () => {
|
||||
expect(isRollout(standardBranchMapping)).toBe(false);
|
||||
expect(isRolloutBranchMapping(standardBranchMapping)).toBe(false);
|
||||
|
||||
const customMapping1 = {
|
||||
version: 0,
|
||||
@@ -166,7 +236,7 @@ describe(isRollout, () => {
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping1)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping1)).toBe(false);
|
||||
|
||||
const customMapping2 = {
|
||||
version: 0,
|
||||
@@ -189,7 +259,7 @@ describe(isRollout, () => {
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping2)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping2)).toBe(false);
|
||||
|
||||
const customMapping3 = {
|
||||
version: 0,
|
||||
@@ -205,7 +275,7 @@ describe(isRollout, () => {
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping3)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping3)).toBe(false);
|
||||
|
||||
const customMapping4 = {
|
||||
version: 0,
|
||||
@@ -220,7 +290,7 @@ describe(isRollout, () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping4)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping4)).toBe(false);
|
||||
|
||||
const customMapping5 = {
|
||||
version: 0,
|
||||
@@ -248,7 +318,7 @@ describe(isRollout, () => {
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping5)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping5)).toBe(false);
|
||||
|
||||
const customMapping6 = {
|
||||
version: 0,
|
||||
@@ -264,6 +334,6 @@ describe(isRollout, () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(isRollout(customMapping6)).toBe(false);
|
||||
expect(isRolloutBranchMapping(customMapping6)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,55 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
alwaysTrue,
|
||||
andStatement,
|
||||
equalsOperator,
|
||||
hashLtOperator,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { BranchMapping, alwaysTrue } from '../../channel/branch-mapping';
|
||||
import { ConstrainedRolloutBranchMapping, LegacyRolloutBranchMapping } from '../branch-mapping';
|
||||
|
||||
export const rolloutBranchMapping = {
|
||||
export const rolloutBranchMapping: ConstrainedRolloutBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: uuidv4(),
|
||||
branchMappingLogic: andStatement([
|
||||
branchMappingLogic: [
|
||||
'and',
|
||||
{
|
||||
operand: '1.0.0',
|
||||
clientKey: 'runtimeVersion',
|
||||
branchMappingOperator: equalsOperator(),
|
||||
branchMappingOperator: '==',
|
||||
},
|
||||
{
|
||||
operand: 10 / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: hashLtOperator(),
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
|
||||
export const rolloutBranchMappingLegacy = {
|
||||
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: [
|
||||
{
|
||||
@@ -37,14 +57,14 @@ export const rolloutBranchMappingLegacy = {
|
||||
branchMappingLogic: {
|
||||
operand: 10 / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: hashLtOperator(),
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
},
|
||||
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
|
||||
],
|
||||
};
|
||||
|
||||
export const standardBranchMapping = {
|
||||
export const standardBranchMapping: BranchMapping = {
|
||||
version: 0,
|
||||
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { SelectChannel } from '../../channel/actions/SelectChannel';
|
||||
import { getBranchMapping } from '../../channel/branch-mapping';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -9,14 +10,29 @@ import { isRollout } from '../branch-mapping';
|
||||
*/
|
||||
export class SelectRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
|
||||
const rolloutFilterPredicate = (channelInfo: UpdateChannelBasicInfoFragment): boolean => {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isRollout(branchMapping);
|
||||
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 selectChannel = new SelectChannel({
|
||||
const selectChannelAction = new SelectChannel({
|
||||
printedType: 'rollout',
|
||||
filterPredicate: rolloutFilterPredicate,
|
||||
filterPredicate: isRollout,
|
||||
afterEachFilterQuery,
|
||||
});
|
||||
return await selectChannel.runAsync(ctx);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,56 @@
|
||||
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../../channel/__tests__/fixtures';
|
||||
import { getChannelsDatasetAsync } from '../../../channel/queries';
|
||||
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('../../../channel/queries');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/ChannelQuery');
|
||||
describe(SelectRollout, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('returns null if project has no rollouts', async () => {
|
||||
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([]);
|
||||
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 () => {
|
||||
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([testBasicChannelInfo]);
|
||||
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(getChannelsDatasetAsync)
|
||||
.mockResolvedValue([testBasicChannelInfo, testBasicChannelInfo2]);
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicChannelInfo, cursor: 'cursor' },
|
||||
{ node: testBasicChannelInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
channel: testBasicChannelInfo,
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const ctx = createCtxMock();
|
||||
const selectRollout = new SelectRollout();
|
||||
@@ -41,12 +60,14 @@ describe(SelectRollout, () => {
|
||||
it('filters the channels for rollouts', async () => {
|
||||
const standardBranchMappingChannel = { ...testBasicChannelInfo };
|
||||
standardBranchMappingChannel.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
const mockDataset = [standardBranchMappingChannel];
|
||||
jest.mocked(getChannelsDatasetAsync).mockImplementation((_gqlClient, args: any) => {
|
||||
const filterPredicate = args.filterPredicate as any;
|
||||
return mockDataset.filter(filterPredicate) as any;
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from 'assert';
|
||||
|
||||
import {
|
||||
BranchMapping,
|
||||
BranchMappingAlwaysTrue,
|
||||
BranchMappingNode,
|
||||
BranchMappingValidationError,
|
||||
alwaysTrue,
|
||||
assertNodeObject,
|
||||
assertNumber,
|
||||
assertStatement,
|
||||
@@ -38,6 +41,52 @@ type LegacyRolloutInfo = {
|
||||
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);
|
||||
}
|
||||
@@ -51,13 +100,11 @@ export function isConstrainedRollout(rollout: Rollout): rollout is ConstrainedRo
|
||||
}
|
||||
|
||||
export function getRolloutInfo(basicChannelInfo: UpdateChannelBasicInfoFragment): RolloutInfo {
|
||||
const branchMapping = getBranchMapping(basicChannelInfo.branchMapping);
|
||||
assertRollout(branchMapping);
|
||||
return getRolloutInfoFromBranchMapping(branchMapping);
|
||||
const rolloutBranchMapping = getRolloutBranchMapping(basicChannelInfo.branchMapping);
|
||||
return getRolloutInfoFromBranchMapping(rolloutBranchMapping);
|
||||
}
|
||||
|
||||
export function getRolloutInfoFromBranchMapping(branchMapping: BranchMapping): RolloutInfo {
|
||||
assertRollout(branchMapping);
|
||||
export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapping): RolloutInfo {
|
||||
const rolledOutBranchId = branchMapping.data[0].branchId;
|
||||
const defaultBranchId = branchMapping.data[1].branchId;
|
||||
|
||||
@@ -97,14 +144,32 @@ export function getRolloutInfoFromBranchMapping(branchMapping: BranchMapping): R
|
||||
}
|
||||
|
||||
export function getRollout(channel: UpdateChannelObject): Rollout {
|
||||
const branchMapping = getBranchMapping(channel.branchMapping);
|
||||
assertRollout(branchMapping);
|
||||
const rolledOutBranchId = branchMapping.data[0].branchId;
|
||||
const rolloutBranchMapping = getRolloutBranchMapping(channel.branchMapping);
|
||||
const rolledOutBranchId = rolloutBranchMapping.data[0].branchId;
|
||||
const rolledOutBranch = getUpdateBranch(channel, rolledOutBranchId);
|
||||
const defaultBranchId = branchMapping.data[1].branchId;
|
||||
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,
|
||||
@@ -112,6 +177,12 @@ export function getRollout(channel: UpdateChannelObject): Rollout {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRolloutBranchMapping(branchMappingString: string): RolloutBranchMapping {
|
||||
const branchMapping = getBranchMapping(branchMappingString);
|
||||
assertRolloutBranchMapping(branchMapping);
|
||||
return branchMapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a branch mapping is a rollout.
|
||||
*
|
||||
@@ -163,19 +234,58 @@ export function getRollout(channel: UpdateChannelObject): Rollout {
|
||||
],
|
||||
}
|
||||
*/
|
||||
export function isRollout(branchMapping: BranchMapping): boolean {
|
||||
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: BranchMapping,
|
||||
branchMapping: RolloutBranchMapping,
|
||||
percent: number
|
||||
): BranchMapping {
|
||||
assert(
|
||||
Number.isInteger(percent) && percent >= 0 && percent <= 100,
|
||||
'The rollout percentage must be an integer between 0 and 100 inclusive.'
|
||||
);
|
||||
assertRollout(branchMapping);
|
||||
): RolloutBranchMapping {
|
||||
assertPercent(percent);
|
||||
if (isRtvConstrainedRollout(branchMapping)) {
|
||||
return editRtvConstrainedRollout(branchMapping, percent);
|
||||
} else {
|
||||
@@ -183,45 +293,42 @@ export function editRolloutBranchMapping(
|
||||
}
|
||||
}
|
||||
|
||||
function editRtvConstrainedRollout(branchMapping: BranchMapping, percent: number): BranchMapping {
|
||||
function editRtvConstrainedRollout(
|
||||
branchMapping: ConstrainedRolloutBranchMapping,
|
||||
percent: number
|
||||
): ConstrainedRolloutBranchMapping {
|
||||
const newBranchMapping = { ...branchMapping };
|
||||
const statementNode = newBranchMapping.data[0].branchMappingLogic;
|
||||
assertStatement(statementNode);
|
||||
const nodesFromStatement = getNodesFromStatement(statementNode);
|
||||
|
||||
const rolloutNode = nodesFromStatement.find(isRolloutNode);
|
||||
assert(rolloutNode, 'Rollout node must be defined.');
|
||||
assertNodeObject(rolloutNode);
|
||||
rolloutNode.operand = percent / 100;
|
||||
return newBranchMapping;
|
||||
}
|
||||
|
||||
function editLegacyRollout(branchMapping: BranchMapping, percent: number): BranchMapping {
|
||||
function editLegacyRollout(
|
||||
branchMapping: LegacyRolloutBranchMapping,
|
||||
percent: number
|
||||
): LegacyRolloutBranchMapping {
|
||||
const newBranchMapping = { ...branchMapping };
|
||||
const rolloutNode = newBranchMapping.data[0].branchMappingLogic;
|
||||
assertNodeObject(rolloutNode);
|
||||
rolloutNode.operand = percent / 100;
|
||||
return newBranchMapping;
|
||||
}
|
||||
|
||||
function assertRollout(branchMapping: BranchMapping): asserts branchMapping is BranchMapping {
|
||||
assert(
|
||||
isRollout(branchMapping),
|
||||
'Branch mapping node must be a rollout. Received: ' + JSON.stringify(branchMapping)
|
||||
);
|
||||
}
|
||||
|
||||
function isRtvConstrainedRollout(branchMapping: BranchMapping): boolean {
|
||||
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): boolean {
|
||||
function isRtvConstrainedRolloutNode(node: BranchMappingNode): node is RtvConstrainedRolloutNode {
|
||||
if (!isStatement(node) || !isAndStatement(node)) {
|
||||
return false;
|
||||
}
|
||||
@@ -235,7 +342,9 @@ function isRtvConstrainedRolloutNode(node: BranchMappingNode): boolean {
|
||||
return hasRuntimeVersionNode && hasRolloutNode;
|
||||
}
|
||||
|
||||
function isUnconstrainedRollout(branchMapping: BranchMapping): boolean {
|
||||
function isUnconstrainedRollout(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is LegacyRolloutBranchMapping {
|
||||
if (branchMapping.data.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
@@ -244,7 +353,7 @@ function isUnconstrainedRollout(branchMapping: BranchMapping): boolean {
|
||||
return hasRolloutNode && defaultsToAlwaysTrueNode;
|
||||
}
|
||||
|
||||
function isRuntimeVersionNode(node: BranchMappingNode): boolean {
|
||||
function isRuntimeVersionNode(node: BranchMappingNode): node is RuntimeVersionNode {
|
||||
if (typeof node === 'string') {
|
||||
return false;
|
||||
}
|
||||
@@ -254,7 +363,7 @@ function isRuntimeVersionNode(node: BranchMappingNode): boolean {
|
||||
return node.clientKey === 'runtimeVersion' && node.branchMappingOperator === '==';
|
||||
}
|
||||
|
||||
function isRolloutNode(node: BranchMappingNode): boolean {
|
||||
function isRolloutNode(node: BranchMappingNode): node is RolloutNode {
|
||||
if (typeof node === 'string') {
|
||||
return false;
|
||||
}
|
||||
@@ -263,3 +372,22 @@ function isRolloutNode(node: BranchMappingNode): boolean {
|
||||
}
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
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 { getRollout, isConstrainedRollout } from './branch-mapping';
|
||||
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.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Rollout:'));
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('🚀 Rollout:'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Channel', value: channel.name },
|
||||
{ label: 'Channel', value: channelName },
|
||||
...(isConstrainedRollout(rollout)
|
||||
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
|
||||
: []),
|
||||
@@ -27,24 +34,68 @@ export function printRollout(channel: UpdateChannelObject): void {
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
|
||||
export function printBranch(channel: UpdateChannelObject): void {
|
||||
const rollout = getRollout(channel);
|
||||
const rolledOutPercent = rollout.percentRolledOut;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Rollout:'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Channel', value: channel.name },
|
||||
...(isConstrainedRollout(rollout)
|
||||
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
|
||||
: []),
|
||||
{
|
||||
label: 'Branches',
|
||||
value: `${rollout.rolledOutBranch.name} (${rolledOutPercent}%), ${
|
||||
rollout.defaultBranch.name
|
||||
} (${100 - rolledOutPercent}%)`,
|
||||
},
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
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));
|
||||
}
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,8 +1,5 @@
|
||||
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';
|
||||
@@ -12,7 +9,6 @@ import {
|
||||
maybePrintBuildProfileDeprecationWarningsAsync,
|
||||
} from '../profiles';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('@expo/eas-json', () => {
|
||||
const actual = jest.requireActual('@expo/eas-json');
|
||||
@@ -36,7 +32,6 @@ const getBuildProfileDeprecationWarningsAsync = jest.spyOn(
|
||||
);
|
||||
const newLineSpy = jest.spyOn(Log, 'newLine');
|
||||
const warnSpy = jest.spyOn(Log, 'warn');
|
||||
const projectDir = '/app';
|
||||
|
||||
describe(getProfilesAsync, () => {
|
||||
afterEach(() => {
|
||||
@@ -56,7 +51,6 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profileName).toBe('production');
|
||||
@@ -77,7 +71,6 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
})
|
||||
).rejects.toThrowError(errors.MissingProfileError);
|
||||
});
|
||||
@@ -93,7 +86,6 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: 'custom-profile',
|
||||
type: 'build',
|
||||
projectDir,
|
||||
});
|
||||
|
||||
expect(result[0].profileName).toBe('custom-profile');
|
||||
@@ -106,52 +98,6 @@ 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');
|
||||
@@ -163,7 +109,6 @@ describe(getProfilesAsync, () => {
|
||||
platforms: [Platform.ANDROID, Platform.IOS],
|
||||
profileName: undefined,
|
||||
type: 'build',
|
||||
projectDir,
|
||||
})
|
||||
).rejects.toThrowError(/eas.json is not valid/);
|
||||
});
|
||||
|
||||
@@ -1,94 +1,795 @@
|
||||
import { getPaginatedDatasetAsync } from '../relay';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import {
|
||||
Connection,
|
||||
FilterPagination,
|
||||
NEXT_PAGE_OPTION,
|
||||
PREV_PAGE_OPTION,
|
||||
QueryParams,
|
||||
selectPaginatedAsync,
|
||||
} from '../relay';
|
||||
|
||||
describe('getPaginatedDatasetAsync', () => {
|
||||
const mockQueryAsync = jest.fn(({ first, after }) =>
|
||||
Promise.resolve({
|
||||
edges: Array(first).fill({ node: 'node', cursor: 'cursor' }),
|
||||
pageInfo: {
|
||||
hasNextPage: after !== 'next',
|
||||
endCursor: after ? null : 'next',
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
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(() => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should query data page by page until no more data', async () => {
|
||||
const dataset = await getPaginatedDatasetAsync({
|
||||
queryAsync: mockQueryAsync,
|
||||
batchSize: 1,
|
||||
test('returns null when there are no items', async () => {
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(mockQueryAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockQueryAsync).toHaveBeenCalledWith({ first: 1, after: undefined });
|
||||
expect(mockQueryAsync).toHaveBeenCalledWith({ first: 1, after: 'next' });
|
||||
expect(dataset).toHaveLength(2);
|
||||
const result = await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
|
||||
});
|
||||
|
||||
it('should call beforeEachQuery and afterEachQuery with correct parameters', async () => {
|
||||
// dataset is pass by reference, so we make a copy at each call
|
||||
const datasetAtNthCall_beforeEachQuery = [] as any;
|
||||
const mockBeforeEachQuery = jest.fn((totalNodesFetched, dataset) => {
|
||||
datasetAtNthCall_beforeEachQuery.push([...dataset]);
|
||||
test('returns the only item when there is only one', async () => {
|
||||
const node = { id: '1', name: 'Node 1' };
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node }],
|
||||
});
|
||||
|
||||
// dataset is pass by reference, so we make a copy at each call
|
||||
const datasetAtNthCall_afterEachQuery = [] as any;
|
||||
const mockAfterEachQuery = jest.fn((totalNodesFetched, dataset, batch, pageInfo) => {
|
||||
datasetAtNthCall_afterEachQuery.push([...dataset]);
|
||||
const result = await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
await getPaginatedDatasetAsync({
|
||||
queryAsync: mockQueryAsync,
|
||||
beforeEachQuery: mockBeforeEachQuery,
|
||||
afterEachQuery: mockAfterEachQuery,
|
||||
batchSize: 1,
|
||||
expect(result).toEqual(node);
|
||||
expect(queryAsync).toHaveBeenCalledWith({
|
||||
first: pageSize,
|
||||
});
|
||||
|
||||
expect(mockBeforeEachQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mockBeforeEachQuery).toHaveBeenNthCalledWith(1, 0, expect.any(Array));
|
||||
expect(datasetAtNthCall_beforeEachQuery[0]).toStrictEqual([]);
|
||||
expect(mockBeforeEachQuery).toHaveBeenNthCalledWith(2, 1, expect.any(Array));
|
||||
expect(datasetAtNthCall_beforeEachQuery[1]).toStrictEqual(['node']);
|
||||
|
||||
expect(mockAfterEachQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mockAfterEachQuery).toHaveBeenNthCalledWith(1, 1, expect.any(Array), ['node'], {
|
||||
hasNextPage: true,
|
||||
endCursor: 'next',
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
expect(datasetAtNthCall_afterEachQuery[0]).toStrictEqual(['node']);
|
||||
expect(mockAfterEachQuery).toHaveBeenNthCalledWith(2, 2, expect.any(Array), ['node'], {
|
||||
hasNextPage: false,
|
||||
endCursor: null,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
expect(datasetAtNthCall_afterEachQuery[1]).toStrictEqual(['node', 'node']);
|
||||
});
|
||||
|
||||
it('should filter nodes if filterPredicate is provided', async () => {
|
||||
const filterPredicate = (node: string): boolean => node !== 'node';
|
||||
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 dataset = await getPaginatedDatasetAsync({
|
||||
queryAsync: mockQueryAsync,
|
||||
filterPredicate,
|
||||
batchSize: 1,
|
||||
const result = await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
expect(dataset).toEqual([]); // expecting an empty array because our filterPredicate will filter out 'node'
|
||||
expect(result).toEqual(node1);
|
||||
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
|
||||
expect(getTitleAsync).toHaveBeenCalledWith(node1);
|
||||
expect(getTitleAsync).toHaveBeenCalledWith(node2);
|
||||
});
|
||||
|
||||
it('should stop fetching when maxNodesFetched limit is reached', async () => {
|
||||
const dataset = await getPaginatedDatasetAsync({
|
||||
queryAsync: mockQueryAsync,
|
||||
batchSize: 2,
|
||||
maxNodesFetched: 2,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dataset).toHaveLength(2);
|
||||
expect(mockQueryAsync).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,6 @@ 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';
|
||||
|
||||
@@ -27,23 +24,19 @@ 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 readProfileWithOverridesAsync({
|
||||
const profile = await readProfileAsync({
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
type,
|
||||
profileName,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
return {
|
||||
profile,
|
||||
profileName: profileName ?? 'production',
|
||||
@@ -54,53 +47,16 @@ export async function getProfilesAsync<T extends ProfileType>({
|
||||
return await Promise.all(results);
|
||||
}
|
||||
|
||||
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>({
|
||||
async function readProfileAsync<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(
|
||||
@@ -110,7 +66,6 @@ async function readProfileWithOverridesAsync<T extends ProfileType>({
|
||||
);
|
||||
|
||||
await maybePrintBuildProfileDeprecationWarningsAsync(easJsonAccessor, platform, profileName);
|
||||
await setNodeVersionFromFileAsync(projectDir, buildProfile);
|
||||
|
||||
return buildProfile as EasProfile<T>;
|
||||
} else {
|
||||
|
||||
@@ -1,73 +1,363 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { PageInfo } from '../graphql/generated';
|
||||
import { promptAsync } from '../prompts';
|
||||
|
||||
export type Connection<T> = {
|
||||
edges: Edge<T>[];
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
type Edge<T> = {
|
||||
export type Edge<T> = {
|
||||
cursor: string;
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type QueryParams = {
|
||||
first: number;
|
||||
first?: number;
|
||||
after?: string;
|
||||
last?: number;
|
||||
before?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches dataset in paginated manner (batch by batch) using GraphQL queries.
|
||||
*
|
||||
* 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 filterPredicate Optional. A predicate function to filter the node.
|
||||
* @param batchSize Optional. The batch size of the pagination. Defaults to 100.
|
||||
* @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.
|
||||
*
|
||||
* @return {Promise<T[]>} - A promise that resolves to an array (the dataset).
|
||||
* @throws {Error} - If an error occurs during execution of the query or pagination.
|
||||
*/
|
||||
export async function getPaginatedDatasetAsync<T>({
|
||||
queryAsync,
|
||||
beforeEachQuery,
|
||||
afterEachQuery,
|
||||
filterPredicate,
|
||||
batchSize = 100,
|
||||
maxNodesFetched = 10_000,
|
||||
}: {
|
||||
queryAsync: ({ first, after }: QueryParams) => Promise<Connection<T>>;
|
||||
beforeEachQuery?: (totalNodesFetched: number, dataset: T[]) => void;
|
||||
afterEachQuery?: (
|
||||
totalNodesFetched: number,
|
||||
dataset: T[],
|
||||
batch: T[],
|
||||
pageInfo: PageInfo
|
||||
) => void;
|
||||
filterPredicate?: (node: T) => boolean;
|
||||
batchSize?: number;
|
||||
maxNodesFetched?: number;
|
||||
}): Promise<T[]> {
|
||||
const dataset = [];
|
||||
let hasMore = true;
|
||||
let after: string | undefined;
|
||||
let totalNodesFetched = 0;
|
||||
while (hasMore) {
|
||||
if (beforeEachQuery) {
|
||||
beforeEachQuery(totalNodesFetched, dataset);
|
||||
}
|
||||
const result = await queryAsync({ first: batchSize, after });
|
||||
const { edges, pageInfo } = result;
|
||||
const nodes = edges.map(edge => edge.node);
|
||||
const batch = filterPredicate ? nodes.filter(filterPredicate) : nodes;
|
||||
dataset.push(...batch);
|
||||
hasMore = pageInfo.hasNextPage;
|
||||
after = pageInfo.endCursor ?? undefined;
|
||||
totalNodesFetched += nodes.length;
|
||||
if (afterEachQuery) {
|
||||
afterEachQuery(totalNodesFetched, dataset, batch, pageInfo);
|
||||
}
|
||||
if (totalNodesFetched >= maxNodesFetched) {
|
||||
return dataset;
|
||||
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;
|
||||
}
|
||||
return dataset;
|
||||
}
|
||||
Reference in new issue
Block a user