diff --git a/Scripts/generate_azure_devops_services.swift b/Scripts/generate_azure_devops_services.swift new file mode 100755 index 0000000..dd1023d --- /dev/null +++ b/Scripts/generate_azure_devops_services.swift @@ -0,0 +1,143 @@ +#!/usr/bin/swift + +import Foundation + +struct AzureDevOpsService { + let serviceName: String + + var friendlyName: String { + let friendlyServiceName = serviceName.components(separatedBy: " ").map { + $0.capitalized(firstLetterOnly: true) + }.joined(separator: " ") + + return "Azure DevOps \(friendlyServiceName)" + } + + var className: String { + var sanitizedName = serviceName + sanitizedName = sanitizedName.replacingOccurrences(of: " & ", with: "And") + sanitizedName = sanitizedName.replacingOccurrences(of: "/", with: "") + sanitizedName = sanitizedName.replacingOccurrences(of: ":", with: "") + sanitizedName = sanitizedName.components(separatedBy: " ").map { $0.capitalized(firstLetterOnly: true) }.joined(separator: "") + return "AzureDevOps\(sanitizedName)" + } + + var output: String { + return """ + class \(className): AzureDevOps { + let name = "\(friendlyName)" + let serviceName = "\(serviceName)" + } + """ + } +} + +extension String { + subscript(_ range: NSRange) -> String { + // Why we still have to do this shit in 2019 I don't know + let start = self.index(self.startIndex, offsetBy: range.lowerBound) + let end = self.index(self.startIndex, offsetBy: range.upperBound) + let subString = self[start.. String { + return firstLetterOnly ? (prefix(1).capitalized + dropFirst()) : self + } +} + +struct AzureDevOpsDataProviders: Codable { + struct ResponseData: Codable { + struct MetadataProvider: Codable { + let services: [[String: String]] + + var serviceNames: [String] { + return services.compactMap { $0["id"] } + } + } + + enum CodingKeys: String, CodingKey { + case metadataProvider = "ms.vss-status-web.public-status-metadata-data-provider" + } + + let metadataProvider: MetadataProvider + } + + let data: ResponseData +} + +func envVariable(forKey key: String) -> String { + guard let variable = ProcessInfo.processInfo.environment[key] else { + print("error: Environment variable '\(key)' not set") + exit(1) + } + + return variable +} + +func discoverServices() -> [AzureDevOpsService] { + var result = [AzureDevOpsService]() + + var dataResult: Data? + + let semaphore = DispatchSemaphore(value: 0) + URLSession.shared.dataTask(with: URL(string: "https://status.dev.azure.com")!) { data, _, _ in + dataResult = data + semaphore.signal() + }.resume() + + _ = semaphore.wait(timeout: .now() + .seconds(10)) + + guard let data = dataResult, var body = String(data: data, encoding: .utf8) else { + print("warning: Build script generate_azure_devops_services could not retrieve list of Azure DevOps services") + exit(0) + } + + body = body.replacingOccurrences(of: "\n", with: "") + + // swiftlint:disable:next force_try + let regex = try! NSRegularExpression( + pattern: "", + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) + + let range = NSRange(location: 0, length: body.count) + regex.enumerateMatches(in: body, options: [], range: range) { textCheckingResult, _, _ in + guard let textCheckingResult = textCheckingResult, textCheckingResult.numberOfRanges == 2 else { return } + + let json = body[textCheckingResult.range(at: 1)] + guard let decodedProviders = try? JSONDecoder().decode(AzureDevOpsDataProviders.self, from: json.data(using: .utf8)!) else { + print("warning: Build script generate_azure_devops_services could not retrieve list of Azure DevOps services") + exit(0) + } + + decodedProviders.data.metadataProvider.serviceNames.forEach { + result.append(AzureDevOpsService(serviceName: $0)) + } + } + + return result +} + +func main() { + let srcRoot = envVariable(forKey: "SRCROOT") + let outputPath = "\(srcRoot)/stts/Services/Generated/AzureDevOpsServices.swift" + let services = discoverServices() + + let header = """ + // This file is generated by generate_azure_devops_services.swift and should not be modified manually. + + import Foundation + + """ + + let content = services.map { $0.output }.joined(separator: "\n\n") + let footer = "" + + let output = [header, content, footer].joined(separator: "\n") + + // swiftlint:disable:next force_try + try! output.write(toFile: outputPath, atomically: true, encoding: .utf8) +} + +main() diff --git a/stts.xcodeproj/project.pbxproj b/stts.xcodeproj/project.pbxproj index 1ee7aa7..bedf03e 100644 --- a/stts.xcodeproj/project.pbxproj +++ b/stts.xcodeproj/project.pbxproj @@ -85,6 +85,9 @@ B271688A2291AEEE001E608C /* Asana.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27168892291AEEE001E608C /* Asana.swift */; }; B276C8F41DD474200098B451 /* MBPopup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B276C8F11DD474200098B451 /* MBPopup.framework */; }; B276C8F71DD4742D0098B451 /* MBPopup.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = B276C8F11DD474200098B451 /* MBPopup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + B277BC9222B4BB5C000B55C3 /* AzureDevOps.swift in Sources */ = {isa = PBXBuildFile; fileRef = B277BC9122B4BB5C000B55C3 /* AzureDevOps.swift */; }; + B277BC9422B4BB6D000B55C3 /* AzureDevOpsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B277BC9322B4BB6D000B55C3 /* AzureDevOpsStore.swift */; }; + B277BC9822B4C16A000B55C3 /* AzureDevOpsServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = B277BC9722B4C16A000B55C3 /* AzureDevOpsServices.swift */; }; B2841CEB20BA6207004AFDB7 /* SorryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2841CEA20BA6207004AFDB7 /* SorryService.swift */; }; B2894CE420EEE2780009CCA3 /* Buildkite.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2894CE320EEE2780009CCA3 /* Buildkite.swift */; }; B2898FD01DC7441D0005F58F /* StatusIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2898FCF1DC7441D0005F58F /* StatusIndicator.swift */; }; @@ -275,6 +278,10 @@ B27090F0209F52EB0094C3D7 /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = ""; }; B27168892291AEEE001E608C /* Asana.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Asana.swift; sourceTree = ""; }; B276C8F11DD474200098B451 /* MBPopup.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MBPopup.framework; path = Carthage/Build/Mac/MBPopup.framework; sourceTree = SOURCE_ROOT; }; + B277BC9122B4BB5C000B55C3 /* AzureDevOps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureDevOps.swift; sourceTree = ""; }; + B277BC9322B4BB6D000B55C3 /* AzureDevOpsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureDevOpsStore.swift; sourceTree = ""; }; + B277BC9522B4BC6C000B55C3 /* generate_azure_devops_services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = generate_azure_devops_services.swift; sourceTree = ""; }; + B277BC9722B4C16A000B55C3 /* AzureDevOpsServices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AzureDevOpsServices.swift; sourceTree = ""; }; B2841CEA20BA6207004AFDB7 /* SorryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SorryService.swift; sourceTree = ""; }; B2894CE320EEE2780009CCA3 /* Buildkite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buildkite.swift; sourceTree = ""; }; B2898FCF1DC7441D0005F58F /* StatusIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusIndicator.swift; sourceTree = ""; }; @@ -647,6 +654,7 @@ B2E70FD222B0650A000BCAD2 /* Scripts */ = { isa = PBXGroup; children = ( + B277BC9522B4BC6C000B55C3 /* generate_azure_devops_services.swift */, B2CEE5DC22B4A9900073C743 /* generate_azure_services.swift */, B2E70FD622B066EC000BCAD2 /* generate_google_services.swift */, B2E70FD322B0657F000BCAD2 /* generate_services_plist.sh */, @@ -657,6 +665,7 @@ B2E70FDF22B0844F000BCAD2 /* Generated */ = { isa = PBXGroup; children = ( + B277BC9722B4C16A000B55C3 /* AzureDevOpsServices.swift */, B2CEE5DA22B4A97B0073C743 /* AzureServices.swift */, B2E70FDC22B07FB0000BCAD2 /* FirebaseServices.swift */, B2E70FD722B069BA000BCAD2 /* GoogleCloudPlatformServices.swift */, @@ -699,6 +708,8 @@ isa = PBXGroup; children = ( B2CEE5D622B49CB50073C743 /* Azure.swift */, + B277BC9122B4BB5C000B55C3 /* AzureDevOps.swift */, + B277BC9322B4BB6D000B55C3 /* AzureDevOpsStore.swift */, B2CEE5D822B49CC80073C743 /* AzureStore.swift */, B2BB19B920B8FEFD00A97A87 /* CachetService.swift */, B2EB51CD1E7004A3001D6F78 /* ExanaService.swift */, @@ -977,6 +988,7 @@ B2D2B6CF20870E700011723B /* StatusPage.swift in Sources */, B28FF68B1E94761A005897A9 /* Codecov.swift in Sources */, B256955A1E247C8C00FAE413 /* Cloudinary.swift in Sources */, + B277BC9822B4C16A000B55C3 /* AzureDevOpsServices.swift in Sources */, B2DCE6902215992E001447D8 /* Apple.swift in Sources */, B2F127A91E0B84B50035B20C /* Twilio.swift in Sources */, B28E9FC01ED26A40006B89F1 /* Lob.swift in Sources */, @@ -1019,6 +1031,7 @@ B299C8451DD046F20024D2E9 /* AmazonWebServices.swift in Sources */, B256955B1E247C8C00FAE413 /* HipChat.swift in Sources */, B299C8551DD051210024D2E9 /* EngineYard.swift in Sources */, + B277BC9222B4BB5C000B55C3 /* AzureDevOps.swift in Sources */, B2C10F3C203646A7008E1E7D /* Gandi.swift in Sources */, B21308561E55594E009FCF02 /* Quandl.swift in Sources */, B22600311E1562E700EBDD40 /* MediaTemple.swift in Sources */, @@ -1047,6 +1060,7 @@ B20699D2214E504F008670B0 /* Robinhood.swift in Sources */, B22527F51E5BD0C70098E73D /* Contentful.swift in Sources */, B24F91231E8B51A400A77460 /* Auth0.swift in Sources */, + B277BC9422B4BB6D000B55C3 /* AzureDevOpsStore.swift in Sources */, B230CE7D21B95B5D00E2005D /* Wasabi.swift in Sources */, 2F65F490227A198400432E64 /* Recurly.swift in Sources */, B2F34041222630F20055C96F /* CloudApp.swift in Sources */, diff --git a/stts/Services/Generated/AzureDevOpsServices.swift b/stts/Services/Generated/AzureDevOpsServices.swift new file mode 100644 index 0000000..769dd2d --- /dev/null +++ b/stts/Services/Generated/AzureDevOpsServices.swift @@ -0,0 +1,38 @@ +// This file is generated by generate_azure_devops_services.swift and should not be modified manually. + +import Foundation + +class AzureDevOpsCoreServices: AzureDevOps { + let name = "Azure DevOps Core Services" + let serviceName = "Core services" +} + +class AzureDevOpsBoards: AzureDevOps { + let name = "Azure DevOps Boards" + let serviceName = "Boards" +} + +class AzureDevOpsRepos: AzureDevOps { + let name = "Azure DevOps Repos" + let serviceName = "Repos" +} + +class AzureDevOpsPipelines: AzureDevOps { + let name = "Azure DevOps Pipelines" + let serviceName = "Pipelines" +} + +class AzureDevOpsTestPlans: AzureDevOps { + let name = "Azure DevOps Test Plans" + let serviceName = "Test Plans" +} + +class AzureDevOpsArtifacts: AzureDevOps { + let name = "Azure DevOps Artifacts" + let serviceName = "Artifacts" +} + +class AzureDevOpsOtherServices: AzureDevOps { + let name = "Azure DevOps Other Services" + let serviceName = "Other services" +} diff --git a/stts/Services/Super/AzureDevOps.swift b/stts/Services/Super/AzureDevOps.swift new file mode 100644 index 0000000..de41f8a --- /dev/null +++ b/stts/Services/Super/AzureDevOps.swift @@ -0,0 +1,28 @@ +// +// AzureDevOpsDevOps.swift +// stts +// + +import Foundation + +typealias AzureDevOps = BaseAzureDevOps & RequiredServiceProperties & AzureDevOpsStoreService + +class BaseAzureDevOps: BaseService { + private static var store = AzureDevOpsStore() + + let url = URL(string: "https://status.dev.azure.com")! + + override func updateStatus(callback: @escaping (BaseService) -> Void) { + guard let realSelf = self as? AzureDevOps else { fatalError("BaseAzureDevOps should not be used directly.") } + + BaseAzureDevOps.store.loadStatus { [weak realSelf] in + guard let strongSelf = realSelf else { return } + + let (status, message) = BaseAzureDevOps.store.status(for: strongSelf) + strongSelf.status = status + strongSelf.message = message + + callback(strongSelf) + } + } +} diff --git a/stts/Services/Super/AzureDevOpsStore.swift b/stts/Services/Super/AzureDevOpsStore.swift new file mode 100644 index 0000000..f6a0cb6 --- /dev/null +++ b/stts/Services/Super/AzureDevOpsStore.swift @@ -0,0 +1,126 @@ +// +// AzureDevOpsStore.swift +// stts +// + +import Kanna + +protocol AzureDevOpsStoreService { + var serviceName: String { get } +} + +private struct AzureDevOpsDataProviders: Codable { + struct ResponseData: Codable { + struct DataProvider: Codable { + struct DataServiceStatus: Codable { + struct DataService: Codable { + struct DataGeography: Codable { + let name: String + let health: Int + + var status: ServiceStatus { + switch health { + case 1: return .major + case 2: return .minor + case 3: return .notice + case 4: return .good + default: return .undetermined + } + } + } + + let id: String + let geographies: [DataGeography] + + var status: ServiceStatus { + return geographies.map { $0.status }.max() ?? .undetermined + } + } + + let services: [DataService] + } + + let serviceStatus: DataServiceStatus + } + + enum CodingKeys: String, CodingKey { + case dataProvider = "ms.vss-status-web.public-status-data-provider" + } + + let dataProvider: DataProvider + } + + let data: ResponseData +} + +class AzureDevOpsStore { + private var url = URL(string: "https://status.dev.azure.com")! + private var statuses: [String: ServiceStatus] = [:] + private var loadErrorMessage: String? + private var callbacks: [() -> Void] = [] + private var lastUpdateTime: TimeInterval = 0 + private var currentlyReloading: Bool = false + + func loadStatus(_ callback: @escaping () -> Void) { + callbacks.append(callback) + + guard !currentlyReloading else { return } + + // Throttling to prevent multiple requests if the first one finishes too quickly + guard Date.timeIntervalSinceReferenceDate - lastUpdateTime >= 3 else { return clearCallbacks() } + + currentlyReloading = true + + URLSession.sharedWithoutCaching.dataTask(with: url) { data, _, error in + defer { + self.currentlyReloading = false + self.clearCallbacks() + } + + self.statuses = [:] + + guard let data = data else { return self._fail(error) } + + guard + let doc = try? HTML(html: data, encoding: .utf8), + let json = doc.css("script#dataProviders").first?.innerHTML, + let jsonData = json.data(using: .utf8), + let providers = try? JSONDecoder().decode(AzureDevOpsDataProviders.self, from: jsonData) + else { + return self._fail("Couldn't parse response") + } + + providers.data.dataProvider.serviceStatus.services.forEach { + self.statuses[$0.id] = $0.status + } + + self.lastUpdateTime = Date.timeIntervalSinceReferenceDate + }.resume() + } + + func status(for service: AzureDevOpsStoreService) -> (ServiceStatus, String) { + let status = statuses[service.serviceName] + + switch status { + case .good?: return (.good, "Healthy") + case .minor?: return (.minor, "Degraded") + case .major?: return (.major, "Unhealthy") + case .notice?: return (.notice, "Advisory") + default: return (.undetermined, loadErrorMessage ?? "Unexpected error") + } + } + + private func clearCallbacks() { + callbacks.forEach { $0() } + callbacks = [] + } + + private func _fail(_ error: Error?) { + _fail(ServiceStatusMessage.from(error)) + } + + private func _fail(_ message: String) { + loadErrorMessage = message + lastUpdateTime = 0 + } +}