From bfc8f4b04f31a62d74e09ebc2425c45033577c86 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Tue, 9 Jul 2024 17:59:56 -0400 Subject: [PATCH] feat(shorebird_cli): add `patches promote` command (#2331) --- .../lib/src/code_push_client_wrapper.dart | 20 +- .../lib/src/commands/commands.dart | 1 + .../lib/src/commands/patches/patches.dart | 2 + .../src/commands/patches/patches_command.dart | 18 ++ .../src/commands/patches/promote_command.dart | 101 ++++++++++ .../lib/src/shorebird_cli_command_runner.dart | 1 + .../src/code_push_client_wrapper_test.dart | 56 ++++++ .../test/src/commands/init_command_test.dart | 2 +- .../patches/promote_command_test.dart | 186 ++++++++++++++++++ packages/shorebird_cli/test/src/fakes.dart | 2 + .../lib/src/code_push_client.dart | 18 ++ .../test/src/code_push_client_test.dart | 55 ++++++ .../get_release_patches_response.dart | 33 ---- .../lib/src/models/models.dart | 1 + .../lib/src/models/release_patch.dart | 6 +- .../test/src/models/release_patch_test.dart | 27 +++ 16 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/patches/patches.dart create mode 100644 packages/shorebird_cli/lib/src/commands/patches/patches_command.dart create mode 100644 packages/shorebird_cli/lib/src/commands/patches/promote_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart create mode 100644 packages/shorebird_code_push_protocol/test/src/models/release_patch_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 7453c46b..afd6aefc 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -129,7 +129,6 @@ This app may not exist or you may not have permission to view it.''', return apps.firstWhereOrNull((a) => a.appId == appId); } - @visibleForTesting Future maybeGetChannel({ required String appId, required String name, @@ -236,6 +235,24 @@ Please create a release using "shorebird release" and try again. return releases.firstWhereOrNull((r) => r.version == releaseVersion); } + /// Gets the patches for [appId]'s [releaseId]. + Future> getReleasePatches({ + required String appId, + required int releaseId, + }) async { + final fetchReleasePatchesProgress = logger.progress('Fetching patches'); + try { + final patches = await codePushClient.getPatches( + appId: appId, + releaseId: releaseId, + ); + fetchReleasePatchesProgress.complete(); + return patches; + } catch (error) { + _handleErrorAndExit(error, progress: fetchReleasePatchesProgress); + } + } + Future createRelease({ required String appId, required String version, @@ -713,7 +730,6 @@ aar artifact already exists, continuing...''', createArtifactProgress.complete(); } - @visibleForTesting Future promotePatch({ required String appId, required int patchId, diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 00e53787..4ab59817 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -7,6 +7,7 @@ export 'login_ci_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; export 'patch/patch.dart'; +export 'patches/patches.dart'; export 'preview_command.dart'; export 'release/release.dart'; export 'run_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches.dart b/packages/shorebird_cli/lib/src/commands/patches/patches.dart new file mode 100644 index 00000000..c093f2dc --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/patches.dart @@ -0,0 +1,2 @@ +export 'patches_command.dart'; +export 'promote_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 new file mode 100644 index 00000000..c0e05348 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart @@ -0,0 +1,18 @@ +import 'package:shorebird_cli/src/commands/patches/patches.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; + +/// {@template patches_command} +/// Commands for managing Shorebird patches. +/// {@endtemplate} +class PatchesCommand extends ShorebirdCommand { + /// {@macro patches_command} + PatchesCommand() { + addSubcommand(PromoteCommand()); + } + + @override + String get name => 'patches'; + + @override + String get description => 'Manage Shorebird patches'; +} diff --git a/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart new file mode 100644 index 00000000..812c515e --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart @@ -0,0 +1,101 @@ +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/deployment_track.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; + +/// {@template promote_command} +/// Promotes a patch to the production channel. +/// {@endtemplate} +class PromoteCommand extends ShorebirdCommand { + /// {@macro promote_command} + PromoteCommand() { + argParser + ..addOption( + 'flavor', + help: 'The product flavor to use when building the app.', + ) + ..addOption( + 'release-version', + help: 'The release being patched', + mandatory: true, + ) + ..addOption( + 'patch-number', + help: 'The number of the patch to promote to the stable channel', + mandatory: true, + ); + } + + @override + String get name => 'promote'; + + @override + String get description => 'Promotes a patch to the "stable" channel.'; + + @override + Future run() async { + final releaseVersion = results['release-version'] as String; + final patchNumber = int.parse(results['patch-number'] as String); + final flavor = results.findOption('flavor', argParser: argParser); + final appId = shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + + final release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + final patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + 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; + } + + if (patchToPromote.channel == DeploymentTrack.production.channel) { + logger.err('Patch ${patchToPromote.number} is already live'); + return ExitCode.usage.code; + } + + final channel = await codePushClientWrapper.maybeGetChannel( + appId: appId, + name: DeploymentTrack.production.channel, + ); + if (channel == null) { + // This is a symptom that something bigger is wrong. Apps should always + // have a production channel. + logger.err( + ''' +No production channel found for app $appId. + +This is a bug and should never happen. Please file an issue at https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=fix%3A+''', + ); + return ExitCode.software.code; + } + + await codePushClientWrapper.promotePatch( + appId: appId, + patchId: patchToPromote.id, + channel: channel, + ); + + logger.success( + 'Patch ${patchToPromote.number} is now live for release $releaseVersion!', + ); + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart index 992e6171..56392e55 100644 --- a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart +++ b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart @@ -76,6 +76,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(LoginCiCommand()); addCommand(LogoutCommand()); addCommand(PatchCommand()); + addCommand(PatchesCommand()); addCommand(PreviewCommand()); addCommand(ReleaseCommand()); addCommand(RunCommand()); diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index 3bc557d4..6fecbca7 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -712,6 +712,62 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }); }); + group('getReleasePatches', () { + group('when getPatches request fails', () { + setUp(() { + when( + () => codePushClient.getPatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow('something went wrong'); + }); + + test('exits with code 70', () async { + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: releaseId, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify(() => progress.fail(any())).called(1); + }); + }); + + group('when getPatches request succeeds', () { + final patch = ReleasePatch( + id: 0, + number: 1, + channel: DeploymentTrack.production.channel, + artifacts: const [], + ); + + setUp(() { + when( + () => codePushClient.getPatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [patch]); + }); + + test('returns list of patches', () async { + final result = await runWithOverrides( + () => codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: releaseId, + ), + ); + + expect(result, equals([patch])); + verify(() => progress.complete()).called(1); + }); + }); + }); + group('createRelease', () { test('exits with code 70 when creating release fails', () async { const error = 'something went wrong'; diff --git a/packages/shorebird_cli/test/src/commands/init_command_test.dart b/packages/shorebird_cli/test/src/commands/init_command_test.dart index 24797315..4af50acd 100644 --- a/packages/shorebird_cli/test/src/commands/init_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/init_command_test.dart @@ -921,7 +921,7 @@ flutter: '''🚀 To push an update use: "${lightCyan.wrap('shorebird patch')}".''', '''👀 To preview a release use: "${lightCyan.wrap('shorebird preview')}".''', '''For more information about Shorebird, visit ${link(uri: Uri.parse('https://shorebird.dev'))}''', - '' + '', ], ), ), 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 new file mode 100644 index 00000000..b41e4a4c --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart @@ -0,0 +1,186 @@ +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/patches.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/deployment_track.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; + +void main() { + group(PromoteCommand, () { + const appId = 'app-id'; + const releaseVersion = '1.0.0'; + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const releasePlatform = ReleasePlatform.android; + const shorebirdYaml = ShorebirdYaml(appId: appId); + final stableChannel = Channel( + id: 0, + appId: appId, + name: DeploymentTrack.production.channel, + ); + final release = Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {releasePlatform: ReleaseStatus.active}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + final patch = ReleasePatch( + id: 0, + number: 1, + channel: DeploymentTrack.staging.channel, + artifacts: const [], + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdEnv shorebirdEnv; + late ShorebirdLogger logger; + + late PromoteCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + }, + ); + } + + setUpAll(() { + registerFallbackValue(FakeChannel()); + }); + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + shorebirdEnv = MockShorebirdEnv(); + + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['release-version']).thenReturn('1.0.0'); + when(() => argResults['patch-number']).thenReturn('1'); + + 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 => stableChannel); + when( + () => codePushClientWrapper.promotePatch( + appId: any(named: 'appId'), + patchId: any(named: 'patchId'), + channel: any(named: 'channel'), + ), + ).thenAnswer((_) async => {}); + + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + + command = PromoteCommand()..testArgResults = argResults; + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + group('when an invalid patch number is provided', () { + setUp(() { + when(() => argResults['patch-number']).thenReturn('5'); + }); + + test('should log an error', () { + runWithOverrides(() async { + final result = await command.run(); + + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err('No patch found with number 5')).called(1); + verify(() => logger.info('Available patches: 1')).called(1); + }); + }); + }); + + group('when patch is already in production', () { + setUp(() { + final prodPatch = ReleasePatch( + id: 0, + number: 1, + channel: DeploymentTrack.production.channel, + artifacts: const [], + ); + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [prodPatch]); + }); + + test('tells user patch is already in prod, exits with usage code', + () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err('Patch 1 is already live')).called(1); + }); + }); + + group('when app has no stable channel', () { + setUp(() { + when( + () => codePushClientWrapper.maybeGetChannel( + appId: any(named: 'appId'), + name: DeploymentTrack.production.channel, + ), + ).thenAnswer((_) async => null); + }); + + test('exits with software error code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + }); + }); + + group('when patch is successfully promoted', () { + test('exits with success code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => codePushClientWrapper.promotePatch( + appId: appId, + patchId: patch.id, + channel: stableChannel, + ), + ); + verify(() => logger.success('Patch 1 is now live for release 1.0.0!')) + .called(1); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/fakes.dart b/packages/shorebird_cli/test/src/fakes.dart index 0d5ef022..01aede6d 100644 --- a/packages/shorebird_cli/test/src/fakes.dart +++ b/packages/shorebird_cli/test/src/fakes.dart @@ -11,6 +11,8 @@ class FakeArgResults extends Fake implements ArgResults {} class FakeBaseRequest extends Fake implements http.BaseRequest {} +class FakeChannel extends Fake implements Channel {} + class FakeDiffStatus extends Fake implements DiffStatus {} class FakeIOSink extends Fake implements IOSink {} diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 117350e8..c551d390 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -415,6 +415,24 @@ class CodePushClient { return decoded.releases; } + /// Gets [ReleasePatch]es associated with [appId]'s [releaseId]. + Future> getPatches({ + required String appId, + required int releaseId, + }) async { + final response = await _httpClient.get( + Uri.parse('$_v1/apps/$appId/releases/$releaseId/patches'), + ); + + if (!response.isSuccess) { + throw _parseErrorResponse(response.statusCode, response.body); + } + + return GetReleasePatchesResponse.fromJson( + json.decode(response.body) as Map, + ).patches; + } + /// Get all release artifacts for a specific [releaseId] /// and optional [arch] and [platform]. Future> getReleaseArtifacts({ diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 29c6a1f3..16a6fd97 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -1639,6 +1639,61 @@ void main() { }); }); + group('getPatches', () { + group('when request is not successful', () { + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.failedDependency, + ), + ); + }); + + test('throws exception', () async { + expect( + () async => codePushClient.getPatches(appId: appId, releaseId: 123), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + }); + + group('when request is successful', () { + late GetReleasePatchesResponse response; + late ReleasePatch patch; + + setUp(() { + patch = ReleasePatch( + id: 0, + number: 1, + channel: 'stable', + artifacts: [], + ); + response = GetReleasePatchesResponse(patches: [patch]); + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + Stream.value(utf8.encode(json.encode(response))), + HttpStatus.ok, + ), + ); + }); + + test('deserializes GetReleasePatchesResponse', () async { + final patches = await codePushClient.getPatches( + appId: appId, + releaseId: 123, + ); + expect(patches, equals([patch])); + }); + }); + }); + group('getReleaseArtifacts', () { const appId = 'test-app-id'; const releaseId = 0; diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_release_patches/get_release_patches_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_release_patches/get_release_patches_response.dart index 0af3662c..549d2f24 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/get_release_patches/get_release_patches_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_release_patches/get_release_patches_response.dart @@ -21,36 +21,3 @@ class GetReleasePatchesResponse { /// List of patches. final List patches; } - -/// {@template release_patch} -/// A patch for a given release. -/// {@endtemplate} -@JsonSerializable() -class ReleasePatch { - /// {@macro release_patch} - const ReleasePatch({ - required this.id, - required this.number, - required this.channel, - required this.artifacts, - }); - - /// Converts a Map to a [ReleasePatch] - factory ReleasePatch.fromJson(Map json) => - _$ReleasePatchFromJson(json); - - /// Converts a [ReleasePatch] to a Map - Json toJson() => _$ReleasePatchToJson(this); - - /// The patch id. - final int id; - - /// The patch number. - final int number; - - /// The channel associated with the patch. - final String? channel; - - /// The associated patch artifacts. - final List artifacts; -} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/models.dart b/packages/shorebird_code_push_protocol/lib/src/models/models.dart index c973cacc..3ed43aee 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/models.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/models.dart @@ -9,6 +9,7 @@ export 'patch.dart'; export 'patch_artifact.dart'; export 'release.dart'; export 'release_artifact.dart'; +export 'release_patch.dart'; export 'release_platform.dart'; export 'release_status.dart'; export 'update_release_metadata.dart'; diff --git a/packages/shorebird_code_push_protocol/lib/src/models/release_patch.dart b/packages/shorebird_code_push_protocol/lib/src/models/release_patch.dart index 21a2ad46..8c249f47 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/release_patch.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/release_patch.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; @@ -7,7 +8,7 @@ part 'release_patch.g.dart'; /// A patch for a given release. /// {@endtemplate} @JsonSerializable() -class ReleasePatch { +class ReleasePatch extends Equatable { /// {@macro release_patch} const ReleasePatch({ required this.id, @@ -34,4 +35,7 @@ class ReleasePatch { /// The associated patch artifacts. final List artifacts; + + @override + List get props => [id, number, channel, artifacts]; } diff --git a/packages/shorebird_code_push_protocol/test/src/models/release_patch_test.dart b/packages/shorebird_code_push_protocol/test/src/models/release_patch_test.dart new file mode 100644 index 00000000..eaf10ed1 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/src/models/release_patch_test.dart @@ -0,0 +1,27 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group(ReleasePatch, () { + test('is equatable', () { + expect( + ReleasePatch( + id: 0, + number: 1, + channel: 'channel', + artifacts: const [], + ), + equals( + ReleasePatch( + id: 0, + number: 1, + channel: 'channel', + artifacts: const [], + ), + ), + ); + }); + }); +}