feat(shorebird_cli): add shorebird patches set-track command (#3419)
This commit is contained in:
@@ -180,7 +180,6 @@ This app may not exist or you may not have permission to view it.''');
|
||||
}
|
||||
|
||||
/// Creates a channel for the provided [appId] with the given [name].
|
||||
@visibleForTesting
|
||||
Future<Channel> createChannel({
|
||||
required String appId,
|
||||
required String name,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'patches_command.dart';
|
||||
export 'promote_command.dart';
|
||||
export 'set_track_command.dart';
|
||||
|
||||
@@ -8,6 +8,7 @@ class PatchesCommand extends ShorebirdCommand {
|
||||
/// {@macro patches_command}
|
||||
PatchesCommand() {
|
||||
addSubcommand(PromoteCommand());
|
||||
addSubcommand(SetTrackCommand());
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -40,6 +40,10 @@ class PromoteCommand extends ShorebirdCommand {
|
||||
|
||||
@override
|
||||
Future<int> run() async {
|
||||
logger.warn(
|
||||
'''This command is deprecated and will be removed in a future release. Use `shorebird patches set-channel --channel=stable` instead.''',
|
||||
);
|
||||
|
||||
try {
|
||||
await shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/config/config.dart';
|
||||
import 'package:shorebird_cli/src/extensions/arg_results.dart';
|
||||
import 'package:shorebird_cli/src/logging/shorebird_logger.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_validator.dart';
|
||||
|
||||
/// {@template set_track_command}
|
||||
/// Sets the channel of a patch.
|
||||
///
|
||||
/// Sample usage:
|
||||
/// ```sh
|
||||
/// shorebird patches set-track --release=1.0.0+1 --patch=1 --track=beta
|
||||
/// ```
|
||||
///
|
||||
/// {@endtemplate
|
||||
class SetTrackCommand extends ShorebirdCommand {
|
||||
/// {@macro set_track_command}
|
||||
SetTrackCommand() {
|
||||
argParser
|
||||
..addOption(
|
||||
'flavor',
|
||||
help: 'The product flavor to use when building the app.',
|
||||
)
|
||||
..addOption(
|
||||
'release',
|
||||
help: 'The release version that the patch belongs to (ex: "1.0.0")',
|
||||
mandatory: true,
|
||||
)
|
||||
..addOption(
|
||||
'patch',
|
||||
help: 'The patch number to set the channel for (ex: "1")',
|
||||
mandatory: true,
|
||||
)
|
||||
..addOption(
|
||||
'track',
|
||||
help: 'The channel to set the patch to',
|
||||
mandatory: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'set-track';
|
||||
|
||||
@override
|
||||
String get description => 'Sets the track of a patch';
|
||||
|
||||
@override
|
||||
Future<int> run() async {
|
||||
try {
|
||||
await shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
checkShorebirdInitialized: true,
|
||||
);
|
||||
} on PreconditionFailedException catch (error) {
|
||||
return error.exitCode.code;
|
||||
}
|
||||
|
||||
final releaseVersion = results['release'] as String;
|
||||
final patchNumber = int.parse(results['patch'] as String);
|
||||
final flavor = results.findOption('flavor', argParser: argParser);
|
||||
final appId = shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor);
|
||||
final targetChannel = results['track'] as String;
|
||||
|
||||
final release = await codePushClientWrapper.getRelease(
|
||||
appId: appId,
|
||||
releaseVersion: releaseVersion,
|
||||
);
|
||||
final patches = await codePushClientWrapper.getReleasePatches(
|
||||
appId: appId,
|
||||
releaseId: release.id,
|
||||
);
|
||||
if (patches.isEmpty) {
|
||||
logger.err('No patches found for release $releaseVersion');
|
||||
return ExitCode.usage.code;
|
||||
}
|
||||
|
||||
final patchToPromote = patches.firstWhereOrNull(
|
||||
(patch) => patch.number == patchNumber,
|
||||
);
|
||||
if (patchToPromote == null) {
|
||||
logger
|
||||
..err('No patch found with number $patchNumber')
|
||||
..info(
|
||||
'''Available patches: ${patches.map((patch) => patch.number).join(', ')}''',
|
||||
);
|
||||
|
||||
return ExitCode.usage.code;
|
||||
}
|
||||
|
||||
var channel = await codePushClientWrapper.maybeGetChannel(
|
||||
appId: appId,
|
||||
name: targetChannel,
|
||||
);
|
||||
if (channel == null) {
|
||||
final shouldCreateChannel = logger.confirm(
|
||||
'''No channel named ${lightCyan.wrap(targetChannel)} found. Do you want to create it?''',
|
||||
);
|
||||
if (!shouldCreateChannel) {
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
channel = await codePushClientWrapper.createChannel(
|
||||
appId: appId,
|
||||
name: targetChannel,
|
||||
);
|
||||
}
|
||||
|
||||
if (patchToPromote.channel == targetChannel) {
|
||||
logger.err(
|
||||
'Patch ${patchToPromote.number} is already in channel $targetChannel',
|
||||
);
|
||||
return ExitCode.usage.code;
|
||||
}
|
||||
|
||||
await codePushClientWrapper.promotePatch(
|
||||
appId: appId,
|
||||
patchId: patchToPromote.id,
|
||||
channel: channel,
|
||||
);
|
||||
|
||||
logger.success(
|
||||
'''Patch ${patchToPromote.number} on release $releaseVersion is now in channel $targetChannel!''',
|
||||
);
|
||||
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,18 @@ void main() {
|
||||
expect(command.description, isNotEmpty);
|
||||
});
|
||||
|
||||
test('logs warning about deprecation', () async {
|
||||
await runWithOverrides(() async {
|
||||
final result = await command.run();
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.warn(
|
||||
'This command is deprecated and will be removed in a future release. Use `shorebird patches set-channel --channel=stable` instead.',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when validation fails', () {
|
||||
final exception = ShorebirdNotInitializedException();
|
||||
setUp(() {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'package:args/args.dart';
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:scoped_deps/scoped_deps.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/commands/patches/set_track_command.dart';
|
||||
import 'package:shorebird_cli/src/config/config.dart';
|
||||
import 'package:shorebird_cli/src/logging/shorebird_logger.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_validator.dart';
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../fakes.dart';
|
||||
import '../../mocks.dart';
|
||||
|
||||
void main() {
|
||||
group(SetTrackCommand, () {
|
||||
const appId = 'app-id';
|
||||
const shorebirdYaml = ShorebirdYaml(appId: appId);
|
||||
const patchNumberArg = 1;
|
||||
final release = Release(
|
||||
id: 0,
|
||||
appId: appId,
|
||||
version: '1.0.0',
|
||||
flutterRevision: 'flutter-revision',
|
||||
flutterVersion: 'flutter-version',
|
||||
displayName: '1.0.0',
|
||||
platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active},
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
const patch = ReleasePatch(
|
||||
id: 0,
|
||||
number: patchNumberArg,
|
||||
channel: 'stable',
|
||||
isRolledBack: false,
|
||||
artifacts: [],
|
||||
);
|
||||
const newChannel = Channel(
|
||||
id: 1,
|
||||
appId: appId,
|
||||
name: 'new-channel',
|
||||
);
|
||||
|
||||
late ArgResults argResults;
|
||||
late CodePushClientWrapper codePushClientWrapper;
|
||||
late ShorebirdEnv shorebirdEnv;
|
||||
late ShorebirdValidator shorebirdValidator;
|
||||
late ShorebirdLogger logger;
|
||||
|
||||
late SetTrackCommand command;
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeChannel());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
argResults = MockArgResults();
|
||||
codePushClientWrapper = MockCodePushClientWrapper();
|
||||
logger = MockShorebirdLogger();
|
||||
shorebirdEnv = MockShorebirdEnv();
|
||||
shorebirdValidator = MockShorebirdValidator();
|
||||
|
||||
when(() => argResults.wasParsed(any())).thenReturn(false);
|
||||
when(() => argResults.rest).thenReturn([]);
|
||||
when(() => argResults['release']).thenReturn('1.0.0');
|
||||
when(
|
||||
() => argResults['patch'],
|
||||
).thenReturn(patchNumberArg.toString());
|
||||
when(() => argResults['track']).thenReturn(newChannel.name);
|
||||
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
|
||||
|
||||
when(
|
||||
() => codePushClientWrapper.getRelease(
|
||||
appId: any(named: 'appId'),
|
||||
releaseVersion: any(named: 'releaseVersion'),
|
||||
),
|
||||
).thenAnswer((_) async => release);
|
||||
when(
|
||||
() => codePushClientWrapper.getReleasePatches(
|
||||
appId: any(named: 'appId'),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
),
|
||||
).thenAnswer((_) async => [patch]);
|
||||
when(
|
||||
() => codePushClientWrapper.maybeGetChannel(
|
||||
appId: any(named: 'appId'),
|
||||
name: any(named: 'name'),
|
||||
),
|
||||
).thenAnswer((_) async => newChannel);
|
||||
when(
|
||||
() => codePushClientWrapper.createChannel(
|
||||
appId: any(named: 'appId'),
|
||||
name: any(named: 'name'),
|
||||
),
|
||||
).thenAnswer((_) async => newChannel);
|
||||
when(
|
||||
() => codePushClientWrapper.promotePatch(
|
||||
appId: any(named: 'appId'),
|
||||
patchId: any(named: 'patchId'),
|
||||
channel: any(named: 'channel'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
command = SetTrackCommand()..testArgResults = argResults;
|
||||
});
|
||||
|
||||
test('name is correct', () {
|
||||
expect(command.name, 'set-track');
|
||||
});
|
||||
|
||||
test('description is correct', () {
|
||||
expect(command.description, 'Sets the track of a patch');
|
||||
});
|
||||
|
||||
group('when validation fails', () {
|
||||
final exception = ShorebirdNotInitializedException();
|
||||
setUp(() {
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
|
||||
),
|
||||
).thenThrow(exception);
|
||||
});
|
||||
|
||||
test('exits with exit code from validation error', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(exception.exitCode.code));
|
||||
verify(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
checkShorebirdInitialized: true,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when release has no patches', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getReleasePatches(
|
||||
appId: any(named: 'appId'),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
),
|
||||
).thenAnswer((_) async => []);
|
||||
});
|
||||
|
||||
test('exits with code 70', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.usage.code));
|
||||
verify(
|
||||
() => logger.err('No patches found for release 1.0.0'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when no patch matching arg values is found', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getReleasePatches(
|
||||
appId: any(named: 'appId'),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
const ReleasePatch(
|
||||
id: 1,
|
||||
number: patchNumberArg + 1,
|
||||
channel: 'stable',
|
||||
isRolledBack: false,
|
||||
artifacts: [],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('exits with code 70', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.usage.code));
|
||||
verify(
|
||||
() => logger.err('No patch found with number 1'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when no channel with the specified name is found', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.maybeGetChannel(
|
||||
appId: any(named: 'appId'),
|
||||
name: any(named: 'name'),
|
||||
),
|
||||
).thenAnswer((_) async => null);
|
||||
when(() => logger.confirm(any())).thenReturn(false);
|
||||
});
|
||||
|
||||
test('prompts to create the channel', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.confirm(
|
||||
'''No channel named ${lightCyan.wrap(newChannel.name)} found. Do you want to create it?''',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
group('when user confirms to create the channel', () {
|
||||
setUp(() {
|
||||
when(() => logger.confirm(any())).thenReturn(true);
|
||||
});
|
||||
|
||||
test('creates the channel', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => codePushClientWrapper.createChannel(
|
||||
appId: any(named: 'appId'),
|
||||
name: any(named: 'name'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when user declines to create the channel', () {
|
||||
setUp(() {
|
||||
when(() => logger.confirm(any())).thenReturn(false);
|
||||
});
|
||||
|
||||
test('exits with code 70', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verifyNever(
|
||||
() => codePushClientWrapper.createChannel(
|
||||
appId: any(named: 'appId'),
|
||||
name: any(named: 'name'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('when patch is already in the specified channel', () {
|
||||
setUp(() {
|
||||
final patch = ReleasePatch(
|
||||
id: 0,
|
||||
number: patchNumberArg,
|
||||
channel: newChannel.name,
|
||||
isRolledBack: false,
|
||||
artifacts: const [],
|
||||
);
|
||||
when(
|
||||
() => codePushClientWrapper.getReleasePatches(
|
||||
appId: any(named: 'appId'),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
),
|
||||
).thenAnswer((_) async => [patch]);
|
||||
});
|
||||
|
||||
test('exits with code 70', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.usage.code));
|
||||
verify(
|
||||
() => logger.err(
|
||||
'Patch ${patch.number} is already in channel ${newChannel.name}',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when patch is not in the specified channel', () {
|
||||
test('promotes the patch to the specified channel', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => codePushClientWrapper.promotePatch(
|
||||
appId: appId,
|
||||
patchId: patch.id,
|
||||
channel: newChannel,
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.success(
|
||||
'''Patch ${patch.number} on release ${release.version} is now in channel ${newChannel.name}!''',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user