feat(shorebird_cli): add patches promote command (#2331)
This commit is contained in:
@@ -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;
|
||||
|
||||
-33
@@ -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 [],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user