From 000ad754a6776643a9e6e1d8a4edc6c6f6c39e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Lech?= Date: Thu, 23 May 2024 11:04:18 +0200 Subject: [PATCH] Milestone api (#186) * Add milestone api * Add async methods * Use string instead of date * Update page properties * Update Milestone.swift * Format code using swiftformat * Use old if lets * Update Milestone.swift * Update MilestoneTests.swift * Update MilestoneTests.swift * Update state property docs * Update Milestone.swift --- OctoKit/Milestone.swift | 395 ++++++++++++++++++++ Tests/OctoKitTests/Fixtures/milestone.json | 37 ++ Tests/OctoKitTests/Fixtures/milestones.json | 39 ++ Tests/OctoKitTests/MilestoneTests.swift | 156 ++++++++ 4 files changed, 627 insertions(+) create mode 100644 Tests/OctoKitTests/Fixtures/milestone.json create mode 100644 Tests/OctoKitTests/Fixtures/milestones.json create mode 100644 Tests/OctoKitTests/MilestoneTests.swift diff --git a/OctoKit/Milestone.swift b/OctoKit/Milestone.swift index c475326..664a51b 100644 --- a/OctoKit/Milestone.swift +++ b/OctoKit/Milestone.swift @@ -1,4 +1,5 @@ import Foundation +import RequestKit open class Milestone: Codable { open var url: URL? @@ -67,3 +68,397 @@ open class Milestone: Codable { case dueOn = "due_on" } } + +// MARK: Request + +public extension Octokit { + /** + Create a milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter title: The title of the new milestone. + - parameter state: The state of the milestone. Either open or closed + - parameter description: The description of the new milestone. + - parameter date: The milestone due date + - parameter completion: Callback for the outcome of the create + */ + @discardableResult + func createMilestone(owner: String, + repo: String, + title: String, + state: Openness? = nil, + description: String? = nil, + dueDate: Date? = nil, + completion: @escaping (_ response: Result) -> Void) -> URLSessionDataTaskProtocol? { + let router = MilestoneRouter.createMilestone(configuration, owner, repo, title, state, description, dueDate) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return router.post(session, decoder: decoder, expectedResultType: Milestone.self) { milestone, error in + if let error = error { + completion(.failure(error)) + } else { + if let milestone = milestone { + completion(.success(milestone)) + } + } + } + } + + #if compiler(>=5.5.2) && canImport(_Concurrency) + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + /** + Create a milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter title: The title of the new milestone. + - parameter state: The state of the milestone. Either open or closed + - parameter description: The description of the new milestone. + - parameter date: The milestone due date + */ + @discardableResult + func createMilestone(owner: String, + repo: String, + title: String, + state: Openness? = nil, + description: String? = nil, + dueDate: Date? = nil) async throws -> Milestone { + let router = MilestoneRouter.createMilestone(configuration, owner, repo, title, state, description, dueDate) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return try await router.post(session, + decoder: decoder, + expectedResultType: Milestone.self) + } + #endif + + /** + Get a single milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + - parameter completion: Callback for the outcome of the fetch. + */ + func milestone(owner: String, + repo: String, + number: Int, + completion: @escaping (_ response: Result) -> Void) -> URLSessionDataTaskProtocol? { + let router = MilestoneRouter.readMilestone(configuration, owner, repo, number) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return router.post(session, decoder: decoder, expectedResultType: Milestone.self) { milestone, error in + if let error = error { + completion(.failure(error)) + } else { + if let milestone = milestone { + completion(.success(milestone)) + } + } + } + } + + #if compiler(>=5.5.2) && canImport(_Concurrency) + /** + Get a single milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func milestone(owner: String, + repo: String, + number: Int) async throws -> Milestone { + let router = MilestoneRouter.readMilestone(configuration, owner, repo, number) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return try await router.post(session, decoder: decoder, expectedResultType: Milestone.self) + } + #endif + + /** + Get a list of milestones + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter state: The state of the milestone. Either open, closed, or all + - parameter direction: The direction of the sort. + - parameter page: The page to request. + - parameter perPage: The number of pulls to return on each page, max is 100. + - parameter completion: Callback for the outcome of the fetch. + */ + @discardableResult + func milestones(owner: String, + repo: String, + state: Openness = .open, + sort: SortType = .created, + direction: SortDirection = .desc, + page: Int? = nil, + perPage: Int? = nil, + completion: @escaping (_ response: Result<[Milestone], Error>) -> Void) -> URLSessionDataTaskProtocol? { + let router = MilestoneRouter.readMilestones(configuration, owner, repo, state, sort, direction, perPage, page) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return router.load(session, decoder: decoder, expectedResultType: [Milestone].self) { milestones, error in + if let error = error { + completion(.failure(error)) + } else { + if let milestones = milestones { + completion(.success(milestones)) + } + } + } + } + + #if compiler(>=5.5.2) && canImport(_Concurrency) + /** + Get a list of milestones + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter state: The state of the milestone. Either open, closed, or all + - parameter direction: The direction of the sort. + - parameter page: The page to request. + - parameter perPage: The number of pulls to return on each page, max is 100. + - parameter completion: Callback for the outcome of the fetch. + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func milestones(owner: String, + repo: String, + state: Openness = .open, + sort: SortType = .created, + direction: SortDirection = .desc, + page: Int? = nil, + perPage: Int? = nil) async throws -> [Milestone] { + let router = MilestoneRouter.readMilestones(configuration, owner, repo, state, sort, direction, perPage, page) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return try await router.load(session, decoder: decoder, expectedResultType: [Milestone].self) + } + #endif + + /** + Update a milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + - parameter title: The title of the new milestone. + - parameter state: The state of the milestone. Either open or closed + - parameter description: The description of the new milestone. + - parameter date: The milestone due date + - parameter completion: Callback for the outcome of the update + */ + @discardableResult + func updateMilestone(owner: String, + repo: String, + number: Int, + title: String? = nil, + state: Openness? = nil, + description: String? = nil, + dueDate: Date? = nil, + completion: @escaping (_ response: Result) -> Void) -> URLSessionDataTaskProtocol? { + let router = MilestoneRouter.updateMilestone(configuration, owner, repo, number, title, state, description, dueDate) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return router.post(session, decoder: decoder, expectedResultType: Milestone.self) { milestone, error in + if let error = error { + completion(.failure(error)) + } else { + if let milestone = milestone { + completion(.success(milestone)) + } + } + } + } + + #if compiler(>=5.5.2) && canImport(_Concurrency) + /** + Update a milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + - parameter title: The title of the new milestone. + - parameter state: The state of the milestone. Either open or closed + - parameter description: The description of the new milestone. + - parameter date: The milestone due date + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func updateMilestone(owner: String, + repo: String, + number: Int, + title: String? = nil, + state: Openness? = nil, + description: String? = nil, + dueDate: Date? = nil) async throws -> Milestone { + let router = MilestoneRouter.updateMilestone(configuration, owner, repo, number, title, state, description, dueDate) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(Time.rfc3339DateFormatter) + return try await router.post(session, decoder: decoder, expectedResultType: Milestone.self) + } + #endif + + /** + Delete a single milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + - parameter completion: Callback for the outcome of the deletion. + */ + @discardableResult + func deleteMilestone(owner: String, + repo: String, + number: Int, + completion: @escaping (_ response: Error?) -> Void) -> URLSessionDataTaskProtocol? { + let router = MilestoneRouter.deleteMilestone(configuration, owner, repo, number) + return router.load(session, completion: completion) + } + + #if compiler(>=5.5.2) && canImport(_Concurrency) + /** + Delete a single milestone + - parameter owner: The user or organization that owns the repositories. + - parameter repo: The name of the repository. + - parameter number: The number that identifies the milestone. + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func deleteMilestone(owner: String, + repo: String, + number: Int) async throws { + let router = MilestoneRouter.deleteMilestone(configuration, owner, repo, number) + return try await router.load(session) + } + #endif +} + +// MARK: Router + +enum MilestoneRouter: Router, JSONPostRouter { + typealias Owner = String + typealias Repo = String + typealias Page = Int + typealias PerPage = Int + typealias Title = String + typealias Description = String + typealias MilestoneNumber = Int + + case readMilestones(Configuration, Owner, Repo, Openness, SortType, SortDirection, PerPage?, Page?) + case readMilestone(Configuration, Owner, Repo, MilestoneNumber) + case createMilestone(Configuration, Owner, Repo, Title, Openness?, Description?, Date?) + case updateMilestone(Configuration, Owner, Repo, MilestoneNumber, Title?, Openness?, Description?, Date?) + case deleteMilestone(Configuration, Owner, Repo, MilestoneNumber) + + var configuration: Configuration { + switch self { + case let .readMilestones(config, _, _, _, _, _, _, _): return config + case let .readMilestone(config, _, _, _): return config + case let .createMilestone(config, _, _, _, _, _, _): return config + case let .updateMilestone(config, _, _, _, _, _, _, _): return config + case let .deleteMilestone(config, _, _, _): return config + } + } + + var method: HTTPMethod { + switch self { + case .readMilestones, .readMilestone: + return .GET + case .createMilestone: + return .POST + case .updateMilestone: + return .PATCH + case .deleteMilestone: + return .DELETE + } + } + + var encoding: HTTPEncoding { + switch self { + case .readMilestones, .readMilestone, .deleteMilestone: + return .url + case .createMilestone, .updateMilestone: + return .json + } + } + + var params: [String: Any] { + switch self { + case let .readMilestones(_, _, _, state, sort, direction, perPage, page): + var parameters: [String: Any] = [ + "state": state.rawValue, + "sort": sort.rawValue, + "direction": direction.rawValue + ] + + if let page = page { + parameters["page"] = String(page) + } + + if let perPage = perPage { + parameters["per_page"] = String(perPage) + } + + return parameters + + case let .createMilestone(_, _, _, title, state, description, date): + var parameters: [String: Any] = [ + "title": title + ] + + if let state = state { + if state == .all { + parameters["state"] = Openness.open.rawValue + } else { + parameters["state"] = state.rawValue + } + } + + if let description = description { + parameters["description"] = description + } + + if let date = date { + parameters["due_on"] = Time.rfc3339DateFormatter.string(from: date) + } + + return parameters + + case .readMilestone: return [:] + + case let .updateMilestone(_, _, _, _, title, state, description, date): + var parameters: [String: Any] = [:] + + if let title = title { + parameters["title"] = title + } + if let state = state { + if state == .all { + parameters["state"] = Openness.open.rawValue + } else { + parameters["state"] = state.rawValue + } + } + + if let description = description { + parameters["description"] = description + } + + if let date = date { + parameters["due_on"] = Time.rfc3339DateFormatter.string(from: date) + } + + return parameters + + case .deleteMilestone: return [:] + } + } + + var path: String { + switch self { + case let .readMilestones(_, owner, repo, _, _, _, _, _): + return "repos/\(owner)/\(repo)/milestones" + case let .readMilestone(_, owner, repo, number): + return "repos/\(owner)/\(repo)/milestones/\(number)" + case let .createMilestone(_, owner, repo, _, _, _, _): + return "repos/\(owner)/\(repo)/milestones" + case let .updateMilestone(_, owner, repo, number, _, _, _, _): + return "repos/\(owner)/\(repo)/milestones/\(number)" + case let .deleteMilestone(_, owner, repo, number): + return "repos/\(owner)/\(repo)/milestones/\(number)" + } + } +} diff --git a/Tests/OctoKitTests/Fixtures/milestone.json b/Tests/OctoKitTests/Fixtures/milestone.json new file mode 100644 index 0000000..cabb838 --- /dev/null +++ b/Tests/OctoKitTests/Fixtures/milestone.json @@ -0,0 +1,37 @@ +{ + "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", + "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", + "id": 1002604, + "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", + "number": 1, + "state": "open", + "title": "v1.0", + "description": "Tracking milestone for version 1.0", + "creator": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "open_issues": 4, + "closed_issues": 8, + "created_at": "2011-04-10T20:09:31Z", + "updated_at": "2014-03-03T18:58:10Z", + "closed_at": "2013-02-12T13:22:01Z", + "due_on": "2012-10-09T23:39:01Z" +} diff --git a/Tests/OctoKitTests/Fixtures/milestones.json b/Tests/OctoKitTests/Fixtures/milestones.json new file mode 100644 index 0000000..a0d1960 --- /dev/null +++ b/Tests/OctoKitTests/Fixtures/milestones.json @@ -0,0 +1,39 @@ + [ + { + "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", + "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", + "id": 1002604, + "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", + "number": 1, + "state": "open", + "title": "v1.0", + "description": "Tracking milestone for version 1.0", + "creator": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "open_issues": 4, + "closed_issues": 8, + "created_at": "2011-04-10T20:09:31Z", + "updated_at": "2014-03-03T18:58:10Z", + "closed_at": "2013-02-12T13:22:01Z", + "due_on": "2012-10-09T23:39:01Z" + } + ] diff --git a/Tests/OctoKitTests/MilestoneTests.swift b/Tests/OctoKitTests/MilestoneTests.swift new file mode 100644 index 0000000..3f09c3a --- /dev/null +++ b/Tests/OctoKitTests/MilestoneTests.swift @@ -0,0 +1,156 @@ +import OctoKit +import XCTest + +class MilestoneTests: XCTestCase { + func testGetMilestone() { + let session = OctoKitURLTestSession(expectedURL: "https://api.github.com/repos/octocat/Hello-World/milestones/1", + expectedHTTPMethod: "GET", + jsonFile: "milestone", + statusCode: 200) + + let task = Octokit(session: session).milestone(owner: "octocat", + repo: "Hello-World", + number: 1) { response in + switch response { + case let .success(milestone): + XCTAssertEqual(milestone.id, 1002604) + XCTAssertEqual(milestone.number, 1) + XCTAssertEqual(milestone.title, "v1.0") + XCTAssertEqual(milestone.url?.absoluteString, "https://api.github.com/repos/octocat/Hello-World/milestones/1") + XCTAssertEqual(milestone.state, .open) + XCTAssertEqual(milestone.milestoneDescription, "Tracking milestone for version 1.0") + XCTAssertEqual(milestone.openIssues, 4) + XCTAssertEqual(milestone.closedIssues, 8) + XCTAssertEqual(milestone.creator?.login, "octocat") + XCTAssertEqual(milestone.creator?.id, 1) + XCTAssertEqual(milestone.creator?.type, "User") + XCTAssertEqual(milestone.dueOn, Date(timeIntervalSince1970: 1349825941)) + + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + XCTAssertNotNil(task) + XCTAssertTrue(session.wasCalled) + } + + func testGetMilestones() { + let session = OctoKitURLTestSession(expectedURL: "https://api.github.com/repos/octocat/Hello-World/milestones?direction=desc&page=2&per_page=10&sort=created&state=open", + expectedHTTPMethod: "GET", + jsonFile: "milestones", + statusCode: 200) + + let task = Octokit(session: session).milestones(owner: "octocat", + repo: "Hello-World", + sort: .created, + direction: .desc, + page: 2, + perPage: 10) { response in + switch response { + case let .success(milestone): + XCTAssertEqual(milestone.count, 1) + let milestone = milestone.first! + XCTAssertEqual(milestone.id, 1002604) + XCTAssertEqual(milestone.number, 1) + XCTAssertEqual(milestone.title, "v1.0") + XCTAssertEqual(milestone.url?.absoluteString, "https://api.github.com/repos/octocat/Hello-World/milestones/1") + XCTAssertEqual(milestone.state, .open) + XCTAssertEqual(milestone.milestoneDescription, "Tracking milestone for version 1.0") + XCTAssertEqual(milestone.openIssues, 4) + XCTAssertEqual(milestone.closedIssues, 8) + XCTAssertEqual(milestone.creator?.login, "octocat") + XCTAssertEqual(milestone.creator?.id, 1) + XCTAssertEqual(milestone.creator?.type, "User") + XCTAssertEqual(milestone.dueOn, Date(timeIntervalSince1970: 1349825941)) + + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + XCTAssertNotNil(task) + XCTAssertTrue(session.wasCalled) + } + + func testCreateMilestone() { + let session = OctoKitURLTestSession(expectedURL: "https://api.github.com/repos/octocat/Hello-World/milestones", + expectedHTTPMethod: "POST", + jsonFile: "milestone", + statusCode: 200) + + let task = Octokit(session: session).createMilestone(owner: "octocat", + repo: "Hello-World", + title: "v1.0", + description: "Description", + dueDate: .init()) { response in + switch response { + case let .success(milestone): + XCTAssertEqual(milestone.id, 1002604) + XCTAssertEqual(milestone.number, 1) + XCTAssertEqual(milestone.title, "v1.0") + XCTAssertEqual(milestone.url?.absoluteString, "https://api.github.com/repos/octocat/Hello-World/milestones/1") + XCTAssertEqual(milestone.state, .open) + XCTAssertEqual(milestone.milestoneDescription, "Tracking milestone for version 1.0") + XCTAssertEqual(milestone.openIssues, 4) + XCTAssertEqual(milestone.closedIssues, 8) + XCTAssertEqual(milestone.creator?.login, "octocat") + XCTAssertEqual(milestone.creator?.id, 1) + XCTAssertEqual(milestone.creator?.type, "User") + XCTAssertEqual(milestone.dueOn, Date(timeIntervalSince1970: 1349825941)) + + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + XCTAssertNotNil(task) + XCTAssertTrue(session.wasCalled) + } + + func testUpdateMilestone() { + let session = OctoKitURLTestSession(expectedURL: "https://api.github.com/repos/octocat/Hello-World/milestones/1", + expectedHTTPMethod: "PATCH", + jsonFile: "milestone", + statusCode: 200) + + let task = Octokit(session: session).updateMilestone(owner: "octocat", + repo: "Hello-World", + number: 1) { response in + switch response { + case let .success(milestone): + XCTAssertEqual(milestone.id, 1002604) + XCTAssertEqual(milestone.number, 1) + XCTAssertEqual(milestone.title, "v1.0") + XCTAssertEqual(milestone.url?.absoluteString, "https://api.github.com/repos/octocat/Hello-World/milestones/1") + XCTAssertEqual(milestone.state, .open) + XCTAssertEqual(milestone.milestoneDescription, "Tracking milestone for version 1.0") + XCTAssertEqual(milestone.openIssues, 4) + XCTAssertEqual(milestone.closedIssues, 8) + XCTAssertEqual(milestone.creator?.login, "octocat") + XCTAssertEqual(milestone.creator?.id, 1) + XCTAssertEqual(milestone.creator?.type, "User") + XCTAssertEqual(milestone.dueOn, Date(timeIntervalSince1970: 1349825941)) + + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + XCTAssertNotNil(task) + XCTAssertTrue(session.wasCalled) + } + + func testDeletMilestone() { + let session = OctoKitURLTestSession(expectedURL: "https://api.github.com/repos/octocat/Hello-World/milestones/1", + expectedHTTPMethod: "DELETE", + jsonFile: "milestone", + statusCode: 200) + + let task = Octokit(session: session).deleteMilestone(owner: "octocat", + repo: "Hello-World", + number: 1) { response in + if let error = response { + XCTFail(error.localizedDescription) + } + } + XCTAssertNotNil(task) + XCTAssertTrue(session.wasCalled) + } +}