From 7f195df3a2a73eb0b648c08933d9fb2145e8d756 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Fri, 17 Jan 2025 17:15:14 -0600 Subject: [PATCH] feat(shorebird_cli): `shorebird patch --release-version=latest` (#2782) --- .../lib/src/commands/patch/patch_command.dart | 41 +++++- .../commands/patch/patch_command_test.dart | 131 +++++++++++++++++- 2 files changed, 161 insertions(+), 11 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 1c91f485..0142afef 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -77,8 +77,9 @@ class PatchCommand extends ShorebirdCommand { ..addOption( 'release-version', help: ''' -The version of the associated release (e.g. "1.0.0"). This should be the version -of the iOS app that is using this module.''', +The version of the associated release (e.g. "1.0.0"). +If you are building an xcframework or aar, this number needs to match the host app's release version. +To target the latest release (e.g. the release that was most recently updated) use --release-version=latest.''', ) ..addFlag( 'allow-native-diffs', @@ -186,6 +187,10 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl /// Whether the patch is for the staging environment. bool get isStaging => track == DeploymentTrack.staging; + /// Whether the patch is targeting the latest release version + /// (--release-version=latest). + bool get useLatestRelease => results['release-version'] == 'latest'; + /// The deployment track to publish the patch to. DeploymentTrack get track { final channel = results['track'] as String; @@ -285,14 +290,30 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl File? patchArtifactFile; final Release release; - if (results.wasParsed('release-version')) { + final releasePlatform = patcher.releaseType.releasePlatform; + if (useLatestRelease) { + final releases = await codePushClientWrapper.getReleases(appId: appId); + releases + ..removeWhere( + (release) => !release.platformStatuses.keys.contains(releasePlatform), + ) + ..sortByUpdatedAt(); + if (releases.isEmpty) { + logger.warn( + '''No ${releasePlatform.displayName} releases found for app $appId. You must first create a release before you can create a patch.''', + ); + throw ProcessExit(ExitCode.usage.code); + } + // Use the most recently updated release for the specified platform. + release = releases.last; + } else if (results.wasParsed('release-version')) { final releaseVersion = results['release-version'] as String; release = await codePushClientWrapper.getRelease( appId: appId, releaseVersion: releaseVersion, ); } else if (shorebirdEnv.canAcceptUserInput) { - release = await promptForRelease(patcher.releaseType.releasePlatform); + release = await promptForRelease(releasePlatform); } else { logger.info( '''Tip: make your patches build faster by specifying --release-version''', @@ -321,7 +342,7 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl appId: appId, releaseId: release.id, arch: patcher.primaryReleaseArtifactArch, - platform: patcher.releaseType.releasePlatform, + platform: releasePlatform, ); final supplementalArtifact = @@ -330,7 +351,7 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl appId: appId, releaseId: release.id, arch: patcher.supplementaryReleaseArtifactArch!, - platform: patcher.releaseType.releasePlatform, + platform: releasePlatform, ) : null; @@ -516,7 +537,7 @@ Please re-run the release command for this version or create a new release.'''); '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}', 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.displayName)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', trackSummary, if (patcher.linkPercentage != null && patcher.linkPercentage! < Patcher.minLinkPercentage) @@ -559,3 +580,9 @@ ${summary.join('\n')} return artifactFile; } } + +/// Extension on list of releases for sorting the releases. +extension SortReleases on List { + /// Sort the list of releases by when they were last updated ascending. + void sortByUpdatedAt() => sort((a, b) => a.updatedAt.compareTo(b.updatedAt)); +} diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 0b668d6f..28a721e2 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:scoped_deps/scoped_deps.dart'; @@ -31,6 +32,16 @@ import '../../helpers.dart'; import '../../matchers.dart'; import '../../mocks.dart'; +class _FakeRelease extends Fake with EquatableMixin implements Release { + _FakeRelease({required this.updatedAt}); + + @override + final DateTime updatedAt; + + @override + List get props => [updatedAt]; +} + void main() { group(PatchCommand, () { const appId = 'test-app-id'; @@ -590,7 +601,7 @@ void main() { '''📱 App: ${lightCyan.wrap(appDisplayName)} ${lightCyan.wrap('($appId)')}''', '🍧 Flavor: ${lightCyan.wrap(flavor)}', 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[arm32 (42 B)]')}''', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.displayName)} ${lightCyan.wrap('[arm32 (42 B)]')}''', 'đŸŸĸ Track: ${lightCyan.wrap('Stable')}', ]; await expectLater( @@ -627,7 +638,7 @@ void main() { final expectedSummary = [ '''📱 App: ${lightCyan.wrap(appDisplayName)} ${lightCyan.wrap('($appId)')}''', 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[arm32 (42 B)]')}''', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.displayName)} ${lightCyan.wrap('[arm32 (42 B)]')}''', '🟠 Track: ${lightCyan.wrap('Staging')}', ]; await expectLater( @@ -660,7 +671,7 @@ void main() { final expectedSummary = [ '''📱 App: ${lightCyan.wrap(appDisplayName)} ${lightCyan.wrap('($appId)')}''', 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[arm32 (42 B)]')}''', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.displayName)} ${lightCyan.wrap('[arm32 (42 B)]')}''', 'đŸ”ĩ Track: ${lightCyan.wrap('Beta')}', ]; await expectLater( @@ -695,7 +706,7 @@ void main() { final expectedSummary = [ '''📱 App: ${lightCyan.wrap(appDisplayName)} ${lightCyan.wrap('($appId)')}''', 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[arm32 (42 B)]')}''', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.displayName)} ${lightCyan.wrap('[arm32 (42 B)]')}''', 'đŸŸĸ Track: ${lightCyan.wrap('Stable')}', '''🔍 Debug Info: ${lightCyan.wrap(patcher.debugInfoFile.path)}''', ]; @@ -787,6 +798,99 @@ void main() { }); }); + group('when release version is latest', () { + setUp(() { + when(() => argResults['release-version']).thenReturn('latest'); + }); + + group('when no releases for the target platform exist', () { + setUp(() { + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer( + (_) async => [ + Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + flutterVersion: flutterVersion, + displayName: '1.0.0+1', + platformStatuses: { + ReleasePlatform.windows: ReleaseStatus.active, + }, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ) + ], + ); + }); + + test('warns and exits', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.usage), + ); + + verify( + () => codePushClientWrapper.getReleases(appId: appId), + ).called(1); + verify( + () => logger.warn( + '''No ${releasePlatform.displayName} releases found for app $appId. You must first create a release before you can create a patch.''', + ), + ).called(1); + }); + }); + + group('when multiple releases for the target platform exist', () { + setUp(() { + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer( + (_) async => [ + Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + flutterVersion: flutterVersion, + displayName: releaseVersion, + platformStatuses: { + releasePlatform: ReleaseStatus.active, + }, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), + Release( + id: 1, + appId: appId, + version: '99.99.99+99', + flutterRevision: flutterRevision, + flutterVersion: flutterVersion, + displayName: '99.99.99+99', + platformStatuses: { + releasePlatform: ReleaseStatus.active, + }, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ], + ); + }); + + test('uses the latest version', () async { + await expectLater(runWithOverrides(command.run), completes); + verify( + () => codePushClientWrapper.getReleases(appId: appId), + ).called(1); + verify( + () => patcher.buildPatchArtifact(releaseVersion: releaseVersion), + ).called(1); + }); + }); + }); + group('when release version is not specified', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(false); @@ -1211,4 +1315,23 @@ Please re-run the release command for this version or create a new release.''', }); }); }); + + group('sortByUpdatedAt', () { + test('sorts versions correctly', () { + expect( + [ + _FakeRelease(updatedAt: DateTime(2025, 05, 15)), + _FakeRelease(updatedAt: DateTime(2025, 04, 15)), + _FakeRelease(updatedAt: DateTime(2021, 09, 25)), + _FakeRelease(updatedAt: DateTime(2024)), + ]..sortByUpdatedAt(), + equals([ + _FakeRelease(updatedAt: DateTime(2021, 09, 25)), + _FakeRelease(updatedAt: DateTime(2024)), + _FakeRelease(updatedAt: DateTime(2025, 04, 15)), + _FakeRelease(updatedAt: DateTime(2025, 05, 15)), + ]), + ); + }); + }); }