diff --git a/.gitignore b/.gitignore index 50cd8a2..e071dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ Packages .swiftpm xcuserdata *.xcodeproj +!WallEView.xcodeproj Config/secrets .DS_Store diff --git a/Sources/App/Extensions/EnvironmentProperties.swift b/Sources/App/Extensions/EnvironmentProperties.swift index 39c4970..43603b8 100644 --- a/Sources/App/Extensions/EnvironmentProperties.swift +++ b/Sources/App/Extensions/EnvironmentProperties.swift @@ -35,7 +35,6 @@ extension Environment { return PullRequest.Label(name: try Environment.get("MERGE_LABEL")) } - // TODO: OHA: Add this value to env vars in host static func topPriorityLabels() throws -> [PullRequest.Label] { let labelsList: String = try Environment.get("TOP_PRIORITY_LABELS") return labelsList.split(separator: ",").map { name in diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index fd38ed9..ddad7ed 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -13,15 +13,15 @@ public func configure(_ config: inout Config, _ env: inout Environment, _ servic logger.log("👟 Starting up...") - let mergeService = try makeMergeService(with: logger, gitHubEventsService) + let dispatchService = try makeDispatchService(with: logger, gitHubEventsService) - services.register(mergeService) + services.register(dispatchService) services.register(logger, as: PrintLogger.self) services.register(RequestLoggerMiddleware.self) // Register routes to the router let router = EngineRouter.default() - try routes(router, logger: logger, mergeService: mergeService, gitHubEventsService: gitHubEventsService) + try routes(router, logger: logger, dispatchService: dispatchService, gitHubEventsService: gitHubEventsService) services.register(router, as: Router.self) // Register middleware @@ -34,12 +34,12 @@ public func configure(_ config: inout Config, _ env: inout Environment, _ servic logger.log("🏁 Ready") } -private func makeMergeService(with logger: LoggerProtocol, _ gitHubEventsService: GitHubEventsService) throws -> MergeService { +private func makeDispatchService(with logger: LoggerProtocol, _ gitHubEventsService: GitHubEventsService) throws -> DispatchService { let gitHubAPI = GitHubClient(session: URLSession(configuration: .default), token: try Environment.gitHubToken()) .api(for: Repository(owner: try Environment.gitHubOrganization(), name: try Environment.gitHubRepository())) - return MergeService( + return DispatchService( integrationLabel: try Environment.mergeLabel(), topPriorityLabels: try Environment.topPriorityLabels(), requiresAllStatusChecks: try Environment.requiresAllGitHubStatusChecks(), @@ -50,4 +50,4 @@ private func makeMergeService(with logger: LoggerProtocol, _ gitHubEventsService ) } -extension MergeService: Service {} +extension DispatchService: Service {} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 828c4ff..c98e067 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -4,22 +4,22 @@ import Vapor public func routes( _ router: Router, logger: LoggerProtocol, - mergeService: MergeService, + dispatchService: DispatchService, gitHubEventsService: GitHubEventsService ) throws { router.get("/") { request -> Response in let response = Response(using: request) if request.header(named: HTTPHeaderName.accept.description) == "application/json" { - try response.content.encode(mergeService.state.value, as: .json) + try response.content.encode(dispatchService.queueStates, as: .json) } else { - try response.content.encode(String(describing: mergeService.state.value), as: .plainText) + try response.content.encode(dispatchService.queuesDescription, as: .plainText) } return response } router.get("health") { request -> HTTPResponse in - switch mergeService.healthcheck.status.value { + switch dispatchService.healthcheckStatus { case .ok: return HTTPResponse(status: .ok) default: return HTTPResponse(status: .serviceUnavailable) } diff --git a/Sources/Bot/Models/PullRequest.swift b/Sources/Bot/Models/PullRequest.swift index 1eb3dd6..a2b5153 100644 --- a/Sources/Bot/Models/PullRequest.swift +++ b/Sources/Bot/Models/PullRequest.swift @@ -70,3 +70,12 @@ extension PullRequest.Branch: CustomDebugStringConvertible { return "Branch(\(ref), \(sha))" } } + +extension PullRequest { + func isLabelled(with label: PullRequest.Label) -> Bool { + return labels.contains(label) + } + func isLabelled(withOneOf possibleLabels: [PullRequest.Label]) -> Bool { + return labels.contains(where: possibleLabels.contains) + } +} diff --git a/Sources/Bot/Services/DispatchService.swift b/Sources/Bot/Services/DispatchService.swift new file mode 100644 index 0000000..99a6831 --- /dev/null +++ b/Sources/Bot/Services/DispatchService.swift @@ -0,0 +1,181 @@ +import Foundation +import Result +import ReactiveSwift +import ReactiveFeedback + +/// Orchestrates multiple merge services, one per each target branch of PRs enqueued for integration +public final class DispatchService { + private let integrationLabel: PullRequest.Label + private let topPriorityLabels: [PullRequest.Label] + private let requiresAllStatusChecks: Bool + private let statusChecksTimeout: TimeInterval + + private let logger: LoggerProtocol + private let gitHubAPI: GitHubAPIProtocol + private let scheduler: DateScheduler + + /// Merge services per target branch + private var mergeServices: Atomic<[String: MergeService]> + public let mergeServiceLifecycle: Signal + private let mergeServiceLifecycleObserver: Signal.Observer + + public init( + integrationLabel: PullRequest.Label, + topPriorityLabels: [PullRequest.Label], + requiresAllStatusChecks: Bool, + statusChecksTimeout: TimeInterval, + logger: LoggerProtocol, + gitHubAPI: GitHubAPIProtocol, + gitHubEvents: GitHubEventsServiceProtocol, + scheduler: DateScheduler = QueueScheduler() + ) { + self.integrationLabel = integrationLabel + self.topPriorityLabels = topPriorityLabels + self.requiresAllStatusChecks = requiresAllStatusChecks + self.statusChecksTimeout = statusChecksTimeout + + self.logger = logger + self.gitHubAPI = gitHubAPI + self.scheduler = scheduler + + self.mergeServices = Atomic([:]) + (mergeServiceLifecycle, mergeServiceLifecycleObserver) = Signal.pipe() + + gitHubAPI.fetchPullRequests() + .flatMapError { _ in .value([]) } + .map { pullRequests in + pullRequests.filter { $0.isLabelled(with: self.integrationLabel) } + } + .observe(on: scheduler) + .startWithValues { pullRequests in + self.dispatchInitial(pullRequests: pullRequests) + } + + gitHubEvents.events + .observe(on: scheduler) + .observeValues { [weak self] gitHubEvent in + switch gitHubEvent { + case let .pullRequest(event): + self?.pullRequestDidChange(event: event) + case let .status(event): + self?.statusChecksDidChange(event: event) + case .ping: + break + } + } + } + + private func dispatchInitial(pullRequests: [PullRequest]) { + let dispatchTable = Dictionary(grouping: pullRequests) { $0.target.ref } + mergeServices.modify { dict in + for (branch, pullRequestsForBranch) in dispatchTable { + dict[branch] = makeMergeService( + targetBranch: branch, + scheduler: self.scheduler, + initialPullRequests: pullRequestsForBranch + ) + } + } + } + + private func pullRequestDidChange(event: PullRequestEvent) { + logger.log("📣 Pull Request did change \(event.pullRequestMetadata) with action `\(event.action)`") + let targetBranch = event.pullRequestMetadata.reference.target.ref + let existingService = mergeServices.modify { (dict: inout [String: MergeService]) -> MergeService? in + if let service = dict[targetBranch] { + // If service was already existing, return it so we'll send the pullRequestChangesObserver event outside this `modify` below + return service + } else { + dict[targetBranch] = makeMergeService( + targetBranch: targetBranch, + scheduler: self.scheduler, + initialPullRequests: [event.pullRequestMetadata.reference] + ) + // If MergeService didn't exist yet and we just created it, return nil so that we DON'T send the event on pullRequestChangesObserver + // outside this `modify` below (because we already passed the PR to initialPullRequests parameters when creating the service – and + // the service would still be `.starting` and it would not be ready to receive those events anyway) + return nil + } + } + existingService?.pullRequestChangesObserver.send(value: (event.pullRequestMetadata, event.action)) + } + + private func statusChecksDidChange(event: StatusEvent) { + // No way to know which MergeService this event is supposed to be for – isRelative(toBranch:) only checks for head branch not target so not useful here + // So we're sending it to all MergeServices, and they'll filter them themselves based on their own queues + mergeServices.withValue { currentMergeServices in + for mergeServiceForBranch in currentMergeServices.values { + mergeServiceForBranch.statusChecksCompletionObserver.send(value: event) + } + } + } + + private func makeMergeService(targetBranch: String, scheduler: DateScheduler, initialPullRequests: [PullRequest] = []) -> MergeService { + let mergeService = MergeService( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + requiresAllStatusChecks: requiresAllStatusChecks, + statusChecksTimeout: statusChecksTimeout, + initialPullRequests: initialPullRequests, + logger: logger, + gitHubAPI: gitHubAPI, + scheduler: scheduler + ) + mergeServiceLifecycleObserver.send(value: .created(mergeService)) + mergeService.state.producer + .observe(on: scheduler) + .startWithValues { [weak self, service = mergeService] state in + self?.mergeServiceLifecycleObserver.send(value: .stateChanged(service)) + if state.status == .idle { + self?.mergeServices.modify { dict in + dict[targetBranch] = nil + } + self?.mergeServiceLifecycleObserver.send(value: .destroyed(service)) + } + } + + return mergeService + } +} + +extension DispatchService { + public enum MergeServiceLifecycleEvent { + case created(MergeService) + case destroyed(MergeService) + case stateChanged(MergeService) + } +} + +extension DispatchService { + public var queuesDescription: String { + let currentMergeServices = mergeServices.value + guard !currentMergeServices.isEmpty else { + return "No PR pending, all queues empty." + } + return currentMergeServices.map { (entry: (key: String, value: MergeService)) -> String in + """ + ## Merge Queue for target branch: \(entry.key) ## + + \(entry.value.state.value) + """ + }.joined(separator: "\n\n") + } + + public var queueStates: [MergeService.State] { + return self.mergeServices.value.values + .map { $0.state.value } + .sorted { (lhs, rhs) in + lhs.targetBranch < rhs.targetBranch + } + } +} + +// MARK: - Healthcheck + +extension DispatchService { + public var healthcheckStatus: MergeService.Healthcheck.Status { + let currentStatuses = self.mergeServices.value.values.map { $0.healthcheck.status.value } + return currentStatuses.first(where: { $0 != .ok }) ?? .ok + } +} diff --git a/Sources/Bot/Services/MergeService.swift b/Sources/Bot/Services/MergeService.swift index fbea9da..0d3351f 100644 --- a/Sources/Bot/Services/MergeService.swift +++ b/Sources/Bot/Services/MergeService.swift @@ -5,30 +5,29 @@ import ReactiveFeedback public final class MergeService { public let state: Property + public let healthcheck: Healthcheck private let logger: LoggerProtocol private let gitHubAPI: GitHubAPIProtocol private let scheduler: DateScheduler private let pullRequestChanges: Signal<(PullRequestMetadata, PullRequest.Action), NoError> - private let pullRequestChangesObserver: Signal<(PullRequestMetadata, PullRequest.Action), NoError>.Observer + internal let pullRequestChangesObserver: Signal<(PullRequestMetadata, PullRequest.Action), NoError>.Observer private let statusChecksCompletion: Signal - private let statusChecksCompletionObserver: Signal.Observer - - public let healthcheck: Healthcheck + internal let statusChecksCompletionObserver: Signal.Observer public init( + targetBranch: String, integrationLabel: PullRequest.Label, topPriorityLabels: [PullRequest.Label], requiresAllStatusChecks: Bool, statusChecksTimeout: TimeInterval, + initialPullRequests: [PullRequest], logger: LoggerProtocol, gitHubAPI: GitHubAPIProtocol, - gitHubEvents: GitHubEventsServiceProtocol, scheduler: DateScheduler = QueueScheduler() ) { - self.logger = logger self.gitHubAPI = gitHubAPI self.scheduler = scheduler @@ -37,12 +36,19 @@ public final class MergeService { (pullRequestChanges, pullRequestChangesObserver) = Signal.pipe() + let initialState = State.initial( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + statusChecksTimeout: statusChecksTimeout + ) + state = Property( - initial: State.initial(integrationLabel: integrationLabel, topPriorityLabels: topPriorityLabels, statusChecksTimeout: statusChecksTimeout), + initial: initialState, scheduler: scheduler, reduce: MergeService.reduce, feedbacks: [ - Feedbacks.whenStarting(github: self.gitHubAPI, scheduler: scheduler), + Feedbacks.whenStarting(initialPullRequests: initialPullRequests, scheduler: scheduler), Feedbacks.whenReady(github: self.gitHubAPI, scheduler: scheduler), Feedbacks.whenIntegrating(github: self.gitHubAPI, requiresAllStatusChecks: requiresAllStatusChecks, pullRequestChanges: pullRequestChanges, scheduler: scheduler), Feedbacks.whenRunningStatusChecks(github: self.gitHubAPI, logger: logger, requiresAllStatusChecks: requiresAllStatusChecks, statusChecksCompletion: statusChecksCompletion, scheduler: scheduler), @@ -57,30 +63,8 @@ public final class MergeService { state.producer .combinePrevious() .startWithValues { old, new in - logger.log("♻️ Did change state\n - 📜 \(old) \n - 📄 \(new)") + logger.log("♻️ [\(new.targetBranch) queue] Did change state\n - 📜 \(old) \n - 📄 \(new)") } - - gitHubEvents.events - .observe(on: scheduler) - .observeValues { [weak self] event in - switch event { - case let .pullRequest(event): - self?.pullRequestDidChange(event: event) - case let .status(event): - self?.statusChecksDidChange(event: event) - case .ping: - break - } - } - } - - private func pullRequestDidChange(event: PullRequestEvent) { - logger.log("📣 Pull Request did change \(event.pullRequestMetadata) with action `\(event.action)`") - pullRequestChangesObserver.send(value: (event.pullRequestMetadata, event.action)) - } - - private func statusChecksDidChange(event: StatusEvent) { - statusChecksCompletionObserver.send(value: event) } static func reduce(state: State, event: Event) -> State { @@ -106,6 +90,195 @@ public final class MergeService { } } +// MARK: - System types + +extension MergeService { + + public enum FailureReason: String, Equatable, Encodable { + case conflicts + case mergeFailed + case synchronizationFailed + case checkingCommitChecksFailed + case checksFailing + case timedOut + case blocked + case unknown + } + + public struct State: Equatable { + public let status: Status + public let pullRequests: [PullRequest] + + internal let targetBranch: String + internal let integrationLabel: PullRequest.Label + internal let topPriorityLabels: [PullRequest.Label] + internal let statusChecksTimeout: TimeInterval + + init( + targetBranch: String, + integrationLabel: PullRequest.Label, + topPriorityLabels: [PullRequest.Label], + statusChecksTimeout: TimeInterval, + pullRequests: [PullRequest], + status: Status + ) { + self.targetBranch = targetBranch + self.integrationLabel = integrationLabel + self.topPriorityLabels = topPriorityLabels + self.statusChecksTimeout = statusChecksTimeout + self.pullRequests = pullRequests + self.status = status + } + + static func initial(targetBranch: String, integrationLabel: PullRequest.Label, topPriorityLabels: [PullRequest.Label], statusChecksTimeout: TimeInterval) -> State { + return State( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + statusChecksTimeout: statusChecksTimeout, + pullRequests: [], + status: .starting + ) + } + + var isIntegrationOngoing: Bool { + switch status { + case .integrating, .runningStatusChecks: + return true + case .starting, .idle, .ready, .integrationFailed: + return false + } + } + + func with(status: Status) -> State { + return State( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + statusChecksTimeout: statusChecksTimeout, + pullRequests: pullRequests, + status: status + ) + } + + func include(pullRequests pullRequestsToInclude: [PullRequest]) -> State { + let onlyNewPRs = pullRequestsToInclude.filter { + [currentQueue = self.pullRequests] pullRequest in + currentQueue.map({ $0.number }).contains(pullRequest.number) == false + } + let updatedOldPRs = pullRequests.map { (pr: PullRequest) -> PullRequest in + pullRequestsToInclude.first { $0.number == pr.number } ?? pr + } + let newQueue = (updatedOldPRs + onlyNewPRs).slowStablePartition { (pullRequest: PullRequest) in + !pullRequest.isLabelled(withOneOf: topPriorityLabels) + } + return State( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + statusChecksTimeout: statusChecksTimeout, + pullRequests: newQueue, + status: status + ) + } + + func exclude(pullRequest: PullRequest) -> State { + return State( + targetBranch: targetBranch, + integrationLabel: integrationLabel, + topPriorityLabels: topPriorityLabels, + statusChecksTimeout: statusChecksTimeout, + pullRequests: pullRequests.filter { $0.number != pullRequest.number }, + status: status + ) + } + + public enum Status: Equatable, Encodable { + case starting + case idle + case ready + case integrating(PullRequestMetadata) + case runningStatusChecks(PullRequestMetadata) + case integrationFailed(PullRequestMetadata, FailureReason) + + internal var integrationMetadata: PullRequestMetadata? { + switch self { + case let .integrating(metadata): + return metadata + default: + return nil + } + } + + internal var statusChecksMetadata: PullRequestMetadata? { + switch self { + case let .runningStatusChecks(metadata): + return metadata + default: + return nil + } + } + + enum CodingKeys: String, CodingKey { + case status + case metadata + case error + } + + public func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .starting: + try values.encode("starting", forKey: .status) + case .idle: + try values.encode("idle", forKey: .status) + case .ready: + try values.encode("ready", forKey: .status) + case let .integrating(metadata): + try values.encode("integrating", forKey: .status) + try values.encode(metadata, forKey: .metadata) + case let .runningStatusChecks(metadata): + try values.encode("runningStatusChecks", forKey: .status) + try values.encode(metadata, forKey: .metadata) + case let .integrationFailed(metadata, error): + try values.encode("integrationFailed", forKey: .status) + try values.encode(metadata, forKey: .metadata) + try values.encode(error, forKey: .error) + } + + } + } + } + + enum Event { + case noMorePullRequests + case pullRequestsLoaded([PullRequest]) + case pullRequestDidChange(Outcome) + case statusChecksDidComplete(StatusChecksResult) + case integrate(PullRequestMetadata) + case retryIntegration(PullRequestMetadata) + case integrationDidChangeStatus(IntegrationStatus, PullRequestMetadata) + case integrationFailureHandled + + enum Outcome { + case include(PullRequest) + case exclude(PullRequest) + } + + enum StatusChecksResult { + case failed(PullRequestMetadata) + case passed(PullRequestMetadata) + case timedOut(PullRequestMetadata) + } + + enum IntegrationStatus { + case updating + case done + case failed(FailureReason) + } + } +} + // MARK: - Feedbacks extension MergeService { @@ -124,7 +297,7 @@ extension MergeService { .enumerated() .map { index, pullRequest -> SignalProducer<(), NoError> in - guard previous.pullRequests.firstIndex(of: pullRequest) == nil + guard previous.status == .starting || previous.pullRequests.firstIndex(of: pullRequest) == nil else { return .empty } if index == 0 && current.isIntegrationOngoing == false { @@ -135,7 +308,7 @@ extension MergeService { .flatMapError { _ in .empty } } else { return github.postComment( - "Your pull request was accepted and it's currently `#\(index + 1)` in the queue, hold tight ⏳", + "Your pull request was accepted and it's currently `#\(index + 1)` in the `\(current.targetBranch)` queue, hold tight ⏳", in: pullRequest ) .flatMapError { _ in .empty } @@ -147,24 +320,22 @@ extension MergeService { }) } - fileprivate static func whenStarting(github: GitHubAPIProtocol, scheduler: Scheduler) -> Feedback { + fileprivate static func whenStarting(initialPullRequests: [PullRequest], scheduler: DateScheduler) -> Feedback { return Feedback(predicate: { $0.status == .starting }) { state -> SignalProducer in - - return github.fetchPullRequests() - .flatMapError { _ in .value([]) } - .map { pullRequests in - pullRequests.filter { $0.isLabelled(with: state.integrationLabel) } - } - .map(Event.pullRequestsLoaded) - .start(on: scheduler) + return SignalProducer + .value(Event.pullRequestsLoaded(initialPullRequests)) + .observe(on: scheduler) } } fileprivate static func whenReady(github: GitHubAPIProtocol, scheduler: Scheduler) -> Feedback { return Feedback(predicate: { $0.status == .ready }) { state -> SignalProducer in - guard let next = state.pullRequests.first - else { return .value(.noMorePullRequests) } + guard let next = state.pullRequests.first else { + return SignalProducer + .value(.noMorePullRequests) + .observe(on: scheduler) + } // Refresh pull request to ensure an up-to-date state return github.fetchPullRequest(number: next.number) @@ -433,234 +604,6 @@ extension MergeService { } } -// MARK: - System types - -extension MergeService { - public final class Healthcheck { - - public enum Reason: Error, Equatable { - case potentialDeadlock - } - - public enum Status: Equatable { - case ok - case unhealthy(Reason) - } - - public let status: Property - - internal init( - state: Signal, - statusChecksTimeout: TimeInterval, - scheduler: DateScheduler - ) { - status = Property( - initial: .ok, - then: state.combinePrevious() - .skipRepeats { lhs, rhs in - return lhs == rhs - } - .flatMap(.latest) { _, current -> SignalProducer in - switch current.status { - case .starting, .idle: - return SignalProducer(value: .ok) - default: - return SignalProducer(value: .unhealthy(.potentialDeadlock)) - // Status checks have a configurable timeout that is used to prevent blocking the queue - // if for some reason there's an issue with them, we are following a strategy where we - // plan the potential failure and delay it for the expected amount of time that they - // should have take at most (timeout) plus a sensible leeway. Due how `flatMap(.latest)` - // works, any new `state` triggered before this delay will interrupt this signal and - // prevent the false failure otherwise there's something blocking the queue longer than - // we antecipated and we should flag the failure. - .delay(1.5 * statusChecksTimeout, on: scheduler) - } - } - ) - } - } -} - -extension MergeService { - - public enum FailureReason: String, Equatable, Encodable { - case conflicts - case mergeFailed - case synchronizationFailed - case checkingCommitChecksFailed - case checksFailing - case timedOut - case blocked - case unknown - } - - public struct State: Equatable { - - public enum Status: Equatable, Encodable { - case starting - case idle - case ready - case integrating(PullRequestMetadata) - case runningStatusChecks(PullRequestMetadata) - case integrationFailed(PullRequestMetadata, FailureReason) - - internal var integrationMetadata: PullRequestMetadata? { - switch self { - case let .integrating(metadata): - return metadata - default: - return nil - } - } - - internal var statusChecksMetadata: PullRequestMetadata? { - switch self { - case let .runningStatusChecks(metadata): - return metadata - default: - return nil - } - } - - enum CodingKeys: String, CodingKey { - case status - case metadata - case error - } - - public func encode(to encoder: Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .starting: - try values.encode("starting", forKey: .status) - case .idle: - try values.encode("idle", forKey: .status) - case .ready: - try values.encode("ready", forKey: .status) - case let .integrating(metadata): - try values.encode("integrating", forKey: .status) - try values.encode(metadata, forKey: .metadata) - case let .runningStatusChecks(metadata): - try values.encode("runningStatusChecks", forKey: .status) - try values.encode(metadata, forKey: .metadata) - case let .integrationFailed(metadata, error): - try values.encode("integrationFailed", forKey: .status) - try values.encode(metadata, forKey: .metadata) - try values.encode(error, forKey: .error) - } - - } - } - - internal let integrationLabel: PullRequest.Label - internal let topPriorityLabels: [PullRequest.Label] - internal let statusChecksTimeout: TimeInterval - public let pullRequests: [PullRequest] - public let status: Status - - var isIntegrationOngoing: Bool { - switch status { - case .integrating, .runningStatusChecks: - return true - case .starting, .idle, .ready, .integrationFailed: - return false - } - } - - static func initial(integrationLabel: PullRequest.Label, topPriorityLabels: [PullRequest.Label], statusChecksTimeout: TimeInterval) -> State { - return State( - integrationLabel: integrationLabel, - topPriorityLabels: topPriorityLabels, - statusChecksTimeout: statusChecksTimeout, - pullRequests: [], - status: .starting - ) - } - - init( - integrationLabel: PullRequest.Label, - topPriorityLabels: [PullRequest.Label], - statusChecksTimeout: TimeInterval, - pullRequests: [PullRequest], - status: Status - ) { - self.integrationLabel = integrationLabel - self.topPriorityLabels = topPriorityLabels - self.statusChecksTimeout = statusChecksTimeout - self.pullRequests = pullRequests - self.status = status - } - - func with(status: Status) -> State { - return State( - integrationLabel: integrationLabel, - topPriorityLabels: topPriorityLabels, - statusChecksTimeout: statusChecksTimeout, - pullRequests: pullRequests, - status: status - ) - } - - func include(pullRequests pullRequestsToInclude: [PullRequest]) -> State { - let onlyNewPRs = pullRequestsToInclude.filter { - [currentQueue = self.pullRequests] pullRequest in - currentQueue.map({ $0.number }).contains(pullRequest.number) == false - } - let updatedOldPRs = pullRequests.map { (pr: PullRequest) -> PullRequest in - pullRequestsToInclude.first { $0.number == pr.number } ?? pr - } - let newQueue = (updatedOldPRs + onlyNewPRs).slowStablePartition { (pullRequest: PullRequest) in - !pullRequest.isLabelled(withOneOf: topPriorityLabels) - } - return State( - integrationLabel: integrationLabel, - topPriorityLabels: topPriorityLabels, - statusChecksTimeout: statusChecksTimeout, - pullRequests: newQueue, - status: status - ) - } - - func exclude(pullRequest: PullRequest) -> State { - return State( - integrationLabel: integrationLabel, - topPriorityLabels: topPriorityLabels, - statusChecksTimeout: statusChecksTimeout, - pullRequests: pullRequests.filter { $0.number != pullRequest.number }, - status: status - ) - } - } - - enum Event { - case noMorePullRequests - case pullRequestsLoaded([PullRequest]) - case pullRequestDidChange(Outcome) - case statusChecksDidComplete(StatusChecksResult) - case integrate(PullRequestMetadata) - case retryIntegration(PullRequestMetadata) - case integrationDidChangeStatus(IntegrationStatus, PullRequestMetadata) - case integrationFailureHandled - - enum Outcome { - case include(PullRequest) - case exclude(PullRequest) - } - - enum StatusChecksResult { - case failed(PullRequestMetadata) - case passed(PullRequestMetadata) - case timedOut(PullRequestMetadata) - } - - enum IntegrationStatus { - case updating - case done - case failed(FailureReason) - } - } -} - // MARK: - Reducers extension MergeService.State { @@ -751,18 +694,56 @@ extension MergeService.State { } } -// MARK: - Helpers +// MARK: - Healthcheck -private extension PullRequest { +extension MergeService { + public final class Healthcheck { + public let status: Property - func isLabelled(with label: PullRequest.Label) -> Bool { - return labels.contains(label) - } - func isLabelled(withOneOf possibleLabels: [PullRequest.Label]) -> Bool { - return labels.contains(where: possibleLabels.contains) + public enum Reason: Error, Equatable { + case potentialDeadlock + } + + public enum Status: Equatable { + case ok + case unhealthy(Reason) + } + + internal init( + state: Signal, + statusChecksTimeout: TimeInterval, + scheduler: DateScheduler + ) { + status = Property( + initial: .ok, + then: state.combinePrevious() + // Can't just use skipRepeats() as (at least as of Swift 4), tuple of Equatable is not itself Equatable + .skipRepeats { lhs, rhs in + return lhs == rhs + } + .flatMap(.latest) { _, current -> SignalProducer in + switch current.status { + case .starting, .idle: + return SignalProducer(value: .ok) + default: + return SignalProducer(value: .unhealthy(.potentialDeadlock)) + // Status checks have a configurable timeout that is used to prevent blocking the queue + // if for some reason there's an issue with them, we are following a strategy where we + // plan the potential failure and delay it for the expected amount of time that they + // should have take at most (timeout) plus a sensible leeway. Due how `flatMap(.latest)` + // works, any new `state` triggered before this delay will interrupt this signal and + // prevent the false failure otherwise there's something blocking the queue longer than + // we antecipated and we should flag the failure. + .delay(1.5 * statusChecksTimeout, on: scheduler) + } + } + ) + } } } +// MARK: - Helpers + extension MergeService.State: CustomStringConvertible { private var queueDescription: String { @@ -785,12 +766,14 @@ extension MergeService.State: CustomStringConvertible { extension MergeService.State: Encodable { enum CodingKeys: String, CodingKey { + case targetBranch case status case queue } public func encode(to encoder: Encoder) throws { var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(targetBranch, forKey: .targetBranch) try values.encode(status, forKey: .status) try values.encode(pullRequests, forKey: .queue) } diff --git a/Tests/BotTests/Canned Data/DispatchServiceQueueStates.swift b/Tests/BotTests/Canned Data/DispatchServiceQueueStates.swift new file mode 100644 index 0000000..6074ed6 --- /dev/null +++ b/Tests/BotTests/Canned Data/DispatchServiceQueueStates.swift @@ -0,0 +1,89 @@ +let DispatchServiceQueueStates: String = """ +[ + { + "targetBranch" : "branch1", + "status" : { + "status" : "integrating", + "metadata" : { + "mergeable_state" : "behind", + "reference" : { + "head" : { + "ref" : "some-branch", + "sha" : "abcdef" + }, + "number" : 1, + "title" : "Best Pull Request", + "labels" : [ + { + "name" : "Please Merge 🙏" + } + ], + "base" : { + "ref" : "branch1", + "sha" : "abc" + }, + "user" : { + "login" : "John Doe" + } + }, + "merged" : false + } + }, + "queue" : [ + { + "head" : { + "ref" : "abcdef", + "sha" : "abcdef" + }, + "number" : 2, + "title" : "Best Pull Request", + "labels" : [ + { + "name" : "Please Merge 🙏" + } + ], + "base" : { + "ref" : "branch1", + "sha" : "abc" + }, + "user" : { + "login" : "John Doe" + } + } + ] + }, + { + "targetBranch" : "branch2", + "status" : { + "status" : "integrating", + "metadata" : { + "mergeable_state" : "behind", + "reference" : { + "head" : { + "ref" : "abcdef", + "sha" : "abcdef" + }, + "number" : 3, + "title" : "Best Pull Request", + "labels" : [ + { + "name" : "Please Merge 🙏" + } + ], + "base" : { + "ref" : "branch2", + "sha" : "abc" + }, + "user" : { + "login" : "John Doe" + } + }, + "merged" : false + } + }, + "queue" : [ + + ] + } +] +""" diff --git a/Tests/BotTests/MergeService/DispatchServiceContext.swift b/Tests/BotTests/MergeService/DispatchServiceContext.swift new file mode 100644 index 0000000..52b8cc3 --- /dev/null +++ b/Tests/BotTests/MergeService/DispatchServiceContext.swift @@ -0,0 +1,54 @@ +import ReactiveSwift +import Result +@testable import Bot + +enum DispatchServiceEvent: Equatable { + case created(branch: String) + case state(MergeService.State) + case destroyed(branch: String) + + init(from lifecycleEvent: DispatchService.MergeServiceLifecycleEvent) { + switch lifecycleEvent { + case .created(let service): + self = .created(branch: service.state.value.targetBranch) + case .stateChanged(let service): + self = .state(service.state.value) + case .destroyed(let service): + self = .destroyed(branch: service.state.value.targetBranch) + } + } + + var branch: String { + switch self { + case .created(let branch), .destroyed(let branch): + return branch + case .state(let state): + return state.targetBranch + } + } +} + +class DispatchServiceContext { + let dispatchService: DispatchService + var events: [DispatchServiceEvent] = [] + + init(requiresAllStatusChecks: Bool, gitHubAPI: GitHubAPIProtocol, gitHubEvents: GitHubEventsServiceProtocol, scheduler: DateScheduler) { + self.dispatchService = DispatchService( + integrationLabel: LabelFixture.integrationLabel, + topPriorityLabels: LabelFixture.topPriorityLabels, + requiresAllStatusChecks: requiresAllStatusChecks, + statusChecksTimeout: MergeServiceFixture.defaultStatusChecksTimeout, + logger: MockLogger(), + gitHubAPI: gitHubAPI, + gitHubEvents: gitHubEvents, + scheduler: scheduler + ) + + self.dispatchService.mergeServiceLifecycle + .map(DispatchServiceEvent.init) + .observe(on: scheduler) + .observeValues { [weak self] event in + self?.events.append(event) + } + } +} diff --git a/Tests/BotTests/MergeService/DispatchServiceTests.swift b/Tests/BotTests/MergeService/DispatchServiceTests.swift new file mode 100644 index 0000000..4a7068c --- /dev/null +++ b/Tests/BotTests/MergeService/DispatchServiceTests.swift @@ -0,0 +1,239 @@ +import XCTest +import Nimble +import ReactiveSwift +import Result +@testable import Bot + +class DispatchServiceTests: XCTestCase { + func test_multiple_pull_requests_with_different_target_branches() { + + let pullRequests = (1...3).map { + PullRequestMetadata.stub(number: $0, baseRef: "branch\($0)", labels: [LabelFixture.integrationLabel]) + .with(mergeState: .clean) + } + func returnPR() -> (UInt) -> PullRequestMetadata { + return { number in pullRequests[Int(number-1)] } + } + + perform( + stubs: [ + .getPullRequests { pullRequests.map { $0.reference } }, + .getPullRequest(returnPR()), + .postComment { _, _ in }, + .getPullRequest(returnPR()), + .postComment { _, _ in }, + .getPullRequest(returnPR()), + .postComment { _, _ in }, + .mergePullRequest { _ in }, + .deleteBranch { _ in }, + .mergePullRequest { _ in }, + .deleteBranch { _ in }, + .mergePullRequest { _ in }, + .deleteBranch { _ in }, + ], + when: { service, scheduler in + scheduler.advance() + }, + assert: { events in + let perBranchEvents = Dictionary(grouping: events) { $0.branch } + expect(perBranchEvents.count) == 3 + for (branch, events) in perBranchEvents { + let filteredPRs = pullRequests.filter { $0.reference.target.ref == branch } + expect(filteredPRs.count) == 1 + let prForBranch = filteredPRs.first! + expect(events) == [ + .created(branch: branch), + .state(.stub(targetBranch: branch, status: .starting)), + .state(.stub(targetBranch: branch, status: .ready, pullRequests: [prForBranch.reference])), + .state(.stub(targetBranch: branch, status: .integrating(prForBranch))), + .state(.stub(targetBranch: branch, status: .ready)), + .state(.stub(targetBranch: branch, status: .idle)), + .destroyed(branch: branch), + ] + } + } + ) + } + + func test_adding_new_pull_requests_during_integration() { + let (developBranch, releaseBranch) = ("develop", "release/app/1.2.3") + + let dev1 = PullRequestMetadata.stub(number: 1, headRef: MergeServiceFixture.defaultBranch, baseRef: developBranch, labels: [LabelFixture.integrationLabel], mergeState: .behind) + let dev2 = PullRequestMetadata.stub(number: 2, baseRef: developBranch, labels: [LabelFixture.integrationLabel], mergeState: .clean) + let rel3 = PullRequestMetadata.stub(number: 3, baseRef: releaseBranch, labels: [LabelFixture.integrationLabel], mergeState: .clean) + + perform( + stubs: [ + .getPullRequests { [dev1.reference] }, + .getPullRequest(checkReturnPR(dev1)), + .postComment(checkComment(1, "Your pull request was accepted and is going to be handled right away 🏎")), + .mergeIntoBranch { head, base in + expect(head.ref) == MergeServiceFixture.defaultBranch + expect(base.ref) == developBranch + return .success + }, + + .postComment(checkComment(2, "Your pull request was accepted and it's currently `#1` in the `develop` queue, hold tight ⏳")), + + .getPullRequest(checkReturnPR(rel3)), + .postComment(checkComment(3, "Your pull request was accepted and is going to be handled right away 🏎")), + .mergePullRequest(checkPRNumber(3)), + .deleteBranch(checkBranch(rel3.reference.source)), + + .getPullRequest(checkReturnPR(dev1.with(mergeState: .clean))), + .getCommitStatus { pullRequest in + expect(pullRequest.number) == 1 + return CommitState.stub(states: [.success]) + }, + + .mergePullRequest(checkPRNumber(1)), + .deleteBranch(checkBranch(dev1.reference.source)), + .getPullRequest(checkReturnPR(dev2)), + .mergePullRequest(checkPRNumber(2)), + .deleteBranch(checkBranch(dev2.reference.source)), + ], + when: { service, scheduler in + + scheduler.advance() + + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: dev1.with(mergeState: .blocked)) + + scheduler.advance() + + service.sendPullRequestEvent(action: .labeled, pullRequestMetadata: dev2) + + scheduler.advance() + + service.sendPullRequestEvent(action: .labeled, pullRequestMetadata: rel3) + + scheduler.advance() + + service.sendStatusEvent(state: .success) + + scheduler.advance(by: .seconds(60)) + }, + assert: { + expect($0) == [ + .created(branch: developBranch), + .state(.stub(targetBranch: developBranch, status: .starting)), + .state(.stub(targetBranch: developBranch, status: .ready, pullRequests: [dev1.reference])), + .state(.stub(targetBranch: developBranch, status: .integrating(dev1))), + .state(.stub(targetBranch: developBranch, status: .runningStatusChecks(dev1.with(mergeState: .blocked)))), + .state(.stub(targetBranch: developBranch, status: .runningStatusChecks(dev1.with(mergeState: .blocked)), pullRequests: [dev2.reference])), + + .created(branch: releaseBranch), + .state(.stub(targetBranch: releaseBranch, status: .starting)), + .state(.stub(targetBranch: releaseBranch, status: .ready, pullRequests: [rel3.reference])), + .state(.stub(targetBranch: releaseBranch, status: .integrating(rel3))), + .state(.stub(targetBranch: releaseBranch, status: .ready)), + .state(.stub(targetBranch: releaseBranch, status: .idle)), + .destroyed(branch: releaseBranch), + + .state(.stub(targetBranch: developBranch, status: .integrating(dev1.with(mergeState: .clean)), pullRequests: [dev2.reference])), + .state(.stub(targetBranch: developBranch, status: .ready, pullRequests: [dev2.reference])), + .state(.stub(targetBranch: developBranch, status: .integrating(dev2))), + .state(.stub(targetBranch: developBranch, status: .ready)), + .state(.stub(targetBranch: developBranch, status: .idle)), + .destroyed(branch: developBranch) + ] + } + ) + } + + func test_json_queue_description() throws { + let (branch1, branch2) = ("branch1", "branch2") + let pr1 = PullRequestMetadata.stub(number: 1, headRef: MergeServiceFixture.defaultBranch, baseRef: branch1, labels: [LabelFixture.integrationLabel], mergeState: .behind) + let pr2 = PullRequestMetadata.stub(number: 2, baseRef: branch1, labels: [LabelFixture.integrationLabel], mergeState: .clean) + let pr3 = PullRequestMetadata.stub(number: 3, baseRef: branch2, labels: [LabelFixture.integrationLabel], mergeState: .behind) + + let stubs: [MockGitHubAPI.Stubs] = [ + .getPullRequests { [pr1.reference] }, + .getPullRequest(checkReturnPR(pr1)), + .postComment(checkComment(1, "Your pull request was accepted and is going to be handled right away 🏎")), + .mergeIntoBranch { _, _ in .success }, + .postComment(checkComment(2, "Your pull request was accepted and it's currently `#1` in the `branch1` queue, hold tight ⏳")), + .getPullRequest(checkReturnPR(pr3)), + .postComment(checkComment(3, "Your pull request was accepted and is going to be handled right away 🏎")), + .mergeIntoBranch { _, _ in .success }, + ] + + let scheduler = TestScheduler() + let gitHubAPI = MockGitHubAPI(stubs: stubs) + let gitHubEvents = MockGitHubEventsService() + + let dispatchServiceContext = DispatchServiceContext( + requiresAllStatusChecks: true, + gitHubAPI: gitHubAPI, + gitHubEvents: gitHubEvents, + scheduler: scheduler + ) + + expect(dispatchServiceContext.dispatchService.queueStates) == [] + + scheduler.advance() + gitHubEvents.sendPullRequestEvent(action: .labeled, pullRequestMetadata: pr2) + scheduler.advance() + gitHubEvents.sendPullRequestEvent(action: .labeled, pullRequestMetadata: pr3) + scheduler.advance() + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(dispatchServiceContext.dispatchService.queueStates) + let json = String(data: data, encoding: .utf8) + expect(json) == DispatchServiceQueueStates + } + + // MARK: - Helpers + + private func checkComment(_ expectedPRNumber: UInt, _ expectedMessage: String, file: FileString = #file, line: UInt = #line) -> (String, PullRequest) -> Void { + return { message, pullRequest in + expect(pullRequest.number, file: file, line: line) == expectedPRNumber + expect(message, file: file, line: line) == expectedMessage + } + } + + private func checkReturnPR(_ prToReturn: PullRequestMetadata, file: FileString = #file, line: UInt = #line) -> (UInt) -> PullRequestMetadata { + return { number in + expect(number, file: file, line: line) == prToReturn.reference.number + return prToReturn + } + } + + private func checkPRNumber(_ expectedNumber: UInt, file: FileString = #file, line: UInt = #line) -> (PullRequest) -> Void { + return { pullRequest in + expect(pullRequest.number, file: file, line: line) == expectedNumber + } + } + + private func checkBranch(_ expectedBranch: PullRequest.Branch, file: FileString = #file, line: UInt = #line) -> (PullRequest.Branch) -> Void { + return { branch in + expect(branch.ref, file: file, line: line) == expectedBranch.ref + } + } + + private func perform( + requiresAllStatusChecks: Bool = false, + stubs: [MockGitHubAPI.Stubs], + when: (MockGitHubEventsService, TestScheduler) -> Void, + assert: ([DispatchServiceEvent]) -> Void + ) { + + let scheduler = TestScheduler() + let gitHubAPI = MockGitHubAPI(stubs: stubs) + let gitHubEvents = MockGitHubEventsService() + + let dispatchServiceContext = DispatchServiceContext( + requiresAllStatusChecks: requiresAllStatusChecks, + gitHubAPI: gitHubAPI, + gitHubEvents: gitHubEvents, + scheduler: scheduler + ) + + when(gitHubEvents, scheduler) + + assert(dispatchServiceContext.events) + + expect(gitHubAPI.assert()) == true + } + +} diff --git a/Tests/BotTests/MergeService/MergeServiceTests.swift b/Tests/BotTests/MergeService/MergeServiceTests.swift index c0daac1..0a4cc28 100644 --- a/Tests/BotTests/MergeService/MergeServiceTests.swift +++ b/Tests/BotTests/MergeService/MergeServiceTests.swift @@ -21,10 +21,7 @@ class MergeServiceTests: XCTestCase { scheduler.advance() }, assert: { - expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .idle) - ] + expect($0) == [] } ) } @@ -42,10 +39,7 @@ class MergeServiceTests: XCTestCase { scheduler.advance() }, assert: { - expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .idle) - ] + expect($0) == [] } ) @@ -65,11 +59,13 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -104,15 +100,17 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }), - MergeService.State.stub(status: .integrating(pullRequests[0]), pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray), - MergeService.State.stub(status: .integrating(pullRequests[1]), pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray), - MergeService.State.stub(status: .integrating(pullRequests[2])), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference })), + .state(.stub(status: .integrating(pullRequests[0]), pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray)), + .state(.stub(status: .integrating(pullRequests[1]), pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray)), + .state(.stub(status: .integrating(pullRequests[2]))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -135,12 +133,14 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [target.reference]), - MergeService.State.stub(status: .integrating(target)), - MergeService.State.stub(status: .integrationFailed(target, .conflicts)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [target.reference])), + .state(.stub(status: .integrating(target))), + .state(.stub(status: .integrationFailed(target, .conflicts))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -162,9 +162,7 @@ class MergeServiceTests: XCTestCase { scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() @@ -174,13 +172,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -206,12 +206,14 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -232,19 +234,18 @@ class MergeServiceTests: XCTestCase { ], when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .labeled, pullRequestMetadata: targetLabeled)) - ) + service.sendPullRequestEvent(action: .labeled, pullRequestMetadata: targetLabeled) scheduler.advance() }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .idle), - MergeService.State.stub(status: .ready, pullRequests: [targetLabeled.reference]), - MergeService.State.stub(status: .integrating(targetLabeled)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [targetLabeled.reference])), + .state(.stub(status: .integrating(targetLabeled))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -262,7 +263,7 @@ class MergeServiceTests: XCTestCase { .postComment { _, _ in }, .mergeIntoBranch { _, _ in .success }, .postComment { message, pullRequest in - expect(message) == "Your pull request was accepted and it's currently `#1` in the queue, hold tight ⏳" + expect(message) == "Your pull request was accepted and it's currently `#1` in the `master` queue, hold tight ⏳" expect(pullRequest.number) == 2 }, .getPullRequest { _ in first.with(mergeState: .clean) }, @@ -277,15 +278,11 @@ class MergeServiceTests: XCTestCase { scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: first.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: first.with(mergeState: .blocked)) scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .labeled, pullRequestMetadata: second)) - ) + service.sendPullRequestEvent(action: .labeled, pullRequestMetadata: second) scheduler.advance() @@ -295,16 +292,18 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [first.reference]), - MergeService.State.stub(status: .integrating(first)), - MergeService.State.stub(status: .runningStatusChecks(first.with(mergeState: .blocked))), - MergeService.State.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)), pullRequests: [second.reference]), - MergeService.State.stub(status: .integrating(first.with(mergeState: .clean)), pullRequests: [second.reference]), - MergeService.State.stub(status: .ready, pullRequests: [second.reference]), - MergeService.State.stub(status: .integrating(second)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [first.reference])), + .state(.stub(status: .integrating(first))), + .state(.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)))), + .state(.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)), pullRequests: [second.reference])), + .state(.stub(status: .integrating(first.with(mergeState: .clean)), pullRequests: [second.reference])), + .state(.stub(status: .ready, pullRequests: [second.reference])), + .state(.stub(status: .integrating(second))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -321,26 +320,24 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .closed, pullRequestMetadata: MergeServiceFixture.defaultTarget)) - ) + service.sendPullRequestEvent(action: .closed, pullRequestMetadata: MergeServiceFixture.defaultTarget) scheduler.advance() }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -357,26 +354,24 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() - service.eventsObserver.send(value: - .pullRequest(.init(action: .unlabeled, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(labels: []))) - ) + service.sendPullRequestEvent(action: .unlabeled, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(labels: [])) scheduler.advance() }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -398,9 +393,7 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() @@ -410,13 +403,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .blocked), .checksFailing)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .blocked), .checksFailing))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -445,9 +440,7 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() @@ -458,13 +451,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -499,9 +494,7 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() // 1 @@ -516,13 +509,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unstable))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unstable)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -552,9 +547,7 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() // 1 @@ -569,13 +562,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .unstable), .checksFailing)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .unstable), .checksFailing))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -597,22 +592,22 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: - .pullRequest(.init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) // 1.5 ensures we trigger the timeout scheduler.advance(by: .minutes(1.5 * MergeServiceFixture.defaultStatusChecksTimeout)) }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .blocked), .timedOut)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .blocked), .timedOut))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -635,12 +630,14 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unknown))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unknown)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -665,12 +662,14 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unknown))), - MergeService.State.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .unknown), .unknown)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .unknown)))), + .state(.stub(status: .integrationFailed(MergeServiceFixture.defaultTarget.with(mergeState: .unknown), .unknown))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -697,15 +696,11 @@ class MergeServiceTests: XCTestCase { scheduler.advance() - service.eventsObserver.send(value: - .pullRequest(.init(action: .synchronize, pullRequestMetadata: first.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: first.with(mergeState: .blocked)) scheduler.advance() - service.eventsObserver.send(value: - .pullRequest(.init(action: .unlabeled, pullRequestMetadata: second.with(labels: []))) - ) + service.sendPullRequestEvent(action: .unlabeled, pullRequestMetadata: second.with(labels: [])) scheduler.advance() @@ -715,14 +710,16 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [first, second].map { $0.reference}), - MergeService.State.stub(status: .integrating(first), pullRequests: [second.reference]), - MergeService.State.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)), pullRequests: [second.reference]), - MergeService.State.stub(status: .runningStatusChecks(first.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(first.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [first, second].map { $0.reference})), + .state(.stub(status: .integrating(first), pullRequests: [second.reference])), + .state(.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)), pullRequests: [second.reference])), + .state(.stub(status: .runningStatusChecks(first.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(first.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -785,17 +782,13 @@ class MergeServiceTests: XCTestCase { scheduler.advance() // #1 - service.eventsObserver.send(value: - .pullRequest(.init(action: .labeled, pullRequestMetadata: pr3.with( - labels: [LabelFixture.integrationLabel, LabelFixture.topPriorityLabels[1]] - ))) - ) + service.sendPullRequestEvent(action: .labeled, pullRequestMetadata: pr3.with( + labels: [LabelFixture.integrationLabel, LabelFixture.topPriorityLabels[1]] + )) scheduler.advance() // #2 - service.eventsObserver.send(value: - .pullRequest(.init(action: .synchronize, pullRequestMetadata: pr2.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: pr2.with(mergeState: .blocked)) scheduler.advance() // #3 @@ -806,20 +799,22 @@ class MergeServiceTests: XCTestCase { assert: { let pr3_tp = pr3.with(labels: [LabelFixture.integrationLabel, LabelFixture.topPriorityLabels[1]]) expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [pr2, pr1, pr3, pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr2), pullRequests: [pr1, pr3, pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr2), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .runningStatusChecks(pr2.with(mergeState: .blocked)), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr2.with(mergeState: .clean)), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .ready, pullRequests: [pr3_tp, pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr3), pullRequests: [pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .ready, pullRequests: [pr1, pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr1), pullRequests: [pr4].map{$0.reference}), - MergeService.State.stub(status: .ready, pullRequests: [pr4].map{$0.reference}), - MergeService.State.stub(status: .integrating(pr4)), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [pr2, pr1, pr3, pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr2), pullRequests: [pr1, pr3, pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr2), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference})), + .state(.stub(status: .runningStatusChecks(pr2.with(mergeState: .blocked)), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr2.with(mergeState: .clean)), pullRequests: [pr3_tp, pr1, pr4].map{$0.reference})), + .state(.stub(status: .ready, pullRequests: [pr3_tp, pr1, pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr3), pullRequests: [pr1, pr4].map{$0.reference})), + .state(.stub(status: .ready, pullRequests: [pr1, pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr1), pullRequests: [pr4].map{$0.reference})), + .state(.stub(status: .ready, pullRequests: [pr4].map{$0.reference})), + .state(.stub(status: .integrating(pr4))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -844,11 +839,11 @@ class MergeServiceTests: XCTestCase { expect(pullRequest.number) == 144 }, .postComment { message, pullRequest in - expect(message) == "Your pull request was accepted and it's currently `#2` in the queue, hold tight ⏳" + expect(message) == "Your pull request was accepted and it's currently `#2` in the `master` queue, hold tight ⏳" expect(pullRequest.number) == 233 }, .postComment { message, pullRequest in - expect(message) == "Your pull request was accepted and it's currently `#3` in the queue, hold tight ⏳" + expect(message) == "Your pull request was accepted and it's currently `#3` in the `master` queue, hold tight ⏳" expect(pullRequest.number) == 377 }, .mergePullRequest { _ in }, @@ -865,15 +860,17 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }), - MergeService.State.stub(status: .integrating(pullRequests[0]), pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray), - MergeService.State.stub(status: .integrating(pullRequests[1]), pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray), - MergeService.State.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray), - MergeService.State.stub(status: .integrating(pullRequests[2])), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference })), + .state(.stub(status: .integrating(pullRequests[0]), pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(2).asArray)), + .state(.stub(status: .integrating(pullRequests[1]), pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray)), + .state(.stub(status: .ready, pullRequests: pullRequests.map { $0.reference }.suffix(1).asArray)), + .state(.stub(status: .integrating(pullRequests[2]))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -902,9 +899,7 @@ class MergeServiceTests: XCTestCase { when: { service, scheduler in scheduler.advance() - service.eventsObserver.send(value: .pullRequest( - .init(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked))) - ) + service.sendPullRequestEvent(action: .synchronize, pullRequestMetadata: MergeServiceFixture.defaultTarget.with(mergeState: .blocked)) scheduler.advance() @@ -929,13 +924,15 @@ class MergeServiceTests: XCTestCase { }, assert: { expect($0) == [ - MergeService.State.stub(status: .starting), - MergeService.State.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference]), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget)), - MergeService.State.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked))), - MergeService.State.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean))), - MergeService.State.stub(status: .ready), - MergeService.State.stub(status: .idle) + .created(branch: MergeServiceFixture.defaultTargetBranch), + .state(.stub(status: .starting)), + .state(.stub(status: .ready, pullRequests: [MergeServiceFixture.defaultTarget.reference])), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget))), + .state(.stub(status: .runningStatusChecks(MergeServiceFixture.defaultTarget.with(mergeState: .blocked)))), + .state(.stub(status: .integrating(MergeServiceFixture.defaultTarget.with(mergeState: .clean)))), + .state(.stub(status: .ready)), + .state(.stub(status: .idle)), + .destroyed(branch: MergeServiceFixture.defaultTargetBranch) ] } ) @@ -945,59 +942,27 @@ class MergeServiceTests: XCTestCase { // MARK: - Helpers - struct MockGitHubEventsService: GitHubEventsServiceProtocol { - let eventsObserver: Signal.Observer - let events: Signal - - init() { - (events, eventsObserver) = Signal.pipe() - } - - func sendStatusEvent( - index: Int = 0, - state: StatusEvent.State, - branches: [StatusEvent.Branch] = [.init(name: MergeServiceFixture.defaultBranch)] - ) { - eventsObserver.send(value: .status( - StatusEvent( - sha: "abcdef", - context: CommitState.stubContextName(index), - description: "N/A", - state: state, - branches: branches - ) - )) - } - } - private func perform( requiresAllStatusChecks: Bool = false, stubs: [MockGitHubAPI.Stubs], when: (MockGitHubEventsService, TestScheduler) -> Void, - assert: ([MergeService.State]) -> Void + assert: ([DispatchServiceEvent]) -> Void ) { let scheduler = TestScheduler() let gitHubAPI = MockGitHubAPI(stubs: stubs) let gitHubEvents = MockGitHubEventsService() - let service = MergeService( - integrationLabel: LabelFixture.integrationLabel, - topPriorityLabels: LabelFixture.topPriorityLabels, + let dispatchServiceContext = DispatchServiceContext( requiresAllStatusChecks: requiresAllStatusChecks, - statusChecksTimeout: MergeServiceFixture.defaultStatusChecksTimeout, - logger: MockLogger(), gitHubAPI: gitHubAPI, gitHubEvents: gitHubEvents, scheduler: scheduler ) - var states: [MergeService.State] = [] - - service.state.producer.observe(on: scheduler).startWithValues { states.append($0) } - when(gitHubEvents, scheduler) - assert(states) + + assert(dispatchServiceContext.events) expect(gitHubAPI.assert()) == true } diff --git a/Tests/BotTests/MergeService/Stubs/MergeService+Stub.swift b/Tests/BotTests/MergeService/Stubs/MergeService+Stub.swift index 9db1957..b48b72c 100644 --- a/Tests/BotTests/MergeService/Stubs/MergeService+Stub.swift +++ b/Tests/BotTests/MergeService/Stubs/MergeService+Stub.swift @@ -10,10 +10,12 @@ struct MergeServiceFixture { static let defaultStatusChecksTimeout = 30.minutes static let defaultBranch = "some-branch" + static let defaultTargetBranch = "master" static let defaultTarget = PullRequestMetadata.stub( number: 1, headRef: MergeServiceFixture.defaultBranch, + baseRef: MergeServiceFixture.defaultTargetBranch, labels: [LabelFixture.integrationLabel], mergeState: .behind ) @@ -23,6 +25,7 @@ struct MergeServiceFixture { extension MergeService.State { static func stub( + targetBranch: String = MergeServiceFixture.defaultTargetBranch, status: MergeService.State.Status, pullRequests: [PullRequest] = [], integrationLabel: PullRequest.Label = LabelFixture.integrationLabel, @@ -30,6 +33,7 @@ extension MergeService.State { statusChecksTimeout: TimeInterval = MergeServiceFixture.defaultStatusChecksTimeout ) -> MergeService.State { return .init( + targetBranch: targetBranch, integrationLabel: integrationLabel, topPriorityLabels: topPriorityLabels, statusChecksTimeout: statusChecksTimeout, diff --git a/Tests/BotTests/MergeService/Stubs/PullRequestMetadata+Stub.swift b/Tests/BotTests/MergeService/Stubs/PullRequestMetadata+Stub.swift index fac01d0..c2dd941 100644 --- a/Tests/BotTests/MergeService/Stubs/PullRequestMetadata+Stub.swift +++ b/Tests/BotTests/MergeService/Stubs/PullRequestMetadata+Stub.swift @@ -5,6 +5,7 @@ extension PullRequestMetadata { static func stub( number: UInt, headRef: String = "abcdef", + baseRef: String = "master", labels: [PullRequest.Label] = [], mergeState: PullRequestMetadata.MergeState = .clean ) -> PullRequestMetadata { @@ -14,7 +15,7 @@ extension PullRequestMetadata { title: "Best Pull Request", author: .init(login: "John Doe"), source: .init(ref: headRef, sha: "abcdef"), - target: .init(ref: "master", sha: "abc"), + target: .init(ref: baseRef, sha: "abc"), labels: labels ), isMerged: false, diff --git a/Tests/BotTests/Mocks/MockGitHubEventsService.swift b/Tests/BotTests/Mocks/MockGitHubEventsService.swift new file mode 100644 index 0000000..ae4a6de --- /dev/null +++ b/Tests/BotTests/Mocks/MockGitHubEventsService.swift @@ -0,0 +1,34 @@ +import ReactiveSwift +import Result +@testable import Bot + +struct MockGitHubEventsService: GitHubEventsServiceProtocol { + let eventsObserver: Signal.Observer + let events: Signal + + init() { + (events, eventsObserver) = Signal.pipe() + } + + func sendStatusEvent( + index: Int = 0, + state: StatusEvent.State, + branches: [StatusEvent.Branch] = [.init(name: MergeServiceFixture.defaultBranch)] + ) { + eventsObserver.send(value: .status( + StatusEvent( + sha: "abcdef", + context: CommitState.stubContextName(index), + description: "N/A", + state: state, + branches: branches + ) + )) + } + + func sendPullRequestEvent(action: PullRequest.Action, pullRequestMetadata: PullRequestMetadata) { + eventsObserver.send(value: .pullRequest( + .init(action: action, pullRequestMetadata: pullRequestMetadata)) + ) + } +} diff --git a/WallEView.xcodeproj/project.pbxproj b/WallEView.xcodeproj/project.pbxproj new file mode 100644 index 0000000..41772d0 --- /dev/null +++ b/WallEView.xcodeproj/project.pbxproj @@ -0,0 +1,342 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + B5BCDAE5238D8E170010DE06 /* PullRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BCDAE4238D8E170010DE06 /* PullRequest.swift */; }; + B5CACCED238D8CFB000D3F14 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CACCEC238D8CFB000D3F14 /* AppDelegate.swift */; }; + B5CACCEF238D8CFB000D3F14 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CACCEE238D8CFB000D3F14 /* ViewController.swift */; }; + B5CACCF1238D8CFB000D3F14 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5CACCF0238D8CFB000D3F14 /* Assets.xcassets */; }; + B5CACCF4238D8CFB000D3F14 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5CACCF2238D8CFB000D3F14 /* Main.storyboard */; }; + B5CACCFD238D8DA8000D3F14 /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CACCFC238D8DA8000D3F14 /* EventMonitor.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B5BCDAE4238D8E170010DE06 /* PullRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PullRequest.swift; path = ../Sources/Bot/Models/PullRequest.swift; sourceTree = ""; }; + B5CACCE9238D8CFB000D3F14 /* WallEView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WallEView.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B5CACCEC238D8CFB000D3F14 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + B5CACCEE238D8CFB000D3F14 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + B5CACCF0238D8CFB000D3F14 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B5CACCF3238D8CFB000D3F14 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + B5CACCF5238D8CFB000D3F14 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B5CACCF6238D8CFB000D3F14 /* WallEView.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WallEView.entitlements; sourceTree = ""; }; + B5CACCFC238D8DA8000D3F14 /* EventMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B5CACCE6238D8CFB000D3F14 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B5CACCE0238D8CFB000D3F14 = { + isa = PBXGroup; + children = ( + B5CACCEB238D8CFB000D3F14 /* WallEView */, + B5CACCEA238D8CFB000D3F14 /* Products */, + ); + sourceTree = ""; + }; + B5CACCEA238D8CFB000D3F14 /* Products */ = { + isa = PBXGroup; + children = ( + B5CACCE9238D8CFB000D3F14 /* WallEView.app */, + ); + name = Products; + sourceTree = ""; + }; + B5CACCEB238D8CFB000D3F14 /* WallEView */ = { + isa = PBXGroup; + children = ( + B5CACCEC238D8CFB000D3F14 /* AppDelegate.swift */, + B5CACCEE238D8CFB000D3F14 /* ViewController.swift */, + B5CACCFC238D8DA8000D3F14 /* EventMonitor.swift */, + B5BCDAE4238D8E170010DE06 /* PullRequest.swift */, + B5CACCF0238D8CFB000D3F14 /* Assets.xcassets */, + B5CACCF2238D8CFB000D3F14 /* Main.storyboard */, + B5CACCF5238D8CFB000D3F14 /* Info.plist */, + B5CACCF6238D8CFB000D3F14 /* WallEView.entitlements */, + ); + path = WallEView; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B5CACCE8238D8CFB000D3F14 /* WallEView */ = { + isa = PBXNativeTarget; + buildConfigurationList = B5CACCF9238D8CFB000D3F14 /* Build configuration list for PBXNativeTarget "WallEView" */; + buildPhases = ( + B5CACCE5238D8CFB000D3F14 /* Sources */, + B5CACCE6238D8CFB000D3F14 /* Frameworks */, + B5CACCE7238D8CFB000D3F14 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WallEView; + productName = WallEView; + productReference = B5CACCE9238D8CFB000D3F14 /* WallEView.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B5CACCE1238D8CFB000D3F14 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1110; + LastUpgradeCheck = 1110; + ORGANIZATIONNAME = babylon; + TargetAttributes = { + B5CACCE8238D8CFB000D3F14 = { + CreatedOnToolsVersion = 11.1; + }; + }; + }; + buildConfigurationList = B5CACCE4238D8CFB000D3F14 /* Build configuration list for PBXProject "WallEView" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B5CACCE0238D8CFB000D3F14; + productRefGroup = B5CACCEA238D8CFB000D3F14 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B5CACCE8238D8CFB000D3F14 /* WallEView */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B5CACCE7238D8CFB000D3F14 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B5CACCF1238D8CFB000D3F14 /* Assets.xcassets in Resources */, + B5CACCF4238D8CFB000D3F14 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B5CACCE5238D8CFB000D3F14 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B5CACCFD238D8DA8000D3F14 /* EventMonitor.swift in Sources */, + B5CACCEF238D8CFB000D3F14 /* ViewController.swift in Sources */, + B5BCDAE5238D8E170010DE06 /* PullRequest.swift in Sources */, + B5CACCED238D8CFB000D3F14 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + B5CACCF2238D8CFB000D3F14 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + B5CACCF3238D8CFB000D3F14 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + B5CACCF7238D8CFB000D3F14 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B5CACCF8238D8CFB000D3F14 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + B5CACCFA238D8CFB000D3F14 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = WallEView/WallEView.entitlements; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = WallEView/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.WallEView; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B5CACCFB238D8CFB000D3F14 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = WallEView/WallEView.entitlements; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = WallEView/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.WallEView; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B5CACCE4238D8CFB000D3F14 /* Build configuration list for PBXProject "WallEView" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B5CACCF7238D8CFB000D3F14 /* Debug */, + B5CACCF8238D8CFB000D3F14 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B5CACCF9238D8CFB000D3F14 /* Build configuration list for PBXNativeTarget "WallEView" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B5CACCFA238D8CFB000D3F14 /* Debug */, + B5CACCFB238D8CFB000D3F14 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B5CACCE1238D8CFB000D3F14 /* Project object */; +} diff --git a/WallEView.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/WallEView.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/WallEView.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/WallEView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/WallEView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/WallEView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/WallEView.xcodeproj/xcshareddata/xcschemes/WallEView.xcscheme b/WallEView.xcodeproj/xcshareddata/xcschemes/WallEView.xcscheme new file mode 100644 index 0000000..7b59ec1 --- /dev/null +++ b/WallEView.xcodeproj/xcshareddata/xcschemes/WallEView.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WallEView/ViewController.swift b/WallEView/ViewController.swift index 031c988..88b5cf4 100644 --- a/WallEView/ViewController.swift +++ b/WallEView/ViewController.swift @@ -39,13 +39,16 @@ class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelega didSet { tableView.reloadData() - let hideContent = state?.isIdle == true || state?.isFailing == true + // TODO: this logic should pick the queue depending on user selection of target branch + let queue = state?.queues.first + + let hideContent = queue?.isIdle == true || queue?.isFailing == true background.isHidden = !hideContent tableViewContainer.isHidden = hideContent - if state?.isFailing == true { + if state?.queues.first?.isFailing == true { backgroundImage.image = #imageLiteral(resourceName: "foot") - backgroundLabel.stringValue = "Something failin':\n\n\(state?.error.map(String.init(describing:)) ?? "")" + backgroundLabel.stringValue = "Something failin':\n\n\(queue?.error.map(String.init(describing:)) ?? "")" } else { backgroundImage.image = #imageLiteral(resourceName: "green") backgroundLabel.stringValue = "Doin' nothin', just chillin'" @@ -53,10 +56,17 @@ class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelega } } - struct State { + struct State: Decodable { + let queues: [Queue] + + static let empty = State(queues: []) + } + + struct Queue { struct Current: Decodable { let reference: PullRequest } + let targetBranch: String let current: Current? let queue: [PullRequest] let error: Error? @@ -75,12 +85,12 @@ class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelega } func numberOfRows(in tableView: NSTableView) -> Int { - return state?.pullRequests.count ?? 0 + return state?.queues.first?.pullRequests.count ?? 0 } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let cell: PullRequestCell? = tableView.makeCell() - let pullRequest = state!.pullRequests[row] + let pullRequest = state!.queues.first!.pullRequests[row] cell?.title.stringValue = pullRequest.title cell?.subtitle.stringValue = "#\(pullRequest.number) by \(pullRequest.author.login)" return cell @@ -93,7 +103,7 @@ class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelega func updateState() { guard let host = UserDefaults.standard.string(forKey: "Host") else { - state = State(current: nil, queue: [], error: "No host set") + state = .empty return } @@ -103,13 +113,14 @@ class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelega DispatchQueue.main.async { if let data = data { do { - let state = try JSONDecoder().decode(State.self, from: data) - self?.state = state + let queues = try JSONDecoder().decode([Queue].self, from: data) + // TODO: filter only PRs targeting develop until branch selector is implemented in UI + self?.state = State(queues: queues.filter { $0.targetBranch == "develop" }) } catch { - self?.state = State(current: nil, queue: [], error: error) + self?.state = .empty } } else { - self?.state = State(current: nil, queue: [], error: error) + self?.state = .empty } } }).resume() @@ -120,14 +131,15 @@ extension String: Error, LocalizedError { public var localizedDescription: String { self } } -extension ViewController.State: Decodable { +extension ViewController.Queue: Decodable { enum CodingKeys: String, CodingKey { - case status, queue, metadata, reference + case status, queue, metadata, reference, targetBranch } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) let status = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .status) + targetBranch = try status.decode(String.self, forKey: .targetBranch) current = try status.decodeIfPresent(Current.self, forKey: .metadata) queue = try values.decode([PullRequest].self, forKey: .queue) error = nil