diff --git a/.gitignore b/.gitignore index 4d5d1896..8774c544 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ Temporary Items /Packages /*.xcodeproj xcuserdata/ -config/auth.yml +/config \ No newline at end of file diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift new file mode 100644 index 00000000..46e3d9f2 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift @@ -0,0 +1,34 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser +import Foundation +import FileSystem + +struct TestFlightPullCommand: CommonParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull down existing TestFlight configuration, refreshing local configuration files." + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/apps", + help: "Path to the folder containing the TestFlight configuration." + ) var outputPath: String + + func run() throws { + let service = try makeService() + + print("Loading server TestFlight configurations... \n") + let configs = try service.pullTestFlightConfigurations() + print("Loading completed.") + + print("\nRefreshing local configurations...") + try configs.save(in: outputPath) + print("Refreshing completed.") + } + +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift new file mode 100644 index 00000000..4e5765ab --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -0,0 +1,312 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser +import struct FileSystem.TestFlightConfiguration +import struct FileSystem.BetaGroup +import struct FileSystem.BetaTester +import Foundation +import Model + +struct TestFlightPushCommand: CommonParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "push", + abstract: "Push the local configuration to TestFlight." + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/apps", + help: "Path to the folder containing the TestFlight configuration." + ) + var inputPath: String + + @Option( + parsing: .upToNextOption, + help: "Array of bundle IDs that uniquely identifies the apps that you would like to sync." + ) + var bundleIds: [String] + + @Flag(help: "Perform a dry run.") + var dryRun: Bool + + func run() throws { + let service = try makeService() + + print("Loading local TestFlight configurations...") + let localConfigurations = try [TestFlightConfiguration](from: inputPath, with: bundleIds) + print("Loading completed.") + + print("\nLoading server TestFlight configurations...") + let serverConfigurations = try service.pullTestFlightConfigurations(with: bundleIds) + print("Loading completed.") + + let actions = compare( + serverConfigurations: serverConfigurations, + with: localConfigurations + ) + + if dryRun { + render(actions: actions) + } else { + try process(actions: actions, with: service) + + print("\nRefreshing local configurations...") + try service.pullTestFlightConfigurations().save(in: inputPath) + print("Refreshing completed.") + } + } + + func render(actions: [AppSyncActions]) { + print("\n'Dry Run' mode activated, changes will not be applied. ") + + actions.forEach { action in + print("\n\(action.app.name ?? ""): ") + + // 1. app testers + if action.appTestersSyncActions.isNotEmpty { + print("\n- Testers in App: ") + action.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } + } + + // 2. BetaGroups in App + if action.betaGroupSyncActions.isNotEmpty { + print("\n- BetaGroups in App: ") + action.betaGroupSyncActions.forEach { + $0.render(dryRun: dryRun) + + if case .create(let betagroup) = $0 { + action.testerInGroupsAction + .append( + .init( + betaGroup: betagroup, + testerActions: betagroup.testers.map { + SyncAction.create($0) + } + ) + ) + } + } + } + + // 3. Testers in BetaGroup + if action.testerInGroupsAction.isNotEmpty { + print("\n- Testers In Beta Group: ") + action.testerInGroupsAction.forEach { + if $0.testerActions.isNotEmpty { + print("\($0.betaGroup.groupName):") + $0.testerActions.forEach { $0.render(dryRun: dryRun) } + } + } + } + } + } + + private func process(actions: [AppSyncActions], with service: AppStoreConnectService) throws { + try actions.forEach { appAction in + print("\n\(appAction.app.name ?? ""): ") + + var appAction = appAction + + // 1. app testers + try processAppTesterActions( + appAction.appTestersSyncActions, + appId: appAction.app.id, + service: service + ) + + // 2. beta groups in app + try processBetagroupsActions( + appAction.betaGroupSyncActions, + appId: appAction.app.id, + appAction: &appAction, + service: service + ) + + // 3. testers in beta group + if appAction.testerInGroupsAction.isNotEmpty { + print("\n- Testers In Beta Group: ") + try appAction.testerInGroupsAction.forEach { + print("\($0.betaGroup.groupName): ") + try processTestersInBetaGroupActions( + $0.testerActions, + betagroupId: $0.betaGroup.id!, + appTesters: appAction.appTesters, + service: service + ) + } + } + } + } + + private func compare( + serverConfigurations: [TestFlightConfiguration], + with localConfigurations: [TestFlightConfiguration] + ) -> [AppSyncActions] { + return serverConfigurations.compactMap { serverConfiguration in + guard + let localConfiguration = localConfigurations + .first(where: { $0.app.id == serverConfiguration.app.id }) else { + return nil + } + + let appTesterSyncActions = SyncResourceComparator( + localResources: localConfiguration.testers, + serverResources: serverConfiguration.testers + ) + .compare() + + let betaGroupSyncActions = SyncResourceComparator( + localResources: localConfiguration.betagroups, + serverResources: serverConfiguration.betagroups + ) + .compare() + + let testerInGroupsAction = localConfiguration.betagroups.compactMap { localBetagroup -> BetaTestersInGroupActions? in + guard + let serverBetaGroup = serverConfiguration + .betagroups + .first(where: { $0.id == localBetagroup.id }) else { + return nil + } + + let testerActions = SyncResourceComparator( + localResources: localBetagroup.testers, + serverResources: serverBetaGroup.testers + ) + .compare() + + if testerActions.isEmpty { return nil } + + return BetaTestersInGroupActions( + betaGroup: localBetagroup, + testerActions: testerActions + ) + } + + guard appTesterSyncActions.isNotEmpty || + betaGroupSyncActions.isNotEmpty || + testerInGroupsAction.isNotEmpty else { + return nil + } + + return AppSyncActions( + app: localConfiguration.app, + appTesters: localConfiguration.testers, + appTestersSyncActions: appTesterSyncActions, + betaGroupSyncActions: betaGroupSyncActions, + testerInGroupsAction: testerInGroupsAction + ) + } + } + + func processAppTesterActions( + _ actions: [SyncAction], + appId: String, + service: AppStoreConnectService + ) throws { + let testersToRemoveActionsWithEmails = actions.compactMap { action -> + (action: SyncAction, email: String)? in + if case .delete(let betaTesters) = action { + return (action, betaTesters.email) + } + return nil + } + + if testersToRemoveActionsWithEmails.isNotEmpty { + print("\n- Testers in App: ") + try service.removeTestersFromApp(testersEmails: testersToRemoveActionsWithEmails.map { $0.email }, appId: appId) + + testersToRemoveActionsWithEmails.map { $0.action }.forEach { $0.render(dryRun: dryRun) } + } + } + + func processBetagroupsActions( + _ actions: [SyncAction], + appId: String, + appAction: inout AppSyncActions, + service: AppStoreConnectService + ) throws { + if actions.isNotEmpty { + print("\n- BetaGroups in App: ") + + try actions.forEach { action in + switch action { + case .create(let betagroup): + let newCreatedBetaGroup = try service.createBetaGroup( + appId: appId, + groupName: betagroup.groupName, + publicLinkEnabled: betagroup.publicLinkEnabled ?? false, + publicLinkLimit: betagroup.publicLinkLimit + ) + action.render(dryRun: dryRun) + + if betagroup.testers.isNotEmpty { + appAction.testerInGroupsAction + .append( + .init( + betaGroup: newCreatedBetaGroup, + testerActions: betagroup.testers.map { + SyncAction.create($0) + } + ) + ) + } + + case .delete(let betagroup): + try service.deleteBetaGroup(with: betagroup.id!) + action.render(dryRun: dryRun) + case .update(let betagroup): + try service.updateBetaGroup(betaGroup: betagroup) + action.render(dryRun: dryRun) + } + } + } + } + + func processTestersInBetaGroupActions( + _ actions: [SyncAction], + betagroupId: String, + appTesters: [BetaTester], + service: AppStoreConnectService + ) throws { + let deletingEmailsWithStrategy: [(email: String, strategy: SyncAction)] = actions + .compactMap { action in + if case .delete(let email) = action { + return (email, action) + } + return nil + } + + if deletingEmailsWithStrategy.isNotEmpty { + try service.removeTestersFromGroup( + emails: deletingEmailsWithStrategy.map { $0.email }, + groupId: betagroupId + ) + + deletingEmailsWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + } + + let creatingTestersWithStrategy = actions + .compactMap { (strategy: SyncAction) -> + (tester: BetaTester, strategy: SyncAction)? in + if case .create(let email) = strategy, + let betatester = appTesters.first(where: { $0.email == email }) { + return (betatester, strategy) + } + return nil + } + + if creatingTestersWithStrategy.isNotEmpty { + try service.inviteTestersToGroup( + betaTesters: creatingTestersWithStrategy.map { $0.tester }, + groupId: betagroupId + ) + + creatingTestersWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + } + } + +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift new file mode 100644 index 00000000..be8870ca --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift @@ -0,0 +1,14 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser + +struct TestFlightSyncCommand: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "sync", + abstract: "Sync information about testflight with provided configuration file.", + subcommands: [ + TestFlightPullCommand.self, + TestFlightPushCommand.self, + ] + ) +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift index 8b0a996b..7ec35b7f 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift @@ -13,6 +13,7 @@ public struct TestFlightCommand: ParsableCommand { TestFlightBetaTestersCommand.self, TestFlightBuildsCommand.self, TestFlightPreReleaseVersionCommand.self, + TestFlightSyncCommand.self, ]) public init() { diff --git a/Sources/AppStoreConnectCLI/Helpers/Array+Helpers.swift b/Sources/AppStoreConnectCLI/Helpers/Array+Helpers.swift new file mode 100644 index 00000000..7b67c70e --- /dev/null +++ b/Sources/AppStoreConnectCLI/Helpers/Array+Helpers.swift @@ -0,0 +1,11 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/Sources/AppStoreConnectCLI/Helpers/Publisher+Helpers.swift b/Sources/AppStoreConnectCLI/Helpers/Publisher+Helpers.swift index e12a1f20..87096baa 100644 --- a/Sources/AppStoreConnectCLI/Helpers/Publisher+Helpers.swift +++ b/Sources/AppStoreConnectCLI/Helpers/Publisher+Helpers.swift @@ -72,3 +72,9 @@ extension Publisher { } } } + +extension Sequence where Element: Publisher { + func merge() -> Publishers.MergeMany { + Publishers.MergeMany(self) + } +} diff --git a/Sources/AppStoreConnectCLI/Model/API/Sales.Filter+ExpressibleByArgument.swift b/Sources/AppStoreConnectCLI/Model/API/Sales.Filter+ExpressibleByArgument.swift index d4a03996..6e36173e 100644 --- a/Sources/AppStoreConnectCLI/Model/API/Sales.Filter+ExpressibleByArgument.swift +++ b/Sources/AppStoreConnectCLI/Model/API/Sales.Filter+ExpressibleByArgument.swift @@ -9,7 +9,7 @@ private typealias Filter = DownloadSalesAndTrendsReports.Filter extension Filter.Frequency: ExpressibleByArgument, CustomStringConvertible { private typealias AllCases = [Filter.Frequency] - public static var allCases: AllCases { + private static var allCases: AllCases { [.DAILY, .MONTHLY, .WEEKLY, .YEARLY] } @@ -25,7 +25,7 @@ extension Filter.Frequency: ExpressibleByArgument, CustomStringConvertible { extension Filter.ReportType: ExpressibleByArgument, CustomStringConvertible { private typealias AllCases = [Filter.ReportType] - public static var allCases: AllCases { + private static var allCases: AllCases { [.SALES, .PRE_ORDER, .NEWSSTAND, .SUBSCRIPTION, .SUBSCRIPTION_EVENT, .SUBSCRIBER] } @@ -41,7 +41,7 @@ extension Filter.ReportType: ExpressibleByArgument, CustomStringConvertible { extension Filter.ReportSubType: ExpressibleByArgument, CustomStringConvertible { private typealias AllCases = [Filter.ReportSubType] - public static var allCases: AllCases { + private static var allCases: AllCases { [.SUMMARY, .DETAILED, .OPT_IN] } diff --git a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift index 979f1982..9ae24598 100755 --- a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift @@ -3,11 +3,11 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation -import struct Model.App -import struct Model.BetaGroup +import FileSystem +import Model import SwiftyTextTable -extension BetaGroup: TableInfoProvider, ResultRenderable { +extension Model.BetaGroup: TableInfoProvider, ResultRenderable { static func tableColumns() -> [TextTableColumn] { [ @@ -40,13 +40,13 @@ extension BetaGroup: TableInfoProvider, ResultRenderable { } } -extension BetaGroup { +extension Model.BetaGroup { init( _ apiApp: AppStoreConnect_Swift_SDK.App, _ apiBetaGroup: AppStoreConnect_Swift_SDK.BetaGroup ) { self.init( - app: App(apiApp), + app: Model.App(apiApp), groupName: apiBetaGroup.attributes?.name, isInternal: apiBetaGroup.attributes?.isInternalGroup, publicLink: apiBetaGroup.attributes?.publicLink, @@ -57,3 +57,34 @@ extension BetaGroup { ) } } + +extension FileSystem.BetaGroup: SyncResourceProcessable { + + var compareIdentity: String { + id ?? "" + } + + var syncResultText: String { + groupName + } + +} + +extension FileSystem.BetaGroup { + init( + _ apiBetaGroup: AppStoreConnect_Swift_SDK.BetaGroup, + testersEmails: [String] + ) { + self.init( + id: apiBetaGroup.id, + groupName: (apiBetaGroup.attributes?.name)!, + isInternal: apiBetaGroup.attributes?.isInternalGroup, + publicLink: apiBetaGroup.attributes?.publicLink, + publicLinkEnabled: apiBetaGroup.attributes?.publicLinkEnabled, + publicLinkLimit: apiBetaGroup.attributes?.publicLinkLimit, + publicLinkLimitEnabled: apiBetaGroup.attributes?.publicLinkLimitEnabled, + creationDate: apiBetaGroup.attributes?.createdDate?.formattedDate, + testers: testersEmails + ) + } +} diff --git a/Sources/AppStoreConnectCLI/Model/BetaTester.swift b/Sources/AppStoreConnectCLI/Model/BetaTester.swift index 18b2930b..2fca0edc 100644 --- a/Sources/AppStoreConnectCLI/Model/BetaTester.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaTester.swift @@ -3,10 +3,11 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation -import struct Model.BetaTester +import FileSystem +import Model import SwiftyTextTable -extension BetaTester { +extension Model.BetaTester { init(_ output: GetBetaTesterOperation.Output) { let attributes = output.betaTester.attributes let relationships = output.betaTester.relationships @@ -35,7 +36,7 @@ extension BetaTester { } } -extension BetaTester: ResultRenderable, TableInfoProvider { +extension Model.BetaTester: ResultRenderable, TableInfoProvider { static func tableColumns() -> [TextTableColumn] { return [ TextTableColumn(header: "Email"), @@ -58,3 +59,41 @@ extension BetaTester: ResultRenderable, TableInfoProvider { ] } } + +extension FileSystem.BetaTester: SyncResourceProcessable { + + var syncResultText: String { + email + } + + var compareIdentity: String { + email + } + +} + +extension FileSystem.BetaTester { + init(_ betaTester: AppStoreConnect_Swift_SDK.BetaTester) { + self.init( + email: (betaTester.attributes?.email)!, + firstName: betaTester.attributes?.firstName, + lastName: betaTester.attributes?.lastName + ) + } +} + +extension String: SyncResourceProcessable { + var syncResultText: String { + self + } + + var compareIdentity: String { + self + } +} + +extension Array where Element == FileSystem.BetaTester { + init(_ sdkBetaTesters: [AppStoreConnect_Swift_SDK.BetaTester]) { + self = sdkBetaTesters.map { FileSystem.BetaTester($0) } + } +} diff --git a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift index bf37fcdf..578e79bd 100644 --- a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift +++ b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift @@ -113,3 +113,31 @@ extension ResultRenderable where Self: TableInfoProvider { return table.render() } } + +protocol SyncResultRenderable: Equatable { + var syncResultText: String { get } +} + +struct SyncResultRenderer { + func render(_ strategy: [SyncAction], isDryRun: Bool) { + strategy.forEach { renderResultText($0, isDryRun) } + } + + func render(_ strategy: SyncAction, isDryRun: Bool) { + renderResultText(strategy, isDryRun) + } + + private func renderResultText(_ strategy: SyncAction, _ isDryRun: Bool) { + let resultText: String + switch strategy { + case .create(let input): + resultText = "➕ \(input.syncResultText)" + case .delete(let input): + resultText = "➖\(input.syncResultText)" + case .update(let input): + resultText = "⬆️ \(input.syncResultText)" + } + + print("\(isDryRun ? "" : "✅") \(resultText)") + } +} diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 8a63e56e..2e1fa7c6 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -3,6 +3,7 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation +import FileSystem import Model class AppStoreConnectService { @@ -158,6 +159,29 @@ class AppStoreConnectService { return Model.BetaTester(output) } + func inviteTestersToGroup( + betaTesters: [FileSystem.BetaTester], + groupId: String + ) throws { + _ = try betaTesters + .chunked(into: 5) + .map { + try $0.map { + try InviteTesterOperation( + options: .init( + firstName: $0.firstName, + lastName: $0.lastName, + email: $0.email, + identifers: .resourceId([groupId]) + ) + ) + .execute(with: requestor) + } + .merge() + .awaitMany() + } + } + func addTestersToGroup( bundleId: String, groupName: String, @@ -191,6 +215,27 @@ class AppStoreConnectService { .await() } + func addTestersToGroup( + groupId: String, + emails: [String] + ) throws { + let testerIds = try emails.map { + try GetBetaTesterOperation(options: .init(identifier: .email($0))) + .execute(with: requestor) + .await() + .betaTester + .id + } + + try AddTesterToGroupOperation( + options: .init( + addStrategy: .addTestersToGroup(testerIds: testerIds, groupId: groupId) + ) + ) + .execute(with: requestor) + .await() + } + func addTesterToGroups( email: String, bundleId: String, @@ -401,6 +446,62 @@ class AppStoreConnectService { try operation.execute(with: requestor).await() } + func removeTestersFromGroup(emails: [String], groupId: String) throws { + let testerIds = try emails + .chunked(into: 5) + .flatMap { + try $0.map { + try GetBetaTesterOperation( + options: .init(identifier: .email($0)) + ) + .execute(with: requestor) + } + .merge() + .awaitMany() + .map { $0.betaTester.id } + } + + let operation = RemoveTesterOperation( + options: .init( + removeStrategy: .removeTestersFromGroup(testerIds: testerIds, groupId: groupId) + ) + ) + + try operation.execute(with: requestor).await() + } + + func removeTestersFromApp(testersEmails: [String], appId: String) throws { + let testerIds = try testersEmails + .chunked(into: 5) + .flatMap { + try $0.map { + try GetBetaTesterOperation( + options: .init( + identifier: .email($0), + limitApps: nil, + limitBuilds: nil, + limitBetaGroups: nil + ) + ) + .execute(with: requestor) + .map { $0.betaTester.id } + } + .merge() + .awaitMany() + } + + try RemoveTesterOperation( + options: .init( + removeStrategy: .removeTestersFromApp( + testerIds: testerIds, + appId: appId + ) + ) + ) + .execute(with: requestor) + .await() + } + func readBetaGroup(bundleId: String, groupName: String) throws -> Model.BetaGroup { let app = try ReadAppOperation(options: .init(identifier: .bundleId(bundleId))) .execute(with: requestor) @@ -436,6 +537,32 @@ class AppStoreConnectService { return try betaGroupResponse.map(Model.BetaGroup.init).await() } + func createBetaGroup( + appId: String, + groupName: String, + publicLinkEnabled: Bool, + publicLinkLimit: Int? + ) throws -> FileSystem.BetaGroup { + let sdkGroup = try CreateBetaGroupWithAppIdOperation( + options: .init( + appId: appId, + groupName: groupName, + publicLinkEnabled: publicLinkEnabled, + publicLinkLimit: publicLinkLimit + ) + ) + .execute(with: requestor) + .await() + + return FileSystem.BetaGroup(sdkGroup, testersEmails: []) + } + + func updateBetaGroup(betaGroup: FileSystem.BetaGroup) throws { + _ = try UpdateBetaGroupOperation(options: .init(betaGroup: betaGroup)) + .execute(with: requestor) + .await() + } + func deleteBetaGroup(appBundleId: String, betaGroupName: String) throws { let appId = try GetAppsOperation(options: .init(bundleIds: [appBundleId])) .execute(with: requestor) @@ -454,6 +581,12 @@ class AppStoreConnectService { .await() } + func deleteBetaGroup(with id: String) throws { + try DeleteBetaGroupOperation(options: .init(betaGroupId: id)) + .execute(with: requestor) + .await() + } + func listBetaGroups( filterIdentifiers: [AppLookupIdentifier], names: [String], @@ -857,6 +990,58 @@ class AppStoreConnectService { .await() } + func populateFileSystemBetaGroup(from sdkGroup: AppStoreConnect_Swift_SDK.BetaGroup) -> AnyPublisher { + Just(sdkGroup) + .setFailureType(to: Error.self) + .combineLatest( + ListBetaTestersByGroupOperation( + options: .init(groupId: sdkGroup.id) + ) + .execute(with: requestor) + ) + .map { (sdkGroup, testers) -> FileSystem.BetaGroup in + FileSystem.BetaGroup( + sdkGroup, + testersEmails: testers.compactMap { $0.attributes?.email } + ) + } + .eraseToAnyPublisher() + } + + func pullTestFlightConfigurations(with bundleIds: [String] = []) throws -> [TestFlightConfiguration] { + let apps = try listApps(bundleIds: bundleIds, names: [], skus: [], limit: nil) + + let configurations: [TestFlightConfiguration] = try apps.map { app in + let appTesters = try ListBetaTestersOperation( + options: .init(appIds: [app.id]) + ) + .execute(with: self.requestor) + .map { $0.compactMap { $0.betaTester } } + .await() + + let fileSystemBetaGroups = try ListBetaGroupsOperation( + options: .init(appIds: [app.id], names: [], sort: nil) + ) + .execute(with: self.requestor) + .await() + .map { $0.betaGroup } + .chunked(into: 5) + .flatMap { + try $0.map(self.populateFileSystemBetaGroup) + .merge() + .awaitMany() + } + + return TestFlightConfiguration( + app: app, + testers: [FileSystem.BetaTester](appTesters), + betagroups: fileSystemBetaGroups + ) + } + + return configurations + } + /// Make a request for something `Decodable`. /// /// - Parameters: diff --git a/Sources/AppStoreConnectCLI/Services/Operations/CreateBetaGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/CreateBetaGroupOperation.swift index d3a2c54d..11d02f75 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/CreateBetaGroupOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/CreateBetaGroupOperation.swift @@ -39,3 +39,36 @@ struct CreateBetaGroupOperation: APIOperation { .eraseToAnyPublisher() } } + +struct CreateBetaGroupWithAppIdOperation: APIOperation { + + struct Options { + let appId: String + let groupName: String + let publicLinkEnabled: Bool + let publicLinkLimit: Int? + } + + typealias BetaGroup = AppStoreConnect_Swift_SDK.BetaGroup + + private let options: Options + + init(options: Options) { + self.options = options + } + + func execute(with requestor: EndpointRequestor) -> AnyPublisher { + let endpoint = APIEndpoint.create( + betaGroupForAppWithId: options.appId, + name: options.groupName, + publicLinkEnabled: options.publicLinkEnabled, + publicLinkLimit: options.publicLinkLimit, + publicLinkLimitEnabled: options.publicLinkLimit != nil + ) + + return requestor + .request(endpoint) + .map { $0.data } + .eraseToAnyPublisher() + } +} diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift index 621b9890..68967d5d 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift @@ -19,9 +19,9 @@ struct ListBetaTestersByGroupOperation: APIOperation { self.options = options } - func execute(with requestor: EndpointRequestor) throws -> AnyPublisher { + func execute(with requestor: EndpointRequestor) -> AnyPublisher { requestor.requestAllPages { - .betaTesters(inBetaGroupWithId: self.options.groupId, next: $0) + .betaTesters(inBetaGroupWithId: self.options.groupId, limit: 200, next: $0) } .map { $0.flatMap(\.data) } .eraseToAnyPublisher() diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift index 1f246525..b8ea33c7 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift @@ -7,26 +7,15 @@ import Foundation struct ListBetaTestersOperation: APIOperation { struct Options { - let email: String? - let firstName: String? - let lastName: String? - let inviteType: BetaInviteType? - let appIds: [String]? - let groupIds: [String]? - let sort: ListBetaTesters.Sort? - let limit: Int? - let relatedResourcesLimit: Int? - } - - enum Error: LocalizedError { - case notFound - - var errorDescription: String? { - switch self { - case .notFound: - return "Beta testers with provided filters not found." - } - } + var email: String? + var firstName: String? + var lastName: String? + var inviteType: BetaInviteType? + var appIds: [String]? + var groupIds: [String]? + var sort: ListBetaTesters.Sort? + var limit: Int? + var relatedResourcesLimit: Int? } private let options: Options @@ -94,11 +83,27 @@ struct ListBetaTestersOperation: APIOperation { func execute(with requestor: EndpointRequestor) throws -> AnyPublisher { let filters = self.filters - let limits = self.limits + var limits = self.limits let sorts = self.sorts let includes: [ListBetaTesters.Include] = [.apps, .betaGroups] - return requestor.requestAllPages { + if options.limit != nil { + return requestor.request( + .betaTesters( + filter: filters, + include: includes, + limit: self.limits, + sort: sorts + ) + ) + .map { (response: BetaTestersResponse) -> Output in + return response.data.map { .init(betaTester: $0, includes: response.included) } + } + .eraseToAnyPublisher() + } else { + limits.append(.betaTesters(200)) + + return requestor.requestAllPages { .betaTesters( filter: filters, include: includes, @@ -108,17 +113,14 @@ struct ListBetaTestersOperation: APIOperation { ) } .tryMap { (responses: [BetaTestersResponse]) throws -> Output in - try responses.flatMap { (response: BetaTestersResponse) -> Output in - guard !response.data.isEmpty else { - throw Error.notFound - } - + responses.flatMap { (response: BetaTestersResponse) -> Output in return response.data.map { .init(betaTester: $0, includes: response.included) } } } .eraseToAnyPublisher() + } } } diff --git a/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift index 84222210..40d58353 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift @@ -10,6 +10,7 @@ struct RemoveTesterOperation: APIOperation { enum RemoveStrategy { case removeTestersFromGroup(testerIds: [String], groupId: String) case removeTesterFromGroups(testerId: String, groupIds: [String]) + case removeTestersFromApp(testerIds: [String], appId: String) } let removeStrategy: RemoveStrategy @@ -23,6 +24,8 @@ struct RemoveTesterOperation: APIOperation { return APIEndpoint.remove(betaTesterWithId: testerId, fromBetaGroupsWithIds: groupIds) case .removeTestersFromGroup(let testerIds, let groupId): return APIEndpoint.remove(betaTestersWithIds: testerIds, fromBetaGroupWithId: groupId) + case .removeTestersFromApp(let testerIds, let appId): + return APIEndpoint.remove(betaTestersWithIds: testerIds, fromGroupsAndBuildsOfAppWithId: appId) } } diff --git a/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift new file mode 100644 index 00000000..b8ff4e9e --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift @@ -0,0 +1,34 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import AppStoreConnect_Swift_SDK +import Combine +import Foundation +import struct FileSystem.BetaGroup + +struct UpdateBetaGroupOperation: APIOperation { + + struct Options { + let betaGroup: BetaGroup + } + + private let options: Options + + init(options: Options) { + self.options = options + } + + func execute(with requestor: EndpointRequestor) throws -> AnyPublisher { + let betaGroup = options.betaGroup + + let endpoint = APIEndpoint.modify( + betaGroupWithId: betaGroup.id!, + name: betaGroup.groupName, + publicLinkEnabled: betaGroup.publicLinkEnabled, + publicLinkLimit: betaGroup.publicLinkLimit, + publicLinkLimitEnabled: betaGroup.publicLinkLimitEnabled + ) + + return requestor.request(endpoint).eraseToAnyPublisher() + } + +} diff --git a/Sources/AppStoreConnectCLI/Services/SyncActions.swift b/Sources/AppStoreConnectCLI/Services/SyncActions.swift new file mode 100644 index 00000000..f68fbdfb --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/SyncActions.swift @@ -0,0 +1,65 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation +import FileSystem +import Model + +class AppSyncActions { + var app: Model.App + var appTesters: [FileSystem.BetaTester] + + var appTestersSyncActions: [SyncAction] + var betaGroupSyncActions: [SyncAction] + + var testerInGroupsAction: [BetaTestersInGroupActions] + + init( + app: Model.App, + appTesters: [FileSystem.BetaTester], + appTestersSyncActions: [SyncAction], + betaGroupSyncActions: [SyncAction], + testerInGroupsAction: [BetaTestersInGroupActions] + ) { + self.app = app + self.appTesters = appTesters + self.appTestersSyncActions = appTestersSyncActions + self.betaGroupSyncActions = betaGroupSyncActions + self.testerInGroupsAction = testerInGroupsAction + } +} + +struct BetaTestersInGroupActions { + let betaGroup: FileSystem.BetaGroup + let testerActions: [SyncAction] +} + +extension SyncAction where T == FileSystem.BetaGroup { + func render(dryRun: Bool) { + switch self { + case .create, .delete, .update: + SyncResultRenderer().render(self, isDryRun: dryRun) + } + } +} + +extension SyncAction where T == FileSystem.BetaTester { + func render(dryRun: Bool) { + switch self { + case .delete: + SyncResultRenderer().render(self, isDryRun: dryRun) + default: + return + } + } +} + +extension SyncAction where T == FileSystem.BetaGroup.EmailAddress { + func render(dryRun: Bool) { + switch self { + case .create, .delete: + SyncResultRenderer().render(self, isDryRun: dryRun) + default: + return + } + } +} diff --git a/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift new file mode 100644 index 00000000..e6275690 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift @@ -0,0 +1,48 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +enum SyncAction: Equatable { + case delete(T) + case create(T) + case update(T) +} + +protocol SyncResourceProcessable: SyncResourceComparable, SyncResultRenderable { } + +protocol SyncResourceComparable: Hashable { + associatedtype T: Comparable + + var compareIdentity: T { get } +} + +struct SyncResourceComparator { + + let localResources: [T] + let serverResources: [T] + + private var localResourcesSet: Set { Set(localResources) } + private var serverResourcesSet: Set { Set(serverResources) } + + func compare() -> [SyncAction] { + serverResourcesSet + .subtracting(localResourcesSet) + .compactMap { resource -> SyncAction? in + localResources + .contains(where: { resource.compareIdentity == $0.compareIdentity }) + ? nil + : .delete(resource) + } + + + localResourcesSet + .subtracting(serverResourcesSet) + .compactMap { resource -> SyncAction? in + serverResourcesSet + .contains( + where: { resource.compareIdentity == $0.compareIdentity } + ) + ? .update(resource) + : .create(resource) + } + } +} diff --git a/Sources/FileSystem/Helpers/String+Helpers.swift b/Sources/FileSystem/Helpers/String+Helpers.swift new file mode 100644 index 00000000..8fd90e30 --- /dev/null +++ b/Sources/FileSystem/Helpers/String+Helpers.swift @@ -0,0 +1,10 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +extension String { + func filenameSafe() -> String { + let unsafeFilenameCharacters = CharacterSet(charactersIn: " *?:/\\.") + return self.components(separatedBy: unsafeFilenameCharacters).joined(separator: "_") + } +} diff --git a/Sources/FileSystem/Model/BetaGroup.swift b/Sources/FileSystem/Model/BetaGroup.swift new file mode 100644 index 00000000..fc2144c3 --- /dev/null +++ b/Sources/FileSystem/Model/BetaGroup.swift @@ -0,0 +1,58 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +public struct BetaGroup: Codable, Equatable { + + public typealias EmailAddress = String + + public var id: String? + public var groupName: String + public var isInternal: Bool? + public var publicLink: String? + public var publicLinkEnabled: Bool? + public var publicLinkLimit: Int? + public var publicLinkLimitEnabled: Bool? + public var creationDate: String? + public var testers: [EmailAddress] + + public init( + id: String?, + groupName: String, + isInternal: Bool?, + publicLink: String?, + publicLinkEnabled: Bool?, + publicLinkLimit: Int?, + publicLinkLimitEnabled: Bool?, + creationDate: String?, + testers: [String] = [] + ) { + self.id = id + self.groupName = groupName + self.isInternal = isInternal + self.publicLink = publicLink + self.publicLinkEnabled = publicLinkEnabled + self.publicLinkLimit = publicLinkLimit + self.publicLinkLimitEnabled = publicLinkLimitEnabled + self.creationDate = creationDate + self.testers = testers + } +} + +extension BetaGroup: Hashable { + public static func == (lhs: BetaGroup, rhs: BetaGroup) -> Bool { + return lhs.id == rhs.id && + lhs.groupName == rhs.groupName && + lhs.publicLinkEnabled == rhs.publicLinkEnabled && + lhs.publicLinkLimit == rhs.publicLinkLimit && + lhs.publicLinkLimitEnabled == rhs.publicLinkLimitEnabled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(groupName) + hasher.combine(publicLinkEnabled) + hasher.combine(publicLinkLimit) + hasher.combine(publicLinkLimitEnabled) + } +} diff --git a/Sources/FileSystem/Model/BetaTester.swift b/Sources/FileSystem/Model/BetaTester.swift new file mode 100644 index 00000000..b0e6f92d --- /dev/null +++ b/Sources/FileSystem/Model/BetaTester.swift @@ -0,0 +1,54 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import CodableCSV +import Foundation +import Model + +public struct BetaTester: Codable, Equatable, Hashable { + public var email: String + public var firstName: String + public var lastName: String + + public init( + email: String, + firstName: String?, + lastName: String? + ) { + self.email = email + self.firstName = firstName ?? "" + self.lastName = lastName ?? "" + } +} + +extension BetaTester { + + private enum CodingKeys: String, CodingKey { + case email = "Email" + case firstName = "First Name" + case lastName = "Last Name" + } + +} + +protocol CSVRenderable: Codable { + var headers: [String] { get } + var rows: [[String]] { get } +} + +extension CSVRenderable { + func renderAsCSV() -> String { + let wholeTable = [headers] + rows + + return try! CSVWriter.encode(rows: wholeTable, into: String.self) // swiftlint:disable:this force_try + } +} + +extension Array: CSVRenderable where Element == BetaTester { + var headers: [String] { + ["Email", "First Name", "Last Name"] + } + + var rows: [[String]] { + self.map { [$0.email, $0.firstName, $0.lastName] } + } +} diff --git a/Sources/FileSystem/Model/TestFlightConfiguration.swift b/Sources/FileSystem/Model/TestFlightConfiguration.swift new file mode 100644 index 00000000..dae08f8b --- /dev/null +++ b/Sources/FileSystem/Model/TestFlightConfiguration.swift @@ -0,0 +1,92 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Files +import Foundation +import Model +import Yams + +public struct TestFlightConfiguration: Codable, Equatable { + public let app: Model.App + public let testers: [BetaTester] + public let betagroups: [BetaGroup] + + public init( + app: Model.App, + testers: [BetaTester], + betagroups: [BetaGroup] + ) { + self.app = app + self.testers = testers + self.betagroups = betagroups + } +} + +extension TestFlightConfiguration { + func save(in appFolder: Folder) throws { + let appFile = try appFolder.createFile(named: "app.yml") + try appFile.write(try YAMLEncoder().encode(self.app)) + + let testersFile = try appFolder.createFile(named: "beta-testers.csv") + try testersFile.write(self.testers.renderAsCSV()) + + let groupFolder = try appFolder.createSubfolder(named: "betagroups") + + try self.betagroups.forEach { + try groupFolder + .createFile(named: "\($0.groupName.filenameSafe()).yml") + .append(try YAMLEncoder().encode($0)) + } + } + + init(from appFolder: Folder) throws { + let appFile = try appFolder.file(named: "app.yml") + let app: Model.App = Readers.FileReader(format: .yaml) + .readYAML(from: appFile.path) + + let testersFile = try appFolder.file(named: "beta-testers.csv") + + let testers: [BetaTester] = Readers.FileReader<[BetaTester]>(format: .csv) + .readCSV(from: testersFile.path) + + let betagroupsFolder = try appFolder.subfolder(named: "betagroups") + let betagroups: [BetaGroup] = betagroupsFolder.files.map { + Readers + .FileReader(format: .yaml) + .readYAML(from: $0.path) + } + + self = TestFlightConfiguration(app: app, testers: testers, betagroups: betagroups) + } +} + +extension Array where Element == TestFlightConfiguration { + public func save(in appsFolderPath: String) throws { + let appsFolder = try Folder(path: appsFolderPath) + + try appsFolder.delete() + + try self.forEach { + let appFolder = try appsFolder.createSubfolder(named: $0.app.bundleId!) + try $0.save(in: appFolder) + } + } + + public init(from appsFolderPath: String) throws { + self = try Folder(path: appsFolderPath).subfolders.map { + try TestFlightConfiguration(from: $0) + } + } + + public init(from appsFolderPath: String, with buildIds: [String]) throws { + if buildIds.isEmpty { + try self.init(from: appsFolderPath) + } else { + self = try Folder(path: appsFolderPath).subfolders.compactMap { + if buildIds.contains($0.name) { + return try TestFlightConfiguration(from: $0) + } + return nil + } + } + } +} diff --git a/Sources/FileSystem/Readers.swift b/Sources/FileSystem/Readers.swift index c792bccc..c10eb647 100644 --- a/Sources/FileSystem/Readers.swift +++ b/Sources/FileSystem/Readers.swift @@ -60,8 +60,7 @@ public enum Readers { } guard - let url = URL(string: "file://\(filePath)"), - let result = try? decoder.decode(T.self, from: url) else { + let result = try? decoder.decode(T.self, from: URL(fileURLWithPath: filePath)) else { fatalError("Could not read CSV file: \(filePath)") } diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift new file mode 100644 index 00000000..38dea22d --- /dev/null +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift @@ -0,0 +1,63 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +@testable import AppStoreConnectCLI +import FileSystem +import Foundation +import XCTest + +final class ResourceComparatorCompareGroupsTests: XCTestCase { + + func testCompareBetaGroups() { + let localBetaGroups = [ + BetaGroup(id: nil, name: "group to create", publicLinkEnabled: true), + BetaGroup(id: "1002", name: "group to update", publicLinkEnabled: false), + ] + + let serverBetaGroups = [ + BetaGroup(id: "1002", name: "group to update", publicLinkEnabled: true), + BetaGroup(id: "1003", name: "group to delete", publicLinkEnabled: true), + ] + + let strategies = SyncResourceComparator( + localResources: localBetaGroups, + serverResources: serverBetaGroups + ) + .compare() + + XCTAssertEqual(strategies.count, 3) + + XCTAssertTrue(strategies.contains(where: { + $0 == .delete(serverBetaGroups[1]) + })) + + XCTAssertTrue(strategies.contains(where: { + $0 == .create(localBetaGroups[0]) + })) + + XCTAssertTrue(strategies.contains(where: { + $0 == .update(localBetaGroups[1]) + })) + } + +} + +private extension BetaGroup { + init( + id: String?, + name: String, + publicLinkEnabled: Bool = true, + publicLinkLimitEnabled: Bool = true + ) { + self = BetaGroup( + id: id, + groupName: name, + isInternal: true, + publicLink: "", + publicLinkEnabled: publicLinkEnabled, + publicLinkLimit: 10, + publicLinkLimitEnabled: publicLinkLimitEnabled, + creationDate: "", + testers: [] + ) + } +} diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift new file mode 100644 index 00000000..b530fc74 --- /dev/null +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift @@ -0,0 +1,63 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +@testable import AppStoreConnectCLI +import FileSystem +import Foundation +import XCTest + +final class ResourceComparatorCompareTestersTests: XCTestCase { + + func testCompareTesters() { + let serverTesters = [ + BetaTester( + email: "foo@gmail.com", + firstName: nil, + lastName: nil), + BetaTester( + email: "bar@gmail.com", + firstName: nil, + lastName: nil), + ] + + let localTesters: [BetaTester] = [] + + let strategies = SyncResourceComparator(localResources: localTesters, serverResources: serverTesters).compare() + + XCTAssertEqual(strategies.count, 2) + + XCTAssertTrue(strategies.contains(where: { + $0 == .delete(serverTesters[0]) + })) + + XCTAssertTrue(strategies.contains(where: { + $0 == .delete(serverTesters[1]) + })) + } + + func testCompareTestersInGroups() { + let serverTestersInGroup = ["foo@gmail.com", "bar@gmail.com"] + + let localTestersInGroup = ["hi@gmail.com", "hello@gmail.com", "foo@gmail.com"] + + let strategies = SyncResourceComparator( + localResources: localTestersInGroup, + serverResources: serverTestersInGroup + ) + .compare() + + XCTAssertEqual(strategies.count, 3) + + XCTAssertTrue(strategies.contains(where: { + $0 == .delete(serverTestersInGroup[1]) + })) + + XCTAssertTrue(strategies.contains(where: { + $0 == .create(localTestersInGroup[0]) + })) + + XCTAssertTrue(strategies.contains(where: { + $0 == .create(localTestersInGroup[1]) + })) + } + +} diff --git a/config/apps/codes.orj.app1/betaGroups.yml b/config/apps/codes.orj.app1/betaGroups.yml deleted file mode 100644 index 5b9c69e5..00000000 --- a/config/apps/codes.orj.app1/betaGroups.yml +++ /dev/null @@ -1,2 +0,0 @@ -- name: Test Group - publicLinkEnabled: false