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
19 changed files with 813 additions and 394 deletions

No files matched your search

+23 -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,
@@ -12574,6 +12590,12 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NORMAL_PLUS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
+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;
}
+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}`);
+114 -113
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);
@@ -470,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
@@ -599,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(
@@ -696,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;
@@ -724,16 +689,52 @@ 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 => {
+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 {
+13 -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;
@@ -1817,7 +1818,8 @@ export type BuildParamsInput = {
export enum BuildPriority {
High = 'HIGH',
Normal = 'NORMAL'
Normal = 'NORMAL',
NormalPlus = 'NORMAL_PLUS'
}
/** Publicly visible data for a Build. */
@@ -3849,6 +3851,7 @@ export type DeleteUpdateBranchMutation = { __typename?: 'RootMutation', updateBr
export type BranchesByAppQueryVariables = Exact<{
appId: Scalars['String'];
limit: Scalars['Int'];
offset: Scalars['Int'];
}>;
@@ -3910,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'];
}>;
@@ -4476,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'];
}>;
@@ -4531,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'];
@@ -4544,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; }>;
@@ -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,14 +1,14 @@
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';
@@ -22,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', () => ({
@@ -33,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(
@@ -97,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);
}
@@ -145,7 +138,7 @@ function createMockUpdates(
break;
}
}
return updates;
return updates as AppUpdateObject[];
}
describe('UpdatePublish', () => {
@@ -155,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],
@@ -185,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?`
);
@@ -205,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(
@@ -216,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 })
@@ -238,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 })
@@ -262,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;
@@ -277,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 })
@@ -292,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 }),
+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
)})`;
+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 [];
}