-
Notifications
You must be signed in to change notification settings - Fork 18
Implement sync testflight config command #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 5 commits
e972651
114d95b
abefab7
53c6436
c59e0eb
2066c54
c298746
6ebab0c
ebdbae0
2a0fe03
41ca74d
2ca5b7a
af0b5d8
bec00a4
a4388f4
d0c1fad
4ef448d
5d36cf9
5be708c
3e4f8fe
92d3dc4
b69901d
c2fe7ff
0e5363c
27f4ebd
47a4b8f
49a5a8e
b8d4f02
e5fca76
8e600f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,4 +30,4 @@ Temporary Items | |
| /Packages | ||
| /*.xcodeproj | ||
| xcuserdata/ | ||
| config/auth.yml | ||
| /config | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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." | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| ) var outputPath: String | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should be able to filter the pulled apps by bundleId. |
||
| func run() throws { | ||
| let service = try makeService() | ||
|
|
||
| let configs = try service.pullTestFlightConfigs() | ||
|
|
||
| configs.forEach { | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| print($0.app.name) | ||
| print($0.betagroups.count) | ||
| } | ||
|
|
||
| try TestFlightConfigLoader().save(configs, in: outputPath) | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| @OptionGroup() | ||
| var common: CommonOptions | ||
|
|
||
| @Option( | ||
| default: "./config/apps", | ||
| help: "Path to the Folder containing the testflight configs." | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| ) var inputPath: String | ||
|
|
||
| @Flag(help: "Perform a dry run.") | ||
| var dryRun: Bool | ||
|
|
||
| func run() throws { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So the fundamental problem with this command is that you're executing the service requests as you're processing. This should be more functional. Eg: |
||
| 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<FileSystem.BetaTester>], appId: String) { | ||
| if dryRun { | ||
| SyncResultRenderer<FileSystem.BetaTester>().render(strategies, isDryRun: true) | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| strategies.forEach { strategy in | ||
| switch strategy { | ||
| case .delete(let betatester): | ||
| print("delete testers \(betatester) from app \(appId)") | ||
| default: | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } | ||
|
|
||
| func processBetagroupsStrategies(_ strategies: [SyncStrategy<FileSystem.BetaGroup>], appId: String) { | ||
| if dryRun { | ||
| SyncResultRenderer<FileSystem.BetaGroup>().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<BetaGroup.EmailAddress>], betagroupId: String, appTesters: [BetaTester]) { | ||
| if dryRun { | ||
| SyncResultRenderer<FileSystem.BetaGroup.EmailAddress>().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<T: Codable>(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)!) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| ] | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,3 +113,32 @@ extension ResultRenderable where Self: TableInfoProvider { | |
| return table.render() | ||
| } | ||
| } | ||
|
|
||
| protocol SyncResultRenderable: Equatable { | ||
| var syncResultText: String { get } | ||
| } | ||
|
|
||
| struct SyncResultRenderer<T: SyncResultRenderable> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should be printing the results as we execute the commands not after. |
||
|
|
||
| func render(_ strategy: [SyncStrategy<T>], isDryRun: Bool) { | ||
| strategy.forEach { renderResultText($0, isDryRun) } | ||
| } | ||
|
|
||
| func render(_ strategy: SyncStrategy<T>, isDryRun: Bool) { | ||
| renderResultText(strategy, isDryRun) | ||
| } | ||
|
|
||
| private func renderResultText(_ strategy: SyncStrategy<T>, _ 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)") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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] { | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
|
|
||
| let apps = try listApps(bundleIds: [], names: [], skus: [], limit: nil) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We will need to support pulling a subset of apps (filtered by bundleIds probably). |
||
|
|
||
|
|
||
| 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() | ||
|
DechengMa marked this conversation as resolved.
Outdated
|
||
| .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: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.