From e97265138fe789446b263e052393d105c855f9b8 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 10 Jul 2020 15:50:21 +1000 Subject: [PATCH 01/28] Add ./config to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 114d95b0c89790639d89b64ae7a1cb1973500563 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 10 Jul 2020 15:51:07 +1000 Subject: [PATCH 02/28] Create TestFlightConfig and Loader with new Filesystem models --- Sources/FileSystem/Model/BetaGroup.swift | 40 ++++++++++++ Sources/FileSystem/Model/BetaTester.swift | 59 +++++++++++++++++ .../Model/TestFlightConfiguration.swift | 22 +++++++ .../TestFlight/TestFlightConfigLoader.swift | 63 +++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 Sources/FileSystem/Model/BetaGroup.swift create mode 100644 Sources/FileSystem/Model/BetaTester.swift create mode 100644 Sources/FileSystem/Model/TestFlightConfiguration.swift create mode 100644 Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift diff --git a/Sources/FileSystem/Model/BetaGroup.swift b/Sources/FileSystem/Model/BetaGroup.swift new file mode 100644 index 00000000..86f922a6 --- /dev/null +++ b/Sources/FileSystem/Model/BetaGroup.swift @@ -0,0 +1,40 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +public struct BetaGroup: Codable, Equatable, Hashable { + + public typealias EmailAddress = String + + public let id: String + public let groupName: String + public let isInternal: Bool? + public let publicLink: String? + public let publicLinkEnabled: Bool? + public let publicLinkLimit: Int? + public let publicLinkLimitEnabled: Bool? + public let creationDate: String? + public let 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 + } +} diff --git a/Sources/FileSystem/Model/BetaTester.swift b/Sources/FileSystem/Model/BetaTester.swift new file mode 100644 index 00000000..d289878a --- /dev/null +++ b/Sources/FileSystem/Model/BetaTester.swift @@ -0,0 +1,59 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import CodableCSV +import Foundation +import Model + +public struct BetaTester: Codable, Equatable, Hashable { + public let email: String + public let firstName: String + public let lastName: String + public let inviteType: String + + public init( + email: String, + firstName: String?, + lastName: String?, + inviteType: String? + ) { + self.email = email + self.firstName = firstName ?? "" + self.lastName = lastName ?? "" + self.inviteType = inviteType ?? "" + } +} + +extension BetaTester { + + private enum CodingKeys: String, CodingKey { + case email = "Email" + case firstName = "First Name" + case lastName = "Last Name" + case inviteType = "Invite Type" + } + +} + + +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", "Invite Type"] + } + + var rows: [[String]] { + self.map { [$0.email, $0.firstName, $0.lastName, $0.inviteType].compactMap { $0 } } + } +} diff --git a/Sources/FileSystem/Model/TestFlightConfiguration.swift b/Sources/FileSystem/Model/TestFlightConfiguration.swift new file mode 100644 index 00000000..05c70399 --- /dev/null +++ b/Sources/FileSystem/Model/TestFlightConfiguration.swift @@ -0,0 +1,22 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation +import Model + +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 + } + +} diff --git a/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift b/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift new file mode 100644 index 00000000..8c60cd10 --- /dev/null +++ b/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift @@ -0,0 +1,63 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation +import Files +import Model +import Yams + +public struct TestFlightConfigLoader { + + public init() { } + + public func load(appsFolderPath: String) throws -> [TestFlightConfiguration] { + try Folder(path: appsFolderPath).subfolders.map { + try load(in: $0) + } + } + + func load(in appFolder: Folder) throws -> TestFlightConfiguration { + + 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) + } + + return TestFlightConfiguration(app: app, testers: testers, betagroups: betagroups) + } + + func save(_ config: TestFlightConfiguration, in appFolder: Folder) throws { + let appFile = try appFolder.createFile(named: "app.yml") + try appFile.write(try YAMLEncoder().encode(config.app)) + + let testersFile = try appFolder.createFile(named: "beta-testers.csv") + try testersFile.write(config.testers.renderAsCSV()) + + let groupFolder = try appFolder.createSubfolder(named: "betagroups") + try config.betagroups.forEach { + try groupFolder.createFile(named: "\($0.groupName.filenameSafe()).yml").append(try YAMLEncoder().encode($0)) + } + } + + public func save(_ config: [TestFlightConfiguration], in appsFolderPath: String) throws { + let appsFolder = try Folder(path: appsFolderPath) + + try appsFolder.delete() + + try config.forEach { + let appFolder = try appsFolder.createSubfolder(named: $0.app.bundleId!) + + try save($0, in: appFolder) + } + } +} From abefab7b5f4eb5567b4b0e62933638287bb35ae4 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 10 Jul 2020 15:52:45 +1000 Subject: [PATCH 03/28] Create SyncResourceComparator for comparing local and server res --- .../Readers and Renderers/Renderers.swift | 29 +++++++++ .../Services/SyncResourceComparator.swift | 48 ++++++++++++++ .../Sync/SyncTestFlightTestersTests.swift | 62 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift create mode 100644 Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift diff --git a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift index bf37fcdf..418346a2 100644 --- a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift +++ b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift @@ -113,3 +113,32 @@ extension ResultRenderable where Self: TableInfoProvider { return table.render() } } + +protocol SyncResultRenderable: Equatable { + var syncResultText: String { get } +} + +struct SyncResultRenderer { + + func render(_ strategy: [SyncStrategy], isDryRun: Bool) { + strategy.forEach { renderResultText($0, isDryRun) } + } + + func render(_ strategy: SyncStrategy, isDryRun: Bool) { + renderResultText(strategy, isDryRun) + } + + private func renderResultText(_ strategy: SyncStrategy, _ 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/SyncResourceComparator.swift b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift new file mode 100644 index 00000000..54e5db99 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift @@ -0,0 +1,48 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +enum SyncStrategy { + 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() -> [SyncStrategy] { + serverResourcesSet + .subtracting(localResourcesSet) + .compactMap { resource -> SyncStrategy? in + localResources + .contains(where: { resource.compareIdentity == $0.compareIdentity }) + ? nil + : .delete(resource) + } + + + localResourcesSet + .subtracting(serverResourcesSet) + .compactMap { resource -> SyncStrategy? in + serverResourcesSet + .contains( + where: { resource.compareIdentity == $0.compareIdentity } + ) + ? .update(resource) + : .create(resource) + } + } +} diff --git a/Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift new file mode 100644 index 00000000..ed8987c8 --- /dev/null +++ b/Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift @@ -0,0 +1,62 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +@testable import AppStoreConnectCLI +import FileSystem +import Foundation +import XCTest + +final class SyncTestFlightTestersTests: XCTestCase { + + func testCompareTesters() throws { + let serverTesters = [ + BetaTester( + email: "foo@gmail.com", + firstName: nil, + lastName: nil, + inviteType: nil), + BetaTester( + email: "bar@gmail.com", + firstName: nil, + lastName: nil, + inviteType: 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 testCompareBetaGroups() { + // TODO + } + + func testCompareTestersInGroup() { + // TODO + } + +} + +extension SyncStrategy: Equatable { + public static func == (lhs: SyncStrategy, rhs: SyncStrategy) -> Bool { + switch (lhs, rhs) { + case (let .create(lhsItem), let .create(rhsItem)): + return lhsItem == rhsItem + case (let .update(lhsItem), let .update(rhsItem)): + return lhsItem == rhsItem + case (let .delete(lhsItem), let .delete(rhsItem)): + return lhsItem == rhsItem + default: + return false + } + } +} From 53c6436841b069d3adcc340ee657ab2965ea03b2 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 10 Jul 2020 15:53:42 +1000 Subject: [PATCH 04/28] Create essential pull TestFlight config service func with sync commands --- .../Sync/TestFlightPullCommand.swift | 35 +++++ .../Sync/TestFlightPushCommand.swift | 148 ++++++++++++++++++ .../Sync/TestFlightSyncCommand.swift | 14 ++ .../TestFlight/TestFlightCommand.swift | 1 + .../AppStoreConnectCLI/Model/BetaGroup.swift | 13 ++ .../AppStoreConnectCLI/Model/BetaTester.swift | 23 +++ .../Services/AppStoreConnectService.swift | 57 +++++++ 7 files changed, 291 insertions(+) create mode 100644 Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift create mode 100644 Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift create mode 100644 Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift new file mode 100644 index 00000000..5ca5f0c2 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift @@ -0,0 +1,35 @@ +// 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 configs, refreshing local config files" + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/apps", + help: "Path to the Folder containing the testflight configs." + ) var outputPath: String + + func run() throws { + let service = try makeService() + + let configs = try service.pullTestFlightConfigs() + + configs.forEach { + print($0.app.name) + print($0.betagroups.count) + } + + try TestFlightConfigLoader().save(configs, in: outputPath) + } + +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift new file mode 100644 index 00000000..6542cca0 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -0,0 +1,148 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser +import FileSystem +import Foundation + +struct TestFlightPushCommand: CommonParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "push", + abstract: "Push local testflight config files to server, update server configs" + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/apps", + help: "Path to the Folder containing the testflight configs." + ) var inputPath: String + + @Flag(help: "Perform a dry run.") + var dryRun: Bool + + func run() throws { + let service = try makeService() + + let localConfigs = try TestFlightConfigLoader().load(appsFolderPath: inputPath) + + let serverConfigs = try service.pullTestFlightConfigs() + + serverConfigs.forEach { serverConfig in + guard + let localConfig = localConfigs + .first(where: { $0.app.id == serverConfig.app.id }) else { + return + } + + let appId = localConfig.app.id + + // 1. compare shared testers in app + let sharedTestersHandleStrategies = SyncResourceComparator( + localResources: localConfig.testers, + serverResources: serverConfig.testers + ).compare() + + // 1.1 handle shared testers delete only + processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId) + + + // 2. compare beta groups + let localBetagroups = localConfig.betagroups + let serverBetagroups = serverConfig.betagroups + + let betaGroupHandlingStrategies = SyncResourceComparator( + localResources: localBetagroups, + serverResources: serverBetagroups + ) + .compare() + + // 2.1 handle groups create, update, delete + processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId) + + + // 3. compare testers in group and add, delete + localBetagroups.forEach { localBetagroup in + guard let serverBetagroup = serverBetagroups + .first(where: { $0.id == localBetagroup.id } ) else { + return + } + + let betagroupId = serverBetagroup.id + + let localGroupTesters = localBetagroup.testers + + let serverGroupTesters = serverBetagroup.testers + + let testersInGroupHandlingStrategies = SyncResourceComparator( + localResources: localGroupTesters, + serverResources: serverGroupTesters + ).compare() + + // 3.1 handling adding/deleting testers per group + processTestersInBetaGroupStrategies(testersInGroupHandlingStrategies, betagroupId: betagroupId, appTesters: localConfig.testers) + } + } + } + + func processAppTesterStrategies(_ strategies: [SyncStrategy], appId: String) { + if dryRun { + SyncResultRenderer().render(strategies, isDryRun: true) + } else { + strategies.forEach { strategy in + switch strategy { + case .delete(let betatester): + print("delete testers \(betatester) from app \(appId)") + default: + return + } + } + } + + } + + func processBetagroupsStrategies(_ strategies: [SyncStrategy], appId: String) { + if dryRun { + SyncResultRenderer().render(strategies, isDryRun: true) + } else { + strategies.forEach { strategy in + switch strategy { + case .create(let betagroup): + print("create new beta group \(betagroup) in app \(appId)") + case .delete(let betagroup): + print("delete betagroup \(betagroup)") + case .update(let betagroup): + print("update betagroup \(betagroup)") + } + } + } + + } + + func processTestersInBetaGroupStrategies(_ strategies: [SyncStrategy], betagroupId: String, appTesters: [BetaTester]) { + if dryRun { + SyncResultRenderer().render(strategies, isDryRun: true) + } else { + strategies.forEach { strategy in + switch strategy { + case .create(let email): + print("add tester with email\(email) into betagroup\(betagroupId)") + case .delete(let email): + print("delete tester with email \(email) from betagroup \(betagroupId)") + default: + return + } + } + } + } + +} + + +func testPrint(json: T) { + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let json = try! jsonEncoder.encode(json) // swiftlint:disable:this force_try + print(String(data: json, encoding: .utf8)!) +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift new file mode 100644 index 00000000..017bd713 --- /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..69be0b06 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/Model/BetaGroup.swift b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift index 979f1982..5839a00d 100755 --- a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift @@ -3,6 +3,7 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation +import FileSystem import struct Model.App import struct Model.BetaGroup import SwiftyTextTable @@ -57,3 +58,15 @@ extension BetaGroup { ) } } + +extension FileSystem.BetaGroup: SyncResourceProcessable { + + var compareIdentity: String { + id + } + + var syncResultText: String { + groupName + } + +} diff --git a/Sources/AppStoreConnectCLI/Model/BetaTester.swift b/Sources/AppStoreConnectCLI/Model/BetaTester.swift index 18b2930b..e434f369 100644 --- a/Sources/AppStoreConnectCLI/Model/BetaTester.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaTester.swift @@ -3,6 +3,7 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation +import FileSystem import struct Model.BetaTester import SwiftyTextTable @@ -58,3 +59,25 @@ extension BetaTester: ResultRenderable, TableInfoProvider { ] } } + +extension FileSystem.BetaTester: SyncResourceProcessable { + + var syncResultText: String { + email + } + + var compareIdentity: String { + email + } + +} + +extension String: SyncResourceProcessable { + var syncResultText: String { + self + } + + var compareIdentity: String { + self + } +} diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index ae24a37d..6cbee9ab 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 { @@ -798,6 +799,62 @@ class AppStoreConnectService { .await() } + func pullTestFlightConfigs() throws -> [TestFlightConfiguration] { + + let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) + + + testPrint(json: apps) + + return try apps.map { + let testers: [FileSystem.BetaTester] = try ListBetaTestersOperation(options: + .init(appIds: [$0.id]) + ) + .execute(with: requestor) + .await() + .map{ + FileSystem.BetaTester( + email: ($0.betaTester.attributes?.email)!, + firstName: $0.betaTester.attributes?.firstName, + lastName: $0.betaTester.attributes?.lastName, + inviteType: $0.betaTester.attributes?.inviteType?.rawValue + ) + } + + testPrint(json: testers) + + let betagroups = try ListBetaGroupsOperation( + options: .init(appIds: [$0.id], names: [], sort: nil) + ) + .execute(with: requestor) + .await() + .map { output -> FileSystem.BetaGroup in + let testersEmails = try ListBetaTestersOperation( + options: .init(groupIds: [output.betaGroup.id]) + ) + .execute(with: requestor) + .await() + .compactMap { $0.betaTester.attributes?.email } + + return FileSystem.BetaGroup( + id: output.betaGroup.id, + groupName: (output.betaGroup.attributes?.name)!, + isInternal: output.betaGroup.attributes?.isInternalGroup, + publicLink: output.betaGroup.attributes?.publicLink, + publicLinkEnabled: output.betaGroup.attributes?.publicLinkEnabled, + publicLinkLimit: output.betaGroup.attributes?.publicLinkLimit, + publicLinkLimitEnabled: output.betaGroup.attributes?.publicLinkLimitEnabled, + creationDate: output.betaGroup.attributes?.createdDate?.formattedDate, + testers: testersEmails + ) + } + + testPrint(json: betagroups) + + return TestFlightConfiguration(app: $0, testers: testers, betagroups: betagroups) + } + } + /// Make a request for something `Decodable`. /// /// - Parameters: From c59e0eb7a359d1e68febe7cb2f1443c0dccae2cb Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 10 Jul 2020 15:54:19 +1000 Subject: [PATCH 05/28] Small model and syntax tweaks on Reader and ListTesterOperation --- .../Operations/ListBetaTestersOperation.swift | 35 ++++++------------- .../FileSystem/Helpers/String+Helpers.swift | 10 ++++++ Sources/FileSystem/Readers.swift | 3 +- config/apps/codes.orj.app1/betaGroups.yml | 2 -- 4 files changed, 21 insertions(+), 29 deletions(-) create mode 100644 Sources/FileSystem/Helpers/String+Helpers.swift delete mode 100644 config/apps/codes.orj.app1/betaGroups.yml diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift index 1f246525..5e13a3ca 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 @@ -108,11 +97,7 @@ 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) } 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/Readers.swift b/Sources/FileSystem/Readers.swift index c792bccc..0704ddab 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/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 From 2066c54534c1fe19ae473e684f19984100f4c53e Mon Sep 17 00:00:00 2001 From: Decheng Date: Wed, 15 Jul 2020 15:58:55 +1000 Subject: [PATCH 06/28] Add testing for sync betagroup and testers comparator --- .../Sync/TestFlightPushCommand.swift | 2 +- .../AppStoreConnectCLI/Model/BetaGroup.swift | 2 +- Sources/FileSystem/Model/BetaGroup.swift | 4 +- ...ResourceComparatorCompareGroupsTests.swift | 61 +++++++++++++++++++ ...sourceComparatorCompareTestersTests.swift} | 46 ++++++++++++-- 5 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift rename Tests/appstoreconnect-cliTests/Sync/{SyncTestFlightTestersTests.swift => ResourceComparatorCompareTestersTests.swift} (53%) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 6542cca0..276be72b 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -81,7 +81,7 @@ struct TestFlightPushCommand: CommonParsableCommand { ).compare() // 3.1 handling adding/deleting testers per group - processTestersInBetaGroupStrategies(testersInGroupHandlingStrategies, betagroupId: betagroupId, appTesters: localConfig.testers) + processTestersInBetaGroupStrategies(testersInGroupHandlingStrategies, betagroupId: betagroupId!, appTesters: localConfig.testers) } } } diff --git a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift index 5839a00d..8880c04e 100755 --- a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift @@ -62,7 +62,7 @@ extension BetaGroup { extension FileSystem.BetaGroup: SyncResourceProcessable { var compareIdentity: String { - id + id ?? "" } var syncResultText: String { diff --git a/Sources/FileSystem/Model/BetaGroup.swift b/Sources/FileSystem/Model/BetaGroup.swift index 86f922a6..5552e8a4 100644 --- a/Sources/FileSystem/Model/BetaGroup.swift +++ b/Sources/FileSystem/Model/BetaGroup.swift @@ -6,7 +6,7 @@ public struct BetaGroup: Codable, Equatable, Hashable { public typealias EmailAddress = String - public let id: String + public let id: String? public let groupName: String public let isInternal: Bool? public let publicLink: String? @@ -17,7 +17,7 @@ public struct BetaGroup: Codable, Equatable, Hashable { public let testers: [EmailAddress] public init( - id: String, + id: String?, groupName: String, isInternal: Bool?, publicLink: String?, diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift new file mode 100644 index 00000000..84d87315 --- /dev/null +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift @@ -0,0 +1,61 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +@testable import AppStoreConnectCLI +import FileSystem +import Foundation +import XCTest + +final class ResourceComparatorCompareGroupsTests: XCTestCase { + + func testCompareBetaGroups() { + let localBetaGroups = [ + generateGroup(id: nil, name: "group to create", publicLinkEnabled: true), + generateGroup(id: "1002", name: "group to update", publicLinkEnabled: false) + ] + + let serverBetaGroups = [ + generateGroup(id: "1002", name: "group to update", publicLinkEnabled: true), + generateGroup(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 func generateGroup( + id: String?, + name: String, + publicLinkEnabled: Bool = true, + publicLinkLimitEnabled: Bool = true + ) -> BetaGroup { + BetaGroup( + id: id, + groupName: name, + isInternal: true, + publicLink: "", + publicLinkEnabled: publicLinkEnabled, + publicLinkLimit: 10, + publicLinkLimitEnabled: publicLinkLimitEnabled, + creationDate: "", + testers: [] + ) + } + +} diff --git a/Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift similarity index 53% rename from Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift rename to Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift index ed8987c8..17e21dc9 100644 --- a/Tests/appstoreconnect-cliTests/Sync/SyncTestFlightTestersTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift @@ -5,9 +5,9 @@ import FileSystem import Foundation import XCTest -final class SyncTestFlightTestersTests: XCTestCase { +final class ResourceComparatorCompareTestersTests: XCTestCase { - func testCompareTesters() throws { + func testCompareTesters() { let serverTesters = [ BetaTester( email: "foo@gmail.com", @@ -36,12 +36,46 @@ final class SyncTestFlightTestersTests: XCTestCase { })) } - func testCompareBetaGroups() { - // TODO + 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]) + })) } - func testCompareTestersInGroup() { - // TODO + private func generateGroupWithTesters( + emails: [String] + ) -> BetaGroup { + BetaGroup( + id: "0001", + groupName: name, + isInternal: true, + publicLink: "", + publicLinkEnabled: true, + publicLinkLimit: 10, + publicLinkLimitEnabled: true, + creationDate: "", + testers: emails + ) } } From c298746e5919458445066d9a0dc2d053ade8db9c Mon Sep 17 00:00:00 2001 From: Decheng Date: Wed, 15 Jul 2020 16:05:32 +1000 Subject: [PATCH 07/28] Make SyncStrategy confirm to Equatable --- .../Services/SyncResourceComparator.swift | 2 +- .../ResourceComparatorCompareTestersTests.swift | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift index 54e5db99..4689926a 100644 --- a/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift +++ b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift @@ -2,7 +2,7 @@ import Foundation -enum SyncStrategy { +enum SyncStrategy: Equatable { case delete(T) case create(T) case update(T) diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift index 17e21dc9..39605011 100644 --- a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift @@ -79,18 +79,3 @@ final class ResourceComparatorCompareTestersTests: XCTestCase { } } - -extension SyncStrategy: Equatable { - public static func == (lhs: SyncStrategy, rhs: SyncStrategy) -> Bool { - switch (lhs, rhs) { - case (let .create(lhsItem), let .create(rhsItem)): - return lhsItem == rhsItem - case (let .update(lhsItem), let .update(rhsItem)): - return lhsItem == rhsItem - case (let .delete(lhsItem), let .delete(rhsItem)): - return lhsItem == rhsItem - default: - return false - } - } -} From 6ebab0cc0f4e4439247e92d8e83ddd82957d02ba Mon Sep 17 00:00:00 2001 From: Decheng Date: Wed, 15 Jul 2020 16:12:16 +1000 Subject: [PATCH 08/28] Syntax fixes for sync command and some other tweak for passing swiftLint --- .../TestFlight/Sync/TestFlightPullCommand.swift | 9 ++------- .../TestFlight/Sync/TestFlightPushCommand.swift | 16 +++------------- .../TestFlight/Sync/TestFlightSyncCommand.swift | 2 +- .../Commands/TestFlight/TestFlightCommand.swift | 2 +- .../Services/AppStoreConnectService.swift | 9 +-------- Sources/FileSystem/Model/BetaTester.swift | 1 - Sources/FileSystem/Readers.swift | 2 +- .../ResourceComparatorCompareGroupsTests.swift | 2 +- .../ResourceComparatorCompareTestersTests.swift | 2 +- 9 files changed, 11 insertions(+), 34 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift index 5ca5f0c2..dd735a1b 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift @@ -8,7 +8,7 @@ struct TestFlightPullCommand: CommonParsableCommand { static var configuration = CommandConfiguration( commandName: "pull", - abstract: "Pull down existing testflight configs, refreshing local config files" + abstract: "Pull down existing TestFlight configuration, refreshing local configuration files." ) @OptionGroup() @@ -16,7 +16,7 @@ struct TestFlightPullCommand: CommonParsableCommand { @Option( default: "./config/apps", - help: "Path to the Folder containing the testflight configs." + help: "Path to the folder containing the TestFlight configuration." ) var outputPath: String func run() throws { @@ -24,11 +24,6 @@ struct TestFlightPullCommand: CommonParsableCommand { let configs = try service.pullTestFlightConfigs() - configs.forEach { - print($0.app.name) - print($0.betagroups.count) - } - try TestFlightConfigLoader().save(configs, in: outputPath) } diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 276be72b..30f8ad83 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -8,7 +8,7 @@ struct TestFlightPushCommand: CommonParsableCommand { static var configuration = CommandConfiguration( commandName: "push", - abstract: "Push local testflight config files to server, update server configs" + abstract: "Push the local configuration to TestFlight." ) @OptionGroup() @@ -16,7 +16,7 @@ struct TestFlightPushCommand: CommonParsableCommand { @Option( default: "./config/apps", - help: "Path to the Folder containing the testflight configs." + help: "Path to the folder containing the TestFlight configuration." ) var inputPath: String @Flag(help: "Perform a dry run.") @@ -47,7 +47,6 @@ struct TestFlightPushCommand: CommonParsableCommand { // 1.1 handle shared testers delete only processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId) - // 2. compare beta groups let localBetagroups = localConfig.betagroups let serverBetagroups = serverConfig.betagroups @@ -61,11 +60,10 @@ struct TestFlightPushCommand: CommonParsableCommand { // 2.1 handle groups create, update, delete processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId) - // 3. compare testers in group and add, delete localBetagroups.forEach { localBetagroup in guard let serverBetagroup = serverBetagroups - .first(where: { $0.id == localBetagroup.id } ) else { + .first(where: { $0.id == localBetagroup.id }) else { return } @@ -138,11 +136,3 @@ struct TestFlightPushCommand: CommonParsableCommand { } } - - -func testPrint(json: T) { - let jsonEncoder = JSONEncoder() - jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let json = try! jsonEncoder.encode(json) // swiftlint:disable:this force_try - print(String(data: json, encoding: .utf8)!) -} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift index 017bd713..be8870ca 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightSyncCommand.swift @@ -8,7 +8,7 @@ struct TestFlightSyncCommand: ParsableCommand { abstract: "Sync information about testflight with provided configuration file.", subcommands: [ TestFlightPullCommand.self, - TestFlightPushCommand.self + TestFlightPushCommand.self, ] ) } diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift index 69be0b06..7ec35b7f 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/TestFlightCommand.swift @@ -13,7 +13,7 @@ public struct TestFlightCommand: ParsableCommand { TestFlightBetaTestersCommand.self, TestFlightBuildsCommand.self, TestFlightPreReleaseVersionCommand.self, - TestFlightSyncCommand.self + TestFlightSyncCommand.self, ]) public init() { diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 6cbee9ab..4eaf23a1 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -803,16 +803,13 @@ class AppStoreConnectService { let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) - - testPrint(json: apps) - return try apps.map { let testers: [FileSystem.BetaTester] = try ListBetaTestersOperation(options: .init(appIds: [$0.id]) ) .execute(with: requestor) .await() - .map{ + .map { FileSystem.BetaTester( email: ($0.betaTester.attributes?.email)!, firstName: $0.betaTester.attributes?.firstName, @@ -821,8 +818,6 @@ class AppStoreConnectService { ) } - testPrint(json: testers) - let betagroups = try ListBetaGroupsOperation( options: .init(appIds: [$0.id], names: [], sort: nil) ) @@ -849,8 +844,6 @@ class AppStoreConnectService { ) } - testPrint(json: betagroups) - return TestFlightConfiguration(app: $0, testers: testers, betagroups: betagroups) } } diff --git a/Sources/FileSystem/Model/BetaTester.swift b/Sources/FileSystem/Model/BetaTester.swift index d289878a..940af15c 100644 --- a/Sources/FileSystem/Model/BetaTester.swift +++ b/Sources/FileSystem/Model/BetaTester.swift @@ -34,7 +34,6 @@ extension BetaTester { } - protocol CSVRenderable: Codable { var headers: [String] { get } var rows: [[String]] { get } diff --git a/Sources/FileSystem/Readers.swift b/Sources/FileSystem/Readers.swift index 0704ddab..c10eb647 100644 --- a/Sources/FileSystem/Readers.swift +++ b/Sources/FileSystem/Readers.swift @@ -60,7 +60,7 @@ public enum Readers { } guard - let result = try? decoder.decode(T.self, from: URL(fileURLWithPath: "\(filePath)")) 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 index 84d87315..03ea28b5 100644 --- a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift @@ -10,7 +10,7 @@ final class ResourceComparatorCompareGroupsTests: XCTestCase { func testCompareBetaGroups() { let localBetaGroups = [ generateGroup(id: nil, name: "group to create", publicLinkEnabled: true), - generateGroup(id: "1002", name: "group to update", publicLinkEnabled: false) + generateGroup(id: "1002", name: "group to update", publicLinkEnabled: false), ] let serverBetaGroups = [ diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift index 39605011..c60e7998 100644 --- a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift @@ -18,7 +18,7 @@ final class ResourceComparatorCompareTestersTests: XCTestCase { email: "bar@gmail.com", firstName: nil, lastName: nil, - inviteType: nil) + inviteType: nil), ] let localTesters: [BetaTester] = [] From ebdbae02c570c126db8db1588672fc3bf598649b Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 20 Jul 2020 10:46:34 +1000 Subject: [PATCH 09/28] Create Update and Create beta group operations --- .../Operations/CreateBetaGroupOperation.swift | 33 ++++++++++++++++++ .../Operations/RemoveTesterOperation.swift | 3 ++ .../Operations/UpdateBetaGroupOperation.swift | 34 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift 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/RemoveTesterOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift index 84222210..3ea642bc 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(testerId: 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 testerId, let appId): + return APIEndpoint.remove(accessOfBetaTesterWithId: testerId, toAppsWithIds: [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() + } + +} From 2a0fe0361d81c19fcac1cbcf61175571c3b53347 Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 20 Jul 2020 10:47:03 +1000 Subject: [PATCH 10/28] Wire up Test Flight push command to the APIs --- .../Sync/TestFlightPushCommand.swift | 105 +++++++--- .../Sales.Filter+ExpressibleByArgument.swift | 6 +- .../Services/AppStoreConnectService.swift | 182 ++++++++++++++---- Sources/FileSystem/Model/BetaTester.swift | 2 +- 4 files changed, 227 insertions(+), 68 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 30f8ad83..56f1c459 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -17,7 +17,8 @@ struct TestFlightPushCommand: CommonParsableCommand { @Option( default: "./config/apps", help: "Path to the folder containing the TestFlight configuration." - ) var inputPath: String + ) + var inputPath: String @Flag(help: "Perform a dry run.") var dryRun: Bool @@ -29,7 +30,7 @@ struct TestFlightPushCommand: CommonParsableCommand { let serverConfigs = try service.pullTestFlightConfigs() - serverConfigs.forEach { serverConfig in + try serverConfigs.forEach { serverConfig in guard let localConfig = localConfigs .first(where: { $0.app.id == serverConfig.app.id }) else { @@ -45,7 +46,7 @@ struct TestFlightPushCommand: CommonParsableCommand { ).compare() // 1.1 handle shared testers delete only - processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId) + try processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId, service: service) // 2. compare beta groups let localBetagroups = localConfig.betagroups @@ -58,13 +59,13 @@ struct TestFlightPushCommand: CommonParsableCommand { .compare() // 2.1 handle groups create, update, delete - processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId) + try processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId, service: service) // 3. compare testers in group and add, delete - localBetagroups.forEach { localBetagroup in - guard let serverBetagroup = serverBetagroups - .first(where: { $0.id == localBetagroup.id }) else { - return + try localBetagroups.forEach { localBetagroup in + guard + let serverBetagroup = serverBetagroups.first(where: { $0.id == localBetagroup.id }) else { + return } let betagroupId = serverBetagroup.id @@ -79,58 +80,102 @@ struct TestFlightPushCommand: CommonParsableCommand { ).compare() // 3.1 handling adding/deleting testers per group - processTestersInBetaGroupStrategies(testersInGroupHandlingStrategies, betagroupId: betagroupId!, appTesters: localConfig.testers) + try processTestersInBetaGroupStrategies( + testersInGroupHandlingStrategies, + betagroupId: betagroupId!, + appTesters: localConfig.testers, + service: service + ) } } } - func processAppTesterStrategies(_ strategies: [SyncStrategy], appId: String) { + func processAppTesterStrategies(_ strategies: [SyncStrategy], appId: String, service: AppStoreConnectService) throws { if dryRun { SyncResultRenderer().render(strategies, isDryRun: true) } else { - strategies.forEach { strategy in + try strategies.forEach { strategy in switch strategy { case .delete(let betatester): - print("delete testers \(betatester) from app \(appId)") + try service.removeTesterFromApp(testerEmail: betatester.email, appId: appId) + SyncResultRenderer().render(strategies, isDryRun: false) default: return } } } - } - func processBetagroupsStrategies(_ strategies: [SyncStrategy], appId: String) { + func processBetagroupsStrategies(_ strategies: [SyncStrategy], appId: String, service: AppStoreConnectService) throws { + let renderer = SyncResultRenderer() + if dryRun { - SyncResultRenderer().render(strategies, isDryRun: true) + renderer.render(strategies, isDryRun: true) } else { - strategies.forEach { strategy in + try strategies.forEach { strategy in switch strategy { case .create(let betagroup): - print("create new beta group \(betagroup) in app \(appId)") + _ = try service.createBetaGroup( + appId: appId, + groupName: betagroup.groupName, + publicLinkEnabled: betagroup.publicLinkEnabled ?? false, + publicLinkLimit: betagroup.publicLinkLimit + ) + renderer.render(strategy, isDryRun: false) case .delete(let betagroup): - print("delete betagroup \(betagroup)") + try service.deleteBetaGroup(with: betagroup.id!) + renderer.render(strategy, isDryRun: false) case .update(let betagroup): - print("update betagroup \(betagroup)") + try service.updateBetaGroup(betaGroup: betagroup) + renderer.render(strategy, isDryRun: false) } } } - } - func processTestersInBetaGroupStrategies(_ strategies: [SyncStrategy], betagroupId: String, appTesters: [BetaTester]) { + func processTestersInBetaGroupStrategies( + _ strategies: [SyncStrategy], + betagroupId: String, + appTesters: [BetaTester], + service: AppStoreConnectService + ) throws { + let renderer = SyncResultRenderer() + if dryRun { - SyncResultRenderer().render(strategies, isDryRun: true) + renderer.render(strategies, isDryRun: true) } else { - strategies.forEach { strategy in - switch strategy { - case .create(let email): - print("add tester with email\(email) into betagroup\(betagroupId)") - case .delete(let email): - print("delete tester with email \(email) from betagroup \(betagroupId)") - default: - return + let deletingEmailsWithStrategy = strategies.compactMap { (strategy: SyncStrategy) -> (email: String, strategy: SyncStrategy)? in + if case .delete(let email) = strategy { + return (email, strategy) + } + return nil + } + + try service.removeTestersFromGroup( + emails: deletingEmailsWithStrategy.map { $0.email }, + groupId: betagroupId + ) + renderer.render(deletingEmailsWithStrategy.map { $0.strategy }, isDryRun: false) + + let creatingTestersWithStrategy = strategies + .compactMap { (strategy: SyncStrategy) -> + (tester: BetaTester, strategy: SyncStrategy)? in + if case .create(let email) = strategy, + let betatester = appTesters.first(where: { $0.email == email }) { + return (betatester, strategy) + } + return nil } + + try creatingTestersWithStrategy.forEach { + try service.inviteBetaTesterToGroups( + firstName: $0.tester.firstName, + lastName: $0.tester.lastName, + email: $0.tester.email, + groupId: betagroupId + ) + + renderer.render($0.strategy, isDryRun: false) } } } 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/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 4eaf23a1..bf9d0a00 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -158,6 +158,24 @@ class AppStoreConnectService { return Model.BetaTester(output) } + func inviteBetaTesterToGroups( + firstName: String?, + lastName: String?, + email: String, + groupId: String + ) throws { + _ = try InviteTesterOperation( + options: .init( + firstName: firstName, + lastName: lastName, + email: email, + identifers: .resourceId([groupId]) + ) + ) + .execute(with: requestor) + .await() + } + func addTestersToGroup( bundleId: String, groupName: String, @@ -191,6 +209,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 +440,52 @@ class AppStoreConnectService { try operation.execute(with: requestor).await() } + func removeTestersFromGroup(emails: [String], groupId: String) throws { + let testerIds = try emails.map { + try GetBetaTesterOperation( + options: .init(identifier: .email($0)) + ) + .execute(with: requestor) + .await() + .betaTester + .id + } + + let operation = RemoveTesterOperation( + options: .init( + removeStrategy: .removeTestersFromGroup(testerIds: testerIds, groupId: groupId) + ) + ) + + try operation.execute(with: requestor).await() + } + + func removeTesterFromApp(testerEmail: String, appId: String) throws { + let testerId = try GetBetaTesterOperation( + options: .init( + identifier: .email(testerEmail), + limitApps: nil, + limitBuilds: nil, + limitBetaGroups: nil + ) + ) + .execute(with: requestor) + .await() + .betaTester + .id + + try RemoveTesterOperation( + options: .init( + removeStrategy: .removeTestersFromApp( + testerId: testerId, + 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 +521,30 @@ class AppStoreConnectService { return try betaGroupResponse.map(Model.BetaGroup.init).await() } + func createBetaGroup( + appId: String, + groupName: String, + publicLinkEnabled: Bool, + publicLinkLimit: Int? + ) throws { + _ = try CreateBetaGroupWithAppIdOperation( + options: .init( + appId: appId, + groupName: groupName, + publicLinkEnabled: publicLinkEnabled, + publicLinkLimit: publicLinkLimit + ) + ) + .execute(with: requestor) + .await() + } + + 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 +563,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], @@ -800,49 +915,48 @@ class AppStoreConnectService { } func pullTestFlightConfigs() throws -> [TestFlightConfiguration] { - let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) return try apps.map { - let testers: [FileSystem.BetaTester] = try ListBetaTestersOperation(options: - .init(appIds: [$0.id]) + let testers: [FileSystem.BetaTester] = try ListBetaTestersOperation( + options: .init(appIds: [$0.id]) + ) + .execute(with: requestor) + .await() + .map { + FileSystem.BetaTester( + email: ($0.betaTester.attributes?.email)!, + firstName: $0.betaTester.attributes?.firstName, + lastName: $0.betaTester.attributes?.lastName, + inviteType: $0.betaTester.attributes?.inviteType?.rawValue ) - .execute(with: requestor) - .await() - .map { - FileSystem.BetaTester( - email: ($0.betaTester.attributes?.email)!, - firstName: $0.betaTester.attributes?.firstName, - lastName: $0.betaTester.attributes?.lastName, - inviteType: $0.betaTester.attributes?.inviteType?.rawValue - ) - } + } let betagroups = try ListBetaGroupsOperation( - options: .init(appIds: [$0.id], names: [], sort: nil) + options: .init(appIds: [$0.id], names: [], sort: nil) + ) + .execute(with: requestor) + .await() + .map { output -> FileSystem.BetaGroup in + let testersEmails = try ListBetaTestersOperation( + options: .init(groupIds: [output.betaGroup.id]) ) .execute(with: requestor) .await() - .map { output -> FileSystem.BetaGroup in - let testersEmails = try ListBetaTestersOperation( - options: .init(groupIds: [output.betaGroup.id]) - ) - .execute(with: requestor) - .await() - .compactMap { $0.betaTester.attributes?.email } - - return FileSystem.BetaGroup( - id: output.betaGroup.id, - groupName: (output.betaGroup.attributes?.name)!, - isInternal: output.betaGroup.attributes?.isInternalGroup, - publicLink: output.betaGroup.attributes?.publicLink, - publicLinkEnabled: output.betaGroup.attributes?.publicLinkEnabled, - publicLinkLimit: output.betaGroup.attributes?.publicLinkLimit, - publicLinkLimitEnabled: output.betaGroup.attributes?.publicLinkLimitEnabled, - creationDate: output.betaGroup.attributes?.createdDate?.formattedDate, - testers: testersEmails - ) - } + .compactMap { $0.betaTester.attributes?.email } + + return FileSystem.BetaGroup( + id: output.betaGroup.id, + groupName: (output.betaGroup.attributes?.name)!, + isInternal: output.betaGroup.attributes?.isInternalGroup, + publicLink: output.betaGroup.attributes?.publicLink, + publicLinkEnabled: output.betaGroup.attributes?.publicLinkEnabled, + publicLinkLimit: output.betaGroup.attributes?.publicLinkLimit, + publicLinkLimitEnabled: output.betaGroup.attributes?.publicLinkLimitEnabled, + creationDate: output.betaGroup.attributes?.createdDate?.formattedDate, + testers: testersEmails + ) + } return TestFlightConfiguration(app: $0, testers: testers, betagroups: betagroups) } diff --git a/Sources/FileSystem/Model/BetaTester.swift b/Sources/FileSystem/Model/BetaTester.swift index 940af15c..0d28876b 100644 --- a/Sources/FileSystem/Model/BetaTester.swift +++ b/Sources/FileSystem/Model/BetaTester.swift @@ -53,6 +53,6 @@ extension Array: CSVRenderable where Element == BetaTester { } var rows: [[String]] { - self.map { [$0.email, $0.firstName, $0.lastName, $0.inviteType].compactMap { $0 } } + self.map { [$0.email, $0.firstName, $0.lastName, $0.inviteType] } } } From 41ca74de06d70fe4df7ca1757df34388bc2eaaa5 Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 20 Jul 2020 10:50:45 +1000 Subject: [PATCH 11/28] Make swiftlint pass in PushCommand --- .../Commands/TestFlight/Sync/TestFlightPushCommand.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 56f1c459..dc5ebb1c 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -144,7 +144,8 @@ struct TestFlightPushCommand: CommonParsableCommand { if dryRun { renderer.render(strategies, isDryRun: true) } else { - let deletingEmailsWithStrategy = strategies.compactMap { (strategy: SyncStrategy) -> (email: String, strategy: SyncStrategy)? in + let deletingEmailsWithStrategy = strategies + .compactMap { (strategy: SyncStrategy) -> (email: String, strategy: SyncStrategy)? in if case .delete(let email) = strategy { return (email, strategy) } From 2ca5b7aa950b58e2d97f143651b07b526db6aba8 Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 20 Jul 2020 16:33:08 +1000 Subject: [PATCH 12/28] Add custom Hashable logic to Filesystem.BetaGroup --- Sources/FileSystem/Model/BetaGroup.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/FileSystem/Model/BetaGroup.swift b/Sources/FileSystem/Model/BetaGroup.swift index 5552e8a4..a5d8761e 100644 --- a/Sources/FileSystem/Model/BetaGroup.swift +++ b/Sources/FileSystem/Model/BetaGroup.swift @@ -2,7 +2,7 @@ import Foundation -public struct BetaGroup: Codable, Equatable, Hashable { +public struct BetaGroup: Codable, Equatable { public typealias EmailAddress = String @@ -38,3 +38,21 @@ public struct BetaGroup: Codable, Equatable, Hashable { 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) + } +} From af0b5d8690281380ca3e28ef295d27e212bcd784 Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 20 Jul 2020 16:33:31 +1000 Subject: [PATCH 13/28] Some UI Updates in TestFlightPushCommand --- .../Sync/TestFlightPushCommand.swift | 116 +++++++++++++----- .../Readers and Renderers/Renderers.swift | 43 ++++--- 2 files changed, 106 insertions(+), 53 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index dc5ebb1c..26072bd7 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -26,8 +26,14 @@ struct TestFlightPushCommand: CommonParsableCommand { func run() throws { let service = try makeService() + if dryRun { + print("'Dry Run' mode activated, changes will not be applied. \n") + } + + print("Loading local TestFlight configs... \n") let localConfigs = try TestFlightConfigLoader().load(appsFolderPath: inputPath) + print("Loading server TestFlight configs... \n") let serverConfigs = try service.pullTestFlightConfigs() try serverConfigs.forEach { serverConfig in @@ -39,51 +45,99 @@ struct TestFlightPushCommand: CommonParsableCommand { let appId = localConfig.app.id - // 1. compare shared testers in app - let sharedTestersHandleStrategies = SyncResourceComparator( - localResources: localConfig.testers, - serverResources: serverConfig.testers - ).compare() + print("Syncing App '\(localConfig.app.bundleId ?? appId)':") - // 1.1 handle shared testers delete only - try processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId, service: service) + try processAppSharedTesters( + localTesters: localConfig.testers, + serverTesters: serverConfig.testers, + appId: appId, + service: service + ) - // 2. compare beta groups let localBetagroups = localConfig.betagroups let serverBetagroups = serverConfig.betagroups - let betaGroupHandlingStrategies = SyncResourceComparator( - localResources: localBetagroups, - serverResources: serverBetagroups - ) - .compare() + try processBetaGroups( + localGroups: localBetagroups, + serverGroups: serverBetagroups, + appId: appId, + service: service + ) - // 2.1 handle groups create, update, delete - try processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId, service: service) + try processTestersInGroups(localGroups: localBetagroups, serverGroups: serverBetagroups, sharedTesters: localConfig.testers, service: service) - // 3. compare testers in group and add, delete - try localBetagroups.forEach { localBetagroup in - guard - let serverBetagroup = serverBetagroups.first(where: { $0.id == localBetagroup.id }) else { - return - } + print("Syncing Completed. \n") + } + } - let betagroupId = serverBetagroup.id + private func processAppSharedTesters( + localTesters: [BetaTester], + serverTesters: [BetaTester], + appId: String, + service: AppStoreConnectService + ) throws { + // 1. compare shared testers in app + let sharedTestersHandleStrategies = SyncResourceComparator( + localResources: localTesters, + serverResources: serverTesters + ) + .compare() + + // 1.1 handle shared testers delete only + if sharedTestersHandleStrategies.isNotEmpty { + print("- App Testers Changes: ") + try processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId, service: service) + } + } - let localGroupTesters = localBetagroup.testers + private func processBetaGroups( + localGroups: [BetaGroup], + serverGroups: [BetaGroup], + appId: String, + service: AppStoreConnectService + ) throws { + // 2. compare beta groups + let betaGroupHandlingStrategies = SyncResourceComparator( + localResources: localGroups, + serverResources: serverGroups + ).compare() + + // 2.1 handle groups create, update, delete + if betaGroupHandlingStrategies.isNotEmpty { + print("- Beta Group Changes: ") + try processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId, service: service) + } + } - let serverGroupTesters = serverBetagroup.testers + private func processTestersInGroups( + localGroups: [BetaGroup], + serverGroups: [BetaGroup], + sharedTesters: [BetaTester], + service: AppStoreConnectService + ) throws { + // 3. compare testers in group, perform adding/deleting + try localGroups.forEach { localBetagroup in + guard + let serverBetagroup = serverGroups.first(where: { $0.id == localBetagroup.id }) else { + return + } - let testersInGroupHandlingStrategies = SyncResourceComparator( - localResources: localGroupTesters, - serverResources: serverGroupTesters - ).compare() + let localGroupTesters = localBetagroup.testers + + let serverGroupTesters = serverBetagroup.testers + + let testersInGroupHandlingStrategies = SyncResourceComparator( + localResources: localGroupTesters, + serverResources: serverGroupTesters + ).compare() - // 3.1 handling adding/deleting testers per group + // 3.1 handling adding/deleting testers per group + if testersInGroupHandlingStrategies.isNotEmpty { + print("- Beta Group '\(serverBetagroup.groupName)' Testers Changes: ") try processTestersInBetaGroupStrategies( testersInGroupHandlingStrategies, - betagroupId: betagroupId!, - appTesters: localConfig.testers, + betagroupId: serverBetagroup.id!, + appTesters: sharedTesters, service: service ) } diff --git a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift index 418346a2..81a5efd5 100644 --- a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift +++ b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift @@ -119,26 +119,25 @@ protocol SyncResultRenderable: Equatable { } struct SyncResultRenderer { + func render(_ strategy: [SyncStrategy], isDryRun: Bool) { + strategy.forEach { renderResultText($0, isDryRun) } + } + + func render(_ strategy: SyncStrategy, isDryRun: Bool) { + renderResultText(strategy, isDryRun) + } - func render(_ strategy: [SyncStrategy], isDryRun: Bool) { - strategy.forEach { renderResultText($0, isDryRun) } - } - - func render(_ strategy: SyncStrategy, isDryRun: Bool) { - renderResultText(strategy, isDryRun) - } - - private func renderResultText(_ strategy: SyncStrategy, _ 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)") - } - } + private func renderResultText(_ strategy: SyncStrategy, _ 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)") + } +} From a4388f48f1c02206b98f64c86c8518ad609d4204 Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 21 Jul 2020 13:37:10 +1000 Subject: [PATCH 14/28] Add refresh local after sync completed feature --- .../Sync/TestFlightPushCommand.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 26072bd7..fea7c987 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -64,10 +64,24 @@ struct TestFlightPushCommand: CommonParsableCommand { service: service ) - try processTestersInGroups(localGroups: localBetagroups, serverGroups: serverBetagroups, sharedTesters: localConfig.testers, service: service) + try processTestersInGroups( + localGroups: localBetagroups, + serverGroups: serverBetagroups, + sharedTesters: localConfig.testers, + service: service + ) - print("Syncing Completed. \n") + print("Syncing completed. \n") } + + print("Refreshing local configurations...") + + try TestFlightConfigLoader().save( + try service.pullTestFlightConfigs(), + in: inputPath + ) + + print("Refreshing completed.") } private func processAppSharedTesters( From 4ef448d722b0bf4b72556eb2073372d2c90dd0af Mon Sep 17 00:00:00 2001 From: Decheng Date: Thu, 23 Jul 2020 14:59:11 +1000 Subject: [PATCH 15/28] remove TestFlightConfigLoader -> add init to TestFlightConfiguration --- .../Sync/TestFlightPullCommand.swift | 4 +- .../Sync/TestFlightPushCommand.swift | 19 +++--- .../Model/TestFlightConfiguration.swift | 59 ++++++++++++++++- .../TestFlight/TestFlightConfigLoader.swift | 63 ------------------- 4 files changed, 69 insertions(+), 76 deletions(-) delete mode 100644 Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift index dd735a1b..de60641a 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift @@ -22,9 +22,9 @@ struct TestFlightPullCommand: CommonParsableCommand { func run() throws { let service = try makeService() - let configs = try service.pullTestFlightConfigs() + let configs = try service.pullTestFlightConfigurations() - try TestFlightConfigLoader().save(configs, in: outputPath) + try configs.save(in: outputPath) } } diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index fea7c987..28fe8aa7 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -31,14 +31,15 @@ struct TestFlightPushCommand: CommonParsableCommand { } print("Loading local TestFlight configs... \n") - let localConfigs = try TestFlightConfigLoader().load(appsFolderPath: inputPath) + + let localConfigurations = try [TestFlightConfiguration](from: inputPath) print("Loading server TestFlight configs... \n") - let serverConfigs = try service.pullTestFlightConfigs() + let serverConfigs = try service.pullTestFlightConfigurations() try serverConfigs.forEach { serverConfig in guard - let localConfig = localConfigs + let localConfig = localConfigurations .first(where: { $0.app.id == serverConfig.app.id }) else { return } @@ -76,10 +77,7 @@ struct TestFlightPushCommand: CommonParsableCommand { print("Refreshing local configurations...") - try TestFlightConfigLoader().save( - try service.pullTestFlightConfigs(), - in: inputPath - ) + try service.pullTestFlightConfigurations().save(in: inputPath) print("Refreshing completed.") } @@ -237,11 +235,12 @@ struct TestFlightPushCommand: CommonParsableCommand { } try creatingTestersWithStrategy.forEach { + try service.inviteBetaTesterToGroups( - firstName: $0.tester.firstName, - lastName: $0.tester.lastName, email: $0.tester.email, - groupId: betagroupId + groupId: betagroupId, + firstName: $0.tester.firstName, + lastName: $0.tester.lastName ) renderer.render($0.strategy, isDryRun: false) diff --git a/Sources/FileSystem/Model/TestFlightConfiguration.swift b/Sources/FileSystem/Model/TestFlightConfiguration.swift index 05c70399..55423002 100644 --- a/Sources/FileSystem/Model/TestFlightConfiguration.swift +++ b/Sources/FileSystem/Model/TestFlightConfiguration.swift @@ -1,10 +1,11 @@ // 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] @@ -18,5 +19,61 @@ public struct TestFlightConfiguration: Codable, Equatable { 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) + } + } } diff --git a/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift b/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift deleted file mode 100644 index 8c60cd10..00000000 --- a/Sources/FileSystem/TestFlight/TestFlightConfigLoader.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2020 Itty Bitty Apps Pty Ltd - -import Foundation -import Files -import Model -import Yams - -public struct TestFlightConfigLoader { - - public init() { } - - public func load(appsFolderPath: String) throws -> [TestFlightConfiguration] { - try Folder(path: appsFolderPath).subfolders.map { - try load(in: $0) - } - } - - func load(in appFolder: Folder) throws -> TestFlightConfiguration { - - 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) - } - - return TestFlightConfiguration(app: app, testers: testers, betagroups: betagroups) - } - - func save(_ config: TestFlightConfiguration, in appFolder: Folder) throws { - let appFile = try appFolder.createFile(named: "app.yml") - try appFile.write(try YAMLEncoder().encode(config.app)) - - let testersFile = try appFolder.createFile(named: "beta-testers.csv") - try testersFile.write(config.testers.renderAsCSV()) - - let groupFolder = try appFolder.createSubfolder(named: "betagroups") - try config.betagroups.forEach { - try groupFolder.createFile(named: "\($0.groupName.filenameSafe()).yml").append(try YAMLEncoder().encode($0)) - } - } - - public func save(_ config: [TestFlightConfiguration], in appsFolderPath: String) throws { - let appsFolder = try Folder(path: appsFolderPath) - - try appsFolder.delete() - - try config.forEach { - let appFolder = try appsFolder.createSubfolder(named: $0.app.bundleId!) - - try save($0, in: appFolder) - } - } -} From 5d36cf976bc9e8f6aba366497a64f144489c92e1 Mon Sep 17 00:00:00 2001 From: Decheng Date: Thu, 23 Jul 2020 14:59:29 +1000 Subject: [PATCH 16/28] Tweak ResourceComparatorCompareTests --- ...ResourceComparatorCompareGroupsTests.swift | 18 ++++++++------- ...esourceComparatorCompareTestersTests.swift | 22 ++----------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift index 03ea28b5..38dea22d 100644 --- a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareGroupsTests.swift @@ -9,13 +9,13 @@ final class ResourceComparatorCompareGroupsTests: XCTestCase { func testCompareBetaGroups() { let localBetaGroups = [ - generateGroup(id: nil, name: "group to create", publicLinkEnabled: true), - generateGroup(id: "1002", name: "group to update", publicLinkEnabled: false), + BetaGroup(id: nil, name: "group to create", publicLinkEnabled: true), + BetaGroup(id: "1002", name: "group to update", publicLinkEnabled: false), ] let serverBetaGroups = [ - generateGroup(id: "1002", name: "group to update", publicLinkEnabled: true), - generateGroup(id: "1003", name: "group to delete", publicLinkEnabled: true), + BetaGroup(id: "1002", name: "group to update", publicLinkEnabled: true), + BetaGroup(id: "1003", name: "group to delete", publicLinkEnabled: true), ] let strategies = SyncResourceComparator( @@ -39,13 +39,16 @@ final class ResourceComparatorCompareGroupsTests: XCTestCase { })) } - private func generateGroup( +} + +private extension BetaGroup { + init( id: String?, name: String, publicLinkEnabled: Bool = true, publicLinkLimitEnabled: Bool = true - ) -> BetaGroup { - BetaGroup( + ) { + self = BetaGroup( id: id, groupName: name, isInternal: true, @@ -57,5 +60,4 @@ final class ResourceComparatorCompareGroupsTests: XCTestCase { testers: [] ) } - } diff --git a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift index c60e7998..b530fc74 100644 --- a/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift +++ b/Tests/appstoreconnect-cliTests/Sync/ResourceComparatorCompareTestersTests.swift @@ -12,13 +12,11 @@ final class ResourceComparatorCompareTestersTests: XCTestCase { BetaTester( email: "foo@gmail.com", firstName: nil, - lastName: nil, - inviteType: nil), + lastName: nil), BetaTester( email: "bar@gmail.com", firstName: nil, - lastName: nil, - inviteType: nil), + lastName: nil), ] let localTesters: [BetaTester] = [] @@ -62,20 +60,4 @@ final class ResourceComparatorCompareTestersTests: XCTestCase { })) } - private func generateGroupWithTesters( - emails: [String] - ) -> BetaGroup { - BetaGroup( - id: "0001", - groupName: name, - isInternal: true, - publicLink: "", - publicLinkEnabled: true, - publicLinkLimit: 10, - publicLinkLimitEnabled: true, - creationDate: "", - testers: emails - ) - } - } From 5be708ceef9d13793803bb3a54022e62ea8aec2b Mon Sep 17 00:00:00 2001 From: Decheng Date: Thu, 23 Jul 2020 15:00:14 +1000 Subject: [PATCH 17/28] BetaGroup && BetaTester struct let -> var, remove tester inviteType --- .../Sync/TestFlightPushCommand.swift | 2 +- .../Services/AppStoreConnectService.swift | 11 +++++------ Sources/FileSystem/Model/BetaGroup.swift | 18 +++++++++--------- Sources/FileSystem/Model/BetaTester.swift | 16 ++++++---------- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 28fe8aa7..bde81ed2 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -235,7 +235,7 @@ struct TestFlightPushCommand: CommonParsableCommand { } try creatingTestersWithStrategy.forEach { - + try service.inviteBetaTesterToGroups( email: $0.tester.email, groupId: betagroupId, diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 3f547e5c..6f2ca542 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -160,10 +160,10 @@ class AppStoreConnectService { } func inviteBetaTesterToGroups( - firstName: String?, - lastName: String?, email: String, - groupId: String + groupId: String, + firstName: String?, + lastName: String? ) throws { _ = try InviteTesterOperation( options: .init( @@ -973,7 +973,7 @@ class AppStoreConnectService { .await() } - func pullTestFlightConfigs() throws -> [TestFlightConfiguration] { + func pullTestFlightConfigurations() throws -> [TestFlightConfiguration] { let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) return try apps.map { @@ -986,8 +986,7 @@ class AppStoreConnectService { FileSystem.BetaTester( email: ($0.betaTester.attributes?.email)!, firstName: $0.betaTester.attributes?.firstName, - lastName: $0.betaTester.attributes?.lastName, - inviteType: $0.betaTester.attributes?.inviteType?.rawValue + lastName: $0.betaTester.attributes?.lastName ) } diff --git a/Sources/FileSystem/Model/BetaGroup.swift b/Sources/FileSystem/Model/BetaGroup.swift index a5d8761e..fc2144c3 100644 --- a/Sources/FileSystem/Model/BetaGroup.swift +++ b/Sources/FileSystem/Model/BetaGroup.swift @@ -6,15 +6,15 @@ public struct BetaGroup: Codable, Equatable { public typealias EmailAddress = String - public let id: String? - public let groupName: String - public let isInternal: Bool? - public let publicLink: String? - public let publicLinkEnabled: Bool? - public let publicLinkLimit: Int? - public let publicLinkLimitEnabled: Bool? - public let creationDate: String? - public let testers: [EmailAddress] + 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?, diff --git a/Sources/FileSystem/Model/BetaTester.swift b/Sources/FileSystem/Model/BetaTester.swift index 0d28876b..b0e6f92d 100644 --- a/Sources/FileSystem/Model/BetaTester.swift +++ b/Sources/FileSystem/Model/BetaTester.swift @@ -5,21 +5,18 @@ import Foundation import Model public struct BetaTester: Codable, Equatable, Hashable { - public let email: String - public let firstName: String - public let lastName: String - public let inviteType: String + public var email: String + public var firstName: String + public var lastName: String public init( email: String, firstName: String?, - lastName: String?, - inviteType: String? + lastName: String? ) { self.email = email self.firstName = firstName ?? "" self.lastName = lastName ?? "" - self.inviteType = inviteType ?? "" } } @@ -29,7 +26,6 @@ extension BetaTester { case email = "Email" case firstName = "First Name" case lastName = "Last Name" - case inviteType = "Invite Type" } } @@ -49,10 +45,10 @@ extension CSVRenderable { extension Array: CSVRenderable where Element == BetaTester { var headers: [String] { - ["Email", "First Name", "Last Name", "Invite Type"] + ["Email", "First Name", "Last Name"] } var rows: [[String]] { - self.map { [$0.email, $0.firstName, $0.lastName, $0.inviteType] } + self.map { [$0.email, $0.firstName, $0.lastName] } } } From 3e4f8fed277c685910e2cb0e45deeccb209f8999 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 24 Jul 2020 13:17:42 +1000 Subject: [PATCH 18/28] Introduce AppSyncActions and update TestFlightPushCommand --- .../Sync/TestFlightPushCommand.swift | 307 +++++++++--------- .../Readers and Renderers/Renderers.swift | 6 +- .../Services/SyncActions.swift | 51 +++ .../Services/SyncResourceComparator.swift | 8 +- 4 files changed, 205 insertions(+), 167 deletions(-) create mode 100644 Sources/AppStoreConnectCLI/Services/SyncActions.swift diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index bde81ed2..23b8f63f 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -3,6 +3,7 @@ import ArgumentParser import FileSystem import Foundation +import Model struct TestFlightPushCommand: CommonParsableCommand { @@ -26,53 +27,22 @@ struct TestFlightPushCommand: CommonParsableCommand { func run() throws { let service = try makeService() - if dryRun { - print("'Dry Run' mode activated, changes will not be applied. \n") - } - print("Loading local TestFlight configs... \n") let localConfigurations = try [TestFlightConfiguration](from: inputPath) print("Loading server TestFlight configs... \n") - let serverConfigs = try service.pullTestFlightConfigurations() - - try serverConfigs.forEach { serverConfig in - guard - let localConfig = localConfigurations - .first(where: { $0.app.id == serverConfig.app.id }) else { - return - } - - let appId = localConfig.app.id + let serverConfigurations = try service.pullTestFlightConfigurations() - print("Syncing App '\(localConfig.app.bundleId ?? appId)':") - - try processAppSharedTesters( - localTesters: localConfig.testers, - serverTesters: serverConfig.testers, - appId: appId, - service: service - ) - - let localBetagroups = localConfig.betagroups - let serverBetagroups = serverConfig.betagroups - - try processBetaGroups( - localGroups: localBetagroups, - serverGroups: serverBetagroups, - appId: appId, - service: service - ) - - try processTestersInGroups( - localGroups: localBetagroups, - serverGroups: serverBetagroups, - sharedTesters: localConfig.testers, - service: service - ) + let actions = compare( + serverConfigurations: serverConfigurations, + with: localConfigurations + ) - print("Syncing completed. \n") + if dryRun { + render(actions: actions) + } else { + try process(actions: actions, with: service) } print("Refreshing local configurations...") @@ -82,160 +52,177 @@ struct TestFlightPushCommand: CommonParsableCommand { print("Refreshing completed.") } - private func processAppSharedTesters( - localTesters: [BetaTester], - serverTesters: [BetaTester], - appId: String, - service: AppStoreConnectService - ) throws { - // 1. compare shared testers in app - let sharedTestersHandleStrategies = SyncResourceComparator( - localResources: localTesters, - serverResources: serverTesters - ) - .compare() + func render(actions: [AppSyncActions]) { + print("'Dry Run' mode activated, changes will not be applied. \n") - // 1.1 handle shared testers delete only - if sharedTestersHandleStrategies.isNotEmpty { - print("- App Testers Changes: ") - try processAppTesterStrategies(sharedTestersHandleStrategies, appId: appId, service: service) + actions.forEach { + print("\($0.app.name ?? ""):") + // 1. app testers + print("- Testers in App: ") + $0.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } + + // 2. BetaGroups in App + print("- BetaGroups in App: ") + $0.betaGroupSyncActions.forEach { $0.render(dryRun: dryRun) } + + // 3. Testers in BetaGroup + print("- Testers In Beta Group: ") + $0.testerInGroupsAction.forEach { + print("\($0.betaGroup.groupName):") + $0.testerActions.forEach { $0.render(dryRun: dryRun) } + } } } - private func processBetaGroups( - localGroups: [BetaGroup], - serverGroups: [BetaGroup], - appId: String, - service: AppStoreConnectService - ) throws { - // 2. compare beta groups - let betaGroupHandlingStrategies = SyncResourceComparator( - localResources: localGroups, - serverResources: serverGroups - ).compare() - - // 2.1 handle groups create, update, delete - if betaGroupHandlingStrategies.isNotEmpty { - print("- Beta Group Changes: ") - try processBetagroupsStrategies(betaGroupHandlingStrategies, appId: appId, service: service) + private func process(actions: [AppSyncActions], with service: AppStoreConnectService) throws { + try actions.forEach { appAction in + print("\(appAction.app.name ?? ""): ") + // 1. app testers + print("- Testers in App: ") + try processAppTesterActions( + appAction.appTestersSyncActions, + appId: appAction.app.id, + service: service + ) + + // 2. beta groups in app + print("- BetaGroups in App: ") + try processBetagroupsActions( + appAction.betaGroupSyncActions, + appId: appAction.app.id, + service: service + ) + + // 3. testers in beta group + print("- Testers In Beta Group: ") + try appAction.testerInGroupsAction.forEach { + try processTestersInBetaGroupActions( + $0.testerActions, + betagroupId: $0.betaGroup.id!, + appTesters: appAction.appTesters, + service: service + ) + } } } - private func processTestersInGroups( - localGroups: [BetaGroup], - serverGroups: [BetaGroup], - sharedTesters: [BetaTester], - service: AppStoreConnectService - ) throws { - // 3. compare testers in group, perform adding/deleting - try localGroups.forEach { localBetagroup in + private func compare( + serverConfigurations: [TestFlightConfiguration], + with localConfigurations: [TestFlightConfiguration] + ) -> [AppSyncActions] { + return serverConfigurations.compactMap { serverConfiguration in guard - let serverBetagroup = serverGroups.first(where: { $0.id == localBetagroup.id }) else { - return + let localConfiguration = localConfigurations + .first(where: { $0.app.id == serverConfiguration.app.id }) else { + return nil } - let localGroupTesters = localBetagroup.testers + let appTesterSyncActions = SyncResourceComparator( + localResources: localConfiguration.testers, + serverResources: serverConfiguration.testers + ) + .compare() - let serverGroupTesters = serverBetagroup.testers + let betaGroupSyncActions = SyncResourceComparator( + localResources: localConfiguration.betagroups, + serverResources: serverConfiguration.betagroups + ) + .compare() - let testersInGroupHandlingStrategies = SyncResourceComparator( - localResources: localGroupTesters, - serverResources: serverGroupTesters - ).compare() + let testerInGroupsAction = localConfiguration.betagroups.compactMap { localBetagroup -> BetaTestersInGroupActions? in + guard + let serverBetaGroup = serverConfiguration + .betagroups + .first(where: { $0.id == localBetagroup.id }) else { + return nil + } - // 3.1 handling adding/deleting testers per group - if testersInGroupHandlingStrategies.isNotEmpty { - print("- Beta Group '\(serverBetagroup.groupName)' Testers Changes: ") - try processTestersInBetaGroupStrategies( - testersInGroupHandlingStrategies, - betagroupId: serverBetagroup.id!, - appTesters: sharedTesters, - service: service + return BetaTestersInGroupActions( + betaGroup: localBetagroup, + testerActions: SyncResourceComparator( + localResources: localBetagroup.testers, + serverResources: serverBetaGroup.testers + ) + .compare() ) } + + return AppSyncActions( + app: localConfiguration.app, + appTesters: localConfiguration.testers, + appTestersSyncActions: appTesterSyncActions, + betaGroupSyncActions: betaGroupSyncActions, + testerInGroupsAction: testerInGroupsAction + ) } } - func processAppTesterStrategies(_ strategies: [SyncStrategy], appId: String, service: AppStoreConnectService) throws { - if dryRun { - SyncResultRenderer().render(strategies, isDryRun: true) - } else { - try strategies.forEach { strategy in - switch strategy { - case .delete(let betatester): - try service.removeTesterFromApp(testerEmail: betatester.email, appId: appId) - SyncResultRenderer().render(strategies, isDryRun: false) - default: - return - } + func processAppTesterActions(_ actions: [SyncAction], appId: String, service: AppStoreConnectService) throws { + try actions.forEach { action in + switch action { + case .delete(let betatester): + try service.removeTesterFromApp(testerEmail: betatester.email, appId: appId) + action.render(dryRun: dryRun) + default: + return } } } - func processBetagroupsStrategies(_ strategies: [SyncStrategy], appId: String, service: AppStoreConnectService) throws { - let renderer = SyncResultRenderer() - - if dryRun { - renderer.render(strategies, isDryRun: true) - } else { - try strategies.forEach { strategy in - switch strategy { - case .create(let betagroup): - _ = try service.createBetaGroup( - appId: appId, - groupName: betagroup.groupName, - publicLinkEnabled: betagroup.publicLinkEnabled ?? false, - publicLinkLimit: betagroup.publicLinkLimit - ) - renderer.render(strategy, isDryRun: false) - case .delete(let betagroup): - try service.deleteBetaGroup(with: betagroup.id!) - renderer.render(strategy, isDryRun: false) - case .update(let betagroup): - try service.updateBetaGroup(betaGroup: betagroup) - renderer.render(strategy, isDryRun: false) - } + func processBetagroupsActions(_ actions: [SyncAction], appId: String, service: AppStoreConnectService) throws { + try actions.forEach { action in + switch action { + case .create(let betagroup): + _ = try service.createBetaGroup( + appId: appId, + groupName: betagroup.groupName, + publicLinkEnabled: betagroup.publicLinkEnabled ?? false, + publicLinkLimit: betagroup.publicLinkLimit + ) + action.render(dryRun: dryRun) + 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 processTestersInBetaGroupStrategies( - _ strategies: [SyncStrategy], + func processTestersInBetaGroupActions( + _ actions: [SyncAction], betagroupId: String, - appTesters: [BetaTester], + appTesters: [FileSystem.BetaTester], service: AppStoreConnectService ) throws { - let renderer = SyncResultRenderer() - - if dryRun { - renderer.render(strategies, isDryRun: true) - } else { - let deletingEmailsWithStrategy = strategies - .compactMap { (strategy: SyncStrategy) -> (email: String, strategy: SyncStrategy)? in - if case .delete(let email) = strategy { - return (email, strategy) + let deletingEmailsWithStrategy = actions + .compactMap { (action: SyncAction) -> + (email: String, strategy: SyncAction)? in + if case .delete(let email) = action { + return (email, action) } return nil } - try service.removeTestersFromGroup( - emails: deletingEmailsWithStrategy.map { $0.email }, - groupId: betagroupId - ) - renderer.render(deletingEmailsWithStrategy.map { $0.strategy }, isDryRun: false) - - let creatingTestersWithStrategy = strategies - .compactMap { (strategy: SyncStrategy) -> - (tester: BetaTester, strategy: SyncStrategy)? in - if case .create(let email) = strategy, - let betatester = appTesters.first(where: { $0.email == email }) { - return (betatester, strategy) - } - return nil + try service.removeTestersFromGroup( + emails: deletingEmailsWithStrategy.map { $0.email }, + groupId: betagroupId + ) + + deletingEmailsWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + + let creatingTestersWithStrategy = actions + .compactMap { (strategy: SyncAction) -> + (tester: FileSystem.BetaTester, strategy: SyncAction)? in + if case .create(let email) = strategy, + let betatester = appTesters.first(where: { $0.email == email }) { + return (betatester, strategy) } + return nil + } try creatingTestersWithStrategy.forEach { - try service.inviteBetaTesterToGroups( email: $0.tester.email, groupId: betagroupId, @@ -243,9 +230,9 @@ struct TestFlightPushCommand: CommonParsableCommand { lastName: $0.tester.lastName ) - renderer.render($0.strategy, isDryRun: false) - } + $0.strategy.render(dryRun: dryRun) } + } } diff --git a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift index 81a5efd5..578e79bd 100644 --- a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift +++ b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift @@ -119,15 +119,15 @@ protocol SyncResultRenderable: Equatable { } struct SyncResultRenderer { - func render(_ strategy: [SyncStrategy], isDryRun: Bool) { + func render(_ strategy: [SyncAction], isDryRun: Bool) { strategy.forEach { renderResultText($0, isDryRun) } } - func render(_ strategy: SyncStrategy, isDryRun: Bool) { + func render(_ strategy: SyncAction, isDryRun: Bool) { renderResultText(strategy, isDryRun) } - private func renderResultText(_ strategy: SyncStrategy, _ isDryRun: Bool) { + private func renderResultText(_ strategy: SyncAction, _ isDryRun: Bool) { let resultText: String switch strategy { case .create(let input): diff --git a/Sources/AppStoreConnectCLI/Services/SyncActions.swift b/Sources/AppStoreConnectCLI/Services/SyncActions.swift new file mode 100644 index 00000000..263af1e6 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/SyncActions.swift @@ -0,0 +1,51 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation +import FileSystem +import Model + +struct AppSyncActions { + let app: Model.App + let appTesters: [FileSystem.BetaTester] + + let appTestersSyncActions: [SyncAction] + let betaGroupSyncActions: [SyncAction] + + let testerInGroupsAction: [BetaTestersInGroupActions] +} + +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 index 4689926a..e6275690 100644 --- a/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift +++ b/Sources/AppStoreConnectCLI/Services/SyncResourceComparator.swift @@ -2,7 +2,7 @@ import Foundation -enum SyncStrategy: Equatable { +enum SyncAction: Equatable { case delete(T) case create(T) case update(T) @@ -24,10 +24,10 @@ struct SyncResourceComparator { private var localResourcesSet: Set { Set(localResources) } private var serverResourcesSet: Set { Set(serverResources) } - func compare() -> [SyncStrategy] { + func compare() -> [SyncAction] { serverResourcesSet .subtracting(localResourcesSet) - .compactMap { resource -> SyncStrategy? in + .compactMap { resource -> SyncAction? in localResources .contains(where: { resource.compareIdentity == $0.compareIdentity }) ? nil @@ -36,7 +36,7 @@ struct SyncResourceComparator { + localResourcesSet .subtracting(serverResourcesSet) - .compactMap { resource -> SyncStrategy? in + .compactMap { resource -> SyncAction? in serverResourcesSet .contains( where: { resource.compareIdentity == $0.compareIdentity } From 92d3dc46ad069e2493bc557703a8d92e9a35b90e Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 24 Jul 2020 16:35:11 +1000 Subject: [PATCH 19/28] Add support for Creating group and add tester together --- .../Sync/TestFlightPushCommand.swift | 135 +++++++++++------- .../Services/AppStoreConnectService.swift | 16 ++- .../Services/SyncActions.swift | 26 +++- 3 files changed, 118 insertions(+), 59 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 23b8f63f..77a2de2d 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -43,66 +43,82 @@ struct TestFlightPushCommand: CommonParsableCommand { render(actions: actions) } else { try process(actions: actions, with: service) - } - - print("Refreshing local configurations...") - try service.pullTestFlightConfigurations().save(in: inputPath) - - print("Refreshing completed.") + print("Refreshing local configurations...") + try service.pullTestFlightConfigurations().save(in: inputPath) + print("Refreshing completed.") + } } func render(actions: [AppSyncActions]) { - print("'Dry Run' mode activated, changes will not be applied. \n") + print("'Dry Run' mode activated, changes will not be applied. ") actions.forEach { - print("\($0.app.name ?? ""):") + print("\n\($0.app.name ?? ""): ") + // 1. app testers - print("- Testers in App: ") - $0.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } + if $0.appTestersSyncActions.isNotEmpty { + print("\n- Testers in App: ") + $0.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } + } // 2. BetaGroups in App - print("- BetaGroups in App: ") - $0.betaGroupSyncActions.forEach { $0.render(dryRun: dryRun) } + if $0.betaGroupSyncActions.isNotEmpty { + print("\n- BetaGroups in App: ") + $0.betaGroupSyncActions.forEach { $0.render(dryRun: dryRun) } + } // 3. Testers in BetaGroup - print("- Testers In Beta Group: ") - $0.testerInGroupsAction.forEach { - print("\($0.betaGroup.groupName):") - $0.testerActions.forEach { $0.render(dryRun: dryRun) } + if $0.testerInGroupsAction.isNotEmpty { + print("\n- Testers In Beta Group: ") + $0.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("\(appAction.app.name ?? ""): ") + var appAction = appAction + + print("\n\(appAction.app.name ?? ""): ") // 1. app testers - print("- Testers in App: ") - try processAppTesterActions( - appAction.appTestersSyncActions, - appId: appAction.app.id, - service: service - ) + if appAction.appTestersSyncActions.isNotEmpty { + print("\n- Testers in App: ") + try processAppTesterActions( + appAction.appTestersSyncActions, + appId: appAction.app.id, + service: service + ) + } // 2. beta groups in app - print("- BetaGroups in App: ") - try processBetagroupsActions( - appAction.betaGroupSyncActions, - appId: appAction.app.id, - service: service - ) - - // 3. testers in beta group - print("- Testers In Beta Group: ") - try appAction.testerInGroupsAction.forEach { - try processTestersInBetaGroupActions( - $0.testerActions, - betagroupId: $0.betaGroup.id!, - appTesters: appAction.appTesters, + if appAction.betaGroupSyncActions.isNotEmpty { + print("\n- BetaGroups 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 { + try processTestersInBetaGroupActions( + $0.testerActions, + betagroupId: $0.betaGroup.id!, + appTesters: appAction.appTesters, + service: service + ) + } + } } } @@ -137,13 +153,17 @@ struct TestFlightPushCommand: CommonParsableCommand { return nil } + let testerActions = SyncResourceComparator( + localResources: localBetagroup.testers, + serverResources: serverBetaGroup.testers + ) + .compare() + + if testerActions.isEmpty { return nil } + return BetaTestersInGroupActions( betaGroup: localBetagroup, - testerActions: SyncResourceComparator( - localResources: localBetagroup.testers, - serverResources: serverBetaGroup.testers - ) - .compare() + testerActions: testerActions ) } @@ -169,17 +189,30 @@ struct TestFlightPushCommand: CommonParsableCommand { } } - func processBetagroupsActions(_ actions: [SyncAction], appId: String, service: AppStoreConnectService) throws { + func processBetagroupsActions(_ actions: [SyncAction], appId: String, appAction: inout AppSyncActions, service: AppStoreConnectService) throws { try actions.forEach { action in switch action { case .create(let betagroup): - _ = try service.createBetaGroup( + 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) @@ -222,13 +255,13 @@ struct TestFlightPushCommand: CommonParsableCommand { return nil } - try creatingTestersWithStrategy.forEach { - try service.inviteBetaTesterToGroups( - email: $0.tester.email, - groupId: betagroupId, - firstName: $0.tester.firstName, - lastName: $0.tester.lastName - ) + try creatingTestersWithStrategy.forEach { + try service.inviteBetaTesterToGroups( + email: $0.tester.email, + groupId: betagroupId, + firstName: $0.tester.firstName, + lastName: $0.tester.lastName + ) $0.strategy.render(dryRun: dryRun) } diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 6f2ca542..5f972b7f 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -527,8 +527,8 @@ class AppStoreConnectService { groupName: String, publicLinkEnabled: Bool, publicLinkLimit: Int? - ) throws { - _ = try CreateBetaGroupWithAppIdOperation( + ) throws -> FileSystem.BetaGroup { + let sdkGroup = try CreateBetaGroupWithAppIdOperation( options: .init( appId: appId, groupName: groupName, @@ -538,6 +538,18 @@ class AppStoreConnectService { ) .execute(with: requestor) .await() + + return FileSystem.BetaGroup( + id: sdkGroup.id, + groupName: (sdkGroup.attributes?.name)!, + isInternal: sdkGroup.attributes?.isInternalGroup, + publicLink: sdkGroup.attributes?.publicLink, + publicLinkEnabled: sdkGroup.attributes?.publicLinkEnabled, + publicLinkLimit: sdkGroup.attributes?.publicLinkLimit, + publicLinkLimitEnabled: sdkGroup.attributes?.publicLinkLimitEnabled, + creationDate: sdkGroup.attributes?.createdDate?.formattedDate, + testers: [] + ) } func updateBetaGroup(betaGroup: FileSystem.BetaGroup) throws { diff --git a/Sources/AppStoreConnectCLI/Services/SyncActions.swift b/Sources/AppStoreConnectCLI/Services/SyncActions.swift index 263af1e6..f68fbdfb 100644 --- a/Sources/AppStoreConnectCLI/Services/SyncActions.swift +++ b/Sources/AppStoreConnectCLI/Services/SyncActions.swift @@ -4,14 +4,28 @@ import Foundation import FileSystem import Model -struct AppSyncActions { - let app: Model.App - let appTesters: [FileSystem.BetaTester] +class AppSyncActions { + var app: Model.App + var appTesters: [FileSystem.BetaTester] - let appTestersSyncActions: [SyncAction] - let betaGroupSyncActions: [SyncAction] + var appTestersSyncActions: [SyncAction] + var betaGroupSyncActions: [SyncAction] - let testerInGroupsAction: [BetaTestersInGroupActions] + 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 { From b69901d87cb19bb93adcf91945a15374fc24a6e3 Mon Sep 17 00:00:00 2001 From: Decheng Date: Fri, 24 Jul 2020 16:37:37 +1000 Subject: [PATCH 20/28] fix swift lint issues --- .../Commands/TestFlight/Sync/TestFlightPushCommand.swift | 7 ++++++- .../Services/AppStoreConnectService.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 77a2de2d..1f5f9042 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -189,7 +189,12 @@ struct TestFlightPushCommand: CommonParsableCommand { } } - func processBetagroupsActions(_ actions: [SyncAction], appId: String, appAction: inout AppSyncActions, service: AppStoreConnectService) throws { + func processBetagroupsActions( + _ actions: [SyncAction], + appId: String, + appAction: inout AppSyncActions, + service: AppStoreConnectService + ) throws { try actions.forEach { action in switch action { case .create(let betagroup): diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 5f972b7f..4808ebc0 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -538,7 +538,7 @@ class AppStoreConnectService { ) .execute(with: requestor) .await() - + return FileSystem.BetaGroup( id: sdkGroup.id, groupName: (sdkGroup.attributes?.name)!, From c2fe7ff50a4f824d82ce6d6c358ec6f456822e2b Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 27 Jul 2020 16:13:41 +1000 Subject: [PATCH 21/28] Make convenience init for BetaGroup and Testers for init sdk model --- .../AppStoreConnectCLI/Model/BetaGroup.swift | 28 +++++++++++++++---- .../AppStoreConnectCLI/Model/BetaTester.swift | 16 +++++++++-- .../ListBetaTestersByGroupOperation.swift | 2 +- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift index 8880c04e..9ae24598 100755 --- a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift @@ -4,11 +4,10 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation import FileSystem -import struct Model.App -import struct Model.BetaGroup +import Model import SwiftyTextTable -extension BetaGroup: TableInfoProvider, ResultRenderable { +extension Model.BetaGroup: TableInfoProvider, ResultRenderable { static func tableColumns() -> [TextTableColumn] { [ @@ -41,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, @@ -70,3 +69,22 @@ extension FileSystem.BetaGroup: SyncResourceProcessable { } } + +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 e434f369..0778d34e 100644 --- a/Sources/AppStoreConnectCLI/Model/BetaTester.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaTester.swift @@ -4,10 +4,10 @@ import AppStoreConnect_Swift_SDK import Combine import Foundation import FileSystem -import struct Model.BetaTester +import Model import SwiftyTextTable -extension BetaTester { +extension Model.BetaTester { init(_ output: GetBetaTesterOperation.Output) { let attributes = output.betaTester.attributes let relationships = output.betaTester.relationships @@ -36,7 +36,7 @@ extension BetaTester { } } -extension BetaTester: ResultRenderable, TableInfoProvider { +extension Model.BetaTester: ResultRenderable, TableInfoProvider { static func tableColumns() -> [TextTableColumn] { return [ TextTableColumn(header: "Email"), @@ -72,6 +72,16 @@ extension FileSystem.BetaTester: SyncResourceProcessable { } +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 diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift index 621b9890..8b9861dc 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift @@ -19,7 +19,7 @@ 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) } From 0e5363c60878cc95cdd3f2a340676a52beacbf51 Mon Sep 17 00:00:00 2001 From: Decheng Date: Mon, 27 Jul 2020 16:15:29 +1000 Subject: [PATCH 22/28] Optimised pullTestFlightConfigurations for running request in parallel --- .../Services/AppStoreConnectService.swift | 90 +++++++++---------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 4808ebc0..59dcd78e 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -539,17 +539,7 @@ class AppStoreConnectService { .execute(with: requestor) .await() - return FileSystem.BetaGroup( - id: sdkGroup.id, - groupName: (sdkGroup.attributes?.name)!, - isInternal: sdkGroup.attributes?.isInternalGroup, - publicLink: sdkGroup.attributes?.publicLink, - publicLinkEnabled: sdkGroup.attributes?.publicLinkEnabled, - publicLinkLimit: sdkGroup.attributes?.publicLinkLimit, - publicLinkLimitEnabled: sdkGroup.attributes?.publicLinkLimitEnabled, - creationDate: sdkGroup.attributes?.createdDate?.formattedDate, - testers: [] - ) + return FileSystem.BetaGroup(sdkGroup, testersEmails: []) } func updateBetaGroup(betaGroup: FileSystem.BetaGroup) throws { @@ -985,50 +975,56 @@ 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() throws -> [TestFlightConfiguration] { let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) - return try apps.map { - let testers: [FileSystem.BetaTester] = try ListBetaTestersOperation( - options: .init(appIds: [$0.id]) + return try apps.map { (app: Model.App) -> TestFlightConfiguration in + let appTesters = try ListBetaTestersOperation( + options: .init(appIds: [app.id]) ) - .execute(with: requestor) - .await() - .map { - FileSystem.BetaTester( - email: ($0.betaTester.attributes?.email)!, - firstName: $0.betaTester.attributes?.firstName, - lastName: $0.betaTester.attributes?.lastName - ) - } + .execute(with: self.requestor) + .map { $0.compactMap { $0.betaTester } } - let betagroups = try ListBetaGroupsOperation( - options: .init(appIds: [$0.id], names: [], sort: nil) + let fileSystemBetaGroups = ListBetaGroupsOperation( + options: .init(appIds: [app.id], names: [], sort: nil) ) - .execute(with: requestor) - .await() - .map { output -> FileSystem.BetaGroup in - let testersEmails = try ListBetaTestersOperation( - options: .init(groupIds: [output.betaGroup.id]) - ) - .execute(with: requestor) - .await() - .compactMap { $0.betaTester.attributes?.email } - - return FileSystem.BetaGroup( - id: output.betaGroup.id, - groupName: (output.betaGroup.attributes?.name)!, - isInternal: output.betaGroup.attributes?.isInternalGroup, - publicLink: output.betaGroup.attributes?.publicLink, - publicLinkEnabled: output.betaGroup.attributes?.publicLinkEnabled, - publicLinkLimit: output.betaGroup.attributes?.publicLinkLimit, - publicLinkLimitEnabled: output.betaGroup.attributes?.publicLinkLimitEnabled, - creationDate: output.betaGroup.attributes?.createdDate?.formattedDate, - testers: testersEmails - ) + .execute(with: self.requestor) + .map { $0.compactMap { $0.betaGroup } } + .flatMap { + Publishers + .MergeMany($0.compactMap { self.populateFileSystemBetaGroup(from: $0) }) + .collect() + .eraseToAnyPublisher() } - return TestFlightConfiguration(app: $0, testers: testers, betagroups: betagroups) + return try Publishers + .CombineLatest(appTesters, fileSystemBetaGroups) + .tryMap { (testers, groups) -> TestFlightConfiguration in + TestFlightConfiguration( + app: app, + testers: testers.map { FileSystem.BetaTester($0) }, + betagroups: groups + ) + } + .await() } } From 27f4ebd37e99088aea7f9d243375b92bcf7a4c94 Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 28 Jul 2020 12:20:57 +1000 Subject: [PATCH 23/28] Helper Text polishing. Add sync by bundleId feature for pushing --- .../Sync/TestFlightPushCommand.swift | 58 ++++++++++++++----- .../Services/AppStoreConnectService.swift | 4 +- .../Model/TestFlightConfiguration.swift | 13 +++++ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index 1f5f9042..bad6504d 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -21,18 +21,23 @@ struct TestFlightPushCommand: CommonParsableCommand { ) 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 configs... \n") + print("Loading local TestFlight configurations... \n") + let localConfigurations = try [TestFlightConfiguration](from: inputPath, with: bundleIds) - let localConfigurations = try [TestFlightConfiguration](from: inputPath) - - print("Loading server TestFlight configs... \n") - let serverConfigurations = try service.pullTestFlightConfigurations() + print("Loading server TestFlight configurations... \n") + let serverConfigurations = try service.pullTestFlightConfigurations(with: bundleIds) let actions = compare( serverConfigurations: serverConfigurations, @@ -44,7 +49,7 @@ struct TestFlightPushCommand: CommonParsableCommand { } else { try process(actions: actions, with: service) - print("Refreshing local configurations...") + print("\nRefreshing local configurations...") try service.pullTestFlightConfigurations().save(in: inputPath) print("Refreshing completed.") } @@ -53,25 +58,43 @@ struct TestFlightPushCommand: CommonParsableCommand { func render(actions: [AppSyncActions]) { print("'Dry Run' mode activated, changes will not be applied. ") - actions.forEach { - print("\n\($0.app.name ?? ""): ") + actions.forEach { action in + if action.appTestersSyncActions.isNotEmpty || + action.testerInGroupsAction.isNotEmpty || + action.betaGroupSyncActions.isNotEmpty { + print("\n\(action.app.name ?? ""): ") + } // 1. app testers - if $0.appTestersSyncActions.isNotEmpty { + if action.appTestersSyncActions.isNotEmpty { print("\n- Testers in App: ") - $0.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } + action.appTestersSyncActions.forEach { $0.render(dryRun: dryRun) } } // 2. BetaGroups in App - if $0.betaGroupSyncActions.isNotEmpty { + if action.betaGroupSyncActions.isNotEmpty { print("\n- BetaGroups in App: ") - $0.betaGroupSyncActions.forEach { $0.render(dryRun: dryRun) } + 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 $0.testerInGroupsAction.isNotEmpty { + if action.testerInGroupsAction.isNotEmpty { print("\n- Testers In Beta Group: ") - $0.testerInGroupsAction.forEach { + action.testerInGroupsAction.forEach { if $0.testerActions.isNotEmpty { print("\($0.betaGroup.groupName):") $0.testerActions.forEach { $0.render(dryRun: dryRun) } @@ -83,9 +106,14 @@ struct TestFlightPushCommand: CommonParsableCommand { private func process(actions: [AppSyncActions], with service: AppStoreConnectService) throws { try actions.forEach { appAction in + if appAction.appTestersSyncActions.isNotEmpty || + appAction.testerInGroupsAction.isNotEmpty || + appAction.betaGroupSyncActions.isNotEmpty { + print("\n\(appAction.app.name ?? ""): ") + } + var appAction = appAction - print("\n\(appAction.app.name ?? ""): ") // 1. app testers if appAction.appTestersSyncActions.isNotEmpty { print("\n- Testers in App: ") diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 59dcd78e..e9f0c690 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -993,8 +993,8 @@ class AppStoreConnectService { .eraseToAnyPublisher() } - func pullTestFlightConfigurations() throws -> [TestFlightConfiguration] { - let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) + func pullTestFlightConfigurations(with bundleIds: [String] = []) throws -> [TestFlightConfiguration] { + let apps = try listApps(bundleIds: bundleIds, names: [], skus: [], limit: nil) return try apps.map { (app: Model.App) -> TestFlightConfiguration in let appTesters = try ListBetaTestersOperation( diff --git a/Sources/FileSystem/Model/TestFlightConfiguration.swift b/Sources/FileSystem/Model/TestFlightConfiguration.swift index 55423002..dae08f8b 100644 --- a/Sources/FileSystem/Model/TestFlightConfiguration.swift +++ b/Sources/FileSystem/Model/TestFlightConfiguration.swift @@ -76,4 +76,17 @@ extension Array where Element == TestFlightConfiguration { 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 + } + } + } } From 47a4b8f948c5c8799e8bb4531bd8d90fad924850 Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 28 Jul 2020 13:00:03 +1000 Subject: [PATCH 24/28] Optimise invite Tester to group to run in parallel --- .../Sync/TestFlightPushCommand.swift | 15 ++++------ .../Services/AppStoreConnectService.swift | 30 ++++++++++--------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift index bad6504d..20b7bf3b 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -288,17 +288,12 @@ struct TestFlightPushCommand: CommonParsableCommand { return nil } - try creatingTestersWithStrategy.forEach { - try service.inviteBetaTesterToGroups( - email: $0.tester.email, - groupId: betagroupId, - firstName: $0.tester.firstName, - lastName: $0.tester.lastName - ) - - $0.strategy.render(dryRun: dryRun) - } + try service.inviteTestersToGroup( + betaTesters: creatingTestersWithStrategy.map { $0.tester }, + groupId: betagroupId + ) + creatingTestersWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } } } diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index e9f0c690..377776b8 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -159,22 +159,24 @@ class AppStoreConnectService { return Model.BetaTester(output) } - func inviteBetaTesterToGroups( - email: String, - groupId: String, - firstName: String?, - lastName: String? + func inviteTestersToGroup( + betaTesters: [FileSystem.BetaTester], + groupId: String ) throws { - _ = try InviteTesterOperation( - options: .init( - firstName: firstName, - lastName: lastName, - email: email, - identifers: .resourceId([groupId]) - ) + _ = try Publishers.MergeMany( + betaTesters.map { + try InviteTesterOperation( + options: .init( + firstName: $0.firstName, + lastName: $0.lastName, + email: $0.email, + identifers: .resourceId([groupId]) + ) + ) + .execute(with: requestor) + } ) - .execute(with: requestor) - .await() + .awaitMany() } func addTestersToGroup( From 49a5a8e6c0336a9deecec858857ed2bd64dd00df Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 4 Aug 2020 16:57:59 +1000 Subject: [PATCH 25/28] Operation optimisation in List and remove BetaTester --- .../ListBetaTestersByGroupOperation.swift | 2 +- .../Operations/ListBetaTestersOperation.swift | 21 +++++++++++++++++-- .../Operations/RemoveTesterOperation.swift | 6 +++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift index 8b9861dc..68967d5d 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersByGroupOperation.swift @@ -21,7 +21,7 @@ struct ListBetaTestersByGroupOperation: APIOperation { 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 5e13a3ca..b8ea33c7 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift @@ -83,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, @@ -104,6 +120,7 @@ struct ListBetaTestersOperation: APIOperation { } } .eraseToAnyPublisher() + } } } diff --git a/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift index 3ea642bc..40d58353 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/RemoveTesterOperation.swift @@ -10,7 +10,7 @@ struct RemoveTesterOperation: APIOperation { enum RemoveStrategy { case removeTestersFromGroup(testerIds: [String], groupId: String) case removeTesterFromGroups(testerId: String, groupIds: [String]) - case removeTestersFromApp(testerId: String, appId: String) + case removeTestersFromApp(testerIds: [String], appId: String) } let removeStrategy: RemoveStrategy @@ -24,8 +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 testerId, let appId): - return APIEndpoint.remove(accessOfBetaTesterWithId: testerId, toAppsWithIds: [appId]) + case .removeTestersFromApp(let testerIds, let appId): + return APIEndpoint.remove(betaTestersWithIds: testerIds, fromGroupsAndBuildsOfAppWithId: appId) } } From b8d4f02e67209675c82e3076e3e8c72774ec8951 Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 4 Aug 2020 16:59:53 +1000 Subject: [PATCH 26/28] Optimise service pullTestFlightConfigurations to use chunk(into: ) --- .../Helpers/Array+Helpers.swift | 11 +++ .../Helpers/Publisher+Helpers.swift | 6 ++ .../AppStoreConnectCLI/Model/BetaTester.swift | 6 ++ .../Services/AppStoreConnectService.swift | 90 ++++++++++--------- 4 files changed, 71 insertions(+), 42 deletions(-) create mode 100644 Sources/AppStoreConnectCLI/Helpers/Array+Helpers.swift 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/BetaTester.swift b/Sources/AppStoreConnectCLI/Model/BetaTester.swift index 0778d34e..2fca0edc 100644 --- a/Sources/AppStoreConnectCLI/Model/BetaTester.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaTester.swift @@ -91,3 +91,9 @@ extension String: SyncResourceProcessable { self } } + +extension Array where Element == FileSystem.BetaTester { + init(_ sdkBetaTesters: [AppStoreConnect_Swift_SDK.BetaTester]) { + self = sdkBetaTesters.map { FileSystem.BetaTester($0) } + } +} diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 377776b8..5b70e257 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -163,20 +163,23 @@ class AppStoreConnectService { betaTesters: [FileSystem.BetaTester], groupId: String ) throws { - _ = try Publishers.MergeMany( - betaTesters.map { - try InviteTesterOperation( - options: .init( - firstName: $0.firstName, - lastName: $0.lastName, - email: $0.email, - identifers: .resourceId([groupId]) + _ = 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) + .execute(with: requestor) + } + .merge() + .awaitMany() } - ) - .awaitMany() } func addTestersToGroup( @@ -463,24 +466,30 @@ class AppStoreConnectService { try operation.execute(with: requestor).await() } - func removeTesterFromApp(testerEmail: String, appId: String) throws { - let testerId = try GetBetaTesterOperation( - options: .init( - identifier: .email(testerEmail), - limitApps: nil, - limitBuilds: nil, - limitBetaGroups: nil - ) - ) - .execute(with: requestor) - .await() - .betaTester - .id + 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( - testerId: testerId, + testerIds: testerIds, appId: appId ) ) @@ -1004,29 +1013,26 @@ class AppStoreConnectService { ) .execute(with: self.requestor) .map { $0.compactMap { $0.betaTester } } + .await() - let fileSystemBetaGroups = ListBetaGroupsOperation( + let fileSystemBetaGroups = try ListBetaGroupsOperation( options: .init(appIds: [app.id], names: [], sort: nil) ) .execute(with: self.requestor) - .map { $0.compactMap { $0.betaGroup } } + .await() + .map { $0.betaGroup } + .chunked(into: 5) .flatMap { - Publishers - .MergeMany($0.compactMap { self.populateFileSystemBetaGroup(from: $0) }) - .collect() - .eraseToAnyPublisher() + try $0.map(self.populateFileSystemBetaGroup) + .merge() + .awaitMany() } - return try Publishers - .CombineLatest(appTesters, fileSystemBetaGroups) - .tryMap { (testers, groups) -> TestFlightConfiguration in - TestFlightConfiguration( - app: app, - testers: testers.map { FileSystem.BetaTester($0) }, - betagroups: groups - ) - } - .await() + return TestFlightConfiguration( + app: app, + testers: [FileSystem.BetaTester](appTesters), + betagroups: fileSystemBetaGroups + ) } } From e5fca763269c8befaef55e1a7311170d2df998e7 Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 4 Aug 2020 17:31:07 +1000 Subject: [PATCH 27/28] UI optimisation in Push and Pull command --- .../Sync/TestFlightPullCommand.swift | 4 + .../Sync/TestFlightPushCommand.swift | 183 ++++++++++-------- .../Services/AppStoreConnectService.swift | 4 +- 3 files changed, 105 insertions(+), 86 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift index de60641a..46e3d9f2 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPullCommand.swift @@ -22,9 +22,13 @@ struct TestFlightPullCommand: CommonParsableCommand { 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 index 20b7bf3b..4e5765ab 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/Sync/TestFlightPushCommand.swift @@ -1,7 +1,9 @@ // Copyright 2020 Itty Bitty Apps Pty Ltd import ArgumentParser -import FileSystem +import struct FileSystem.TestFlightConfiguration +import struct FileSystem.BetaGroup +import struct FileSystem.BetaTester import Foundation import Model @@ -33,11 +35,13 @@ struct TestFlightPushCommand: CommonParsableCommand { func run() throws { let service = try makeService() - print("Loading local TestFlight configurations... \n") + print("Loading local TestFlight configurations...") let localConfigurations = try [TestFlightConfiguration](from: inputPath, with: bundleIds) + print("Loading completed.") - print("Loading server TestFlight configurations... \n") + print("\nLoading server TestFlight configurations...") let serverConfigurations = try service.pullTestFlightConfigurations(with: bundleIds) + print("Loading completed.") let actions = compare( serverConfigurations: serverConfigurations, @@ -56,14 +60,10 @@ struct TestFlightPushCommand: CommonParsableCommand { } func render(actions: [AppSyncActions]) { - print("'Dry Run' mode activated, changes will not be applied. ") + print("\n'Dry Run' mode activated, changes will not be applied. ") actions.forEach { action in - if action.appTestersSyncActions.isNotEmpty || - action.testerInGroupsAction.isNotEmpty || - action.betaGroupSyncActions.isNotEmpty { - print("\n\(action.app.name ?? ""): ") - } + print("\n\(action.app.name ?? ""): ") // 1. app testers if action.appTestersSyncActions.isNotEmpty { @@ -83,7 +83,7 @@ struct TestFlightPushCommand: CommonParsableCommand { .init( betaGroup: betagroup, testerActions: betagroup.testers.map { - SyncAction.create($0) + SyncAction.create($0) } ) ) @@ -106,39 +106,30 @@ struct TestFlightPushCommand: CommonParsableCommand { private func process(actions: [AppSyncActions], with service: AppStoreConnectService) throws { try actions.forEach { appAction in - if appAction.appTestersSyncActions.isNotEmpty || - appAction.testerInGroupsAction.isNotEmpty || - appAction.betaGroupSyncActions.isNotEmpty { - print("\n\(appAction.app.name ?? ""): ") - } + print("\n\(appAction.app.name ?? ""): ") var appAction = appAction // 1. app testers - if appAction.appTestersSyncActions.isNotEmpty { - print("\n- Testers in App: ") - try processAppTesterActions( - appAction.appTestersSyncActions, - appId: appAction.app.id, - service: service - ) - } + try processAppTesterActions( + appAction.appTestersSyncActions, + appId: appAction.app.id, + service: service + ) // 2. beta groups in app - if appAction.betaGroupSyncActions.isNotEmpty { - print("\n- BetaGroups in App: ") - try processBetagroupsActions( - appAction.betaGroupSyncActions, - appId: appAction.app.id, - appAction: &appAction, - service: service - ) - } + 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!, @@ -195,6 +186,12 @@ struct TestFlightPushCommand: CommonParsableCommand { ) } + guard appTesterSyncActions.isNotEmpty || + betaGroupSyncActions.isNotEmpty || + testerInGroupsAction.isNotEmpty else { + return nil + } + return AppSyncActions( app: localConfiguration.app, appTesters: localConfiguration.testers, @@ -205,82 +202,96 @@ struct TestFlightPushCommand: CommonParsableCommand { } } - func processAppTesterActions(_ actions: [SyncAction], appId: String, service: AppStoreConnectService) throws { - try actions.forEach { action in - switch action { - case .delete(let betatester): - try service.removeTesterFromApp(testerEmail: betatester.email, appId: appId) - action.render(dryRun: dryRun) - default: - return + 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], + _ actions: [SyncAction], appId: String, appAction: inout AppSyncActions, service: AppStoreConnectService ) throws { - 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) - } + 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) + 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], + _ actions: [SyncAction], betagroupId: String, - appTesters: [FileSystem.BetaTester], + appTesters: [BetaTester], service: AppStoreConnectService ) throws { - let deletingEmailsWithStrategy = actions - .compactMap { (action: SyncAction) -> - (email: String, strategy: SyncAction)? in + let deletingEmailsWithStrategy: [(email: String, strategy: SyncAction)] = actions + .compactMap { action in if case .delete(let email) = action { return (email, action) } return nil } - try service.removeTestersFromGroup( - emails: deletingEmailsWithStrategy.map { $0.email }, - groupId: betagroupId - ) + if deletingEmailsWithStrategy.isNotEmpty { + try service.removeTestersFromGroup( + emails: deletingEmailsWithStrategy.map { $0.email }, + groupId: betagroupId + ) - deletingEmailsWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + deletingEmailsWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + } let creatingTestersWithStrategy = actions - .compactMap { (strategy: SyncAction) -> - (tester: FileSystem.BetaTester, strategy: SyncAction)? in + .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) @@ -288,12 +299,14 @@ struct TestFlightPushCommand: CommonParsableCommand { return nil } - try service.inviteTestersToGroup( - betaTesters: creatingTestersWithStrategy.map { $0.tester }, - groupId: betagroupId - ) + if creatingTestersWithStrategy.isNotEmpty { + try service.inviteTestersToGroup( + betaTesters: creatingTestersWithStrategy.map { $0.tester }, + groupId: betagroupId + ) - creatingTestersWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + creatingTestersWithStrategy.forEach { $0.strategy.render(dryRun: dryRun) } + } } } diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index 5b70e257..ac8e1326 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -1007,7 +1007,7 @@ class AppStoreConnectService { func pullTestFlightConfigurations(with bundleIds: [String] = []) throws -> [TestFlightConfiguration] { let apps = try listApps(bundleIds: bundleIds, names: [], skus: [], limit: nil) - return try apps.map { (app: Model.App) -> TestFlightConfiguration in + let configurations: [TestFlightConfiguration] = try apps.map { app in let appTesters = try ListBetaTestersOperation( options: .init(appIds: [app.id]) ) @@ -1034,6 +1034,8 @@ class AppStoreConnectService { betagroups: fileSystemBetaGroups ) } + + return configurations } /// Make a request for something `Decodable`. From 8e600f14961fa65039a3dff682b658a6a35e825b Mon Sep 17 00:00:00 2001 From: Decheng Date: Tue, 4 Aug 2020 18:23:38 +1000 Subject: [PATCH 28/28] optimised removeTestersFromGroup to request 5 times per sec --- .../Services/AppStoreConnectService.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index ac8e1326..2e1fa7c6 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -447,15 +447,19 @@ class AppStoreConnectService { } func removeTestersFromGroup(emails: [String], groupId: String) throws { - let testerIds = try emails.map { - try GetBetaTesterOperation( - options: .init(identifier: .email($0)) - ) - .execute(with: requestor) - .await() - .betaTester - .id - } + 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(