feat(shorebird_cli): add patches promote command (#2331)

This commit is contained in:
Bryan Oltman
2024-07-09 17:59:56 -04:00
committed by GitHub
parent 3be2c418ab
commit bfc8f4b04f
16 changed files with 492 additions and 37 deletions
@@ -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<Channel?> 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<List<ReleasePatch>> 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<Release> createRelease({
required String appId,
required String version,
@@ -713,7 +730,6 @@ aar artifact already exists, continuing...''',
createArtifactProgress.complete();
}
@visibleForTesting
Future<void> promotePatch({
required String appId,
required int patchId,
@@ -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';
@@ -0,0 +1,2 @@
export 'patches_command.dart';
export 'promote_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';
}
@@ -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<int> 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;
}
}
@@ -76,6 +76,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(LoginCiCommand());
addCommand(LogoutCommand());
addCommand(PatchCommand());
addCommand(PatchesCommand());
addCommand(PreviewCommand());
addCommand(ReleaseCommand());
addCommand(RunCommand());
@@ -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';
@@ -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'))}''',
''
'',
],
),
),
@@ -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>(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);
});
});
});
}
@@ -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 {}
@@ -415,6 +415,24 @@ class CodePushClient {
return decoded.releases;
}
/// Gets [ReleasePatch]es associated with [appId]'s [releaseId].
Future<List<ReleasePatch>> 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<String, dynamic>,
).patches;
}
/// Get all release artifacts for a specific [releaseId]
/// and optional [arch] and [platform].
Future<List<ReleaseArtifact>> getReleaseArtifacts({
@@ -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<CodePushException>().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;
@@ -21,36 +21,3 @@ class GetReleasePatchesResponse {
/// List of patches.
final List<ReleasePatch> 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<String, dynamic> to a [ReleasePatch]
factory ReleasePatch.fromJson(Map<String, dynamic> json) =>
_$ReleasePatchFromJson(json);
/// Converts a [ReleasePatch] to a Map<String, dynamic>
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<PatchArtifact> artifacts;
}
@@ -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';
@@ -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<PatchArtifact> artifacts;
@override
List<Object?> get props => [id, number, channel, artifacts];
}
@@ -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 [],
),
),
);
});
});
}