Compare commits

...
Author SHA1 Message Date
Kirby Cofino f5191b2da6 [eas-cli] Add pagination to supported GQL queries 2022-07-05 18:39:13 -07:00
Cedric van Putten d0b9af2553 [eas-cli] Add review details to metadata store configuration (#1184)
* [eas-cli] Tweak the store configuration validationf or review

* [eas-cli] Implement review details in metadata

* [eas-cli] Use valid fixtures in config tests

* [eas-cli] Add markdown description for review notes

* [eas-cli] Add app review detail task description

* update CHANGELOG.md
2022-06-29 06:17:10 -07:00
Cedric van Putten 80674a3451 [eas-cli] Improve store configuration defaults and schema configuration (#1183)
* [eas-cli] Avoid setting automaticRelease for manual releases in config

* [eas-cli] Use same default app name as schema

* [eas-cli] Prefer releaseNotes over whatsNew in apple info

* [eas-cli] Drop deprecated gamblingAndContest from schema

* [eas-cli] Use better defaults for advisory

* [eas-cli] Remove type from enum as its not required by spec

* [eas-cli] Remove $ref and description workaround for draft 6

* [eas-cli] Update kids age rating description

* [eas-cli] Remove default app title

* update CHANGELOG.md
2022-06-28 05:24:32 -07:00
kirb 41b48a80be [eas-cli] truncates long messages for eas update command (#1178) 2022-06-24 15:27:52 -07:00
Dominik Sokal 3d5b77a263 [ENG-5262] fix URLs returned from AWS S3 (#1179)
* fix URLs returned from AWS S3

* update CHANGELOG.md
2022-06-24 11:17:49 +02:00
Chris WalterandDominik Sokal fa25d259d9 [1/1] Support resource-class flag in eas-cli (#1138)
* [1/1] Support build-resource-class flag in eas-cli

# Why
We want to add a flag to allow users to specify resource-class when they
run `eas build`. The flag is experimental for now, and we haven't
decided exactly how we'll incorporate the resource classes into plan
pricing. But this PR doesn't deal with that - it just makes the
necessary changes to eas-cli to support specifying a resource class when
running `eas build`.

# How
Add a new enum for BuildResourceClass that matches the enum created
for GraphQL in universe/server/www. Then create a new flag
`build-resource-class` whose options are the values of the
BuildResourceClass enum. Then add some validation to prevent resource
classes being used for incompatible platforms (e.g. prevent `-p ios
--build-resource-class=ANDROID_LARGE`). Finally, pass along the flag
value to the call to the WWW graphql mutations for BuildAndroid and
BuildiOS.

# Test Plan
```
yarn test
```
See test plan on this PR for more thorough e2e testing summary:
https://github.com/expo/universe/pull/9800

* Update packages/eas-cli/src/commands/build/index.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Apply suggestions from code review

Co-authored-by: Dominik Sokal <[email protected]>

* Address PR comments

* Regenerate graphql from staging

* update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>
2022-06-23 20:11:20 -07:00
Cedric van PuttenandEvan Bacon 26596f32e9 [eas-cli] Allow users to skip metadata validation (#1175)
* [eas-cli] Allow users to skip validation when schema has an issue

* [eas-cli] Make sure instructions are visible

* update CHANGELOG.md

* [eas-cli] Remove instructions from confirm async

* [eas-cli] Fix typo in confirm question

* [eas-cli] Reword warning about ignoring store config issues

Co-authored-by: Evan Bacon <[email protected]>

* [eas-cli] Reword confirmation question of ignoring issues

Co-authored-by: Evan Bacon <[email protected]>

Co-authored-by: Evan Bacon <[email protected]>
2022-06-23 05:13:39 -07:00
Cedric van Putten 031c610d58 [eas-cli] Update validation rules for metadata info locales (#1174)
* [eas-cli] Update validation rules for metadata info locales

* Add missing codeowner for metadata schema

* update CHANGELOG.md

* Fix path in codeowners
2022-06-23 04:43:28 -07:00
Dominik Sokal fcb5beccb4 update CHANGELOG.md 2022-06-23 09:16:57 +02:00
kirb 3003a712f6 [eas-cli] include bundler output in terminal for eas update (#1171) 2022-06-22 10:46:31 -07:00
Cedric van Putten e89f030c29 fix CHANGELOG 2022-06-17 16:02:19 +02:00
52 changed files with 2035 additions and 660 deletions

No files matched your search

+12 -1
View File
@@ -8,10 +8,21 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Show bundler output by default for eas update command. ([#1171](https://github.com/expo/eas-cli/pull/1171) by [@kgc00](https://github.com/kgc00/))
- Allow users to skip metadata validation. ([#1175](https://github.com/expo/eas-cli/pull/1175) by [@byCedric](https://github.com/byCedric))
- Add experimental `--resource-class` flag for Build commands. ([#1138](https://github.com/expo/eas-cli/pull/1138) by [@christopherwalter](https://github.com/christopherwalter))
- Truncate long messages for `eas update` command, rather than failing. ([#1178](https://github.com/expo/eas-cli/pull/1178) by [@kgc00](https://github.com/kgc00))
- Add review details to metadata store configuration. ([#1184](https://github.com/expo/eas-cli/pull/1184) by [@byCedric](https://github.com/byCedric))
### 🐛 Bug fixes
- Fix submission archive URLs. ([#1179](https://github.com/expo/eas-cli/pull/1179) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Update validation rules for metadata info locales. ([#1174](https://github.com/expo/eas-cli/pull/1174) by [@byCedric](https://github.com/byCedric))
- Improve store configuration defaults and schema documentation. ([#1183](https://github.com/expo/eas-cli/pull/1183) by [@byCedric](https://github.com/byCedric))
## [0.54.1](https://github.com/expo/eas-cli/releases/tag/v0.54.1) - 2022-06-15
### 🎉 New features
@@ -21,6 +32,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- Surface ASC errors in metadata for normal API rejections. ([#1167](https://github.com/expo/eas-cli/pull/1167) by [@byCedric](https://github.com/byCedric))
- Add metadata schema to bundled eas-cli package. ([#1166](https://github.com/expo/eas-cli/pull/1166) by [@byCedric](https://github.com/byCedric))
## [0.54.0](https://github.com/expo/eas-cli/releases/tag/v0.54.0) - 2022-06-15
@@ -37,7 +49,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Improved support for working on branches with many updates. ([#1144](https://github.com/expo/eas-cli/pull/1144), [#1148](https://github.com/expo/eas-cli/pull/1148) by [@kgc00](https://github.com/kgc00/))
- `eas update` project links no longer contain a `)` on unsupported terminals. ([#1152](https://github.com/expo/eas-cli/pull/1152) by [@kgc00](https://github.com/kgc00/))
- Add metadata schema to bundled eas-cli package. ([#1166](https://github.com/expo/eas-cli/pull/1166) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
+1
View File
@@ -6,6 +6,7 @@ packages/eas-cli/src/commands/build @dsokal @wkozyra95
packages/eas-cli/src/submit @barthap @dsokal
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
packages/eas-cli/schema @bycedric
packages/eas-cli/src/metadata @bycedric
packages/eas-cli/src/commands/metadata @bycedric
+127 -1
View File
@@ -10209,7 +10209,7 @@
"args": [
{
"name": "assetContentTypes",
"description": "max 600",
"description": "max 1400",
"type": {
"kind": "NON_NULL",
"name": null,
@@ -10763,6 +10763,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "maxBuildTimeSeconds",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metrics",
"description": null,
@@ -10883,6 +10899,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "resourceClass",
"description": null,
"args": [],
"type": {
"kind": "ENUM",
"name": "BuildResourceClass",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runtimeVersion",
"description": null,
@@ -12280,6 +12308,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildParams",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildParamsInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "job",
"description": null,
@@ -12341,6 +12381,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildParams",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "BuildParamsInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "job",
"description": null,
@@ -12492,6 +12544,33 @@
}
]
},
{
"kind": "INPUT_OBJECT",
"name": "BuildParamsInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "resourceClass",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "BuildResourceClass",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "BuildPriority",
@@ -12511,6 +12590,12 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NORMAL_PLUS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
@@ -12868,6 +12953,47 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "BuildResourceClass",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ANDROID_DEFAULT",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ANDROID_LARGE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "IOS_DEFAULT",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "IOS_LARGE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LEGACY",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "BuildStatus",
+284 -201
View File
@@ -19,10 +19,18 @@
]
},
"AppleKidsAge": {
"enum": ["FIVE_AND_UNDER", "SIX_TO_EIGHT", "NINE_TO_ELEVEN"]
"enum": [
"FIVE_AND_UNDER",
"SIX_TO_EIGHT",
"NINE_TO_ELEVEN"
]
},
"AppleRating": {
"enum": ["NONE", "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE"]
"enum": [
"NONE",
"INFREQUENT_OR_MILD",
"FREQUENT_OR_INTENSE"
]
},
"AppleCategory": {
"enum": [
@@ -94,15 +102,15 @@
"type": "object",
"additionalProperties": false,
"defaultSnippets": [
{
"body": {
"servesAds": false,
"attributesAppInstallationToPreviousAd": false,
"attributesActionWithPreviousAd": false,
"honorsLimitedAdTracking": false
}
{
"body": {
"servesAds": false,
"attributesAppInstallationToPreviousAd": false,
"attributesActionWithPreviousAd": false,
"honorsLimitedAdTracking": false
}
],
}
],
"properties": {
"servesAds": {
"type": "boolean"
@@ -123,240 +131,278 @@
"additionalProperties": false,
"required": [
"title",
"subtitle",
"description",
"keywords",
"marketingUrl",
"supportUrl",
"privacyPolicyUrl"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Create the minimum required locale properties",
"body": {
"title": "${1:App title}",
"privacyPolicyUrl": "https://${3:www.}"
}
},
{
"label": "Interactive",
"description": "Creates localized app info",
"label": "Full",
"description": "Create all available locale properties",
"body": {
"title": "$1",
"subtitle": "$2",
"description": "$3",
"keywords": [],
"marketingUrl": "",
"supportUrl": "",
"privacyPolicyUrl": ""
"title": "${1:App title}",
"subtitle": "${2:Subtitle for your app}",
"description": "${3:A longer description of what your app does}",
"keywords": [
"${4:keyword}"
],
"releaseNotes": "${5:Bug fixes and improved stability}",
"promoText": "${6:Short tagline for your app}",
"marketingUrl": "https://${7:www.}",
"supportUrl": "https://${8:www.}",
"privacyPolicyUrl": "https://${9:www.}",
"privacyChoicesUrl": "https://${10:www.}"
}
}
],
"properties": {
"title": {
"description": "Name of the app in the store. This should be similar to the installed app name.",
"type": "string",
"description": "Name of the app in the store. This should be similar to the installed app name.",
"minLength": 2,
"maxLength": 30,
"meta": {
"storeInfo": "The name will be reviewed before it is made available on the App Store."
}
},
"subtitle": {
"description": "Subtext for the app. Example: 'A Fun Game For Friends'",
"type": "string",
"description": "Subtext for the app. Example: 'A Fun Game For Friends'",
"maxLength": 30,
"meta": {
"storeInfo": "The subtitle will be reviewed before it is made available on the App Store."
}
},
"description": {
"type": "string",
"description": "Main description of what the app does",
"minLength": 10,
"maxLength": 4000,
"meta": {
"versioned": true,
"liveEdits": true
},
"maxLength": 4000,
"description": "Main description of what the app does",
"type": "string"
},
"promoText": {
"meta": {
"versioned": true,
"liveEdits": true,
"aso": false
},
"maxLength": 170,
"description": "Short tagline for the app",
"type": "string"
}
},
"keywords": {
"meta": {
"versioned": true
},
"description": "List of keywords to help users find the app in the App Store",
"type": "array",
"description": "List of keywords to help users find the app in the App Store",
"uniqueItems": true,
"items": {
"maxLength": 100,
"type": "string"
},
"meta": {
"versioned": true
}
},
"releaseNotes": {
"type": "string",
"description": "Changes since the last public version",
"maxLength": 4000,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"promoText": {
"type": "string",
"description": "Short tagline for the app",
"maxLength": 170,
"meta": {
"versioned": true,
"liveEdits": true,
"aso": false
}
},
"marketingUrl": {
"type": "string",
"description": "URL to the app marketing page",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"supportUrl": {
"type": "string",
"description": "URL to the app support page",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"privacyPolicyText": {
"type": "string",
"description": "Privacy policy for Apple TV"
},
"whatsNew": {
"meta": {
"versioned": true,
"liveEdits": true
},
"maxLength": 4000,
"description": "Changes since the last public version",
"type": "string"
},
"marketingUrl": {
"meta": {
"versioned": true,
"liveEdits": true
},
"description": "URL to the app marketing page",
"type": "string"
},
"supportUrl": {
"meta": {
"versioned": true,
"liveEdits": true
},
"description": "URL to the app support page",
"type": "string"
},
"privacyPolicyUrl": {
"type": "string",
"description": "A URL that links to your privacy policy. A privacy policy is required for all apps.",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
},
"maxLength": 255,
"description": "A URL that links to your privacy policy. A privacy policy is required for all apps.",
"type": "string"
}
},
"privacyChoicesUrl": {
"type": "string",
"description": "A URL where users can modify and delete the data collected from the app, or decide how their data is used and shared.",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true,
"optional": true
},
"maxLength": 255,
"description": "A URL where users can modify and delete the data collected from the app, or decide how their data is used and shared.",
"type": "string"
}
}
}
},
"AppleAdvisory": {
"type": "object",
"additionalProperties": false,
"required": [
"alcoholTobaccoOrDrugUseOrReferences",
"contests",
"gamblingSimulated",
"horrorOrFearThemes",
"matureOrSuggestiveThemes",
"medicalOrTreatmentInformation",
"profanityOrCrudeHumor",
"sexualContentGraphicAndNudity",
"sexualContentOrNudity",
"violenceCartoonOrFantasy",
"violenceRealistic",
"violenceRealisticProlongedGraphicOrSadistic",
"gambling",
"unrestrictedWebAccess",
"kidsAgeBand",
"seventeenPlus"
],
"defaultSnippets": [
{
"label": "Minimum",
"description": "The bare minimum rating, your app does nothing",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
"contests": "NONE",
"gamblingSimulated": "NONE",
"medicalOrTreatmentInformation": "NONE",
"profanityOrCrudeHumor": "NONE",
"sexualContentGraphicAndNudity": "NONE",
"sexualContentOrNudity": "NONE",
"horrorOrFearThemes": "NONE",
"matureOrSuggestiveThemes": "NONE",
"violenceCartoonOrFantasy": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
"violenceRealistic": "NONE",
"gamblingAndContests":false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false,
"unrestrictedWebAccess": false
}
},
{
"label": "Interactive",
"description": "Step through options",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "${1|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"contests": "${2|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"gamblingSimulated": "${3|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"medicalOrTreatmentInformation": "${4|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"profanityOrCrudeHumor": "${5|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentGraphicAndNudity": "${6|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentOrNudity": "${7|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"horrorOrFearThemes": "${8|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"matureOrSuggestiveThemes": "${9|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceCartoonOrFantasy": "${10|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealisticProlongedGraphicOrSadistic": "${11|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealistic": "${12|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"gamblingAndContests":false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false,
"unrestrictedWebAccess": false
}
{
"label": "Basic",
"description": "Create an advisory that disables all age-rated properties, for when your app does nothing",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
"contests": "NONE",
"gamblingSimulated": "NONE",
"horrorOrFearThemes": "NONE",
"matureOrSuggestiveThemes": "NONE",
"medicalOrTreatmentInformation": "NONE",
"profanityOrCrudeHumor": "NONE",
"sexualContentGraphicAndNudity": "NONE",
"sexualContentOrNudity": "NONE",
"violenceCartoonOrFantasy": "NONE",
"violenceRealistic": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
"gambling": false,
"unrestrictedWebAccess": false,
"kidsAgeBand": null,
"seventeenPlus": false
}
],
},
{
"label": "Full",
"description": "Create an advisory that enables some of the age-rated properties",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "${1|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"contests": "${2|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"gamblingSimulated": "${3|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"horrorOrFearThemes": "${4|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"matureOrSuggestiveThemes": "${5|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"medicalOrTreatmentInformation": "${6|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"profanityOrCrudeHumor": "${7|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentGraphicAndNudity": "${8|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentOrNudity": "${9|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceCartoonOrFantasy": "${10|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealistic": "${11|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealisticProlongedGraphicOrSadistic": "${12|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"unrestrictedWebAccess": false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false
}
}
],
"properties": {
"alcoholTobaccoOrDrugUseOrReferences": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain alcohol, tobacco, or drug use or references?"
},
"contests": {
"$ref": "#/definitions/apple/AppleRating"
},
"gamblingAndContests": {
"type": "boolean"
},
"gambling": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain contests?"
},
"gamblingSimulated": {
"$ref": "#/definitions/apple/AppleRating"
},
"kidsAgeBand": {
"anyOf": [
{
"$ref": "#/definitions/apple/AppleKidsAge"
},
{
"type": "null"
}
]
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain simulated gambling?"
},
"medicalOrTreatmentInformation": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain medical or treatment information?"
},
"profanityOrCrudeHumor": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain profanity or crude humor?"
},
"sexualContentGraphicAndNudity": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain graphic sexual content and nudity?"
},
"sexualContentOrNudity": {
"$ref": "#/definitions/apple/AppleRating"
},
"seventeenPlus": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain sexual content or nudity?"
},
"horrorOrFearThemes": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain horror or fear themes?"
},
"matureOrSuggestiveThemes": {
"$ref": "#/definitions/apple/AppleRating"
},
"unrestrictedWebAccess": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain mature or suggestive themes?"
},
"violenceCartoonOrFantasy": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain cartoon or fantasy violence?"
},
"violenceRealisticProlongedGraphicOrSadistic": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain prolonged graphic or sadistic realistic violence?"
},
"violenceRealistic": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain realistic violence?"
},
"unrestrictedWebAccess": {
"type": "boolean",
"description": "Does your app contain unrestricted web access, such as with an embedded browser?"
},
"gambling": {
"type": "boolean",
"description": "Does your app contain gambling?"
},
"kidsAgeBand": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/definitions/apple/AppleKidsAge"
}
],
"description": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n@see https://developer.apple.com/news/?id=091202019a",
"markdownDescription": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n\n[Learn more](https://developer.apple.com/news/?id=091202019a)"
},
"seventeenPlus": {
"type": "boolean",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
}
}
},
@@ -402,8 +448,8 @@
}
},
"shouldResetRatings": {
"description": "Should App Store ratings be reset for this version of the app.",
"type": "boolean",
"description": "Should App Store ratings be reset for this version of the app.",
"meta": {
"storeInfo": "You can reset your app’s summary rating for all countries or regions when you release this version. Keep in mind that once this version is released, you won’t be able to restore the rating. Your app’s existing customer reviews will still appear on the App Store."
}
@@ -412,67 +458,96 @@
"type": "boolean"
},
"automaticRelease": {
"description": "Should the app automatically be released when approved by App Store review.",
"type": "boolean"
"type": "boolean",
"description": "Should the app automatically be released when approved by App Store review."
},
"usesNonExemptEncryption": {
"description": "Alternative to setting `ITSAppUsesNonExemptEncryption` in the binary's `Info.plist`.",
"type": "boolean"
"type": "boolean",
"description": "Alternative to setting `ITSAppUsesNonExemptEncryption` in the binary's `Info.plist`."
}
}
},
"AppleReview": {
"type": "object",
"description": "Info provided to the App Store review team to help them understand how to use your app.",
"required": ["firstName", "lastName"],
"required": [
"firstName",
"lastName",
"email",
"phone"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Only define the contact details in case communcation is needed with the App Store review team.",
"body": {
"firstName": "$1",
"lastName": "$2"
"lastName": "$2",
"email": "$3",
"phone": "+$4"
}
},
{
"label": "Full",
"description": "Define the demo account credentials, additional info, and contact details for the App Store review team.",
"body": {
"firstName": "$1",
"lastName": "$2",
"email": "$3",
"phone": "+$4",
"demoUsername": "$5",
"demoPassword": "$6",
"demoRequired": true,
"notes": "$7"
}
}
],
"properties": {
"demoUsername": {
"description": "Optional username for testing the app.",
"type": "string"
},
"demoPassword": {
"description": "Optional password for testing the app.",
"type": "string"
},
"attachment": {
"description": "Local file path to upload to the review team.",
"type": "string"
},
"firstName": {
"description": "Developer first name for the reviewer to contact.",
"type": "string"
"type": "string",
"description": "First name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"lastName": {
"description": "Developer last name (surname) for the reviewer to contact.",
"type": "string"
},
"phone": {
"description": "Developer phone number for the reviewer to contact.",
"type": "string"
"type": "string",
"description": "Last name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"email": {
"description": "Developer email address for the reviewer to contact.",
"type": "string",
"description": "Email address of contact in case communication is needed with the App Store review team.",
"format": "email"
},
"phone": {
"type": "string",
"description": "Phone number of contact in case communication is needed with the App Store review team. Preface the phone number with ‘+’ followed by the country code (for example, +44 844 209 0611)"
},
"demoUsername": {
"type": "string",
"description": "The user name to sign in to your app to review its features."
},
"demoPassword": {
"type": "string",
"description": "The password to sign in to your app to review its features."
},
"demoRequired": {
"type": "boolean",
"description": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review. Credentials must be valid and active for duration of review.",
"markdownDescription": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review.\n\n**Credentials must be valid and active for duration of review.**"
},
"notes": {
"description": "Any additional notes for the App Store reviewer.",
"type": "string"
"type": "string",
"description": "Additional information about your app that can help during the review process. Do not include demo account details.",
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
"minLength": 2,
"maxLength": 4000
}
}
}
},
"AppleConfig": {
"description": "Configuration that is specific to the Apple App Store.",
"type": "object",
"description": "Configuration that is specific to the Apple App Store.",
"additionalProperties": false,
"properties": {
"advisory": {
@@ -492,7 +567,9 @@
"maxItems": 2,
"items": [
{
"enum": ["GAMES"]
"enum": [
"GAMES"
]
},
{
"enum": [
@@ -522,7 +599,9 @@
"maxItems": 2,
"items": [
{
"enum": ["STICKERS"]
"enum": [
"STICKERS"
]
},
{
"enum": [
@@ -550,6 +629,8 @@
}
},
"copyright": {
"type": "string",
"description": "The updated company copyright. Example: 2021 Evan Bacon",
"defaultSnippets": [
{
"body": "$CURRENT_YEAR ${1:name}"
@@ -559,9 +640,7 @@
"versioned": true,
"liveEdits": true,
"storeInfo": "The name of the person or entity that owns the exclusive rights to your app, preceded by the year the rights were obtained (for example, \"2008 Acme Inc.\"). Do not provide a URL."
},
"description": "The updated company copyright. Example: 2021 Evan Bacon",
"type": "string"
}
},
"idfa": {
"$ref": "#/definitions/apple/AppleIdfa"
@@ -901,28 +980,32 @@
]
},
"release": {
"description": "Release strategy",
"$ref": "#/definitions/apple/AppleRelease"
"$ref": "#/definitions/apple/AppleRelease",
"description": "Release strategy"
},
"review": {
"description": "Info for the App Store reviewer",
"$ref": "#/definitions/apple/AppleReview"
"$ref": "#/definitions/apple/AppleReview",
"description": "Info for the App Store reviewer"
},
"priceTier": {
"description": "App price tier. 0 is Free.",
"type": "number"
"type": "number",
"description": "App price tier. 0 is Free."
}
}
}
},
"properties": {
"configVersion": {
"const": 0
"const": 0,
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": ["configVersion", "apple"]
"required": [
"configVersion",
"apple"
]
}
@@ -0,0 +1,17 @@
import { fixArchiveUrl } from '../uploads';
describe(fixArchiveUrl, () => {
it('fixes broken links', () => {
const input =
'https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c';
const expectedOutput =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixArchiveUrl(input)).toBe(expectedOutput);
});
it('does not change correct urls', () => {
const correctUrl =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixArchiveUrl(correctUrl)).toBe(correctUrl);
});
});
+4 -1
View File
@@ -5,6 +5,7 @@ import nullthrows from 'nullthrows';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import {
@@ -87,7 +88,8 @@ export async function prepareAndroidBuildAsync(
sendBuildRequestAsync: async (
appId: string,
job: Android.Job,
metadata: Metadata
metadata: Metadata,
buildParams: BuildParamsInput
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
@@ -95,6 +97,7 @@ export async function prepareAndroidBuildAsync(
appId,
job: graphqlJob,
metadata: graphqlMetadata,
buildParams,
});
},
});
+19 -4
View File
@@ -11,6 +11,7 @@ import { getExpoWebsiteBaseUrl } from '../api';
import {
AppPlatform,
BuildFragment,
BuildParamsInput,
BuildPriority,
BuildStatus,
UploadSessionType,
@@ -53,11 +54,22 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
): Promise<CredentialsResult<Credentials> | undefined>;
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
sendBuildRequestAsync(
appId: string,
job: TJob,
metadata: Metadata,
buildParams: BuildParamsInput
): Promise<BuildResult>;
}
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
function resolveBuildParamsInput<T extends Platform>(ctx: BuildContext<T>): BuildParamsInput {
return {
resourceClass: ctx.resourceClass,
};
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
@@ -100,6 +112,7 @@ export async function prepareBuildRequestForPlatformAsync<
} as const);
const metadata = await collectMetadataAsync(ctx);
const buildParams = resolveBuildParamsInput(ctx);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
credentials: credentialsResult?.credentials,
@@ -111,7 +124,7 @@ export async function prepareBuildRequestForPlatformAsync<
return undefined;
} else {
try {
return await sendBuildRequestAsync(builder, job, metadata);
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
}
@@ -197,7 +210,8 @@ async function uploadProjectAsync<TPlatform extends Platform>(
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
builder: Builder<TPlatform, Credentials, TJob>,
job: TJob,
metadata: Metadata
metadata: Metadata,
buildParams: BuildParamsInput
): Promise<BuildFragment> {
const { ctx } = builder;
return await withAnalyticsAsync(
@@ -209,7 +223,8 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.projectId,
job,
metadata
metadata,
buildParams
);
printDeprecationWarnings(deprecationInfo);
+2
View File
@@ -6,6 +6,7 @@ import type { XCBuildConfiguration } from 'xcode';
import { TrackingContext } from '../analytics/common';
import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User';
@@ -30,6 +31,7 @@ export interface BuildContext<T extends Platform> {
accountName: string;
buildProfile: BuildProfile<T>;
buildProfileName: string;
resourceClass: BuildResourceClass;
clearCache: boolean;
credentialsCtx: CredentialsContext;
exp: ExpoConfig;
@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { TrackingContext } from '../analytics/common';
import { Analytics, BuildEvent } from '../analytics/events';
import { CredentialsContext } from '../credentials/context';
import { BuildResourceClass } from '../graphql/generated';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
@@ -25,6 +26,7 @@ export async function createBuildContextAsync<T extends Platform>({
nonInteractive = false,
platform,
projectDir,
resourceClass,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -33,6 +35,7 @@ export async function createBuildContextAsync<T extends Platform>({
nonInteractive: boolean;
platform: T;
projectDir: string;
resourceClass: BuildResourceClass;
}): Promise<BuildContext<T>> {
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
@@ -70,6 +73,7 @@ export async function createBuildContextAsync<T extends Platform>({
accountName,
buildProfile,
buildProfileName,
resourceClass,
clearCache,
credentialsCtx,
exp,
+4 -1
View File
@@ -3,6 +3,7 @@ import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import type { XCBuildConfiguration } from 'xcode';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
@@ -82,7 +83,8 @@ export async function prepareIosBuildAsync(
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
metadata: Metadata
metadata: Metadata,
buildParams: BuildParamsInput
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
@@ -90,6 +92,7 @@ export async function prepareIosBuildAsync(
appId,
job: graphqlJob,
metadata: graphqlMetadata,
buildParams,
});
},
});
@@ -3,7 +3,13 @@ import { BuildProfile, EasJsonReader, SubmitProfile } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { AppPlatform, BuildFragment, BuildStatus, SubmissionFragment } from '../graphql/generated';
import {
AppPlatform,
BuildFragment,
BuildResourceClass,
BuildStatus,
SubmissionFragment,
} from '../graphql/generated';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import {
@@ -30,6 +36,7 @@ import { BuildContext } from './context';
import { createBuildContextAsync } from './createContext';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildOptions } from './local';
import { UserInputResourceClass } from './types';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
@@ -47,8 +54,23 @@ export interface BuildFlags {
autoSubmit: boolean;
submitProfile?: string;
localBuildOptions: LocalBuildOptions;
userInputResourceClass?: UserInputResourceClass;
}
const platformToGraphQLResourceClassMapping: Record<
Platform,
Record<UserInputResourceClass, BuildResourceClass>
> = {
[Platform.ANDROID]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
},
[Platform.IOS]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.IosLarge,
},
};
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
@@ -85,6 +107,10 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
flags,
moreBuilds: platforms.length > 1,
buildProfile,
resourceClass:
platformToGraphQLResourceClassMapping[buildProfile.platform][
flags.userInputResourceClass ?? UserInputResourceClass.DEFAULT
],
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -161,14 +187,17 @@ async function prepareAndStartBuildAsync({
flags,
moreBuilds,
buildProfile,
resourceClass,
}: {
projectDir: string;
flags: BuildFlags;
moreBuilds: boolean;
buildProfile: ProfileData<'build'>;
resourceClass: BuildResourceClass;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
resourceClass,
clearCache: flags.clearCache,
buildProfile: buildProfile.profile,
nonInteractive: flags.nonInteractive,
+5
View File
@@ -12,3 +12,8 @@ export enum BuildDistributionType {
INTERNAL = 'internal',
SIMULATOR = 'simulator',
}
export enum UserInputResourceClass {
DEFAULT = 'default',
LARGE = 'large',
}
+47 -17
View File
@@ -17,23 +17,24 @@ import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
const BRANCHES_LIMIT = 10_000;
export const BRANCHES_LIMIT = 50;
export async function listBranchesAsync({
projectId,
}: {
projectId: string;
}): Promise<UpdateBranchFragment[]> {
appId,
limit = BRANCHES_LIMIT,
offset = 0,
}: BranchesByAppQueryVariables): Promise<UpdateBranchFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
gql`
query BranchesByAppQuery($appId: String!, $limit: Int!) {
query BranchesByAppQuery($appId: String!, $limit: Int!, $offset: Int!) {
app {
byId(appId: $appId) {
id
updateBranches(offset: 0, limit: $limit) {
updateBranches(offset: $offset, limit: $limit) {
id
...UpdateBranchFragment
}
@@ -43,8 +44,9 @@ export async function listBranchesAsync({
${print(UpdateBranchFragmentNode)}
`,
{
appId: projectId,
limit: BRANCHES_LIMIT,
appId,
limit,
offset,
},
{ additionalTypenames: ['UpdateBranch'] }
)
@@ -73,9 +75,28 @@ export default class BranchList extends EasCommand {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const branches = await listBranchesAsync({ projectId });
await queryForPaginatedBranchesAsync(projectId, flags);
}
}
async function queryForPaginatedBranchesAsync(
projectId: string,
flags: { json: boolean } & { json: boolean | undefined }
): Promise<void> {
const queryAdditionalBranchesAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<UpdateBranchFragment>> => {
const branches = await listBranchesAsync({ appId: projectId, limit: pageSize, offset });
return {
queryResponse: branches,
queryResponseRawLength: branches.length,
};
};
const renderPageOfBranches = (currentPage: UpdateBranchFragment[]): void => {
if (flags.json) {
printJsonOnlyOutput(branches);
printJsonOnlyOutput(currentPage);
} else {
const table = new CliTable({
head: ['Branch', ...UPDATE_COLUMNS],
@@ -83,7 +104,7 @@ export default class BranchList extends EasCommand {
});
table.push(
...branches.map(branch => [
...currentPage.map(branch => [
branch.name,
formatUpdate(branch.updates[0]),
branch.updates[0]?.runtimeVersion ?? 'N/A',
@@ -96,11 +117,20 @@ export default class BranchList extends EasCommand {
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Branches with their most recent update group:'));
Log.log(chalk.bold('Branches:'));
Log.addNewLineIfNone();
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
}
}
}
};
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryAdditionalBranchesAsync,
promptOptions: {
type: 'confirm',
title: 'Fetch next page of branches?',
renderListItems: renderPageOfBranches,
},
});
}
+80 -31
View File
@@ -3,6 +3,7 @@ import chalk from 'chalk';
import Table from 'cli-table3';
import EasCommand from '../../commandUtils/EasCommand';
import { ViewBranchUpdatesQuery } from '../../graphql/generated';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
@@ -12,6 +13,7 @@ import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update
import groupBy from '../../utils/expodash/groupBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
export default class BranchView extends EasCommand {
static description = 'view a branch';
@@ -57,50 +59,97 @@ export default class BranchView extends EasCommand {
}));
}
await queryForPaginatedBranchesAsync(projectId, name, { json: jsonFlag });
}
}
type BranchUpdateObject = Exclude<
ViewBranchUpdatesQuery['app']['byId']['updateBranchByName'],
null | undefined
>['updates'][0];
async function queryForPaginatedBranchesAsync(
projectId: string,
name: string,
flags: { json: boolean } & { json: boolean | undefined }
): Promise<void> {
const queryAdditionalBranchesAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<BranchUpdateObject>> => {
const { app } = await UpdateQuery.viewBranchAsync({
appId: projectId,
name,
limit: pageSize,
offset,
});
const UpdateBranch = app?.byId.updateBranchByName;
if (!UpdateBranch) {
throw new Error(`Could not find branch "${name}"`);
}
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
group => group[0]
const updateGroups = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
updateGroup => ({
...updateGroup[0],
platform: getPlatformsForGroup({
updates: updateGroup,
group: updateGroup[0].group,
}),
})
);
if (jsonFlag) {
printJsonOnlyOutput({ ...UpdateBranch, updates });
return {
queryResponse: updateGroups,
queryResponseRawLength: UpdateBranch.updates.length,
};
};
const renderPageOfBranches = (currentPage: BranchUpdateObject[]): void => {
if (flags.json) {
printJsonOnlyOutput(currentPage);
} else {
const groupTable = new Table({
head: UPDATE_COLUMNS,
wordWrap: true,
});
if (flags.json) {
printJsonOnlyOutput({ currentPage });
} else {
const groupTable = new Table({
head: UPDATE_COLUMNS,
wordWrap: true,
});
for (const update of updates) {
groupTable.push([
formatUpdate(update),
update.runtimeVersion,
update.group,
getPlatformsForGroup({
updates: UpdateBranch.updates,
group: update.group,
}),
]);
for (const update of currentPage) {
groupTable.push([
formatUpdate(update),
update.runtimeVersion,
update.group,
getPlatformsForGroup({
updates: currentPage,
group: update.group,
}),
]);
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Branch:'));
Log.log(
formatFields([
{ label: 'Name', value: currentPage[0]['branch']['name'] },
{ label: 'ID', value: currentPage[0]['branch']['id'] },
])
);
Log.addNewLineIfNone();
Log.log(groupTable.toString());
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Branch:'));
Log.log(
formatFields([
{ label: 'Name', value: UpdateBranch.name },
{ label: 'ID', value: UpdateBranch.id },
])
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Recently published update groups:'));
Log.log(groupTable.toString());
}
}
};
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryAdditionalBranchesAsync,
promptOptions: {
type: 'confirm',
title: 'Fetch next page of branches?',
renderListItems: renderPageOfBranches,
},
});
}
+71 -44
View File
@@ -5,6 +5,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Build,
BuildFragment,
BuildStatus,
CancelBuildMutation,
CancelBuildMutationVariables,
@@ -19,7 +20,8 @@ import {
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../prompts';
import { confirmAsync } from '../../prompts';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
const data = await withErrorHandlingAsync(
@@ -59,48 +61,6 @@ function formatUnfinishedBuild(
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
}
async function selectBuildToCancelAsync(
projectId: string,
projectFullName: string
): Promise<string | null> {
const spinner = ora().start('Fetching the uncompleted builds…');
let builds;
try {
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.New } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InQueue } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InProgress } }),
]);
spinner.stop();
builds = [...newBuilds, ...inQueueBuilds, ...inProgressBuilds];
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
);
throw error;
}
if (builds.length === 0) {
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
return null;
} else if (builds.length === 1) {
Log.log('Found one build');
Log.log(formatUnfinishedBuild(builds[0]));
await confirmAsync({
message: 'Do you want to cancel it?',
});
return builds[0].id;
} else {
const buildId = await selectAsync<string>(
'Which build do you want to cancel?',
builds.map(build => ({
title: formatUnfinishedBuild(build),
value: build.id,
}))
);
return buildId;
}
}
async function ensureBuildExistsAsync(buildId: string): Promise<void> {
try {
await BuildQuery.byIdAsync(buildId);
@@ -126,7 +86,8 @@ export default class BuildCancel extends EasCommand {
await ensureBuildExistsAsync(buildIdFromArg);
}
const buildId = buildIdFromArg || (await selectBuildToCancelAsync(projectId, projectFullName));
const buildId =
buildIdFromArg || (await queryForBuildToCancelAsync(projectId, projectFullName));
if (!buildId) {
return;
}
@@ -146,3 +107,69 @@ export default class BuildCancel extends EasCommand {
}
}
}
async function queryForBuildToCancelAsync(
projectId: string,
projectFullName: string
): Promise<string | null> {
const queryToPerformAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<BuildFragment>> => {
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.allForAppAsync(projectId, {
limit: pageSize,
offset,
filter: { status: BuildStatus.New },
}),
BuildQuery.allForAppAsync(projectId, {
limit: pageSize,
offset,
filter: { status: BuildStatus.InQueue },
}),
BuildQuery.allForAppAsync(projectId, {
limit: pageSize,
offset,
filter: { status: BuildStatus.InProgress },
}),
]);
// this variable controls whether performPaginatedQueryAsync will query the next page.
// force another query if we have hit the query limit for any of these 3 queries.
const queryResponseLength =
newBuilds.length < pageSize &&
inQueueBuilds.length < pageSize &&
inProgressBuilds.length < pageSize
? pageSize - 1
: pageSize;
return {
queryResponse: [...newBuilds, ...inQueueBuilds, ...inProgressBuilds],
queryResponseRawLength: queryResponseLength,
};
};
const getIdentifierForQueryItem = (buildFragment: BuildFragment): string => buildFragment.id;
const selectedBuild = (
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryToPerformAsync,
promptOptions: {
type: 'select',
title: 'Which build would you like to cancel?',
getIdentifierForQueryItem,
createDisplayTextForSelectionPromptListItem: formatUnfinishedBuild,
},
})
).pop();
if (!selectedBuild) {
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
return null;
}
const userConfirmedCancellation = await confirmAsync({
message: `Are you sure you want to cancel ${formatUnfinishedBuild(selectedBuild)}?`,
});
return userConfirmedCancellation ? selectedBuild.id : null;
}
@@ -2,6 +2,7 @@ import { Errors, Flags } from '@oclif/core';
import path from 'path';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { UserInputResourceClass } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
@@ -21,6 +22,7 @@ interface RawBuildFlags {
json: boolean;
'auto-submit': boolean;
'auto-submit-with-profile'?: string;
'resource-class'?: UserInputResourceClass;
}
export default class Build extends EasCommand {
@@ -79,6 +81,11 @@ export default class Build extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'resource-class': Flags.enum({
options: Object.values(UserInputResourceClass),
hidden: true,
description: 'The instance type that will be used to run this build [experimental]',
}),
};
async runAsync(): Promise<void> {
@@ -155,6 +162,7 @@ export default class Build extends EasCommand {
json: flags['json'],
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
userInputResourceClass: flags['resource-class'] ?? UserInputResourceClass.DEFAULT,
};
}
}
+75 -49
View File
@@ -6,12 +6,12 @@ import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import {
AppPlatform,
BuildFragment,
DistributionType,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { BuildQuery, BuildsQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import {
@@ -20,6 +20,7 @@ import {
getProjectIdAsync,
} from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
export default class BuildList extends EasCommand {
static description = 'list all builds for your project';
@@ -56,7 +57,6 @@ export default class BuildList extends EasCommand {
appIdentifier: Flags.string(),
buildProfile: Flags.string(),
gitCommitHash: Flags.string(),
limit: Flags.integer(),
};
async runAsync(): Promise<void> {
@@ -66,7 +66,6 @@ export default class BuildList extends EasCommand {
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
limit = 10,
} = flags;
if (json) {
enableJsonOutput();
@@ -81,51 +80,24 @@ export default class BuildList extends EasCommand {
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
try {
const builds = await BuildQuery.allForAppAsync(projectId, {
limit,
filter: {
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
});
if (builds.length) {
if (platform || graphqlBuildStatus) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
} else {
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
}
if (json) {
printJsonOnlyOutput(builds);
} else {
const list = builds
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
}
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
throw e;
}
await queryForPaginatedBuildsAsync(
projectId,
projectName,
{
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
flags
);
}
}
@@ -170,3 +142,57 @@ const toGraphQLBuildDistribution = (
return undefined;
}
};
async function queryForPaginatedBuildsAsync(
projectId: string,
projectName: string,
filter: BuildsQuery['filter'],
flags: { json: boolean } & { json: boolean | undefined }
): Promise<void> {
const queryAdditionalBuildsAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<BuildFragment>> => {
const builds = await BuildQuery.allForAppAsync(projectId, {
limit: pageSize,
offset,
filter,
});
return {
queryResponse: builds,
queryResponseRawLength: builds.length,
};
};
const renderPageOfBuilds = (currentPage: BuildFragment[]): void => {
if (flags.json) {
printJsonOnlyOutput(currentPage);
} else {
if (currentPage.length) {
const list = currentPage
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.addNewLineIfNone();
Log.withTick(chalk.bold(`Builds for ${projectName}:`));
Log.log(`\n${list}`);
Log.addNewLineIfNone();
} else {
Log.withCross(chalk.bold(`Couldn't find any builds for the project ${projectName}`));
Log.addNewLineIfNone();
}
}
};
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryAdditionalBuildsAsync,
promptOptions: {
type: 'confirm',
title: 'Fetch next page of builds?',
renderListItems: renderPageOfBuilds,
},
});
}
+100 -19
View File
@@ -1,4 +1,5 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -12,31 +13,41 @@ import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
import { BRANCHES_LIMIT } from '../branch/list';
import { logChannelDetails } from './view';
const CHANNEL_LIMIT = 10_000;
const CHANNEL_LIMIT = 50;
async function getAllUpdateChannelForAppAsync({
appId,
}: {
appId: string;
}): Promise<GetAllChannelsForAppQuery> {
channelLimit = CHANNEL_LIMIT,
channelOffset = 0,
branchLimit = BRANCHES_LIMIT,
branchOffset = 0,
}: GetAllChannelsForAppQueryVariables): Promise<GetAllChannelsForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
gql`
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
query GetAllChannelsForApp(
$appId: String!
$channelOffset: Int!
$channelLimit: Int!
$branchLimit: Int!
$branchOffset: Int!
) {
app {
byId(appId: $appId) {
id
updateChannels(offset: $offset, limit: $limit) {
updateChannels(offset: $channelOffset, limit: $channelLimit) {
id
name
branchMapping
updateBranches(offset: 0, limit: $limit) {
updateBranches(offset: $branchOffset, limit: $branchLimit) {
id
name
updates(offset: 0, limit: 10) {
updates(offset: 0, limit: 1) {
id
group
message
@@ -59,7 +70,7 @@ async function getAllUpdateChannelForAppAsync({
}
}
`,
{ appId, offset: 0, limit: CHANNEL_LIMIT },
{ appId, channelOffset, channelLimit, branchLimit, branchOffset },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -88,18 +99,77 @@ export default class ChannelList extends EasCommand {
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
await queryForChannelsAsync(projectId, jsonFlag);
}
}
type UpdateChannelObject = GetAllChannelsForAppQuery['app']['byId']['updateChannels'][0];
async function queryForChannelsAsync(projectId: string, jsonFlag: boolean): Promise<void> {
const queryToPerformAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<UpdateChannelObject>> => {
let queriedAllBranches = false;
let branchOffset = 0;
const branchLimit = 10;
const paginatedQuery: PaginatedQueryResponse<UpdateChannelObject> = {
queryResponse: [],
queryResponseRawLength: 0,
};
const branchesForChannels: Record<string, UpdateChannelObject['updateBranches']> = {};
// we need to paginate our paginated query. Each channel (N) can have M branches
// and we need to fetch all branches to populate our 'branch mapping' portion
// of the UI properly.
do {
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
channelLimit: pageSize,
channelOffset: offset,
branchLimit,
branchOffset,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
queriedAllBranches = true;
// save each array of branches using a record so we can lookup in O(1) later on
channels.forEach(channel => {
if (channel.updateBranches.length === branchLimit) {
queriedAllBranches = false;
}
branchesForChannels[channel.id] = [
...(branchesForChannels[channel.id] ? branchesForChannels[channel.id] : []),
...channel.updateBranches,
];
});
if (!paginatedQuery.queryResponse.length) {
paginatedQuery.queryResponse = channels;
} else {
paginatedQuery.queryResponse.forEach(channel => {
channel.updateBranches = branchesForChannels[channel.id];
});
}
paginatedQuery.queryResponseRawLength = channels.length;
branchOffset += branchLimit + 1;
} while (!queriedAllBranches);
return paginatedQuery;
};
const renderListItems = (currentPage: UpdateChannelObject[]): void => {
if (jsonFlag) {
printJsonOnlyOutput(channels);
printJsonOnlyOutput(currentPage);
} else {
for (const channel of channels) {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channels:'));
Log.addNewLineIfNone();
for (const channel of currentPage) {
Log.addNewLineIfNone();
Log.log(
formatFields([
@@ -110,5 +180,16 @@ export default class ChannelList extends EasCommand {
logChannelDetails(channel);
}
}
}
};
await performPaginatedQueryAsync({
pageSize: 25, // lower, because we need to query all branches per channel O(N*M)
offset: 0,
queryToPerform: queryToPerformAsync,
promptOptions: {
type: 'confirm',
title: 'Fetch next page of channels?',
renderListItems,
},
});
}
@@ -351,6 +351,8 @@ export default class ChannelRollout extends EasCommand {
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName: channelName!,
offset: 0,
limit: 50,
});
if (!channel) {
throw new Error(
@@ -111,7 +111,7 @@ export function logChannelDetails(channel: {
}
const { branchId } = branchMapping.data[index];
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
const branch = channel.updateBranches.find(branch => branch.id === branchId);
if (!branch) {
throw new Error('Branch mapping is pointing at a missing branch.');
}
@@ -184,6 +184,8 @@ export default class ChannelView extends EasCommand {
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName,
offset: 0,
limit: 50,
});
if (!channel) {
throw new Error(`Could not find a channel with name: ${channelName}`);
+126 -115
View File
@@ -19,6 +19,7 @@ import {
Robot,
RootQueryUpdatesByGroupArgs,
Update,
UpdateBranchFragment,
UpdateInfoGroup,
UpdatePublishMutation,
User,
@@ -44,7 +45,7 @@ import {
uploadAssetsAsync,
} from '../../project/publish';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatUpdate } from '../../update/utils';
import { ensureLoggedInAsync } from '../../user/actions';
import {
@@ -53,9 +54,9 @@ import {
getManifestBodyAsync,
signManifestBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
import { getVcsClient } from '../../vcs';
import { createUpdateBranchOnAppAsync } from '../branch/create';
import { listBranchesAsync } from '../branch/list';
@@ -128,8 +129,8 @@ export async function ensureBranchExistsAsync({
}: {
appId: string;
name: string;
limit?: number;
offset?: number;
limit: number;
offset: number;
}): Promise<{
id: string;
updates: Exclude<
@@ -282,40 +283,16 @@ export default class UpdatePublish extends EasCommand {
}
if (!branchName) {
const validationMessage = 'Branch name may not be empty.';
if (nonInteractive) {
throw new Error('Must supply --branch or use --auto when in non-interactive mode');
}
const validationMessage = 'Branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
const branches = await listBranchesAsync({ projectId });
if (branches.length === 0) {
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Creating a new one. Please name the new branch:',
initial:
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
} else {
branchName = await selectAsync<string>(
'Which branch would you like to publish on?',
branches.map(branch => {
return {
title: `${branch.name} ${chalk.grey(
`- current update: ${formatUpdate(branch.updates[0])}`
)}`,
value: branch.name,
};
})
);
}
assert(branchName, 'Branch name must be specified.');
branchName = await getBranchNameToPublishOnAsync(projectId, validationMessage);
}
let unsortedUpdateInfoGroups: UpdateInfoGroup = {};
@@ -341,12 +318,7 @@ export default class UpdatePublish extends EasCommand {
throw new Error('You must specify the update group to republish.');
}
updatesToRepublish = await getUpdatesToRepublishInteractiveAsync(
projectId,
branchName,
platformFlag,
50
);
updatesToRepublish = await getUpdatesToRepublishAsync(projectId, branchName, platformFlag);
}
const updatesToRepublishFilteredByPlatform = updatesToRepublish.filter(
// Only republish to the specified platforms
@@ -411,7 +383,6 @@ export default class UpdatePublish extends EasCommand {
if (nonInteractive) {
throw new Error('Must supply --message or use --auto when in non-interactive mode');
}
const validationMessage = 'publish message may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
@@ -457,6 +428,8 @@ export default class UpdatePublish extends EasCommand {
}
}
const truncatedMessage = truncatePublishUpdateMessage(message!);
const runtimeToPlatformMapping: Record<string, string[]> = {};
for (const runtime of new Set(Object.values(runtimeVersions))) {
runtimeToPlatformMapping[runtime] = Object.entries(runtimeVersions)
@@ -468,6 +441,7 @@ export default class UpdatePublish extends EasCommand {
appId: projectId,
name: branchName,
limit: 0,
offset: 0,
});
// Sort the updates into different groups based on their platform specific runtime versions
@@ -489,7 +463,7 @@ export default class UpdatePublish extends EasCommand {
branchId,
updateInfoGroup: localUpdateInfoGroup,
runtimeVersion: republish ? oldRuntimeVersion : runtime,
message,
message: truncatedMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
@@ -587,7 +561,7 @@ export default class UpdatePublish extends EasCommand {
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: message! },
{ label: 'Message', value: truncatedMessage },
{ label: 'Website link', value: updateGroupLink },
])
);
@@ -597,71 +571,52 @@ export default class UpdatePublish extends EasCommand {
}
}
export async function getUpdatesToRepublishInteractiveAsync(
export async function getUpdatesToRepublishAsync(
projectId: string,
branchName: string,
platformFlag: string,
pageSize: number,
offset: number = 0,
cumulativeUpdates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] = []
): Promise<
Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates']
> {
const fetchMoreValue = '_fetchMore';
platformFlag: string
): Promise<AppUpdateObject[]> {
const queryUpdatesForBranchAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<AppUpdateObject>> => {
const { updates } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName!,
limit: pageSize,
offset,
});
if (!updates.length) {
throw new Error(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
}
const relevantUpdates =
platformFlag === 'all'
? updates
: updates.filter(update => {
// Only show groups that have updates on the specified platform(s).
return update.platform === platformFlag;
});
return {
queryResponse: relevantUpdates,
queryResponseRawLength: updates.length,
};
};
const { updates } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
limit: pageSize + 1, // fetch an extra item so we know if there are additional pages
offset,
const getUpdateIdentifier = (update: AppUpdateObject): string => update.group;
return await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryUpdatesForBranchAsync,
promptOptions: {
type: 'select',
title: 'Which update would you like to republish?',
createDisplayTextForSelectionPromptListItem: formatUpdateTitle,
getIdentifierForQueryItem: getUpdateIdentifier,
},
});
cumulativeUpdates = [
...cumulativeUpdates,
// drop that extra item used for pagination from our render logic
...updates.slice(0, updates.length - 1),
];
const cumulativeUpdatesForTargetPlatforms =
platformFlag === 'all'
? cumulativeUpdates
: cumulativeUpdates.filter(update => {
// Only show groups that have updates on the specified platform(s).
return update.platform === platformFlag;
});
const updateGroups = uniqBy(cumulativeUpdatesForTargetPlatforms, u => u.group).map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (!updateGroups.length) {
throw new Error(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
}
if (updates.length > pageSize) {
updateGroups.push({ title: 'Next page...', value: fetchMoreValue });
}
const selectedUpdateGroup = await selectAsync<string>(
'Which update would you like to republish?',
updateGroups
);
if (selectedUpdateGroup === fetchMoreValue) {
return await getUpdatesToRepublishInteractiveAsync(
projectId,
branchName,
platformFlag,
pageSize,
offset + pageSize,
cumulativeUpdates
);
}
return cumulativeUpdates.filter(update => update.group === selectedUpdateGroup);
}
async function getRuntimeVersionObjectAsync(
@@ -694,12 +649,24 @@ async function getRuntimeVersionObjectAsync(
);
}
function formatUpdateTitle(
update: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'][number]
): string {
async function checkEASUpdateURLIsSetAsync(exp: ExpoConfig): Promise<void> {
const configuredURL = exp.updates?.url;
const projectId = await getProjectIdAsync(exp);
const expectedURL = getEASUpdateURL(projectId);
if (configuredURL !== expectedURL) {
throw new Error(
`The update URL is incorrectly configured for EAS Update. Please set updates.url to ${expectedURL} in your app.json.`
);
}
}
export type AppUpdateObject = Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'][0];
export function formatUpdateTitle(update: AppUpdateObject): string {
const { message, createdAt, actor, runtimeVersion } = update;
let actorName: string;
@@ -722,14 +689,58 @@ function formatUpdateTitle(
)} by ${actorName}, runtimeVersion: ${runtimeVersion}] ${message}`;
}
async function checkEASUpdateURLIsSetAsync(exp: ExpoConfig): Promise<void> {
const configuredURL = exp.updates?.url;
const projectId = await getProjectIdAsync(exp);
const expectedURL = getEASUpdateURL(projectId);
async function getBranchNameToPublishOnAsync(
projectId: string,
validationMessage: string
): Promise<string> {
const queryForBanchesToPublishOnAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<UpdateBranchFragment>> => {
const branches = await listBranchesAsync({ appId: projectId, limit: pageSize, offset });
if (branches.length === 0) {
const { name: newBranchName } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Creating a new one. Please name the new branch:',
initial:
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substring(2, 7)}`,
validate: value => (value ? true : validationMessage),
});
branches.push({
id: '_defaultId',
name: newBranchName,
updates: [],
});
}
return { queryResponse: branches, queryResponseRawLength: branches.length };
};
const formatBranchTitle = (branch: UpdateBranchFragment): string =>
`${branch.name} ${chalk.grey(`- current update: ${formatUpdate(branch.updates[0])}`)}`;
const getBranchIdentifier = (branch: UpdateBranchFragment): string => branch.id;
if (configuredURL !== expectedURL) {
throw new Error(
`The update URL is incorrectly configured for EAS Update. Please set updates.url to ${expectedURL} in your app.json.`
);
}
const branches = await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryForBanchesToPublishOnAsync,
promptOptions: {
type: 'select',
createDisplayTextForSelectionPromptListItem: formatBranchTitle,
getIdentifierForQueryItem: getBranchIdentifier,
title: 'Which branch would you like to publish on?',
},
});
const branchName = branches?.pop()?.name;
assert(branchName, validationMessage);
return branchName;
}
export const truncatePublishUpdateMessage = (originalMessage: string): string => {
if (originalMessage.length > 1024) {
Log.warn('Update message exceeds the allowed 1024 character limit. Truncating message...');
return originalMessage.substring(0, 1021) + '...';
}
return originalMessage;
};
+105 -34
View File
@@ -4,7 +4,7 @@ import chalk from 'chalk';
import CliTable from 'cli-table3';
import EasCommand from '../../commandUtils/EasCommand';
import { ViewAllUpdatesQuery } from '../../graphql/generated';
import { ViewAllUpdatesQuery, ViewBranchUpdatesQuery } from '../../graphql/generated';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
@@ -13,6 +13,7 @@ import { promptAsync } from '../../prompts';
import { FormatUpdateParameter, UPDATE_COLUMNS, formatUpdate } from '../../update/utils';
import groupBy from '../../utils/expodash/groupBy';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { PaginatedQueryResponse, performPaginatedQueryAsync } from '../../utils/queries';
import { getVcsClient } from '../../vcs';
type UpdateGroupDescription = FormatUpdateParameter & {
@@ -53,12 +54,8 @@ export default class BranchView extends EasCommand {
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
let updateGroupDescriptions: UpdateGroupDescription[];
if (all) {
const branchesAndUpdates = await UpdateQuery.viewAllAsync({ appId: projectId });
updateGroupDescriptions = getUpdateGroupDescriptions(
branchesAndUpdates.app.byId.updateBranches
);
await fetchAllUpdatesForAppAsync(projectId, jsonFlag);
} else {
let branchInteractive: string | undefined;
if (!branchFlag) {
@@ -77,46 +74,120 @@ export default class BranchView extends EasCommand {
const branch = branchFlag ?? branchInteractive;
assert(branch, 'Branch name may not be empty.');
const branchesAndUpdates = await UpdateQuery.viewBranchAsync({
appId: projectId,
name: branch,
});
const UpdateBranch = branchesAndUpdates.app?.byId.updateBranchByName;
if (!UpdateBranch) {
throw new Error(`Could not find branch "${branch}"`);
}
updateGroupDescriptions = getUpdateGroupDescriptions([UpdateBranch]);
fetchAllUpdatesForBranchAsync(projectId, branch, jsonFlag);
}
}
}
type BranchUpdateObject = Exclude<
ViewBranchUpdatesQuery['app']['byId']['updateBranchByName'],
null | undefined
>['updates'][0];
type AppUpdateObject = ViewAllUpdatesQuery['app']['byId']['updates'][0];
async function fetchAllUpdatesForBranchAsync(
projectId: string,
branchName: string,
jsonFlag: boolean
): Promise<void> {
const queryToPerformAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<BranchUpdateObject>> => {
const branchesAndUpdates = await UpdateQuery.viewBranchAsync({
appId: projectId,
name: branchName,
limit: pageSize,
offset,
});
const UpdateBranch = branchesAndUpdates.app?.byId.updateBranchByName;
if (!UpdateBranch) {
throw new Error(`Could not find branch "${branchName}"`);
}
return {
queryResponse: UpdateBranch.updates,
queryResponseRawLength: UpdateBranch.updates.length,
};
};
const renderListItems = (currentPage: BranchUpdateObject[]): void => {
const updateGroupDescriptions = getUpdateGroupDescriptions(currentPage);
if (jsonFlag) {
printJsonOnlyOutput(updateGroupDescriptions);
} else {
logAsTable(updateGroupDescriptions);
}
}
};
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryToPerformAsync,
promptOptions: {
type: 'confirm',
title: 'View next page of updates?',
renderListItems,
},
});
}
async function fetchAllUpdatesForAppAsync(projectId: string, jsonFlag: boolean): Promise<void> {
const queryToPerformAsync = async (
pageSize: number,
offset: number
): Promise<PaginatedQueryResponse<AppUpdateObject>> => {
const viewAllUpdates = await UpdateQuery.viewAllAsync({
appId: projectId,
limit: pageSize,
offset,
});
const { updates } = viewAllUpdates.app.byId;
return {
queryResponse: updates,
queryResponseRawLength: updates.length,
};
};
const renderListItems = (currentPage: AppUpdateObject[]): void => {
const updateGroupDescriptions = getUpdateGroupDescriptions(currentPage);
if (jsonFlag) {
printJsonOnlyOutput(updateGroupDescriptions);
} else {
logAsTable(updateGroupDescriptions);
}
};
await performPaginatedQueryAsync({
pageSize: 50,
offset: 0,
queryToPerform: queryToPerformAsync,
promptOptions: {
type: 'confirm',
title: 'View next page of updates?',
renderListItems,
},
});
}
function getUpdateGroupDescriptions(
branchesAndUpdates: ViewAllUpdatesQuery['app']['byId']['updateBranches']
updates: (BranchUpdateObject | AppUpdateObject)[]
): UpdateGroupDescription[] {
const flattenedBranchesAndUpdates = branchesAndUpdates.flatMap(branch =>
branch.updates.map(update => {
return { branch: branch.name, ...update };
return Object.values(groupBy(updates, update => update.group))
.map(updateGroup => {
return {
platforms: updateGroup
.map(update => update.platform)
.sort()
.join(', '),
...updateGroup[0],
branch: updateGroup[0].branch.name,
} as UpdateGroupDescription;
})
);
const updateGroupDescriptions = Object.values(
groupBy(flattenedBranchesAndUpdates, update => update.group)
).map(updateGroup => {
const platforms = updateGroup
.map(update => update.platform)
.sort()
.join(', ');
return { ...updateGroup[0], platforms };
});
updateGroupDescriptions.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return updateGroupDescriptions;
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
function logAsTable(updateGroupDescriptions: UpdateGroupDescription[]): void {
+30 -5
View File
@@ -1584,6 +1584,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
iosEnterpriseProvisioning?: Maybe<BuildIosEnterpriseProvisioning>;
isGitWorkingTreeDirty?: Maybe<Scalars['Boolean']>;
logFiles: Array<Scalars['String']>;
maxBuildTimeSeconds: Scalars['Int'];
metrics?: Maybe<BuildMetrics>;
parentBuild?: Maybe<Build>;
platform: AppPlatform;
@@ -1594,6 +1595,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
queuePosition?: Maybe<Scalars['Int']>;
reactNativeVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
resourceClass?: Maybe<BuildResourceClass>;
runtimeVersion?: Maybe<Scalars['String']>;
sdkVersion?: Maybe<Scalars['String']>;
status: BuildStatus;
@@ -1783,6 +1785,7 @@ export type BuildMutationCancelBuildArgs = {
export type BuildMutationCreateAndroidBuildArgs = {
appId: Scalars['ID'];
buildParams?: InputMaybe<BuildParamsInput>;
job: AndroidJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
};
@@ -1790,6 +1793,7 @@ export type BuildMutationCreateAndroidBuildArgs = {
export type BuildMutationCreateIosBuildArgs = {
appId: Scalars['ID'];
buildParams?: InputMaybe<BuildParamsInput>;
job: IosJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
};
@@ -1808,9 +1812,14 @@ export type BuildOrBuildJob = {
id: Scalars['ID'];
};
export type BuildParamsInput = {
resourceClass: BuildResourceClass;
};
export enum BuildPriority {
High = 'HIGH',
Normal = 'NORMAL'
Normal = 'NORMAL',
NormalPlus = 'NORMAL_PLUS'
}
/** Publicly visible data for a Build. */
@@ -1875,6 +1884,14 @@ export type BuildQueryByIdArgs = {
buildId: Scalars['ID'];
};
export enum BuildResourceClass {
AndroidDefault = 'ANDROID_DEFAULT',
AndroidLarge = 'ANDROID_LARGE',
IosDefault = 'IOS_DEFAULT',
IosLarge = 'IOS_LARGE',
Legacy = 'LEGACY'
}
export enum BuildStatus {
Canceled = 'CANCELED',
Errored = 'ERRORED',
@@ -3834,6 +3851,7 @@ export type DeleteUpdateBranchMutation = { __typename?: 'RootMutation', updateBr
export type BranchesByAppQueryVariables = Exact<{
appId: Scalars['String'];
limit: Scalars['Int'];
offset: Scalars['Int'];
}>;
@@ -3895,8 +3913,10 @@ export type UpdateChannelBranchMappingMutation = { __typename?: 'RootMutation',
export type GetAllChannelsForAppQueryVariables = Exact<{
appId: Scalars['String'];
offset: Scalars['Int'];
limit: Scalars['Int'];
channelOffset: Scalars['Int'];
channelLimit: Scalars['Int'];
branchLimit: Scalars['Int'];
branchOffset: Scalars['Int'];
}>;
@@ -4319,6 +4339,7 @@ export type CreateAndroidBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: AndroidJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
buildParams?: InputMaybe<BuildParamsInput>;
}>;
@@ -4328,6 +4349,7 @@ export type CreateIosBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: IosJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
buildParams?: InputMaybe<BuildParamsInput>;
}>;
@@ -4459,6 +4481,8 @@ export type GetAllBuildsForAppQuery = { __typename?: 'RootQuery', app: { __typen
export type GetChannelByNameForAppQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
limit: Scalars['Int'];
offset: Scalars['Int'];
}>;
@@ -4514,10 +4538,11 @@ export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __
export type ViewAllUpdatesQueryVariables = Exact<{
appId: Scalars['String'];
limit: Scalars['Int'];
offset: Scalars['Int'];
}>;
export type ViewAllUpdatesQuery = { __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, actor?: { __typename?: 'Robot', firstName?: string | null, id: string } | { __typename?: 'User', username: string, id: string } | null }> }> } } };
export type ViewAllUpdatesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, actor?: { __typename?: 'Robot', firstName?: string | null, id: string } | { __typename?: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string } }> } } };
export type ViewBranchUpdatesQueryVariables = Exact<{
appId: Scalars['String'];
@@ -4527,7 +4552,7 @@ export type ViewBranchUpdatesQueryVariables = Exact<{
}>;
export type ViewBranchUpdatesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __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, actor?: { __typename?: 'Robot', firstName?: string | null, id: string } | { __typename?: 'User', username: string, id: string } | null }> } | null } } };
export type ViewBranchUpdatesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __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, actor?: { __typename?: 'Robot', firstName?: string | null, id: string } | { __typename?: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string } }> } | null } } };
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
@@ -7,6 +7,7 @@ import {
AndroidJobInput,
BuildFragment,
BuildMetadataInput,
BuildParamsInput,
CreateAndroidBuildMutation,
CreateAndroidBuildMutationVariables,
CreateIosBuildMutation,
@@ -26,6 +27,7 @@ export const BuildMutation = {
appId: string;
job: AndroidJobInput;
metadata: BuildMetadataInput;
buildParams: BuildParamsInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -35,9 +37,15 @@ export const BuildMutation = {
$appId: ID!
$job: AndroidJobInput!
$metadata: BuildMetadataInput
$buildParams: BuildParamsInput
) {
build {
createAndroidBuild(appId: $appId, job: $job, metadata: $metadata) {
createAndroidBuild(
appId: $appId
job: $job
metadata: $metadata
buildParams: $buildParams
) {
build {
id
...BuildFragment
@@ -62,6 +70,7 @@ export const BuildMutation = {
appId: string;
job: IosJobInput;
metadata: BuildMetadataInput;
buildParams: BuildParamsInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -71,9 +80,15 @@ export const BuildMutation = {
$appId: ID!
$job: IosJobInput!
$metadata: BuildMetadataInput
$buildParams: BuildParamsInput
) {
build {
createIosBuild(appId: $appId, job: $job, metadata: $metadata) {
createIosBuild(
appId: $appId
job: $job
metadata: $metadata
buildParams: $buildParams
) {
build {
id
...BuildFragment
@@ -14,7 +14,7 @@ import {
} from '../generated';
import { BuildFragmentNode } from '../types/Build';
type BuildsQuery = {
export type BuildsQuery = {
offset?: number;
limit?: number;
filter?: {
@@ -3,10 +3,14 @@ import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import { GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables } from '../generated';
const BRANCHES_LIMIT = 50;
export const ChannelQuery = {
async getUpdateChannelByNameForAppAsync({
appId,
channelName,
offset = 0,
limit = BRANCHES_LIMIT,
}: GetChannelByNameForAppQueryVariables): Promise<
GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']
> {
@@ -18,7 +22,12 @@ export const ChannelQuery = {
graphqlClient
.query<GetChannelByNameForAppQuery, GetChannelByNameForAppQueryVariables>(
gql`
query GetChannelByNameForApp($appId: String!, $channelName: String!) {
query GetChannelByNameForApp(
$appId: String!
$channelName: String!
$limit: Int!
$offset: Int!
) {
app {
byId(appId: $appId) {
id
@@ -27,10 +36,10 @@ export const ChannelQuery = {
name
createdAt
branchMapping
updateBranches(offset: 0, limit: 1000) {
updateBranches(offset: $offset, limit: $limit) {
id
name
updates(offset: 0, limit: 10) {
updates(offset: 0, limit: 1) {
id
group
message
@@ -53,7 +62,7 @@ export const ChannelQuery = {
}
}
`,
{ appId, channelName },
{ appId, channelName, offset, limit },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -8,41 +8,41 @@ import {
ViewBranchUpdatesQueryVariables,
} from '../generated';
export const viewBranchUpdatesQueryUpdateLimit = 300;
type ViewBranchUpdatesQueryVariablesWithOptionalLimitAndOffset =
Partial<ViewBranchUpdatesQueryVariables> &
Pick<ViewBranchUpdatesQueryVariables, 'appId' | 'name'>;
const UPDATE_LIMIT = 50;
export const UpdateQuery = {
async viewAllAsync({ appId }: { appId: string }): Promise<ViewAllUpdatesQuery> {
async viewAllAsync({
appId,
limit = UPDATE_LIMIT,
offset = 0,
}: ViewAllUpdatesQueryVariables): Promise<ViewAllUpdatesQuery> {
return withErrorHandlingAsync(
graphqlClient
.query<ViewAllUpdatesQuery, ViewAllUpdatesQueryVariables>(
gql`
query ViewAllUpdates($appId: String!, $limit: Int!) {
query ViewAllUpdates($appId: String!, $limit: Int!, $offset: Int!) {
app {
byId(appId: $appId) {
id
updateBranches(offset: 0, limit: $limit) {
updates(offset: $offset, limit: $limit) {
id
name
updates(offset: 0, limit: $limit) {
group
message
createdAt
runtimeVersion
platform
actor {
id
group
message
createdAt
runtimeVersion
platform
actor {
id
... on User {
username
}
... on Robot {
firstName
}
... on User {
username
}
... on Robot {
firstName
}
}
branch {
id
name
}
}
}
@@ -51,7 +51,8 @@ export const UpdateQuery = {
`,
{
appId,
limit: viewBranchUpdatesQueryUpdateLimit,
limit,
offset,
},
{ additionalTypenames: ['UpdateBranch', 'Update'] }
)
@@ -61,9 +62,9 @@ export const UpdateQuery = {
async viewBranchAsync({
appId,
name,
limit = viewBranchUpdatesQueryUpdateLimit,
limit = UPDATE_LIMIT,
offset = 0,
}: ViewBranchUpdatesQueryVariablesWithOptionalLimitAndOffset) {
}: ViewBranchUpdatesQueryVariables) {
return withErrorHandlingAsync<ViewBranchUpdatesQuery>(
graphqlClient
.query<ViewBranchUpdatesQuery, ViewBranchUpdatesQueryVariables>(
@@ -92,6 +93,10 @@ export const UpdateQuery = {
firstName
}
}
branch {
id
name
}
}
}
}
@@ -4,7 +4,7 @@ export const UpdateBranchFragmentNode = gql`
fragment UpdateBranchFragment on UpdateBranch {
id
name
updates(offset: 0, limit: 10) {
updates(offset: 0, limit: 1) {
id
actor {
__typename
+4
View File
@@ -49,6 +49,10 @@ export default class Log {
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
}
public static withCross(...args: any[]): void {
Log.consoleLog(chalk.red(figures.cross), ...args);
}
public static withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args);
}
@@ -1,23 +1,84 @@
import { AgeRatingDeclaration, KidsAge } from '@expo/apple-utils';
import { AgeRatingDeclaration, KidsAge, Rating } from '@expo/apple-utils';
import { AttributesOf } from '../../../../utils/asc';
export const kidsSixToEightAdvisory: AttributesOf<AgeRatingDeclaration> = {
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
// These attributes is what we get from the API when no questions are answered
export const emptyAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: null,
gamblingSimulated: null,
gambling: null,
contests: null,
gamblingSimulated: null,
horrorOrFearThemes: null,
matureOrSuggestiveThemes: null,
medicalOrTreatmentInformation: null,
profanityOrCrudeHumor: null,
sexualContentGraphicAndNudity: null,
sexualContentOrNudity: null,
horrorOrFearThemes: null,
matureOrSuggestiveThemes: null,
violenceCartoonOrFantasy: null,
violenceRealisticProlongedGraphicOrSadistic: null,
violenceRealistic: null,
violenceRealisticProlongedGraphicOrSadistic: null,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: null,
seventeenPlus: false,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const leastRestrictiveAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.NONE,
contests: Rating.NONE,
gamblingSimulated: Rating.NONE,
horrorOrFearThemes: Rating.NONE,
matureOrSuggestiveThemes: Rating.NONE,
medicalOrTreatmentInformation: Rating.NONE,
profanityOrCrudeHumor: Rating.NONE,
sexualContentGraphicAndNudity: Rating.NONE,
sexualContentOrNudity: Rating.NONE,
violenceCartoonOrFantasy: Rating.NONE,
violenceRealistic: Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic: Rating.NONE,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: null,
seventeenPlus: false,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const mostRestrictiveAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.FREQUENT_OR_INTENSE,
contests: Rating.FREQUENT_OR_INTENSE,
gamblingSimulated: Rating.FREQUENT_OR_INTENSE,
horrorOrFearThemes: Rating.FREQUENT_OR_INTENSE,
matureOrSuggestiveThemes: Rating.FREQUENT_OR_INTENSE,
medicalOrTreatmentInformation: Rating.FREQUENT_OR_INTENSE,
profanityOrCrudeHumor: Rating.FREQUENT_OR_INTENSE,
sexualContentGraphicAndNudity: Rating.FREQUENT_OR_INTENSE,
sexualContentOrNudity: Rating.FREQUENT_OR_INTENSE,
violenceCartoonOrFantasy: Rating.FREQUENT_OR_INTENSE,
violenceRealistic: Rating.FREQUENT_OR_INTENSE,
violenceRealisticProlongedGraphicOrSadistic: Rating.FREQUENT_OR_INTENSE,
gambling: true,
unrestrictedWebAccess: true,
kidsAgeBand: null,
seventeenPlus: true,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const kidsSixToEightAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.NONE,
contests: Rating.NONE,
gamblingSimulated: Rating.NONE,
horrorOrFearThemes: Rating.NONE,
matureOrSuggestiveThemes: Rating.NONE,
medicalOrTreatmentInformation: Rating.NONE,
profanityOrCrudeHumor: Rating.NONE,
sexualContentGraphicAndNudity: Rating.NONE,
sexualContentOrNudity: Rating.NONE,
violenceCartoonOrFantasy: Rating.NONE,
violenceRealistic: Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic: Rating.NONE,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: KidsAge.SIX_TO_EIGHT,
seventeenPlus: false,
kidsAgeBand: KidsAge.SIX_TO_EIGHT,
gamblingAndContests: false,
};
@@ -0,0 +1,25 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import { AttributesOf } from '../../../../utils/asc';
export const nameOnlyReviewDetails: AttributesOf<AppStoreReviewDetail> = {
contactFirstName: 'Evan',
contactLastName: 'Bacon',
contactEmail: '[email protected]',
contactPhone: '+1 (555) 555-5555',
demoAccountName: null,
demoAccountPassword: null,
demoAccountRequired: null,
notes: null,
};
export const nameAndDemoReviewDetails: AttributesOf<AppStoreReviewDetail> = {
contactFirstName: 'Evan',
contactLastName: 'Bacon',
contactEmail: '[email protected]',
contactPhone: '+1 (555) 555-5555',
demoAccountName: '[email protected]',
demoAccountPassword: 'secretpassword',
demoAccountRequired: true,
notes: null,
};
@@ -0,0 +1,19 @@
import { AppleConfigReader } from '../reader';
import { leastRestrictiveAdvisory, mostRestrictiveAdvisory } from './fixtures/ageRatingDeclaration';
describe('setAgeRating', () => {
it('ignores advisory when not set', () => {
const reader = new AppleConfigReader({ advisory: undefined });
expect(reader.getAgeRating()).toBeNull();
});
it('auto-fills least restrictive advisory', () => {
const reader = new AppleConfigReader({ advisory: {} });
expect(reader.getAgeRating()).toMatchObject(leastRestrictiveAdvisory);
});
it('returns most restrictive advisory', () => {
const reader = new AppleConfigReader({ advisory: mostRestrictiveAdvisory });
expect(reader.getAgeRating()).toMatchObject(mostRestrictiveAdvisory);
});
});
@@ -1,7 +1,13 @@
import { AppleConfigWriter } from '../writer';
import { kidsSixToEightAdvisory } from './fixtures/ageRatingDeclaration';
import {
emptyAdvisory,
kidsSixToEightAdvisory,
leastRestrictiveAdvisory,
mostRestrictiveAdvisory,
} from './fixtures/ageRatingDeclaration';
import { primaryAndSecondaryCategory, secondaryOnlyCategory } from './fixtures/appInfo';
import { dutchInfo, englishInfo } from './fixtures/appInfoLocalization';
import { nameAndDemoReviewDetails, nameOnlyReviewDetails } from './fixtures/appStoreReviewDetail';
import { automaticRelease, manualRelease, scheduledRelease } from './fixtures/appStoreVersion';
import { dutchVersion, englishVersion } from './fixtures/appStoreVersionLocalization';
@@ -16,11 +22,23 @@ describe('toSchema', () => {
});
describe('setAgeRating', () => {
it('modifies the advisory', () => {
it('auto-fills least restrictive advisory', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(emptyAdvisory);
expect(writer.schema.advisory).toMatchObject(leastRestrictiveAdvisory);
});
it('modifies kids band rating', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(kidsSixToEightAdvisory);
expect(writer.schema.advisory).toMatchObject(kidsSixToEightAdvisory);
});
it('modifies most restrictive advisory', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(mostRestrictiveAdvisory);
expect(writer.schema.advisory).toMatchObject(mostRestrictiveAdvisory);
});
});
describe('setInfoLocale', () => {
@@ -99,18 +117,16 @@ describe('setVersionRelease', () => {
});
});
it('modifies manual release', () => {
it('does not modify manual release', () => {
const writer = new AppleConfigWriter();
writer.setVersionRelease(manualRelease);
expect(writer.schema.release).toMatchObject({
automaticRelease: false,
});
expect(writer.schema.release).toBeUndefined();
});
it('overwrites all release fields', () => {
const writer = new AppleConfigWriter();
writer.setVersionRelease(scheduledRelease);
writer.setVersionRelease(manualRelease);
writer.setVersionRelease(automaticRelease);
expect(writer.schema.release).not.toHaveProperty('autoReleaseDate');
});
});
@@ -149,3 +165,46 @@ describe('setVersionLocale', () => {
});
});
});
describe('setReviewDetails', () => {
it('modifies name only review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameOnlyReviewDetails);
expect(writer.schema.review).toMatchObject({
firstName: nameOnlyReviewDetails.contactFirstName,
lastName: nameOnlyReviewDetails.contactLastName,
email: nameOnlyReviewDetails.contactEmail,
phone: nameOnlyReviewDetails.contactPhone,
demoUsername: undefined,
demoPassword: undefined,
demoRequired: undefined,
notes: undefined,
});
});
it('modifies name and demo review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameAndDemoReviewDetails);
expect(writer.schema.review).toMatchObject({
firstName: nameAndDemoReviewDetails.contactFirstName,
lastName: nameAndDemoReviewDetails.contactLastName,
email: nameAndDemoReviewDetails.contactEmail,
phone: nameAndDemoReviewDetails.contactPhone,
demoUsername: nameAndDemoReviewDetails.demoAccountName,
demoPassword: nameAndDemoReviewDetails.demoAccountPassword,
demoRequired: nameAndDemoReviewDetails.demoAccountRequired,
notes: undefined,
});
});
it('replaces existing review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameAndDemoReviewDetails);
writer.setReviewDetails(nameOnlyReviewDetails);
expect(writer.schema.review).toMatchObject({
demoUsername: undefined,
demoPassword: undefined,
demoRequired: undefined,
});
});
});
@@ -1,9 +1,11 @@
import {
AgeRatingDeclaration,
AppInfoLocalization,
AppStoreReviewDetail,
AppStoreVersion,
AppStoreVersionLocalization,
CategoryIds,
Rating,
ReleaseType,
} from '@expo/apple-utils';
@@ -25,7 +27,31 @@ export class AppleConfigReader {
public constructor(public readonly schema: AppleMetadata) {}
public getAgeRating(): Partial<AttributesOf<AgeRatingDeclaration>> | null {
return this.schema.advisory || null;
const attributes = this.schema.advisory;
if (!attributes) {
return null;
}
return {
alcoholTobaccoOrDrugUseOrReferences:
attributes.alcoholTobaccoOrDrugUseOrReferences ?? Rating.NONE,
contests: attributes.contests ?? Rating.NONE,
gamblingSimulated: attributes.gamblingSimulated ?? Rating.NONE,
horrorOrFearThemes: attributes.horrorOrFearThemes ?? Rating.NONE,
matureOrSuggestiveThemes: attributes.matureOrSuggestiveThemes ?? Rating.NONE,
medicalOrTreatmentInformation: attributes.medicalOrTreatmentInformation ?? Rating.NONE,
profanityOrCrudeHumor: attributes.profanityOrCrudeHumor ?? Rating.NONE,
sexualContentGraphicAndNudity: attributes.sexualContentGraphicAndNudity ?? Rating.NONE,
sexualContentOrNudity: attributes.sexualContentOrNudity ?? Rating.NONE,
violenceCartoonOrFantasy: attributes.violenceCartoonOrFantasy ?? Rating.NONE,
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
gambling: attributes.gambling ?? false,
unrestrictedWebAccess: attributes.unrestrictedWebAccess ?? false,
kidsAgeBand: attributes.kidsAgeBand ?? null,
seventeenPlus: attributes.seventeenPlus ?? false,
};
}
public getLocales(): string[] {
@@ -43,7 +69,7 @@ export class AppleConfigReader {
return {
locale,
name: info.title ?? 'no name provided',
name: info.title,
subtitle: info.subtitle,
privacyChoicesUrl: info.privacyChoicesUrl,
privacyPolicyText: info.privacyPolicyText,
@@ -119,4 +145,23 @@ export class AppleConfigReader {
supportUrl: info.supportUrl,
};
}
public getReviewDetails(): Partial<AttributesOf<AppStoreReviewDetail>> | null {
const review = this.schema.review;
if (!review) {
return null;
}
return {
contactFirstName: review.firstName,
contactLastName: review.lastName,
contactEmail: review.email,
contactPhone: review.phone,
demoAccountName: review.demoUsername,
demoAccountPassword: review.demoPassword,
demoAccountRequired: review.demoRequired,
notes: review.notes,
// TODO: add attachment
};
}
}
@@ -2,8 +2,10 @@ import {
AgeRatingDeclaration,
AppInfo,
AppInfoLocalization,
AppStoreReviewDetail,
AppStoreVersion,
AppStoreVersionLocalization,
Rating,
ReleaseType,
} from '@expo/apple-utils';
@@ -26,7 +28,26 @@ export class AppleConfigWriter {
}
public setAgeRating(attributes: AttributesOf<AgeRatingDeclaration>): void {
this.schema.advisory = attributes;
this.schema.advisory = {
alcoholTobaccoOrDrugUseOrReferences:
attributes.alcoholTobaccoOrDrugUseOrReferences ?? Rating.NONE,
contests: attributes.contests ?? Rating.NONE,
gamblingSimulated: attributes.gamblingSimulated ?? Rating.NONE,
horrorOrFearThemes: attributes.horrorOrFearThemes ?? Rating.NONE,
matureOrSuggestiveThemes: attributes.matureOrSuggestiveThemes ?? Rating.NONE,
medicalOrTreatmentInformation: attributes.medicalOrTreatmentInformation ?? Rating.NONE,
profanityOrCrudeHumor: attributes.profanityOrCrudeHumor ?? Rating.NONE,
sexualContentGraphicAndNudity: attributes.sexualContentGraphicAndNudity ?? Rating.NONE,
sexualContentOrNudity: attributes.sexualContentOrNudity ?? Rating.NONE,
violenceCartoonOrFantasy: attributes.violenceCartoonOrFantasy ?? Rating.NONE,
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
gambling: attributes.gambling ?? false,
unrestrictedWebAccess: attributes.unrestrictedWebAccess ?? false,
kidsAgeBand: attributes.kidsAgeBand ?? null,
seventeenPlus: attributes.seventeenPlus ?? false,
};
}
public setInfoLocale(attributes: AttributesOf<AppInfoLocalization>): void {
@@ -35,7 +56,7 @@ export class AppleConfigWriter {
this.schema.info[attributes.locale] = {
...existing,
title: attributes.name ?? 'no name provided',
title: attributes.name ?? '',
subtitle: optional(attributes.subtitle),
privacyPolicyUrl: optional(attributes.privacyPolicyUrl),
privacyPolicyText: optional(attributes.privacyPolicyText),
@@ -77,9 +98,9 @@ export class AppleConfigWriter {
}
if (attributes.releaseType === ReleaseType.MANUAL) {
this.schema.release = {
automaticRelease: false,
};
// ReleaseType.MANUAL is the default behavior, so we don't need to configure it.
// Setting `"automaticRelease": false` is a bit confusing for people who don't know what automaticRelease does.
this.schema.release = undefined;
}
}
@@ -99,6 +120,20 @@ export class AppleConfigWriter {
supportUrl: optional(attributes.supportUrl),
};
}
public setReviewDetails(attributes: AttributesOf<AppStoreReviewDetail>): void {
this.schema.review = {
firstName: attributes.contactFirstName ?? '',
lastName: attributes.contactLastName ?? '',
email: attributes.contactEmail ?? '',
phone: attributes.contactPhone ?? '',
demoUsername: optional(attributes.demoAccountName),
demoPassword: optional(attributes.demoAccountPassword),
demoRequired: optional(attributes.demoAccountRequired),
notes: optional(attributes.notes),
// TODO: add attachment
};
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+2 -1
View File
@@ -2,13 +2,14 @@ import type { App } from '@expo/apple-utils';
import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData;
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -0,0 +1,151 @@
import { AppStoreReviewAttachment, AppStoreReviewDetail, AppStoreVersion } from '@expo/apple-utils';
import nock from 'nock';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppReviewDetailTask } from '../app-review-detail';
import { requestContext } from './fixtures/requestContext';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
describe(AppReviewDetailTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('version not init');
});
it('loads review details from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
.query((params: any) => params['include'] === AppStoreReviewAttachment.type)
.reply(200, require('./fixtures/appStoreVersions/get-appStoreReviewDetail-200.json'));
const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
};
await new AppReviewDetailTask().prepareAsync({ context });
expect(context.reviewDetail).toBeInstanceOf(AppStoreReviewDetail);
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('sets review details when loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {} as any);
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: { reviewDetail } as any,
});
expect(writer.setReviewDetails).toBeCalledWith(reviewDetail.attributes);
});
it('skips when no review details are loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: {} as any,
});
expect(writer.setReviewDetails).not.toBeCalled();
});
});
describe('uploadAsync', () => {
it('skips when review details are not configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.post(`/v1/${AppStoreReviewDetail.type}`)
.reply(201, require('./fixtures/appStoreReviewDetails/post-201.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: undefined,
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
} as any,
});
expect(scope.isDone()).toBeFalsy();
nock.cleanAll();
});
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
});
it('updates review details when loaded', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail: new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{} as any
),
} as any,
});
expect(scope.isDone()).toBeTruthy();
});
it('creates and updates review details when not loaded', async () => {
const createReviewDetailScope = nock('https://api.appstoreconnect.apple.com')
.post(`/v1/${AppStoreReviewDetail.type}`)
.reply(201, require('./fixtures/appStoreReviewDetails/post-201.json'));
const updateReviewDetailScope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
} as any,
});
expect(createReviewDetailScope.isDone()).toBeTruthy();
expect(updateReviewDetailScope.isDone()).toBeTruthy();
});
});
});
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
}
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails"
}
}
@@ -0,0 +1,6 @@
{
"data": null,
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreVersions/APP_STORE_VERSION_1/appStoreReviewDetail?include=appStoreReviewAttachments"
}
}
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
}
@@ -0,0 +1,58 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AppReviewData = {
/** The current app info that should be edited */
reviewDetail: AppStoreReviewDetail;
};
/** Handle all contact, demo account, or instruction info that are required for the App Store review team. */
export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
assert(context.version, `App version not initialized, can't download store review details`);
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (context.reviewDetail) {
config.setReviewDetails(context.reviewDetail.attributes);
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const reviewDetail = config.getReviewDetails();
if (!reviewDetail) {
return Log.log(chalk`{dim - Skipped store review details, not configured}`);
}
assert(context.version, `App version not initialized, can't upload store review details`);
const { versionString } = context.version.attributes;
if (!context.reviewDetail) {
// We can't set the demo required property when creating, omit it from the request
const { demoAccountRequired, ...reviewDetailsToCreate } = reviewDetail;
context.reviewDetail = await logAsync(
() => context.version.createReviewDetailAsync(reviewDetailsToCreate),
{
pending: `Creating store review details for ${chalk.bold(versionString)}...`,
success: `Created store review details for ${chalk.bold(versionString)}`,
failure: `Failed creating store review details for ${chalk.bold(versionString)}`,
}
);
}
context.reviewDetail = await logAsync(() => context.reviewDetail.updateAsync(reviewDetail), {
pending: `Updating store review details for ${chalk.bold(versionString)}...`,
success: `Updated store review details for ${chalk.bold(versionString)}`,
failure: `Failed updating store review details for ${chalk.bold(versionString)}`,
});
}
}
@@ -80,8 +80,8 @@ export class AppVersionTask extends AppleTask {
() => context.version.updateAsync({ ...version, ...release }),
{
pending: `Updating ${description} info for ${chalk.bold(versionString)}...`,
success: `Updated ${description} info for ${chalk.bold(versionString)}...`,
failure: `Failed updating ${description} info for ${chalk.bold(versionString)}...`,
success: `Updated ${description} info for ${chalk.bold(versionString)}`,
failure: `Failed updating ${description} info for ${chalk.bold(versionString)}`,
}
);
}
@@ -2,11 +2,12 @@ import { MetadataContext } from '../../context';
import { AppleTask } from '../task';
import { AgeRatingTask } from './age-rating';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionTask } from './app-version';
/**
* List of all eligible tasks to sync local store configuration to the App store.
*/
export function createAppleTasks(_ctx: MetadataContext): AppleTask[] {
return [new AppVersionTask(), new AppInfoTask(), new AgeRatingTask()];
return [new AppVersionTask(), new AppInfoTask(), new AgeRatingTask(), new AppReviewDetailTask()];
}
+6 -5
View File
@@ -45,12 +45,13 @@ export interface AppleInfo {
}
export interface AppleReview {
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
firstName: string;
lastName: string;
phone: string;
email: string;
demoUsername?: string;
demoPassword?: string;
demoRequired?: boolean;
notes?: string;
attachment?: string;
// attachment?: string;
}
+14 -6
View File
@@ -46,18 +46,26 @@ export class MetadataDownloadError extends Error {
}
}
/**
* Log the encountered metadata validation error in detail for the user.
* This should help communicate any possible configuration error and help the user resolve it.
*/
export function logMetadataValidationError(error: MetadataValidationError): void {
Log.newLine();
Log.error(chalk.bold(error.message));
if (error.errors?.length > 0) {
// TODO(cedric): group errors by property to make multiple errors for same property more readable
Log.log(error.errors.map(err => ` - ${chalk.bold(err.dataPath)} ${err.message}`).join('\n'));
}
}
/**
* Handle a thrown metadata error by informing the user what went wrong.
* If a normal error is thrown, this method will re-throw that error to avoid consuming it.
*/
export function handleMetadataError(error: Error): void {
if (error instanceof MetadataValidationError) {
Log.newLine();
Log.error(chalk.bold(error.message));
if (error.errors?.length > 0) {
Log.log(error.errors.map(err => ` - ${err.dataPath} ${err.message}`).join('\n'));
}
return;
return logMetadataValidationError(error);
}
if (error instanceof MetadataDownloadError || error instanceof MetadataUploadError) {
+19 -7
View File
@@ -3,11 +3,12 @@ import path from 'path';
import { MetadataEvent } from '../analytics/events';
import Log from '../log';
import { confirmAsync } from '../prompts';
import { AppleData } from './apple/data';
import { createAppleTasks } from './apple/tasks';
import { createAppleReader, validateConfig } from './config';
import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context';
import { MetadataUploadError, MetadataValidationError } from './errors';
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
import { subscribeTelemetry } from './utils/telemetry';
/**
@@ -22,18 +23,29 @@ export async function uploadMetadataAsync(
throw new MetadataValidationError(`Store configuration file not found "${filePath}"`);
}
const fileData = await fs.readJson(filePath);
const { valid, errors: validationErrors } = validateConfig(fileData);
if (!valid) {
const error = new MetadataValidationError(`Store configuration errors found`, validationErrors);
logMetadataValidationError(error);
Log.newLine();
Log.warn(
'Without further updates, the current store configuration may fail to be synchronized with the App Store or pass App Store review.'
);
const attempt = await confirmAsync({
message: 'Do you still want to push the store configuration?',
});
if (!attempt) {
throw error;
}
}
const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx);
const { unsubscribeTelemetry, executionId } = subscribeTelemetry(
MetadataEvent.APPLE_METADATA_UPLOAD,
{ app, auth }
);
const fileData = await fs.readJson(filePath);
const { valid, errors: validationErrors } = validateConfig(fileData);
if (!valid) {
throw new MetadataValidationError(`Store configuration errors found`, validationErrors);
}
Log.addNewLineIfNone();
Log.log('Uploading App Store configuration...');
+7 -6
View File
@@ -11,7 +11,6 @@ import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated'
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PresignedPost } from '../graphql/mutations/UploadSessionMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import { uploadWithPresignedPostAsync } from '../uploads';
import { expoCommandAsync } from '../utils/expoCli';
import uniqBy from '../utils/expodash/uniqBy';
@@ -158,11 +157,13 @@ export async function buildBundlesAsync({
throw new Error('Could not locate package.json');
}
await expoCommandAsync(
projectDir,
['export', '--output-dir', inputDir, '--experimental-bundle'],
{ silent: !Log.isDebug }
);
await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
'--experimental-bundle',
'--non-interactive',
]);
}
export async function resolveInputDirectoryAsync(customInputDirectory: string): Promise<string> {
@@ -1,13 +1,15 @@
import { v4 as uuidv4 } from 'uuid';
import {
AppUpdateObject,
PublishPlatformFlag,
ensureBranchExistsAsync,
getUpdatesToRepublishInteractiveAsync,
getUpdatesToRepublishAsync,
truncatePublishUpdateMessage,
} from '../../commands/update';
import { graphqlClient } from '../../graphql/client';
import { ViewBranchUpdatesQuery } from '../../graphql/generated';
import { viewBranchUpdatesQueryUpdateLimit } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
const appId = '6c94sxe6-37d2-4700-52fa-1b813204dad2';
@@ -20,7 +22,6 @@ jest.mock('../../graphql/queries/UpdateQuery', () => {
const actual = jest.requireActual('../../graphql/queries/UpdateQuery');
return {
...actual,
getViewBranchUpdatesQueryUpdateLimit: jest.fn(actual.getViewBranchUpdatesQueryUpdateLimit),
};
});
jest.mock('../../prompts', () => ({
@@ -31,7 +32,7 @@ jest.mock('../../commands/update', () => {
return {
...actual,
ensureBranchExistsAsync: jest.fn(actual.ensureBranchExistsAsync),
getUpdatesToRepublishInteractiveAsync: jest.fn(actual.getUpdatesToRepublishInteractiveAsync),
getUpdatesToRepublishAsync: jest.fn(actual.getUpdatesToRepublishAsync),
};
});
const updatesToResolve = jest.fn(
@@ -95,14 +96,8 @@ function createMockUpdates(
updateCount: 1,
platformFlag: 'all',
}
): Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] {
let updates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] = [];
): AppUpdateObject[] {
let updates: Partial<AppUpdateObject>[] = [];
if (platformFlag === 'all') {
updateCount = Math.ceil(updateCount / 2);
}
@@ -143,7 +138,7 @@ function createMockUpdates(
break;
}
}
return updates;
return updates as AppUpdateObject[];
}
describe('UpdatePublish', () => {
@@ -153,18 +148,6 @@ describe('UpdatePublish', () => {
jest.mocked(graphqlClient.mutation).mockClear();
});
it('defaults limit to viewBranchUpdatesQueryUpdateLimit and offset to 0 when none are provided', async () => {
await ensureBranchExistsAsync({ appId, name: appName });
const [[, bindings]] = jest.mocked(graphqlClient.query).mock.calls;
expect(bindings).toEqual({
appId,
name: appName,
limit: viewBranchUpdatesQueryUpdateLimit,
offset: 0,
});
});
it.each([
[0, 0],
[10, 50],
@@ -183,19 +166,20 @@ describe('UpdatePublish', () => {
});
});
describe('getUpdatesToRepublishInteractiveAsync', () => {
describe('getUpdatesToRepublishAsync', () => {
const pageSize = 50;
beforeEach(() => {
jest.mocked(graphqlClient.query).mockClear();
jest.mocked(graphqlClient.mutation).mockClear();
jest.mocked(getUpdatesToRepublishInteractiveAsync).mockClear();
jest.mocked(getUpdatesToRepublishAsync).mockClear();
jest.mocked(selectAsync).mockClear();
});
it('throws when there are no updates', async () => {
const platformFlag = 'all';
await expect(
async () =>
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, platformFlag, 50)
async () => await getUpdatesToRepublishAsync(projectName, branchName, platformFlag)
).rejects.toThrow(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
@@ -203,7 +187,6 @@ describe('UpdatePublish', () => {
it('fetches multiple pages of updates and can select an update from the most recent page', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 2;
updatesToResolve
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }))
.mockReturnValueOnce(
@@ -214,18 +197,12 @@ describe('UpdatePublish', () => {
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
const selectedUpdates = await getUpdatesToRepublishInteractiveAsync(
projectName,
branchName,
'all',
pageSize
);
const selectedUpdates = await getUpdatesToRepublishAsync(projectName, branchName, 'all');
expect(selectedUpdates.every(update => update.group === mockSelectedGroupId)).toBeTruthy();
});
it('fetches multiple pages of updates and can return an update from a previous page', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 5;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
@@ -236,18 +213,12 @@ describe('UpdatePublish', () => {
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
const selectedUpdates = await getUpdatesToRepublishInteractiveAsync(
projectName,
branchName,
'all',
pageSize
);
const selectedUpdates = await getUpdatesToRepublishAsync(projectName, branchName, 'all');
expect(selectedUpdates.every(update => update.group === mockSelectedGroupId)).toBeTruthy();
});
it('displays the option to fetch more pages as long as there are unfetched updates left', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 10;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
@@ -260,7 +231,7 @@ describe('UpdatePublish', () => {
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, 'all', pageSize);
await getUpdatesToRepublishAsync(projectName, branchName, 'all');
const { calls } = jest.mocked(selectAsync).mock;
expect(calls.length).toEqual(3);
const [[, firstOptions], [, secondOptions], [, thirdOptions]] = calls;
@@ -275,7 +246,6 @@ describe('UpdatePublish', () => {
it('paginates update requests as expected', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 50;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
@@ -290,7 +260,7 @@ describe('UpdatePublish', () => {
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, 'all', pageSize);
await getUpdatesToRepublishAsync(projectName, branchName, 'all');
const gqlBindings = jest.mocked(graphqlClient.query).mock.calls.map(call => call[1]);
expect(gqlBindings).toEqual([
expect.objectContaining({ limit: pageSize + 1, offset: pageSize * 0 }),
@@ -300,4 +270,28 @@ describe('UpdatePublish', () => {
]);
});
});
describe(truncatePublishUpdateMessage.name, () => {
const warnSpy = jest.spyOn(Log, 'warn');
beforeEach(() => {
warnSpy.mockClear();
});
it('does not alter messages with less than 1024 characters', () => {
const message = 'Small message =)';
const truncatedMessage = truncatePublishUpdateMessage(message);
expect(truncatedMessage).toEqual(message);
expect(warnSpy).not.toBeCalled();
});
it('truncates messages to a length of 1024, including ellipses', () => {
const longMessage = Array.from({ length: 2024 }, () => 'a').join('');
const truncatedMessage = truncatePublishUpdateMessage(longMessage);
expect(truncatedMessage.length).toEqual(1024);
expect(truncatedMessage.slice(-3)).toEqual('...');
expect(warnSpy).toBeCalledWith(
'Update message exceeds the allowed 1024 character limit. Truncating message...'
);
});
});
});
+8 -1
View File
@@ -36,11 +36,18 @@ export function getPlatformsForGroup({
.join(', ');
}
export const truncateUpdateMessage = (originalMessage: string, length: number = 512): string => {
if (originalMessage.length > length) {
return originalMessage.substring(0, length - 3) + '...';
}
return originalMessage;
};
export function formatUpdate(update: FormatUpdateParameter): string {
if (!update) {
return 'N/A';
}
const message = update.message ? `"${update.message}" ` : '';
const message = update.message ? `"${truncateUpdateMessage(update.message)}" ` : '';
return `${message}(${format(update.createdAt, 'en_US')} by ${getActorDisplayName(
update.actor as any
)})`;
+14 -2
View File
@@ -1,6 +1,7 @@
import assert from 'assert';
import FormData from 'form-data';
import fs from 'fs-extra';
import { URL } from 'url';
import fetch from './fetch';
import { UploadSessionType } from './graphql/generated';
@@ -54,13 +55,24 @@ export async function uploadWithPresignedPostAsync(
try {
const response = await uploadPromise;
handleProgressEvent({ isComplete: true });
return String(response.headers.get('location'));
return fixArchiveUrl(String(response.headers.get('location')));
} catch (error: any) {
handleProgressEvent({ isComplete: true, error });
throw error;
}
} else {
const response = await uploadPromise;
return String(response.headers.get('location'));
return fixArchiveUrl(String(response.headers.get('location')));
}
}
/**
* S3 returns broken URLs, sth like:
* https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c
* This function replaces %2F with /.
*/
export function fixArchiveUrl(archiveUrl: string): string {
const parsed = new URL(archiveUrl);
parsed.pathname = decodeURIComponent(parsed.pathname);
return parsed.toString();
}
+107
View File
@@ -0,0 +1,107 @@
import { confirmAsync, selectAsync } from '../prompts';
import uniqBy from './expodash/uniqBy';
export type PaginatedQueryResponse<QueryReturnType extends Record<string, any>> = {
queryResponse: QueryReturnType[];
queryResponseRawLength: number;
};
export type PromptSelectionListItem = {
title: string;
value: string;
};
export type PaginatedQueryArguments<QueryReturnType extends Record<string, any>> = {
pageSize: number;
offset: number;
queryToPerform: (
pageSize: number,
offset: number
) => Promise<PaginatedQueryResponse<QueryReturnType>>;
promptOptions: PaginatedQueryPromptOptions<QueryReturnType>;
};
export interface PaginatedQuerySelectPrompt<QueryReturnType extends Record<string, any>> {
readonly title: string;
readonly type: 'select';
createDisplayTextForSelectionPromptListItem: (queryItem: QueryReturnType) => string;
getIdentifierForQueryItem: (queryItem: QueryReturnType) => string;
}
export interface PaginatedQueryConfirmPrompt<QueryReturnType extends Record<string, any>> {
readonly type: 'confirm';
readonly title: string;
renderListItems: (currentPage: QueryReturnType[]) => void;
}
type PaginatedQueryPromptOptions<QueryReturnType extends Record<string, any>> =
| PaginatedQueryConfirmPrompt<QueryReturnType>
| PaginatedQuerySelectPrompt<QueryReturnType>;
/**
* Return an empty array when the promptOptions type is PaginatedQueryConfirmPrompt
* Returns an array of the selected items when the promptOptions type is PaginatedQuerySelectPrompt
*/
export async function performPaginatedQueryAsync<QueryReturnType extends Record<string, any>>(
{ pageSize, offset, queryToPerform, promptOptions }: PaginatedQueryArguments<QueryReturnType>,
accumulator?: QueryReturnType[]
): Promise<QueryReturnType[]> {
const fetchMoreValue = '_fetchMore';
// query an extra item to determine if there are more pages left
const { queryResponse: items, queryResponseRawLength } = await queryToPerform(
pageSize + 1,
offset
);
const areMorePagesAvailable = queryResponseRawLength > pageSize;
// drop that extra item used for pagination from our render logic when extra pages are available
const currentPage = items.slice(0, areMorePagesAvailable ? items.length - 1 : undefined);
accumulator = [...(accumulator ?? []), ...currentPage];
let valueOfUserSelectedListItem = '';
if (promptOptions.type === 'confirm') {
promptOptions.renderListItems(currentPage);
if (areMorePagesAvailable) {
valueOfUserSelectedListItem = (await confirmAsync({ message: promptOptions.title }))
? fetchMoreValue
: '';
}
} else {
const selectionPromptListItems = uniqBy(accumulator, queryItem =>
promptOptions.getIdentifierForQueryItem(queryItem)
).map(queryItem => ({
title: promptOptions.createDisplayTextForSelectionPromptListItem(queryItem),
value: promptOptions.getIdentifierForQueryItem(queryItem),
}));
if (areMorePagesAvailable) {
selectionPromptListItems.push({ title: 'Next page...', value: fetchMoreValue });
}
if (selectionPromptListItems.length) {
valueOfUserSelectedListItem = await selectAsync<string>(
promptOptions.title,
selectionPromptListItems
);
}
}
if (valueOfUserSelectedListItem === fetchMoreValue) {
return await performPaginatedQueryAsync(
{
pageSize,
offset: offset + pageSize,
queryToPerform,
promptOptions,
},
accumulator
);
}
if (promptOptions.type === 'select') {
return accumulator.filter(
update => promptOptions.getIdentifierForQueryItem(update) === valueOfUserSelectedListItem
);
}
return [];
}