diff --git a/Sources/APIModels/Github.swift b/Sources/APIModels/Github.swift index 607af5d..b3a1c5b 100644 --- a/Sources/APIModels/Github.swift +++ b/Sources/APIModels/Github.swift @@ -1,11 +1,36 @@ -// -// Github.swift -// APIConnect -// -// Created by Peter Geszten-Kovacs on 2018. 12. 03.. -// +import Foundation public enum Github { + public enum Url { + public static let base = URL(string: "https://api.github.com")! + public static let organisation = "imindeu" + public static let ios = base + .appendingPathComponent(Path.repos.rawValue) + .appendingPathComponent(organisation) + .appendingPathComponent(Repository.ios.rawValue) + public static let android = base + .appendingPathComponent(Path.repos.rawValue) + .appendingPathComponent(organisation) + .appendingPathComponent(Repository.android.rawValue) + public static let search = base + .appendingPathComponent(Path.search.rawValue) + + public enum Repository: String { + case ios = "4dmotion-ios" + case android = "4dmotion-android" + } + } + + public enum Path: String { + case commits + case issues + case labels + case pulls + case repos + case search + case statuses + } + public struct Payload: Equatable, Codable { public let action: Action? public let review: Review? @@ -48,39 +73,44 @@ public enum Github { } public struct PullRequest: Equatable, Codable { - public let url: String + public enum State: String, Codable { + case open, closed, all + } + + enum CodingKeys: String, CodingKey { + case id + case issueId = "number" + case state + case title + case body + case createdAt = "created_at" + case updatedAt = "updated_at" + case mergedAt = "merged_at" + case draft + case head + case base + case labels + case url + } + public let id: Int + public let issueId: Int + public let state: State public let title: String public let body: String? + public let createdAt: Date + public let updatedAt: Date + public let mergedAt: Date + public let draft: Bool public let head: Branch public let base: Branch - public let merged: Bool - - public init(url: String, - id: Int, - title: String, - body: String?, - head: Branch, - base: Branch, - merged: Bool = false) { - self.url = url - self.id = id - self.title = title - self.body = body - self.head = head - self.base = base - self.merged = merged - } + public let labels: [Label] + public let url: String } public struct Repository: Equatable, Codable { public let name: String public let url: String - - public init(name: String, url: String) { - self.name = name - self.url = url - } } public struct User: Equatable, Codable { diff --git a/Sources/APIService/Service.swift b/Sources/APIService/Service.swift index 89a2784..9dddac8 100644 --- a/Sources/APIService/Service.swift +++ b/Sources/APIService/Service.swift @@ -7,9 +7,7 @@ import protocol APIConnect.Context import class APIConnect.IO -import protocol Foundation.LocalizedError -import struct Foundation.CharacterSet -import class Foundation.JSONDecoder +import Foundation import struct HTTP.HTTPBody import struct HTTP.HTTPHeaders @@ -93,7 +91,22 @@ extension IO where T == HTTPResponse { func decode(_ type: A.Type) -> IO { return map { response -> A? in guard let data = response.body.data else { return nil } - return try JSONDecoder().decode(type, from: data) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: data) } } } + +extension URL { + public func appendingQueryItems(_ items: [URLQueryItem]) -> URL? { + URLComponents(string: absoluteString) + .map { components in + var components = components + components.queryItems = (components.queryItems ?? []) + items + return components + }? + .url + } +} diff --git a/Sources/App/Services/CircleCi+Services.swift b/Sources/App/Services/CircleCi+Services.swift index 3e3d4b7..a9770c4 100644 --- a/Sources/App/Services/CircleCi+Services.swift +++ b/Sources/App/Services/CircleCi+Services.swift @@ -674,7 +674,7 @@ extension CircleCi { } switch type { - case let .pullRequestLabeled(label: Github.waitingForReviewLabel, head: head, base: base, platform: platform): + case let .pullRequestLabeled(label: Github.Label.waitingForReview, head: head, base: base, platform: platform): do { if Github.isMaster(branch: base) || Github.isRelease(branch: base) { return try CircleCiTestJob.parse(project: project, diff --git a/Sources/App/Services/Github+Github.swift b/Sources/App/Services/Github+Github.swift index ae28d42..8f1b267 100644 --- a/Sources/App/Services/Github+Github.swift +++ b/Sources/App/Services/Github+Github.swift @@ -11,17 +11,11 @@ import APIService import enum APIModels.Github import enum APIModels.Youtrack -import protocol Foundation.LocalizedError -import struct Foundation.Data -import struct Foundation.URL -import class Foundation.JSONEncoder +import Foundation import enum HTTP.HTTPMethod public extension Github { - - static var waitingForReviewLabel: Label { return Label(name: "waiting for review") } - static func isDev(branch: Branch) -> Bool { return branch.ref == "dev" } static func isMaster(branch: Branch) -> Bool { return ["master", "fourd", "oc", "sp"].contains(branch.ref) } static func isRelease(branch: Branch) -> Bool { return ["release", "release_oc", "release_sp"].contains(branch.ref) } @@ -29,6 +23,11 @@ public extension Github { static func isMain(branch: Branch) -> Bool { return isDev(branch: branch) || isMaster(branch: branch) || isRelease(branch: branch) } } +public extension Github.Label { + static let waitingForReview = Github.Label(name: "waiting for review") + static let stale = Github.Label(name: "stale") +} + public extension Github { struct PayloadResponse: Equatable, Codable { public let value: String? @@ -68,16 +67,19 @@ public extension Github { case branchPushed(Branch) case pullRequestOpened(title: String, url: String, body: String, platform: PlatformType) case pullRequestEdited(title: String, url: String, body: String, platform: PlatformType) - case pullRequestClosed(title: String, head: Branch, base: Branch, merged: Bool, platform: PlatformType) + case pullRequestClosed(title: String, head: Branch, base: Branch, platform: PlatformType) case pullRequestLabeled(label: Label, head: Branch, base: Branch, platform: PlatformType) case changesRequested(url: String) case failedStatus(sha: String) case getPullRequest(url: String) case getStatus(sha: String, url: String) + case getOpenPullRequests + case markPullRequestStale(issueId: Int) + var title: String? { switch self { - case .branchCreated(let t, _), .pullRequestClosed(let t, _, _, _, _), .pullRequestOpened(let t, _, _, _): + case .branchCreated(let t, _), .pullRequestClosed(let t, _, _, _), .pullRequestOpened(let t, _, _, _): return t default: return nil @@ -86,7 +88,7 @@ public extension Github { var platform: PlatformType? { switch self { - case .branchCreated(_, let platform),.pullRequestClosed(_, _, _, _, let platform), .pullRequestOpened(_, _, _, let platform): + case .branchCreated(_, let platform),.pullRequestClosed(_, _, _, let platform), .pullRequestOpened(_, _, _, let platform): return platform default: return nil @@ -115,7 +117,7 @@ public extension Github { } -extension Github.Payload: RequestModel { +extension Github.Payload: @retroactive RequestModel { public typealias ResponseModel = Github.PayloadResponse public enum Config: String, Configuration { @@ -124,9 +126,17 @@ extension Github.Payload: RequestModel { } extension Github.Payload { + // (1b) Incoming webhook to local request func type(headers: Headers?) -> Github.RequestType? { let event = headers?.get(Github.eventHeaderName).flatMap(Github.Event.init) - let platform: Github.PlatformType = repository?.name == "4dmotion-ios" ? .iOS : .android + let platform: Github.PlatformType + switch Github.Url.Repository(rawValue: repository?.name ?? "N/A") { + case .ios: + platform = .iOS + case .android: + platform = .android + case .none: return nil + } switch (event, action, @@ -140,7 +150,7 @@ extension Github.Payload { repository) { case let (.some(.pullRequest), .some(.closed), _, _, .some(pr), _, _, _, _, _): - return .pullRequestClosed(title: pr.title, head: pr.head, base: pr.base, merged: pr.merged, platform: platform) + return .pullRequestClosed(title: pr.title, head: pr.head, base: pr.base, platform: platform) case let (.some(.pullRequest), .some(.opened), _, _, .some(pr), _, _, _, _, _): return .pullRequestOpened(title: pr.title, url: pr.url, body: pr.body ?? "", platform: platform) case let (.some(.pullRequest), .some(.reopened), _, _, .some(pr), _, _, _, _, _): @@ -164,9 +174,6 @@ extension Github.Payload { return .failedStatus(sha: sha) case let (.some(.status), _, _, _, _, _, _, .some(sha), .some(.failure), _): return .failedStatus(sha: sha) - - // case (_, _, _, _, _, _, _, _, _): - default: return nil } @@ -189,7 +196,8 @@ extension Github.APIRequest: TokenRequestable { switch self.type { case .pullRequestOpened, .pullRequestEdited: return .PATCH case .changesRequested: return .DELETE - case .failedStatus, .getPullRequest, .getStatus: return .GET + case .failedStatus, .getPullRequest, .getStatus, .getOpenPullRequests: return .GET + case .markPullRequestStale: return .POST default: return nil } } @@ -197,7 +205,7 @@ extension Github.APIRequest: TokenRequestable { public var body: Data? { switch self.type { case let .pullRequestOpened(title: title, _, body: body, platform: _), - let .pullRequestEdited(title: title, _, body: body, platform: _): + let .pullRequestEdited(title: title, _, body: body, platform: _): let issues = (try? Youtrack.issueURLs(from: title, base: Environment.get(Youtrack.Request.Config.youtrackURL), @@ -213,29 +221,54 @@ extension Github.APIRequest: TokenRequestable { } else { return nil } + case .markPullRequestStale: + struct Body: Encodable { let labels: [String] } + + return try? JSONEncoder().encode(Body(labels: [Github.Label.stale.name])) default: return nil } } public func url(token: String) -> URL? { - let waitingForReview = Github.waitingForReviewLabel.name switch self.type { case let .changesRequested(url: url): - return URL(string: url.replacingOccurrences(of: "/pulls", with: "/issues"))? - .appendingPathComponent("labels") - .appendingPathComponent(waitingForReview) + return URL + .init(string: url.replacingOccurrences( + of: "/\(Github.Path.pulls.rawValue)", + with: "/\(Github.Path.issues.rawValue)" + ))? + .appendingPathComponent(Github.Path.labels.rawValue) + .appendingPathComponent(Github.Label.waitingForReview.name) case let .failedStatus(sha: sha): - let query = "issues?q=\(sha)+label:\"\(waitingForReview)\"+state:open" - return URL(string: "https://api.github.com/search/")?.appendingPathComponent(query) + return Github.Url.search + .appendingPathComponent(Github.Path.issues.rawValue) + .appendingQueryItems([.init( + name: "q", + value: "\(sha)" + + "+label:\"\(Github.Label.waitingForReview.name)\"" + + "+state:open" + )]) case let .pullRequestOpened(_, url: url, _, _), let .pullRequestEdited(_, url: url, _, _), let .getPullRequest(url: url): return URL(string: url) case let .getStatus(sha: sha, url: url): return URL(string: url)? - .appendingPathComponent("commits") + .appendingPathComponent(Github.Path.commits.rawValue) .appendingPathComponent(sha) - .appendingPathComponent("statuses") + .appendingPathComponent(Github.Path.statuses.rawValue) + case .getOpenPullRequests: + return Github.Url.ios + .appendingPathComponent(Github.Path.pulls.rawValue) + .appendingQueryItems([ + .init(name: "state", value: "open"), + .init(name: "per_page", value: "100") + ]) + case let .markPullRequestStale(issueId): + return Github.Url.ios + .appendingPathComponent(Github.Path.issues.rawValue) + .appendingPathComponent("\(issueId)") + .appendingPathComponent(Github.Path.labels.rawValue) default: return nil } } @@ -262,6 +295,7 @@ extension Github { : PayloadResponse(error: Error.signature) } + // (1a) Core: incoming webhook static func githubRequest(_ from: Payload, _ headers: Headers?, _ context: Context) -> EitherIO { @@ -273,6 +307,7 @@ extension Github { return rightIO(context)(APIRequest(installationId: installationId, type: type)) } + // (2a) Core: action static func apiWithGithub(_ context: Context) -> (APIRequest) -> EitherIO { @@ -294,6 +329,7 @@ extension Github { } } + // (3) Core: response back static func responseToGithub(_ from: APIResponse) -> PayloadResponse { return PayloadResponse(value: from.message.map { $0 + " (\(from.errors ?? []))" }) } @@ -334,16 +370,17 @@ extension Github { } private extension Github { - + // (2b) Local: action static func fetchRequest(token: String, request: APIRequest, context: Context) throws -> TokenedIO { let instant = pure(Tokened(token, nil), context) + let req: TokenedIO switch request.type { case .changesRequested: - return try Service.fetch(request, APIResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) + req = try Service.fetch(request, APIResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) case .failedStatus: - return try Service.fetch(request, SearchResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) + req = try Service.fetch(request, SearchResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) .map { result -> String? in return result?.items? .compactMap { item -> String? in @@ -355,15 +392,19 @@ private extension Github { .fetch(context, APIResponse.self, Environment.api) { APIRequest(type: .changesRequested(url: $0.url)) } case .pullRequestOpened, .pullRequestEdited: if request.body == nil { - return instant + req = instant } else { - return try Service.fetch(request, APIResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) + req = try Service.fetch(request, APIResponse.self, token, context, Environment.api, isDebugMode: Environment.isDebugMode()) } default: - return instant + req = instant } + + return req + .checkStalePullRequests(context) } } + extension TokenedIO where T == Tokened { func clean() -> EitherIO { return map { $0.value } @@ -381,5 +422,32 @@ extension TokenedIO where T == Tokened { .map { .right($0) } .catchMap { .left(Github.PayloadResponse(error: Github.Error.underlying($0))) } } +} + +// MARK: - Utility + +private extension TokenedIO where T == Tokened { + private static let staleInterval: TimeInterval = 3 * 7 * 24 * 60 * 60 // 3 weeks + func checkStalePullRequests(_ context: Context) -> TokenedIO { + let now = Date() + return fetch(context, [Github.PullRequest].self, Environment.api) { _ in Github.APIRequest(type: .getOpenPullRequests) } + .flatMap { result in + let stalePRs = result.value? + .compactMap { pull -> TokenedIO? in + guard + !pull.labels.contains(Github.Label.stale), + now.timeIntervalSince(pull.updatedAt) > Self.staleInterval + else { return nil } + return self.fetch(context, Github.APIResponse.self, Environment.api) { _ in + Github.APIRequest(type: .markPullRequestStale(issueId: pull.issueId)) + } + } + return Self.reduce( + .init(result.token, nil), + stalePRs ?? [], + eventLoop: self.eventLoop + ) { r, t in r } + } + } }