From a3b76f0a053a9fc6fad6d39353f11878d95b7ce0 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 17 Dec 2025 13:21:35 -0500 Subject: [PATCH] feat(shorebird_cli): add `shorebird patches set-track` command (#3419) --- .../lib/src/code_push_client_wrapper.dart | 1 - .../lib/src/commands/patches/patches.dart | 1 + .../src/commands/patches/patches_command.dart | 1 + .../src/commands/patches/promote_command.dart | 4 + .../commands/patches/set_track_command.dart | 131 ++++++++ .../patches/promote_command_test.dart | 12 + .../patches/set_track_command_test.dart | 310 ++++++++++++++++++ 7 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 2cd81abf..3fdcbab3 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -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 createChannel({ required String appId, required String name, diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches.dart b/packages/shorebird_cli/lib/src/commands/patches/patches.dart index c093f2dc..94488ca9 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches.dart @@ -1,2 +1,3 @@ export 'patches_command.dart'; export 'promote_command.dart'; +export 'set_track_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart index c0e05348..4b28fc87 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart @@ -8,6 +8,7 @@ class PatchesCommand extends ShorebirdCommand { /// {@macro patches_command} PatchesCommand() { addSubcommand(PromoteCommand()); + addSubcommand(SetTrackCommand()); } @override diff --git a/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart index e32f8e4b..6c50b283 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart @@ -40,6 +40,10 @@ class PromoteCommand extends ShorebirdCommand { @override Future 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, diff --git a/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart b/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart new file mode 100644 index 00000000..b63d2c63 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart @@ -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 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; + } +} diff --git a/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart index effa6be4..adc6ca9a 100644 --- a/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart @@ -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(() { diff --git a/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart new file mode 100644 index 00000000..0d9f7949 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart @@ -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 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); + }); + }); + }); +}